From 4126d973bbbd2abbe5ba7e02ccf00e106dc49145 Mon Sep 17 00:00:00 2001 From: Chase Date: Sat, 25 Jul 2026 00:00:54 +0800 Subject: [PATCH] feat: add declarative Profile Runtime v1 --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 11 + README.md | 9 + README.zh-CN.md | 27 +- ROADMAP.md | 18 +- docs/architecture.md | 30 +- docs/getting-started.md | 16 + docs/grounding-protocol.md | 3 + docs/integration-profiles.md | 350 +++++++++++++ docs/usage.md | 24 +- examples/erp/README.md | 14 + .../external-features/order-cancellation.yaml | 13 + .../profiles/structured-feature.yaml | 28 + package-lock.json | 31 ++ package.json | 4 + schemas/domain-declaration.schema.json | 72 +++ schemas/integration-profile.schema.json | 275 ++++++++++ scripts/smoke-installed-package.mjs | 120 +++++ src/cli.mjs | 186 ++++++- src/frontmatter.mjs | 104 ++-- src/grounding-request.mjs | 239 ++++++++- src/init.mjs | 32 +- src/integration-schema-validator.mjs | 250 +++++++++ src/prepare.mjs | 41 +- src/profile-mapping.mjs | 321 ++++++++++++ src/profile-registry.mjs | 492 ++++++++++++++++++ src/source-unit.mjs | 444 ++++++++++++++++ tests/cli.test.mjs | 240 +++++++++ tests/integration-schema-validator.test.mjs | 157 ++++++ tests/parser.test.mjs | 31 ++ tests/profile-mapping.test.mjs | 297 +++++++++++ tests/profile-registry.test.mjs | 232 +++++++++ tests/protocol.test.mjs | 57 +- tests/source-unit.test.mjs | 209 ++++++++ 34 files changed, 4286 insertions(+), 94 deletions(-) create mode 100644 docs/integration-profiles.md create mode 100644 examples/erp/external-features/order-cancellation.yaml create mode 100644 examples/erp/opendomain/integrations/profiles/structured-feature.yaml create mode 100644 schemas/domain-declaration.schema.json create mode 100644 schemas/integration-profile.schema.json create mode 100644 scripts/smoke-installed-package.mjs create mode 100644 src/integration-schema-validator.mjs create mode 100644 src/profile-mapping.mjs create mode 100644 src/profile-registry.mjs create mode 100644 src/source-unit.mjs create mode 100644 tests/integration-schema-validator.test.mjs create mode 100644 tests/profile-mapping.test.mjs create mode 100644 tests/profile-registry.test.mjs create mode 100644 tests/source-unit.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f9bb4e..4fd1b65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,6 @@ jobs: - name: Validate OpenDomain run: npm run opendomain -- validate + + - name: Run installed-package smoke + run: npm run smoke:package diff --git a/CHANGELOG.md b/CHANGELOG.md index 400241f..384bf01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +- Add repository-local declarative Integration Profile v1 for structured + planning sources beyond the built-in OpenSpec adapter. +- Add safe file and bundle Source Units, Native Mapping, strict Sidecar Domain + Declaration, and deterministic explicit or automatic adapter selection. +- Add `opendomain integrations list`, `opendomain integrations validate`, and + `opendomain prepare --profile `. +- Add Profile-aware initialization, ERP examples, package installation smoke + coverage, and complete usage documentation. + ## 0.1.0-alpha.5 - 2026-07-23 - Harden YAML front matter parsing with deterministic YAML 1.2 policy checks diff --git a/README.md b/README.md index e8974ec..7bc206e 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ This workspace now includes the first MVP slices: - CLI commands for project init, validation, ID listing, reference checks, grounding, indexing, and demo - OpenSpec `affects_domain` grounding +- Declarative repository-local Integration Profiles for structured non-OpenSpec + sources, with Native Mapping and Sidecar Domain Declaration +- Deterministic file or bundle Source Units and explicit or automatic Profile + selection - Domain Candidate boundary checks - Semantic Retrieval Index as a derived read-first view - Deterministic workspace resolution with canonical `opendomain/` sources @@ -73,6 +77,8 @@ npm run opendomain -- help npm run opendomain -- init npm run opendomain -- validate npm run prepare:demo +npm run opendomain -- integrations validate +npm run opendomain -- integrations list npm run opendomain -- candidate list examples/erp npm run opendomain -- candidate show candidate-0001-order-lifecycle examples/erp npm run opendomain -- index build examples/erp --out /tmp/erp-index.json @@ -96,6 +102,7 @@ Useful entry points: - [Getting started](docs/getting-started.md) - [Usage guide](docs/usage.md) - [Grounding Protocol v1](docs/grounding-protocol.md) +- [Integration Profile guide](docs/integration-profiles.md) - [Roadmap](ROADMAP.md) - [Contributing](CONTRIBUTING.md) - [Security policy](SECURITY.md) @@ -115,9 +122,11 @@ License: MIT. │ ├── glossary.md │ ├── architecture.md │ ├── grounding-protocol.md +│ ├── integration-profiles.md │ ├── candidate-workflow.md │ └── decisions/ ├── opendomain/ +│ ├── integrations/profiles/ │ └── README.md ├── examples/ │ └── erp/ diff --git a/README.zh-CN.md b/README.zh-CN.md index 6f39c3a..ea1af10 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -99,8 +99,12 @@ OpenDomain 适合: - Bounded Context、Domain Concept、Business Rule、Lifecycle、Domain Event、Domain Candidate; - 安全 parser 和 Draft 2020-12 Runtime Schema 校验; - 在 Semantic Closure、index 和 grounding 前执行安全语料门禁; -- CLI 命令:init、validate、ids list、refs check、prepare、index、demo; +- CLI 命令:init、validate、ids list、refs check、prepare、integrations、 + index、demo; - OpenSpec `affects_domain` grounding; +- 面向结构化非 OpenSpec 来源的 repository-local 声明式 Integration Profile; +- Native Mapping、Sidecar Domain Declaration,以及确定性的 file / bundle + Source Unit; - Candidate 边界检查; - Semantic Retrieval Index,作为派生的 read-first 检索视图; - 以 canonical `opendomain/` 为入口的确定性工作区解析; @@ -200,6 +204,8 @@ opendomain/ lifecycles/ events/ candidates/ + integrations/ + profiles/ ``` 不传 source path 时,CLI 会从当前项目根目录解析工作区: @@ -244,6 +250,20 @@ OpenSpec 描述这次变更,OpenDomain 描述长期语义。 npm run opendomain -- prepare ``` +对于其他结构化规划格式,可以在 +`opendomain/integrations/profiles/` 中声明 repository-local Profile: + +```bash +npm run opendomain -- integrations validate +npm run opendomain -- integrations list +npm run opendomain -- prepare --profile +``` + +未显式选择时,只有一个 built-in adapter 或 Profile 匹配才会继续;多重匹配会 +失败,不按优先级猜测。Profile 只归一化来源中明确存在的 intent 和 OpenDomain +ID,不扫描正文、不推断 ID,也不创建或提升 Candidate。完整说明见 +[Integration Profile 使用指南](docs/integration-profiles.md)。 + 输出会告诉 Codex: - `Read first`:先读哪些 accepted source files; @@ -293,6 +313,9 @@ Candidate 不是 accepted truth。它只是待人类审查的提案。 | 输出 JSON 验证结果 | `npm run opendomain -- validate examples/erp --json` | | 为 Feature 准备 grounding | `npm run opendomain -- prepare ` | | 显式使用 OpenSpec integration | `npm run opendomain -- prepare --integration openspec ` | +| 列出 integration | `npm run opendomain -- integrations list` | +| 验证 Integration Profile | `npm run opendomain -- integrations validate` | +| 显式使用本地 Profile | `npm run opendomain -- prepare --profile ` | | 列出 Candidate | `npm run opendomain -- candidate list examples/erp` | | 查看 Candidate | `npm run opendomain -- candidate show examples/erp` | | 记录 Candidate review | `npm run opendomain -- candidate review --decision rejected --reviewed-by --reason examples/erp` | @@ -313,6 +336,7 @@ Candidate 不是 accepted truth。它只是待人类审查的提案。 - [MVP PRD](docs/product-prd.md) - [架构说明](docs/architecture.md) - [Grounding Protocol v1](docs/grounding-protocol.md) +- [Integration Profile 使用指南](docs/integration-profiles.md) - [Candidate 工作流](docs/candidate-workflow.md) - [Semantic Retrieval Index](docs/semantic-retrieval-index.md) - [MVP Grounding Demo](docs/mvp-grounding-demo.md) @@ -332,6 +356,7 @@ OpenDomain 目前是 early alpha。 - 运行 ERP 示例; - 验证 OpenDomain 文件格式; - 为 OpenSpec Feature 生成 grounding pack; +- 用声明式 Profile 为其他结构化规划来源生成 grounding pack; - 用 index 生成 read-first plan; - 在本仓库中 dogfood OpenDomain 自身模型。 diff --git a/ROADMAP.md b/ROADMAP.md index ad23ff5..01e8271 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,24 +7,28 @@ human maintainers. - Markdown + YAML front matter source files - Parser and validator -- CLI validation, ID listing, reference checks, grounding, and demo commands +- CLI initialization, validation, Candidate review, indexing, grounding, and + integration inspection commands - OpenSpec `affects_domain` grounding +- Declarative Integration Profile v1 with file and bundle Source Units, Native + Mapping, and Sidecar Domain Declaration - Domain Candidate boundary checks - Semantic Retrieval Index as a derived read-first view +- Deterministic canonical and legacy workspace resolution - OpenDomain self-model dogfooding under `opendomain/` ## Near-Term Work -1. Candidate review commands - - `opendomain candidate list` - - `opendomain candidate show ` - - stale Candidate review support - -2. Codex grounding CI +1. Codex grounding CI - check `affects_domain` references - check grounding report shape - keep CI honest about what it can and cannot prove +2. Integration consumer conformance + - validate Profile-generated Grounding Requests across external tools + - define EchoPath handoff and recovery consumption without protocol coupling + - add tool-specific examples only after the generic Profile contract holds + 3. OpenSpec archive and long-term recall - preserve change history - connect archived changes to current OpenDomain IDs diff --git a/docs/architecture.md b/docs/architecture.md index 069f161..897ee50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,9 @@ CLI ↓ Candidate workflow ↓ -OpenSpec reference checks +Built-in adapters + declarative Integration Profile Runtime + ↓ +Grounding Request v1 ↓ Derived graph, index, search, MCP, or export views ``` @@ -37,6 +39,11 @@ remains a warning-producing fallback when the canonical root is absent. Dual roots are never merged. Explicit targets are available for examples, fixtures, and external corpora. +Repository-local Integration Profiles live under +`opendomain/integrations/profiles/`. They are versioned configuration beside +the semantic source, not accepted domain knowledge and not part of the default +semantic corpus. + The first derived retrieval view is documented in `docs/semantic-retrieval-index.md`. @@ -78,6 +85,8 @@ OpenDomain optimizes for a stable Codex reading path: ```text OpenSpec affects_domain or code path hint ↓ +Built-in adapter or declarative Integration Profile + ↓ Versioned Grounding Request ↓ Shared Semantic Closure @@ -95,9 +104,11 @@ It does not mean moving accepted facts into an index. Indexes and other derived views help Codex find files; OpenDomain Markdown files still decide truth. Grounding Protocol v1 defines the stable request-to-pack contract. Profile -Runtime remains an optional format-normalization layer, and external consumers -must not add tool-specific fields to the stable protocol. See -`docs/grounding-protocol.md`. +Runtime is the replaceable format-normalization layer: it resolves one bounded +file or directory Source Unit and transports explicit structured intent and +OpenDomain IDs. It does not infer, define, review, or promote domain knowledge. +External consumers must not add tool-specific fields to the stable protocol. +See `docs/grounding-protocol.md` and `docs/integration-profiles.md`. ## Planning Layers @@ -145,9 +156,10 @@ promote proposed knowledge to accepted knowledge. Accepted knowledge requires evidence and human review metadata. -## OpenSpec Integration +## Integration Boundary -OpenSpec may reference OpenDomain IDs through fields such as: +The built-in OpenSpec adapter may reference OpenDomain IDs through fields such +as: ```yaml affects_domain: @@ -164,3 +176,9 @@ should explain what the business world means over time. OpenSpec should reference OpenDomain through IDs. It should not copy concept, rule, lifecycle, or event definitions into feature specs. + +Other planning tools may use repository-local declarative Profiles. Native +Mapping reads explicit fields owned by the external format. Sidecar Mapping +associates one strict Domain Declaration with a bounded directory Source Unit. +Both produce the same Grounding Request v1 and neither changes accepted +OpenDomain knowledge. diff --git a/docs/getting-started.md b/docs/getting-started.md index d828022..b53c66b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -44,6 +44,9 @@ opendomain/ events/ candidates/ candidate-0001-first-domain-model.md + integrations/ + profiles/ + README.md AGENTS.md ``` @@ -77,6 +80,7 @@ ERP 示例展示了: - accepted domain event; - proposed Domain Candidate; - OpenSpec `affects_domain` 如何引用 OpenDomain ID。 +- repository-local Integration Profile 如何映射非 OpenSpec 结构化来源。 ## 5. 写第一条真实 Domain Concept @@ -142,6 +146,18 @@ opendomain prepare --integration openspec Codex 应先读取输出中 `Read first` 列出的 accepted source files,并把 `Candidate boundaries` 视为 proposed knowledge。 +如果项目使用其他结构化规划格式,可以先查看和验证本地 Integration Profile: + +```bash +opendomain integrations list +opendomain integrations validate +opendomain prepare --profile +``` + +Profile 位于 `opendomain/integrations/profiles/`,只把显式结构化字段归一化成 +Grounding Request,不从正文推断业务语义。参见 +[Integration Profile 使用指南](integration-profiles.md)。 + ## 7. 基本原则 - OpenDomain 记录长期业务语义。 diff --git a/docs/grounding-protocol.md b/docs/grounding-protocol.md index 6570589..5f525b9 100644 --- a/docs/grounding-protocol.md +++ b/docs/grounding-protocol.md @@ -16,6 +16,9 @@ OpenSpec / Spec Kit / other structured source Profile Runtime 负责格式适配,不属于稳定协议。Grounding Pack 也不是 source of truth;accepted OpenDomain Markdown 文件仍然决定业务语义。 +Profile 格式、Source Unit、Native Mapping 与 Sidecar Declaration 见 +[Integration Profile 使用指南](integration-profiles.md)。 + ## Version 当前协议版本: diff --git a/docs/integration-profiles.md b/docs/integration-profiles.md new file mode 100644 index 0000000..7ee6152 --- /dev/null +++ b/docs/integration-profiles.md @@ -0,0 +1,350 @@ +# OpenDomain Integration Profile 使用指南 + +Integration Profile 让 OpenDomain 接收 OpenSpec 之外的结构化规划格式,同时保持 +Grounding Protocol v1 和领域模型边界不变。 + +```text +OpenSpec / Spec Kit / 其他结构化规划来源 + -> built-in adapter 或 repository-local Integration Profile + -> Grounding Request v1 + -> accepted OpenDomain semantic closure + -> Grounding Pack + -> Codex / 其他 Agent consumer +``` + +Profile Runtime 只做确定性的格式归一化。它不会从正文推断领域 ID,不会创建或 +提升 Domain Candidate,也不会修改 accepted OpenDomain 文件。 + +## 1. 快速体验 + +ERP 示例包含一个 `structured-feature` Profile: + +```bash +cd examples/erp + +# 查看 built-in integration 和本地 Profile +opendomain integrations list + +# 检查全部本地 Profile +opendomain integrations validate + +# 显式选择 Profile +opendomain prepare \ + --profile structured-feature \ + external-features/order-cancellation.yaml + +# 只有一个 adapter 匹配时也可以自动选择 +opendomain prepare external-features/order-cancellation.yaml +``` + +从源码仓库运行时,把 `opendomain` 替换为 +`node ../../bin/opendomain.mjs`。 + +## 2. Profile 放在哪里 + +每个项目把一份 Profile 写成一个 YAML 文件: + +```text +opendomain/ + contexts/ + concepts/ + rules/ + lifecycles/ + events/ + candidates/ + integrations/ + profiles/ + structured-feature.yaml +``` + +Profile 属于版本化的工作区配置,不属于 accepted semantic corpus。 +`opendomain validate` 不会把它当成 Domain Concept、Rule 或 Candidate; +使用以下命令单独检查: + +```bash +opendomain integrations validate +``` + +Profile discovery 遵循统一 Workspace Resolver: + +1. 优先读取 canonical `opendomain/integrations/profiles/`; +2. `0.x` 期间仅存在 legacy `domain/` 时兼容读取并给出 warning; +3. 两个根目录同时存在时只读取 canonical registry,不合并; +4. 只读取所选目录第一层的 `.yaml` 和 `.yml` 普通文件; +5. 不跟随 Profile 目录或文件 symlink。 + +`opendomain init` 会创建空的 `opendomain/integrations/profiles/` 插槽和说明文件, +但不会生成假定外部格式的默认 Profile。 + +## 3. File Source Unit 与 Native Mapping + +当外部工具已经有明确的结构化领域引用字段时,使用 Native Mapping。 + +外部文件: + +```yaml +# external-features/order-cancellation.yaml +intent: + id: external.order-cancellation + name: Order Cancellation from Structured Planning + status: proposed +domain: + concepts: + - sales.order + rules: + - sales.confirmed-order-cannot-be-deleted + lifecycles: + - sales.order-lifecycle + events: + - sales.order-confirmed +``` + +对应 Profile: + +```yaml +# opendomain/integrations/profiles/structured-feature.yaml +schema_version: "1.0" +id: structured-feature +source_type: structured-feature +source_unit: + kind: file + match: + paths: + - external-features/*.yaml +intent: + id: + from: primary.intent.id + name: + from: primary.intent.name + status: + from: primary.intent.status + default: proposed +references: + mode: native + affects_domain: + concepts: + from: primary.domain.concepts + coerce: array + rules: + from: primary.domain.rules + lifecycles: + from: primary.domain.lifecycles + events: + from: primary.domain.events +``` + +File Source Unit 的 `input_path`、`root_path` 和 `primary` member 都是同一个 +workspace-relative 普通文件。输入必须匹配至少一个 `paths` glob。 + +Native Mapping 只传递来源中已经明确存在的 ID。`coerce: array` 只能把一个显式 +string 转成单元素数组,不能从描述文本或路径发明 ID。 + +## 4. Bundle Source Unit 与 Sidecar Declaration + +当外部规划格式没有领域引用字段,或者不应修改其原生文件时,可以在一个受限 +bundle 内增加严格的 Sidecar Domain Declaration。 + +示例目录: + +```text +changes/add-order-cancellation/ + change.yaml + feature.md + opendomain.yaml +``` + +Profile: + +```yaml +schema_version: "1.0" +id: structured-change-sidecar +source_type: structured-change +source_unit: + kind: bundle + match: + paths: + - changes/** + root_marker: change.yaml + members: + primary: + path: feature.md + required: true + manifest: + path: change.yaml + required: true + declaration: + path: opendomain.yaml + required: true +intent: + id: + from: manifest.change.id + name: + first_of: + - manifest.change.name + - primary.title + status: + from: manifest.change.status + default: proposed +references: + mode: sidecar +``` + +Manifest: + +```yaml +change: + id: change.add-order-cancellation + name: Add Order Cancellation + status: proposed +``` + +Sidecar Declaration: + +```yaml +schema_version: "1.0" +affects_domain: + concepts: + - sales.order + rules: + - sales.confirmed-order-cannot-be-deleted + lifecycles: + - sales.order-lifecycle + events: [] +``` + +Declaration 只能包含 `schema_version` 和四个 `affects_domain` 数组,且至少一个 +数组非空。它不能包含 intent、source、status、metadata、Profile 设置或领域定义。 + +Bundle 输入可以是 bundle root,也可以是其中的 descendant。Runtime 向上查找 +最近的精确 `root_marker`,但不会越过项目边界。它只读取 Profile 中声明的精确 +member path,忽略 bundle 内其他文件。 + +## 5. 支持的结构化读取 + +Source Unit member 仅支持: + +| 扩展名 | 可供 selector 使用的内容 | +| --- | --- | +| `.md` | YAML front matter;正文完全忽略 | +| `.yaml` / `.yml` | 一个安全 YAML mapping | +| `.json` | 一个 JSON object | + +三种格式都会进入同一 hardened structured-data boundary。重复 key、anchor、 +alias、tag、merge key、prototype-sensitive key、非字符串 mapping key 和非 JSON +值都会失败。 + +Profile v1 的 field path 以 member role 开头,例如 +`primary.intent.id` 或 `manifest.change.name`。字段名只能包含字母、数字、 +下划线和连字符。 + +## 6. 支持的映射操作 + +Profile v1 只支持以下操作: + +| 操作 | 用途 | 限制 | +| --- | --- | --- | +| `from` | 读取一个结构化字段 | 必须从已配置 member role 开始 | +| `first_of` | 按声明顺序读取第一个存在的字段 | 不合并多个值 | +| `default` | intent 字段缺失时使用 literal | 不能用于领域引用 | +| `coerce: array` | 把显式 string 引用转成单元素数组 | 只能用于 reference selector | + +允许的输出目标只有: + +- `intent.id` +- `intent.name` +- `intent.status` +- `affects_domain.concepts` +- `affects_domain.rules` +- `affects_domain.lifecycles` +- `affects_domain.events` + +三个 intent 字段都必须得到非空 string。四类 reference 至少有一个显式 ID; +数组会按第一次出现的顺序去重。 + +Profile v1 不支持 JavaScript、shell、npm package、template、regular expression、 +environment interpolation、LLM、NLP、数组合并、enum mapping 或从路径派生 intent。 + +## 7. Profile 选择 + +显式选择本地 Profile: + +```bash +opendomain prepare --profile structured-feature +``` + +显式使用 built-in OpenSpec adapter: + +```bash +opendomain prepare --integration openspec +``` + +不指定选择参数时,OpenDomain 会同时探测 built-in adapter 和有效本地 Profile: + +- 恰好一个匹配时使用该 adapter; +- 没有本地 Profile 匹配时保留原有 OpenSpec 行为; +- 多个 Profile,或 OpenSpec 与 Profile 同时匹配时失败并列出全部 ID; +- 不按 built-in 身份、文件顺序或“最佳匹配”静默决定; +- `--profile` 与 `--integration` 同时出现时,在读取输入前失败。 + +自动模式会校验本地 registry。若无关 Profile 已损坏,先运行 +`opendomain integrations validate` 修复,或对 OpenSpec 输入显式使用 +`--integration openspec`。 + +## 8. 路径与安全边界 + +- Profile glob 必须是 project-workspace-relative,不能是绝对路径、包含 `..` + 或反斜杠。 +- Bundle marker 和 member 必须是规范化的精确相对路径。 +- 输入、marker 和 member 都会经过 realpath confinement。 +- 任一路径通过 symlink 逃出项目或 bundle 时,prepare 失败。 +- Profile Runtime 不扫描未声明正文,不执行 Profile 中的代码,也不访问网络。 +- Schema unknown fields 失败;未知 `schema_version` 和 acquisition mode 失败。 + +这些限制让 Profile 保持为可 review 的声明式适配层,而不是项目内的任意代码执行 +插件。 + +## 9. CLI 与 JSON + +列出可用 integration: + +```bash +opendomain integrations list +opendomain integrations list --json +``` + +验证 registry: + +```bash +opendomain integrations validate +opendomain integrations validate --json +``` + +JSON inspection 包含: + +- `workspace` 与 `workspace_mode` +- `profile_directory` +- `profile_file_count` +- `valid_profile_count` +- built-in 和 Profile `integrations` +- `warnings` +- `errors` + +Profile prepare 继续输出 Grounding Protocol v1 的稳定字段。可选 +`grounding_request.integration` metadata 会记录 Profile ID、选择方式、Profile +文件和去除绝对路径后的 Source Unit descriptor;consumer 不应把这些可选字段当成 +accepted domain knowledge。 + +## 10. 当前边界 + +当前版本还不支持: + +- Embedded Domain Declaration; +- 动态或可执行插件; +- built-in Spec Kit adapter; +- 自动生成、接受或提升 Candidate; +- 自动安装 Agent 指令; +- Profile reference index; +- EchoPath consumer conformance。 + +这些能力需要后续独立验证。新增兼容格式应优先写一个 repository-local Profile, +不能扩展或绕过 Grounding Protocol 的核心范式。 diff --git a/docs/usage.md b/docs/usage.md index 1c03c37..21fe15c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -108,6 +108,8 @@ opendomain/ lifecycles/ events/ candidates/ + integrations/ + profiles/ openspec/ changes/ @@ -457,8 +459,21 @@ opendomain prepare --integration openspec openspec/changes/order-cancellation/sp 所属项目的根目录执行。若从本仓库源码运行 ERP 示例,可直接使用 `npm run prepare:demo`。 -当前 MVP 只支持 `openspec` built-in integration。Spec Kit 或其他工具的 -Integration Profile 仍处于设计阶段,不会执行动态插件代码。 +除 built-in OpenSpec adapter 外,项目可以在 +`opendomain/integrations/profiles/` 中声明本地 Integration Profile,把其他 +结构化规划格式归一化成同一 Grounding Request: + +```bash +opendomain integrations validate +opendomain integrations list +opendomain prepare --profile structured-feature external-features/order-cancellation.yaml +``` + +省略选择参数时,Runtime 只在恰好一个 built-in adapter 或 Profile 匹配时继续; +多重匹配会失败。Profile v1 支持安全 Markdown front matter、YAML、JSON, +Native Mapping 和 Sidecar Domain Declaration,但不执行动态插件代码、不扫描 +正文、不推断领域 ID。完整格式与 bundle 示例见 +[Integration Profile 使用指南](integration-profiles.md)。 输出会包含: @@ -545,6 +560,9 @@ Index 不是 source of truth。它只回答“应该读哪些 source files”。 | 为 Feature 准备 grounding | `npm run opendomain -- prepare ` | | 输出 Grounding Protocol JSON | `npm run opendomain -- prepare --json` | | 显式使用 OpenSpec integration | `npm run opendomain -- prepare --integration openspec ` | +| 列出 integration | `npm run opendomain -- integrations list` | +| 验证 Integration Profile | `npm run opendomain -- integrations validate` | +| 显式使用本地 Profile | `npm run opendomain -- prepare --profile ` | | 列出 Candidate | `npm run opendomain -- candidate list examples/erp` | | 查看 Candidate | `npm run opendomain -- candidate show examples/erp` | | 记录 Candidate review | `npm run opendomain -- candidate review --decision rejected --reviewed-by --reason examples/erp` | @@ -594,6 +612,8 @@ AI 可以提出 Candidate,但不能静默提升为 accepted。 当前版本仍然是 MVP: - Candidate promotion 仍是人工流程。 +- Profile Runtime 仅支持声明式 Native / Sidecar 模式;Embedded Declaration、 + 动态插件和 built-in Spec Kit adapter 尚未实现。 - Index 是基于现有 OpenDomain 文件生成的 read-first index,不是 embedding search。 - 还没有 graph export、MCP server、IDE 插件或审查 UI。 - Validator 已执行六类领域 source 的 Runtime Schema,并覆盖引用、review、Candidate 和 lifecycle 规则,但不是完整语义推理器。 diff --git a/examples/erp/README.md b/examples/erp/README.md index c50bd99..d39d50b 100644 --- a/examples/erp/README.md +++ b/examples/erp/README.md @@ -8,5 +8,19 @@ This example demonstrates the smallest useful OpenDomain shape: - one business rule - one domain event - one proposed candidate +- one declarative Integration Profile +- one structured non-OpenSpec feature source + +Run both grounding paths from this directory: + +```bash +opendomain prepare openspec/changes/order-cancellation/spec.md +opendomain integrations validate +opendomain prepare --profile structured-feature external-features/order-cancellation.yaml +``` + +Both planning formats explicitly reference the same accepted OpenDomain IDs. +The Profile only normalizes structured fields; it does not infer domain +knowledge. The example is intentionally simple. It is not a full ERP model. diff --git a/examples/erp/external-features/order-cancellation.yaml b/examples/erp/external-features/order-cancellation.yaml new file mode 100644 index 0000000..e3a8faf --- /dev/null +++ b/examples/erp/external-features/order-cancellation.yaml @@ -0,0 +1,13 @@ +intent: + id: external.order-cancellation + name: Order Cancellation from Structured Planning + status: proposed +domain: + concepts: + - sales.order + rules: + - sales.confirmed-order-cannot-be-deleted + lifecycles: + - sales.order-lifecycle + events: + - sales.order-confirmed diff --git a/examples/erp/opendomain/integrations/profiles/structured-feature.yaml b/examples/erp/opendomain/integrations/profiles/structured-feature.yaml new file mode 100644 index 0000000..816da3a --- /dev/null +++ b/examples/erp/opendomain/integrations/profiles/structured-feature.yaml @@ -0,0 +1,28 @@ +schema_version: "1.0" +id: structured-feature +source_type: structured-feature +source_unit: + kind: file + match: + paths: + - external-features/*.yaml +intent: + id: + from: primary.intent.id + name: + from: primary.intent.name + status: + from: primary.intent.status + default: proposed +references: + mode: native + affects_domain: + concepts: + from: primary.domain.concepts + coerce: array + rules: + from: primary.domain.rules + lifecycles: + from: primary.domain.lifecycles + events: + from: primary.domain.events diff --git a/package-lock.json b/package-lock.json index ec0dec2..5d611a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "minimatch": "^9.0.9", "yaml": "^2.9.0" }, "bin": { @@ -53,6 +54,21 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -81,6 +97,21 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/package.json b/package.json index a329d42..4fe0da7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "files": [ "bin/", "src/", + "scripts/smoke-installed-package.mjs", "schemas/", "examples/erp/", "docs/getting-started.md", @@ -41,6 +42,7 @@ "docs/glossary.md", "docs/architecture.md", "docs/grounding-protocol.md", + "docs/integration-profiles.md", "docs/candidate-workflow.md", "docs/semantic-retrieval-index.md", "docs/mvp-grounding-demo.md", @@ -61,6 +63,7 @@ "validate": "node ./bin/opendomain.mjs validate", "prepare:demo": "cd examples/erp && node ../../bin/opendomain.mjs prepare openspec/changes/order-cancellation/spec.md", "demo": "node ./bin/opendomain.mjs demo order-cancellation", + "smoke:package": "node ./scripts/smoke-installed-package.mjs", "prepublishOnly": "npm test && npm run opendomain -- validate" }, "engines": { @@ -69,6 +72,7 @@ "dependencies": { "ajv": "^8.20.0", "ajv-formats": "^3.0.1", + "minimatch": "^9.0.9", "yaml": "^2.9.0" } } diff --git a/schemas/domain-declaration.schema.json b/schemas/domain-declaration.schema.json new file mode 100644 index 0000000..f608703 --- /dev/null +++ b/schemas/domain-declaration.schema.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opendomain.dev/schemas/domain-declaration.schema.json", + "title": "OpenDomain Sidecar Domain Declaration v1", + "type": "object", + "required": ["schema_version", "affects_domain"], + "properties": { + "schema_version": { + "const": "1.0" + }, + "affects_domain": { + "type": "object", + "required": ["concepts", "rules", "lifecycles", "events"], + "properties": { + "concepts": { + "$ref": "#/$defs/idArray" + }, + "rules": { + "$ref": "#/$defs/idArray" + }, + "lifecycles": { + "$ref": "#/$defs/idArray" + }, + "events": { + "$ref": "#/$defs/idArray" + } + }, + "anyOf": [ + { + "properties": { + "concepts": { + "minItems": 1 + } + } + }, + { + "properties": { + "rules": { + "minItems": 1 + } + } + }, + { + "properties": { + "lifecycles": { + "minItems": 1 + } + } + }, + { + "properties": { + "events": { + "minItems": 1 + } + } + } + ], + "additionalProperties": false + } + }, + "$defs": { + "idArray": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false +} diff --git a/schemas/integration-profile.schema.json b/schemas/integration-profile.schema.json new file mode 100644 index 0000000..ceed4d8 --- /dev/null +++ b/schemas/integration-profile.schema.json @@ -0,0 +1,275 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opendomain.dev/schemas/integration-profile.schema.json", + "title": "OpenDomain Integration Profile v1", + "type": "object", + "required": [ + "schema_version", + "id", + "source_type", + "source_unit", + "intent", + "references" + ], + "properties": { + "schema_version": { + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "source_type": { + "type": "string", + "pattern": "^[a-z][a-z0-9_-]*(\\.[a-z][a-z0-9_-]*)*$" + }, + "source_unit": { + "oneOf": [ + { + "$ref": "#/$defs/fileSourceUnit" + }, + { + "$ref": "#/$defs/bundleSourceUnit" + } + ] + }, + "intent": { + "type": "object", + "required": ["id", "name", "status"], + "properties": { + "id": { + "$ref": "#/$defs/intentSelector" + }, + "name": { + "$ref": "#/$defs/intentSelector" + }, + "status": { + "$ref": "#/$defs/intentSelector" + } + }, + "additionalProperties": false + }, + "references": { + "oneOf": [ + { + "$ref": "#/$defs/nativeReferences" + }, + { + "$ref": "#/$defs/sidecarReferences" + } + ] + } + }, + "$defs": { + "fieldPath": { + "type": "string", + "pattern": "^(primary|manifest)(\\.[A-Za-z0-9_-]+)+$" + }, + "pathPatterns": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "fileSourceUnit": { + "type": "object", + "required": ["kind", "match"], + "properties": { + "kind": { + "const": "file" + }, + "match": { + "type": "object", + "required": ["paths"], + "properties": { + "paths": { + "$ref": "#/$defs/pathPatterns" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "bundleSourceUnit": { + "type": "object", + "required": ["kind", "match", "members"], + "properties": { + "kind": { + "const": "bundle" + }, + "match": { + "type": "object", + "required": ["paths", "root_marker"], + "properties": { + "paths": { + "$ref": "#/$defs/pathPatterns" + }, + "root_marker": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "members": { + "type": "object", + "required": ["primary"], + "properties": { + "primary": { + "$ref": "#/$defs/requiredMember" + }, + "manifest": { + "$ref": "#/$defs/member" + }, + "declaration": { + "$ref": "#/$defs/member" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "requiredMember": { + "type": "object", + "required": ["path", "required"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "required": { + "const": true + } + }, + "additionalProperties": false + }, + "member": { + "type": "object", + "required": ["path", "required"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "intentSelector": { + "type": "object", + "properties": { + "from": { + "$ref": "#/$defs/fieldPath" + }, + "first_of": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/fieldPath" + } + }, + "default": { + "type": "string", + "minLength": 1 + } + }, + "oneOf": [ + { + "required": ["from"], + "not": { + "required": ["first_of"] + } + }, + { + "required": ["first_of"], + "not": { + "required": ["from"] + } + } + ], + "additionalProperties": false + }, + "referenceSelector": { + "type": "object", + "properties": { + "from": { + "$ref": "#/$defs/fieldPath" + }, + "first_of": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/fieldPath" + } + }, + "coerce": { + "const": "array" + } + }, + "oneOf": [ + { + "required": ["from"], + "not": { + "required": ["first_of"] + } + }, + { + "required": ["first_of"], + "not": { + "required": ["from"] + } + } + ], + "additionalProperties": false + }, + "nativeReferences": { + "type": "object", + "required": ["mode", "affects_domain"], + "properties": { + "mode": { + "const": "native" + }, + "affects_domain": { + "type": "object", + "minProperties": 1, + "properties": { + "concepts": { + "$ref": "#/$defs/referenceSelector" + }, + "rules": { + "$ref": "#/$defs/referenceSelector" + }, + "lifecycles": { + "$ref": "#/$defs/referenceSelector" + }, + "events": { + "$ref": "#/$defs/referenceSelector" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "sidecarReferences": { + "type": "object", + "required": ["mode"], + "properties": { + "mode": { + "const": "sidecar" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/scripts/smoke-installed-package.mjs b/scripts/smoke-installed-package.mjs new file mode 100644 index 0000000..9d3e2dc --- /dev/null +++ b/scripts/smoke-installed-package.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback } from "node:child_process"; +import { + access, + mkdtemp, + mkdir, + rm, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFile = promisify(execFileCallback); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "opendomain-package-smoke-")); + +try { + const packResult = await run("npm", [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + temporaryRoot + ], packageRoot); + const packPayload = JSON.parse(packResult.stdout); + const tarball = path.join(temporaryRoot, packPayload[0].filename); + const consumer = path.join(temporaryRoot, "consumer"); + + await mkdir(consumer, { recursive: true }); + await writeFile(path.join(consumer, "package.json"), `${JSON.stringify({ + name: "opendomain-installed-package-smoke", + private: true, + version: "1.0.0" + }, null, 2)}\n`, "utf8"); + await run("npm", [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + tarball + ], consumer); + + const installedRoot = path.join( + consumer, + "node_modules", + "@echopath-labs", + "opendomain" + ); + const cli = path.join(installedRoot, "bin", "opendomain.mjs"); + await access(path.join(installedRoot, "schemas", "integration-profile.schema.json")); + await access(path.join(installedRoot, "schemas", "domain-declaration.schema.json")); + await access(path.join(installedRoot, "docs", "integration-profiles.md")); + await access(path.join(installedRoot, "scripts", "smoke-installed-package.mjs")); + + const init = await runJsonCli(cli, ["init", "--example", "erp", "--json"], consumer); + assert.deepEqual(init.errors, []); + + const exampleRoot = path.join(consumer, "examples", "erp"); + const inspection = await runJsonCli( + cli, + ["integrations", "validate", "--json"], + exampleRoot + ); + assert.deepEqual(inspection.errors, []); + assert.equal(inspection.valid_profile_count, 1); + + const explicit = await runJsonCli(cli, [ + "prepare", + "--profile", + "structured-feature", + "external-features/order-cancellation.yaml", + "--json" + ], exampleRoot); + assert.deepEqual(explicit.errors, []); + assert.equal(explicit.grounding_request.integration.id, "structured-feature"); + assert.equal(explicit.grounding_request.integration.selected, "explicit"); + assert.ok(explicit.read_first.some((item) => item.id === "sales.order")); + + const automatic = await runJsonCli(cli, [ + "prepare", + "external-features/order-cancellation.yaml", + "--json" + ], exampleRoot); + assert.deepEqual(automatic.errors, []); + assert.equal(automatic.grounding_request.integration.id, "structured-feature"); + assert.equal(automatic.grounding_request.integration.selected, "auto"); + assert.deepEqual(automatic.read_first, explicit.read_first); + + process.stdout.write( + `Installed-package smoke passed: ${packPayload[0].filename}, ` + + `${inspection.valid_profile_count} Profile, ` + + `${automatic.read_first.length} grounded sources.\n` + ); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +async function runJsonCli(cli, args, cwd) { + const result = await run(process.execPath, [cli, ...args], cwd); + return JSON.parse(result.stdout); +} + +async function run(command, args, cwd) { + try { + return await execFile(command, args, { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024 + }); + } catch (error) { + const stdout = error.stdout ? `\nstdout:\n${error.stdout}` : ""; + const stderr = error.stderr ? `\nstderr:\n${error.stderr}` : ""; + throw new Error( + `Command failed: ${command} ${args.join(" ")}${stdout}${stderr}`, + { cause: error } + ); + } +} diff --git a/src/cli.mjs b/src/cli.mjs index a576ce2..0f96300 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,7 +1,12 @@ import { validatePath } from "./validator.mjs"; -import { formatGroundingPack, prepareGroundingPack } from "./prepare.mjs"; +import { + emptyGroundingPack, + formatGroundingPack, + prepareGroundingPack +} from "./prepare.mjs"; import { initializeProject } from "./init.mjs"; import { listCandidates, reviewCandidate, showCandidate } from "./candidates.mjs"; +import { inspectIntegrations } from "./profile-registry.mjs"; import { DEFAULT_INDEX_PATH, buildSemanticIndex, @@ -31,6 +36,10 @@ export async function runCli(argv, options = {}) { return runPrepare([subcommand, ...rest].filter(Boolean), io); } + if (command === "integrations" && (subcommand === "list" || subcommand === "validate")) { + return runIntegrations(subcommand, rest, io); + } + if (command === "init") { return runInit([subcommand, ...rest].filter(Boolean), io); } @@ -78,7 +87,9 @@ function printHelp(stream) { Usage: opendomain init [--example erp] [--json] opendomain validate [path] [--json] - opendomain prepare [--integration openspec] [--json] + opendomain prepare [--integration openspec | --profile ] [--json] + opendomain integrations list [--json] + opendomain integrations validate [--json] opendomain index build [path] [--out ] [--json] opendomain index query [--index ] [--json] opendomain index query --context [--index ] [--json] @@ -362,10 +373,16 @@ function parseCandidateReviewArgs(args) { async function runPrepare(args, io) { const parsed = parsePrepareArgs(args); - const pack = await prepareGroundingPack(parsed.path, { - cwd: io.cwd, - integration: parsed.integration - }); + const pack = parsed.errors.length > 0 + ? emptyGroundingPack({ + input: parsed.path, + errors: parsed.errors + }) + : await prepareGroundingPack(parsed.path, { + cwd: io.cwd, + integration: parsed.integration, + profile: parsed.profile + }); if (parsed.json) { io.stdout.write(`${JSON.stringify(pack, null, 2)}\n`); @@ -379,8 +396,10 @@ async function runPrepare(args, io) { function parsePrepareArgs(args) { const parsed = { json: false, - integration: "auto", - path: undefined + integration: undefined, + profile: undefined, + path: undefined, + errors: [] }; for (let index = 0; index < args.length; index += 1) { @@ -390,13 +409,117 @@ function parsePrepareArgs(args) { continue; } if (arg === "--integration") { - parsed.integration = args[index + 1] ?? ""; - index += 1; + if (parsed.integration !== undefined) { + parsed.errors.push(inputIssue( + "integration", + "--integration was provided more than once.", + "Select exactly one built-in integration." + )); + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + parsed.errors.push(inputIssue( + "integration", + "Missing integration ID after --integration.", + "Use --integration openspec." + )); + } else { + parsed.integration = value; + index += 1; + } + continue; + } + if (arg === "--profile") { + if (parsed.profile !== undefined) { + parsed.errors.push(inputIssue( + "profile", + "--profile was provided more than once.", + "Select exactly one repository-local Profile ID." + )); + } + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + parsed.errors.push(inputIssue( + "profile", + "Missing Integration Profile ID after --profile.", + "Use --profile and pass one matching structured source path." + )); + } else { + parsed.profile = value; + index += 1; + } + continue; + } + if (arg.startsWith("--")) { + parsed.errors.push(inputIssue( + "$", + `Unknown prepare argument '${arg}'.`, + "Use --integration openspec, --profile , --json, and one source path." + )); continue; } if (!parsed.path) { parsed.path = arg; + continue; } + parsed.errors.push(inputIssue( + "$", + `Unexpected extra source path '${arg}'.`, + "Pass exactly one feature spec, structured file, or bundle path." + )); + } + + if (parsed.integration !== undefined && parsed.profile !== undefined) { + parsed.errors.push(inputIssue( + "integration", + "--integration and --profile cannot be used together.", + "Select the built-in adapter or one repository-local Profile." + )); + } + + return parsed; +} + +async function runIntegrations(mode, args, io) { + const parsed = parseIntegrationsArgs(args); + const result = parsed.errors.length > 0 + ? { + workspace: null, + workspace_mode: null, + profile_directory: null, + profile_file_count: 0, + valid_profile_count: 0, + integrations: [], + warnings: [], + errors: parsed.errors + } + : await inspectIntegrations({ cwd: io.cwd }); + + if (parsed.json) { + io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + printIntegrationsResult(result, mode, io.stdout); + } + + return result.errors.length > 0 ? 1 : 0; +} + +function parseIntegrationsArgs(args) { + const parsed = { + json: false, + errors: [] + }; + + for (const arg of args) { + if (arg === "--json") { + parsed.json = true; + continue; + } + parsed.errors.push(inputIssue( + "$", + `Unknown integrations argument '${arg}'.`, + "Run opendomain integrations list or opendomain integrations validate, optionally with --json." + )); } return parsed; @@ -611,6 +734,39 @@ function printIndexBuildResult(result, stream) { } } +function printIntegrationsResult(result, mode, stream) { + if (result.errors.length > 0) { + const label = mode === "validate" + ? "Integration Profile validation" + : "Integration discovery"; + stream.write(`${label} failed: ${result.errors.length} errors.\n`); + printIssues([...result.errors, ...result.warnings], stream); + return; + } + + if (mode === "validate") { + stream.write(`Integration Profile validation passed: ${result.valid_profile_count} repository-local Profiles checked.\n`); + } else { + stream.write("OpenDomain Integrations\n\n"); + stream.write(`Workspace: ${result.workspace}\n`); + stream.write(`Profile directory: ${result.profile_directory}\n\n`); + for (const integration of result.integrations) { + const source = integration.source_file ?? ""; + stream.write(`- ${integration.id} [${integration.kind}] -> ${source}\n`); + stream.write(` source_type: ${integration.source_type}\n`); + stream.write(` source_unit: ${integration.source_unit_kind}\n`); + stream.write(` references: ${integration.reference_mode}\n`); + } + } + + if (result.warnings.length > 0) { + stream.write("\nWarnings:\n"); + for (const warning of result.warnings) { + stream.write(`- ${warning.file} ${warning.field}: ${warning.problem}\n`); + } + } +} + function printCandidateListResult(result, stream) { if (result.errors.length > 0) { stream.write(`Candidate list failed: ${result.errors.length} errors.\n`); @@ -870,3 +1026,13 @@ function printIssue(issue, stream) { stream.write(` problem: ${issue.problem}\n`); stream.write(` fix: ${issue.fix}\n`); } + +function inputIssue(field, problem, fix) { + return { + severity: "error", + file: "", + field, + problem, + fix + }; +} diff --git a/src/frontmatter.mjs b/src/frontmatter.mjs index 57ad22f..e9825b2 100644 --- a/src/frontmatter.mjs +++ b/src/frontmatter.mjs @@ -33,13 +33,13 @@ export function parseMarkdown(content, file = "") { return { file, - frontmatter: parseFrontmatter(match[1], file), + frontmatter: parseYamlMapping(match[1], file, { label: "Front matter" }), body: match[2] ?? "" }; } export function serializeFrontmatter(value, file = "") { - const normalized = normalizeJsonValue(value, file); + const normalized = normalizeStructuredValue(value, file, "Front matter"); if (!isMappingValue(normalized)) { throw new FrontMatterError(file, "$", "Front matter must be a YAML mapping."); } @@ -56,17 +56,8 @@ export function serializeFrontmatter(value, file = "") { } } -export class FrontMatterError extends Error { - constructor(file, field, message) { - super(`${file}: ${field}: ${message}`); - this.name = "FrontMatterError"; - this.file = file; - this.field = field; - this.problem = message; - } -} - -function parseFrontmatter(source, file) { +export function parseYamlMapping(source, file = "", options = {}) { + const label = options.label ?? "YAML document"; if (source.trim() === "") { return Object.create(null); } @@ -75,35 +66,68 @@ function parseFrontmatter(source, file) { try { document = parseDocument(source, YAML_OPTIONS); } catch (error) { - throw asFrontMatterError(error, file, "Invalid YAML front matter"); + throw asFrontMatterError(error, file, `Invalid ${label.toLowerCase()}`); } const diagnostic = document.errors[0] ?? document.warnings[0]; if (diagnostic) { - throw yamlDiagnosticError(diagnostic, file); + throw yamlDiagnosticError(diagnostic, file, label); } if (!isMap(document.contents)) { - throw new FrontMatterError(file, "$", "Front matter must be a YAML mapping."); + throw new FrontMatterError(file, "$", `${label} must be a YAML mapping.`); } - assertSupportedYaml(document, file); + assertSupportedYaml(document, file, label); let value; try { value = document.toJS({ mapAsMap: false, maxAliasCount: 0 }); } catch (error) { - throw asFrontMatterError(error, file, "Unable to convert YAML front matter"); + throw asFrontMatterError( + error, + file, + `Unable to convert ${label.toLowerCase()}` + ); + } + return normalizeStructuredValue(value, file, label); +} + +export function parseJsonMapping(source, file = "") { + let value; + try { + value = JSON.parse(source); + } catch (error) { + throw new FrontMatterError(file, "$", `Invalid JSON document: ${error.message}`); + } + + const normalized = normalizeStructuredValue(value, file, "JSON document"); + if (!isMappingValue(normalized)) { + throw new FrontMatterError(file, "$", "JSON document must be an object."); + } + return normalized; +} + +export function normalizeStructuredValue(value, file = "", label = "Structured data") { + return normalizeJsonValue(value, file, new WeakSet(), label); +} + +export class FrontMatterError extends Error { + constructor(file, field, message) { + super(`${file}: ${field}: ${message}`); + this.name = "FrontMatterError"; + this.file = file; + this.field = field; + this.problem = message; } - return normalizeJsonValue(value, file); } -function assertSupportedYaml(document, file) { +function assertSupportedYaml(document, file, label) { visit(document, { Alias() { throw new FrontMatterError( file, "$", - "YAML anchors and aliases are not supported in OpenDomain front matter." + `YAML anchors and aliases are not supported in ${label.toLowerCase()}.` ); }, Node(_key, node) { @@ -111,14 +135,14 @@ function assertSupportedYaml(document, file) { throw new FrontMatterError( file, "$", - "YAML anchors and aliases are not supported in OpenDomain front matter." + `YAML anchors and aliases are not supported in ${label.toLowerCase()}.` ); } if (node.tag) { throw new FrontMatterError( file, "$", - `YAML tags are not supported in OpenDomain front matter ('${node.tag}').` + `YAML tags are not supported in ${label.toLowerCase()} ('${node.tag}').` ); } }, @@ -127,21 +151,21 @@ function assertSupportedYaml(document, file) { throw new FrontMatterError( file, "$", - "Front matter mapping keys must be strings." + `${label} mapping keys must be strings.` ); } - assertSupportedKey(pair.key.value, file); + assertSupportedKey(pair.key.value, file, label); } }); } -function normalizeJsonValue(value, file, ancestors = new WeakSet()) { +function normalizeJsonValue(value, file, ancestors, label) { if (value === null || typeof value === "string" || typeof value === "boolean") { return value; } if (typeof value === "number") { if (!Number.isFinite(value)) { - throw new FrontMatterError(file, "$", "Front matter numbers must be finite JSON values."); + throw new FrontMatterError(file, "$", `${label} numbers must be finite JSON values.`); } return value; } @@ -149,17 +173,17 @@ function normalizeJsonValue(value, file, ancestors = new WeakSet()) { throw new FrontMatterError( file, "$", - `Front matter contains unsupported non-JSON value type '${typeof value}'.` + `${label} contains unsupported non-JSON value type '${typeof value}'.` ); } if (ancestors.has(value)) { - throw new FrontMatterError(file, "$", "Front matter must not contain cyclic values."); + throw new FrontMatterError(file, "$", `${label} must not contain cyclic values.`); } ancestors.add(value); try { if (Array.isArray(value)) { - return value.map((item) => normalizeJsonValue(item, file, ancestors)); + return value.map((item) => normalizeJsonValue(item, file, ancestors, label)); } const prototype = Object.getPrototypeOf(value); @@ -167,27 +191,27 @@ function normalizeJsonValue(value, file, ancestors = new WeakSet()) { throw new FrontMatterError( file, "$", - `Front matter contains unsupported non-JSON object '${value.constructor?.name ?? "Object"}'.` + `${label} contains unsupported non-JSON object '${value.constructor?.name ?? "Object"}'.` ); } const normalized = Object.create(null); for (const key of Reflect.ownKeys(value)) { if (typeof key !== "string") { - throw new FrontMatterError(file, "$", "Front matter mapping keys must be strings."); + throw new FrontMatterError(file, "$", `${label} mapping keys must be strings.`); } - assertSupportedKey(key, file); + assertSupportedKey(key, file, label); const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) { throw new FrontMatterError( file, "$", - `Front matter property '${key}' must be an enumerable data property.` + `${label} property '${key}' must be an enumerable data property.` ); } Object.defineProperty(normalized, key, { - value: normalizeJsonValue(descriptor.value, file, ancestors), + value: normalizeJsonValue(descriptor.value, file, ancestors, label), enumerable: true, configurable: true, writable: true @@ -199,19 +223,19 @@ function normalizeJsonValue(value, file, ancestors = new WeakSet()) { } } -function assertSupportedKey(key, file) { +function assertSupportedKey(key, file, label) { if (UNSAFE_KEYS.has(key)) { throw new FrontMatterError( file, "$", - `Unsupported front matter key '${key}'; prototype-sensitive and merge keys are not allowed.` + `Unsupported ${label.toLowerCase()} key '${key}'; prototype-sensitive and merge keys are not allowed.` ); } if (!KEY_PATTERN.test(key)) { throw new FrontMatterError( file, "$", - `Unsupported front matter key '${key}'; use letters, digits, underscores, or hyphens.` + `Unsupported ${label.toLowerCase()} key '${key}'; use letters, digits, underscores, or hyphens.` ); } } @@ -220,9 +244,9 @@ function isMappingValue(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } -function yamlDiagnosticError(diagnostic, file) { +function yamlDiagnosticError(diagnostic, file, label) { const code = diagnostic.code ? ` (${diagnostic.code})` : ""; - return new FrontMatterError(file, "$", `Invalid YAML front matter${code}: ${diagnostic.message}`); + return new FrontMatterError(file, "$", `Invalid ${label.toLowerCase()}${code}: ${diagnostic.message}`); } function asFrontMatterError(error, file, prefix) { diff --git a/src/grounding-request.mjs b/src/grounding-request.mjs index 3e0561b..8dfc890 100644 --- a/src/grounding-request.mjs +++ b/src/grounding-request.mjs @@ -1,30 +1,58 @@ import { access, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { parseMarkdownFile } from "./frontmatter.mjs"; +import { buildProfileGroundingRequest } from "./profile-mapping.mjs"; +import { loadIntegrationProfiles } from "./profile-registry.mjs"; import { GROUNDING_PROTOCOL_VERSION } from "./protocol.mjs"; +import { + findMatchingProfileSourceUnits, + resolveProfileSourceUnit +} from "./source-unit.mjs"; const SUPPORTED_INTEGRATIONS = new Set(["auto", "openspec"]); const AFFECTS_DOMAIN_FIELDS = ["concepts", "rules", "lifecycles", "events"]; export async function buildGroundingRequest(inputPath, options = {}) { const cwd = options.cwd ?? process.cwd(); + const integrationProvided = options.integration !== undefined && options.integration !== null; + const profileProvided = options.profile !== undefined && options.profile !== null; const integration = options.integration ?? "auto"; + if (integrationProvided && profileProvided) { + return failedRequest(issue({ + file: "", + field: "integration", + problem: "--integration and --profile cannot be used together.", + fix: "Select the built-in adapter with --integration openspec, or one local Profile with --profile ." + })); + } + + if (profileProvided) { + if (typeof options.profile !== "string" || !options.profile.trim()) { + return failedRequest(issue({ + file: "", + field: "profile", + problem: "Missing Integration Profile ID.", + fix: "Run opendomain prepare --profile ." + })); + } + return buildSelectedProfileGroundingRequest(inputPath, options.profile, cwd); + } + if (!SUPPORTED_INTEGRATIONS.has(integration)) { - return { - request: null, - errors: [ - issue({ - file: "", - field: "integration", - problem: `Unsupported integration '${integration}'.`, - fix: "Use --integration openspec or omit --integration for auto-detection." - }) - ] - }; + return failedRequest(issue({ + file: "", + field: "integration", + problem: `Unsupported integration '${integration}'.`, + fix: "Use --integration openspec, --profile , or omit selection for auto-detection." + })); } - return buildOpenSpecGroundingRequest(inputPath, cwd, integration); + if (integration === "openspec") { + return buildOpenSpecGroundingRequest(inputPath, cwd, integration); + } + + return buildAutomaticGroundingRequest(inputPath, cwd); } export function collectAffectedIds(affectsDomain) { @@ -41,7 +69,7 @@ export function collectAffectedIds(affectsDomain) { return ids; } -async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration) { +export async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration = "openspec") { if (!inputPath) { return { request: null, @@ -52,7 +80,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration problem: "Missing feature spec path.", fix: "Run opendomain prepare ." }) - ] + ], + warnings: [] }; } @@ -67,7 +96,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration problem: "Feature spec path does not exist.", fix: "Pass an existing feature spec file or directory." }) - ] + ], + warnings: [] }; } @@ -108,7 +138,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration problem: "No feature_spec found.", fix: "Pass a Markdown feature spec with type: feature_spec." }) - ] + ], + warnings: [] }; } @@ -122,7 +153,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration problem: "Multiple feature_spec files found.", fix: "Pass a single feature spec file for deterministic grounding." }) - ] + ], + warnings: [] }; } @@ -141,7 +173,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration problem: "Feature spec is missing affects_domain.", fix: "Declare affected OpenDomain concepts, rules, lifecycles, or events." }) - ] + ], + warnings: [] }; } @@ -149,7 +182,8 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration if (requestErrors.length > 0) { return { request: null, - errors: requestErrors + errors: requestErrors, + warnings: [] }; } @@ -172,7 +206,164 @@ async function buildOpenSpecGroundingRequest(inputPath, cwd, selectedIntegration }, affects_domain: normalizeAffectsDomain(feature.frontmatter.affects_domain) }, - errors: [] + errors: [], + warnings: [] + }; +} + +async function buildAutomaticGroundingRequest(inputPath, cwd) { + const openSpecResult = await buildOpenSpecGroundingRequest(inputPath, cwd, "auto"); + const registry = await loadIntegrationProfiles({ + cwd, + allowMissingWorkspace: true + }); + if (registry.errors.length > 0) { + return { + request: null, + errors: registry.errors, + warnings: registry.warnings + }; + } + + if (registry.profiles.length === 0) { + return { + ...openSpecResult, + warnings: [...(openSpecResult.warnings ?? []), ...registry.warnings] + }; + } + + const matchResult = await findMatchingProfileSourceUnits( + registry.profiles, + inputPath, + { + cwd, + outsideWorkspaceIsNoMatch: true + } + ); + if (matchResult.errors.length > 0) { + return { + request: null, + errors: matchResult.errors, + warnings: registry.warnings + }; + } + + const candidates = [ + ...(openSpecResult.request + ? [{ + id: "openspec", + kind: "builtin", + requestResult: openSpecResult + }] + : []), + ...matchResult.matches.map((match) => ({ + id: match.entry.id, + kind: "profile", + match + })) + ]; + + if (candidates.length === 0) { + return { + ...openSpecResult, + warnings: [...(openSpecResult.warnings ?? []), ...registry.warnings] + }; + } + + if (candidates.length > 1) { + return { + request: null, + errors: [issue({ + file: inputPath ?? "", + field: "integration", + problem: `Multiple integrations match this input: ${candidates.map((candidate) => candidate.id).sort().join(", ")}.`, + fix: "Narrow Profile path patterns, or select one integration explicitly with --profile or --integration openspec." + })], + warnings: registry.warnings + }; + } + + const selected = candidates[0]; + if (selected.kind === "builtin") { + return { + ...selected.requestResult, + warnings: [ + ...(selected.requestResult.warnings ?? []), + ...registry.warnings + ] + }; + } + + if (selected.match.errors.length > 0) { + return { + request: null, + errors: selected.match.errors, + warnings: registry.warnings + }; + } + + const result = await buildProfileGroundingRequest( + selected.match.entry, + selected.match.sourceUnit, + { selected: "auto" } + ); + return { + ...result, + warnings: [...(result.warnings ?? []), ...registry.warnings] + }; +} + +async function buildSelectedProfileGroundingRequest(inputPath, profileId, cwd) { + const registry = await loadIntegrationProfiles({ cwd }); + if (registry.errors.length > 0) { + return { + request: null, + errors: registry.errors, + warnings: registry.warnings + }; + } + + const entry = registry.profiles.find((profile) => profile.id === profileId); + if (!entry) { + return { + request: null, + errors: [issue({ + file: registry.profile_directory ?? "", + field: "profile", + problem: `Integration Profile '${profileId}' was not found.`, + fix: "Run 'opendomain integrations list' and select an available repository-local Profile ID." + })], + warnings: registry.warnings + }; + } + + const resolution = await resolveProfileSourceUnit(entry, inputPath, { cwd }); + if (resolution.errors.length > 0) { + return { + request: null, + errors: resolution.errors, + warnings: registry.warnings + }; + } + if (!resolution.matched) { + return { + request: null, + errors: [issue({ + file: inputPath ?? "", + field: "profile", + problem: `Input does not match Integration Profile '${profileId}'.`, + fix: "Pass a file or bundle matched by the Profile, or select a different Profile ID." + })], + warnings: registry.warnings + }; + } + + const result = await buildProfileGroundingRequest(entry, resolution.sourceUnit, { + selected: "explicit" + }); + return { + ...result, + warnings: [...(result.warnings ?? []), ...registry.warnings] }; } @@ -235,6 +426,14 @@ function issue(issueFields) { }; } +function failedRequest(error) { + return { + request: null, + errors: [error], + warnings: [] + }; +} + async function exists(file) { try { await access(file); diff --git a/src/init.mjs b/src/init.mjs index 85d0b21..2308aa4 100644 --- a/src/init.mjs +++ b/src/init.mjs @@ -12,7 +12,9 @@ const WORKSPACE_DIRECTORIES = [ "opendomain/rules", "opendomain/lifecycles", "opendomain/events", - "opendomain/candidates" + "opendomain/candidates", + "opendomain/integrations", + "opendomain/integrations/profiles" ]; export async function initializeProject(options = {}) { @@ -69,6 +71,12 @@ export async function initializeProject(options = {}) { cwd, result ); + await writeFileIfMissing( + path.join(cwd, "opendomain/integrations/profiles/README.md"), + profileReadmeTemplate(), + cwd, + result + ); await writeFileIfMissing(path.join(cwd, "AGENTS.md"), agentsTemplate(), cwd, result); if (options.example) { @@ -169,12 +177,34 @@ Start with: - \`lifecycles/\`: states and transitions - \`events/\`: business facts that happened - \`candidates/\`: proposed or inferred domain knowledge awaiting human review +- \`integrations/profiles/\`: declarative mappings from structured planning + artifacts into Grounding Request v1 Do not treat generated starter files as accepted business truth. Replace the example content with reviewed knowledge from your own domain. `; } +function profileReadmeTemplate() { + return `# OpenDomain Integration Profiles + +Store repository-local declarative Integration Profile YAML files here. +Profiles map explicit structured planning metadata into Grounding Request v1; +they are configuration, not accepted domain knowledge. + +Inspect this registry with: + +\`\`\`bash +opendomain integrations list +opendomain integrations validate +\`\`\` + +Profiles must not execute code, infer IDs from prose, or promote Domain +Candidates. See the packaged Integration Profile documentation and schema +before adding a Profile. +`; +} + function contextTemplate() { return `--- type: bounded_context diff --git a/src/integration-schema-validator.mjs b/src/integration-schema-validator.mjs new file mode 100644 index 0000000..ced3b27 --- /dev/null +++ b/src/integration-schema-validator.mjs @@ -0,0 +1,250 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import Ajv2020 from "ajv/dist/2020.js"; + +const DEFAULT_SCHEMA_DIRECTORY = new URL("../schemas/", import.meta.url); +const SCHEMA_ID_PREFIX = "https://opendomain.dev/schemas/"; + +const SCHEMA_DEFINITIONS = Object.freeze({ + profile: "integration-profile.schema.json", + declaration: "domain-declaration.schema.json", + request: "grounding-request.schema.json" +}); + +let defaultRegistry; + +export class IntegrationSchemaRegistryError extends Error { + constructor(message, options = {}) { + super(message, options); + this.name = "IntegrationSchemaRegistryError"; + } +} + +export function getDefaultIntegrationSchemaRegistry() { + if (!defaultRegistry) { + defaultRegistry = createIntegrationSchemaRegistry(); + } + return defaultRegistry; +} + +export function createIntegrationSchemaRegistry(options = {}) { + const schemaDirectory = normalizeSchemaDirectory( + options.schemaDirectory ?? DEFAULT_SCHEMA_DIRECTORY + ); + const schemas = Object.entries(SCHEMA_DEFINITIONS).map(([kind, file]) => ({ + kind, + file, + id: `${SCHEMA_ID_PREFIX}${file}`, + schema: readPackagedSchema(schemaDirectory, file) + })); + + for (const entry of schemas) { + if (entry.schema.$id !== entry.id) { + throw new IntegrationSchemaRegistryError( + `Packaged schema '${entry.file}' has unexpected $id '${entry.schema.$id ?? "missing"}'.` + ); + } + } + + const ajv = new Ajv2020({ + allErrors: true, + coerceTypes: false, + removeAdditional: false, + strict: true, + strictRequired: false, + strictTypes: false, + useDefaults: false, + validateFormats: true, + validateSchema: true + }); + + try { + for (const entry of schemas) { + ajv.addSchema(entry.schema); + } + } catch (error) { + throw new IntegrationSchemaRegistryError( + `Packaged integration schemas could not be registered: ${error.message}`, + { cause: error } + ); + } + + const validators = new Map(); + try { + for (const entry of schemas) { + const validate = ajv.getSchema(entry.id); + if (!validate) { + throw new Error(`No compiled validator for '${entry.file}'.`); + } + validators.set(entry.kind, { + file: entry.file, + validate + }); + } + } catch (error) { + throw new IntegrationSchemaRegistryError( + `Packaged integration schemas could not be compiled: ${error.message}`, + { cause: error } + ); + } + + return Object.freeze({ + validate(kind, value) { + const entry = validators.get(kind); + if (!entry) { + throw new IntegrationSchemaRegistryError( + `No integration schema is registered for kind '${kind}'.` + ); + } + const valid = entry.validate(value); + return { + valid, + schemaFile: entry.file, + errors: valid ? [] : cloneAjvErrors(entry.validate.errors) + }; + } + }); +} + +export function validateIntegrationValue(kind, value, registry = getDefaultIntegrationSchemaRegistry()) { + const validation = registry.validate(kind, value); + if (validation.valid) { + return []; + } + + return validation.errors + .filter((error) => !["if", "not", "oneOf", "anyOf"].includes(error.keyword)) + .map((error) => toSchemaIssue(error, validation.schemaFile)) + .filter(uniqueIssue) + .sort(compareIssues); +} + +function readPackagedSchema(schemaDirectory, file) { + let source; + try { + source = readFileSync(new URL(file, schemaDirectory), "utf8"); + } catch (error) { + throw new IntegrationSchemaRegistryError( + `Packaged schema '${file}' could not be read: ${error.message}`, + { cause: error } + ); + } + + try { + return JSON.parse(source); + } catch (error) { + throw new IntegrationSchemaRegistryError( + `Packaged schema '${file}' is not valid JSON: ${error.message}`, + { cause: error } + ); + } +} + +function normalizeSchemaDirectory(schemaDirectory) { + if (schemaDirectory instanceof URL) { + const normalized = new URL(schemaDirectory.href); + if (!normalized.pathname.endsWith("/")) { + normalized.pathname += "/"; + } + return normalized; + } + + const absolutePath = path.resolve(String(schemaDirectory)); + return pathToFileURL(`${absolutePath}${path.sep}`); +} + +function cloneAjvErrors(errors) { + return (errors ?? []).map((error) => ({ + instancePath: error.instancePath, + keyword: error.keyword, + message: error.message, + params: { ...error.params }, + schemaPath: error.schemaPath + })); +} + +function toSchemaIssue(error, schemaFile) { + const field = fieldFromAjvError(error); + const subject = field === "$" ? "Value" : `Field '${field}'`; + + return { + severity: "error", + field, + problem: `${subject} violates schemas/${schemaFile}: ${schemaErrorDetail(error)}.`, + fix: field === "$" + ? `Update the value to satisfy schemas/${schemaFile}.` + : `Update '${field}' to satisfy schemas/${schemaFile}.` + }; +} + +function fieldFromAjvError(error) { + const segments = decodeJsonPointer(error.instancePath); + + if (error.keyword === "required" && error.params.missingProperty) { + segments.push(error.params.missingProperty); + } else if (error.keyword === "additionalProperties" && error.params.additionalProperty) { + segments.push(error.params.additionalProperty); + } else if (error.keyword === "propertyNames" && error.params.propertyName) { + segments.push(error.params.propertyName); + } + + return formatFieldPath(segments); +} + +function decodeJsonPointer(pointer) { + if (!pointer) { + return []; + } + return pointer + .slice(1) + .split("/") + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")); +} + +function formatFieldPath(segments) { + let field = ""; + for (const segment of segments) { + if (/^(0|[1-9][0-9]*)$/.test(segment)) { + field += `[${segment}]`; + } else { + field += field ? `.${segment}` : segment; + } + } + return field || "$"; +} + +function schemaErrorDetail(error) { + switch (error.keyword) { + case "const": + return `must equal '${String(error.params.allowedValue)}'`; + case "enum": + return `must be one of ${error.params.allowedValues.map(String).join(", ")}`; + case "pattern": + return "does not match the required pattern"; + case "required": + return "is required"; + case "type": + return `must be ${error.params.type}`; + default: + return String(error.message ?? `failed the '${error.keyword}' constraint`) + .replace(/[.]+$/, ""); + } +} + +function uniqueIssue(issue, index, issues) { + return issues.findIndex((candidate) => ( + candidate.field === issue.field + && candidate.keyword === issue.keyword + && candidate.problem === issue.problem + )) === index; +} + +function compareIssues(left, right) { + return compareText(left.field, right.field) + || compareText(left.problem, right.problem); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/prepare.mjs b/src/prepare.mjs index 776b417..f24cef3 100644 --- a/src/prepare.mjs +++ b/src/prepare.mjs @@ -8,13 +8,15 @@ export async function prepareGroundingPack(inputPath, options = {}) { const cwd = options.cwd ?? process.cwd(); const requestResult = await buildGroundingRequest(inputPath, { cwd, - integration: options.integration ?? "auto" + integration: options.integration, + profile: options.profile }); if (requestResult.errors.length > 0) { - return emptyPack({ + return emptyGroundingPack({ input: inputPath, - errors: requestResult.errors + errors: requestResult.errors, + warnings: requestResult.warnings ?? [] }); } @@ -24,7 +26,10 @@ export async function prepareGroundingPack(inputPath, options = {}) { const affectedIds = collectAffectedIds(groundingRequest.affects_domain); const acceptedRootIds = []; const errors = [...corpus.errors]; - const warnings = [...corpus.warnings]; + const warnings = uniqueIssues([ + ...(requestResult.warnings ?? []), + ...corpus.warnings + ]); for (const affected of affectedIds) { const document = documentsById.get(affected.id); @@ -158,6 +163,12 @@ function formatPackIssues(pack) { lines.push(` problem: ${error.problem}`); lines.push(` fix: ${error.fix}`); } + if (pack.warnings.length > 0) { + lines.push("", "Warnings:"); + for (const warning of pack.warnings) { + lines.push(`- ${warning.file} ${warning.field}: ${warning.problem}`); + } + } return `${lines.join("\n")}\n`; } @@ -206,7 +217,7 @@ function compareById(left, right) { return left.id.localeCompare(right.id); } -function emptyPack({ input, errors }) { +export function emptyGroundingPack({ input, errors, warnings = [] }) { return { protocol_version: GROUNDING_PROTOCOL_VERSION, feature: { @@ -225,10 +236,28 @@ function emptyPack({ input, errors }) { }, avoided_semantic_errors: [], errors, - warnings: [] + warnings }; } +function uniqueIssues(issues) { + const seen = new Set(); + return issues.filter((issueItem) => { + const key = [ + issueItem.severity, + issueItem.file, + issueItem.field, + issueItem.problem, + issueItem.fix + ].join("\0"); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} + function issue(issueFields) { return { severity: issueFields.severity ?? "error", diff --git a/src/profile-mapping.mjs b/src/profile-mapping.mjs new file mode 100644 index 0000000..77f2753 --- /dev/null +++ b/src/profile-mapping.mjs @@ -0,0 +1,321 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + parseJsonMapping, + parseMarkdownFile, + parseYamlMapping +} from "./frontmatter.mjs"; +import { validateIntegrationValue } from "./integration-schema-validator.mjs"; +import { GROUNDING_PROTOCOL_VERSION } from "./protocol.mjs"; +import { publicSourceUnit } from "./source-unit.mjs"; + +const AFFECTS_DOMAIN_FIELDS = ["concepts", "rules", "lifecycles", "events"]; + +export async function buildProfileGroundingRequest(entry, sourceUnit, options = {}) { + const memberData = new Map(); + const errors = []; + + for (const member of sourceUnit.members) { + const result = await readStructuredMember(member); + errors.push(...result.errors); + if (result.value) { + memberData.set(member.role, result.value); + } + } + if (errors.length > 0) { + return { + request: null, + errors: errors.sort(compareIssues), + warnings: [] + }; + } + + const intent = {}; + for (const field of ["id", "name", "status"]) { + const selector = entry.profile.intent[field]; + const selected = evaluateSelector(selector, memberData); + const value = selected.found ? selected.value : selector.default; + if (typeof value !== "string" || !value.trim()) { + errors.push(issue({ + file: entry.sourceFile, + field: `intent.${field}`, + problem: `Profile could not produce a non-empty string for intent.${field}.`, + fix: `Map intent.${field} from a structured member field or provide an intent-only default.` + })); + continue; + } + intent[field] = value; + } + + const referenceResult = entry.profile.references.mode === "native" + ? normalizeNativeReferences(entry, memberData) + : normalizeSidecarReferences(entry, sourceUnit, memberData); + errors.push(...referenceResult.errors); + + if (errors.length > 0) { + return { + request: null, + errors: errors.sort(compareIssues), + warnings: [] + }; + } + + const request = { + protocol_version: GROUNDING_PROTOCOL_VERSION, + source: { + type: entry.profile.source_type, + path: sourceUnit.root_path + }, + integration: { + id: entry.id, + kind: "profile", + selected: options.selected ?? "explicit", + profile_file: entry.sourceFile, + source_unit: publicSourceUnit(sourceUnit) + }, + intent, + affects_domain: referenceResult.affectsDomain + }; + + const requestIssues = validateIntegrationValue("request", request).map((schemaIssue) => ({ + ...schemaIssue, + file: sourceUnit.root_path + })); + if (requestIssues.length > 0) { + return { + request: null, + errors: requestIssues, + warnings: [] + }; + } + + return { + request, + errors: [], + warnings: [] + }; +} + +async function readStructuredMember(member) { + const extension = path.extname(member.absolutePath).toLowerCase(); + try { + if (extension === ".md") { + const parsed = await parseMarkdownFile(member.absolutePath); + return { + value: parsed.frontmatter, + errors: [] + }; + } + if (extension === ".yaml" || extension === ".yml") { + return { + value: parseYamlMapping( + await readFile(member.absolutePath, "utf8"), + member.path, + { label: "Structured YAML member" } + ), + errors: [] + }; + } + if (extension === ".json") { + return { + value: parseJsonMapping( + await readFile(member.absolutePath, "utf8"), + member.path + ), + errors: [] + }; + } + } catch (error) { + return { + value: null, + errors: [issue({ + file: member.path, + field: error.field ?? "$", + problem: error.problem ?? error.message, + fix: "Use safe structured Markdown front matter, YAML, or JSON." + })] + }; + } + + return { + value: null, + errors: [issue({ + file: member.path, + problem: `Unsupported structured member extension '${extension || ""}'.`, + fix: "Use a .md, .yaml, .yml, or .json member." + })] + }; +} + +function normalizeNativeReferences(entry, memberData) { + const affectsDomain = emptyAffectsDomain(); + const errors = []; + + for (const field of AFFECTS_DOMAIN_FIELDS) { + const selector = entry.profile.references.affects_domain[field]; + if (!selector) { + continue; + } + + const selected = evaluateSelector(selector, memberData); + if (!selected.found) { + continue; + } + + let values = selected.value; + if (selector.coerce === "array" && typeof values === "string") { + values = [values]; + } + if (!Array.isArray(values)) { + errors.push(issue({ + file: entry.sourceFile, + field: `references.affects_domain.${field}`, + problem: `Native Mapping for affects_domain.${field} did not produce an array.`, + fix: "Map an explicit string array, or add coerce: array for a scalar string." + })); + continue; + } + + const normalized = []; + const seen = new Set(); + values.forEach((id, index) => { + if (typeof id !== "string" || !id.trim()) { + errors.push(issue({ + file: entry.sourceFile, + field: `references.affects_domain.${field}[${index}]`, + problem: "Native Mapping produced an OpenDomain ID that is not a non-empty string.", + fix: "Store explicit non-empty OpenDomain IDs in the selected structured source field." + })); + return; + } + if (!seen.has(id)) { + seen.add(id); + normalized.push(id); + } + }); + affectsDomain[field] = normalized; + } + + if ( + errors.length === 0 + && AFFECTS_DOMAIN_FIELDS.every((field) => affectsDomain[field].length === 0) + ) { + errors.push(issue({ + file: entry.sourceFile, + field: "references.affects_domain", + problem: "Native Mapping produced no explicit OpenDomain references.", + fix: "Map at least one concept, rule, lifecycle, or event ID from structured source data." + })); + } + + return { + affectsDomain, + errors + }; +} + +function normalizeSidecarReferences(entry, sourceUnit, memberData) { + const declarationMember = sourceUnit.members.find((member) => member.role === "declaration"); + const declaration = memberData.get("declaration"); + + if (!declarationMember || !declaration) { + return { + affectsDomain: emptyAffectsDomain(), + errors: [issue({ + file: entry.sourceFile, + field: "references.mode", + problem: "Sidecar mode could not read its required declaration member.", + fix: "Configure and create one safe structured declaration member inside the bundle." + })] + }; + } + + const schemaIssues = validateIntegrationValue("declaration", declaration).map((schemaIssue) => ({ + ...schemaIssue, + file: declarationMember.path + })); + if (schemaIssues.length > 0) { + return { + affectsDomain: emptyAffectsDomain(), + errors: schemaIssues + }; + } + + return { + affectsDomain: Object.fromEntries( + AFFECTS_DOMAIN_FIELDS.map((field) => [ + field, + [...declaration.affects_domain[field]] + ]) + ), + errors: [] + }; +} + +function evaluateSelector(selector, memberData) { + const fieldPaths = selector.from ? [selector.from] : selector.first_of; + for (const fieldPath of fieldPaths) { + const segments = fieldPath.split("."); + const role = segments.shift(); + let value = memberData.get(role); + if (value === undefined) { + continue; + } + + let found = true; + for (const segment of segments) { + if ( + value === null + || typeof value !== "object" + || Array.isArray(value) + || !Object.hasOwn(value, segment) + ) { + found = false; + break; + } + value = value[segment]; + } + if (found && value !== undefined && value !== null) { + return { + found: true, + value, + fieldPath + }; + } + } + + return { + found: false, + value: undefined, + fieldPath: null + }; +} + +function emptyAffectsDomain() { + return { + concepts: [], + rules: [], + lifecycles: [], + events: [] + }; +} + +function issue(fields) { + return { + severity: fields.severity ?? "error", + file: fields.file, + field: fields.field ?? "$", + problem: fields.problem, + fix: fields.fix + }; +} + +function compareIssues(left, right) { + return compareText(left.file, right.file) + || compareText(left.field, right.field) + || compareText(left.problem, right.problem); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/profile-registry.mjs b/src/profile-registry.mjs new file mode 100644 index 0000000..24e30b7 --- /dev/null +++ b/src/profile-registry.mjs @@ -0,0 +1,492 @@ +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; +import { Minimatch } from "minimatch"; +import { parseYamlMapping } from "./frontmatter.mjs"; +import { validateIntegrationValue } from "./integration-schema-validator.mjs"; +import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; + +export const PROFILE_DIRECTORY = "integrations/profiles"; +export const BUILTIN_INTEGRATION_IDS = Object.freeze(["openspec"]); + +const BUILTIN_ID_SET = new Set(BUILTIN_INTEGRATION_IDS); +const PROFILE_EXTENSIONS = new Set([".yaml", ".yml"]); +const MEMBER_ROLES = ["primary", "manifest", "declaration"]; +const GLOB_OPTIONS = Object.freeze({ + dot: true, + nocase: false, + nocomment: true, + nonegate: true, + matchBase: false +}); + +export async function loadIntegrationProfiles(options = {}) { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const workspace = await inspectWorkspaceRoots({ cwd }); + const result = { + workspace: workspace.sourceRootDisplay, + workspace_mode: workspace.mode, + profile_directory: workspace.sourceRoot + ? displayPath(workspace.projectRoot, path.join(workspace.sourceRoot, PROFILE_DIRECTORY)) + : null, + profile_file_count: 0, + profiles: [], + warnings: [...workspace.warnings], + errors: [...workspace.errors] + }; + + if (result.errors.length > 0) { + return result; + } + if (!workspace.sourceRoot) { + if (options.allowMissingWorkspace === true) { + return result; + } + result.errors.push(issue({ + file: "", + problem: "No OpenDomain workspace found for Integration Profiles.", + fix: "Run 'opendomain init' to create 'opendomain/', or use --integration openspec for the built-in adapter." + })); + return result; + } + + const profileDirectory = path.join(workspace.sourceRoot, PROFILE_DIRECTORY); + let directoryStat; + try { + directoryStat = await lstat(profileDirectory); + } catch (error) { + if (error.code === "ENOENT") { + return result; + } + result.errors.push(issue({ + file: result.profile_directory, + problem: `Unable to inspect the Integration Profile directory: ${error.message}`, + fix: "Check the workspace path and permissions." + })); + return result; + } + + if (directoryStat.isSymbolicLink()) { + result.errors.push(issue({ + file: result.profile_directory, + problem: "Integration Profile directory must not be a symbolic link.", + fix: `Replace '${result.profile_directory}' with a real directory inside the selected workspace.` + })); + return result; + } + if (!directoryStat.isDirectory()) { + result.errors.push(issue({ + file: result.profile_directory, + problem: "Integration Profile slot is not a directory.", + fix: `Replace '${result.profile_directory}' with a directory.` + })); + return result; + } + + let resolvedProfileDirectory; + try { + resolvedProfileDirectory = await realpath(profileDirectory); + } catch (error) { + result.errors.push(issue({ + file: result.profile_directory, + problem: `Integration Profile directory cannot be resolved: ${error.message}`, + fix: "Replace symlinked or unreadable path segments with real directories inside the selected workspace." + })); + return result; + } + if ( + resolvedProfileDirectory !== path.resolve(profileDirectory) + || !isWithin(workspace.sourceRoot, resolvedProfileDirectory) + ) { + result.errors.push(issue({ + file: result.profile_directory, + problem: "Integration Profile directory must not resolve through a symbolic link or outside the selected workspace.", + fix: "Store Profiles in a real integrations/profiles directory inside the selected OpenDomain workspace." + })); + return result; + } + + const entries = (await readdir(profileDirectory, { withFileTypes: true })) + .sort((left, right) => compareText(left.name, right.name)); + + for (const entry of entries) { + const extension = path.extname(entry.name).toLowerCase(); + if (!PROFILE_EXTENSIONS.has(extension)) { + continue; + } + + result.profile_file_count += 1; + const absoluteFile = path.join(profileDirectory, entry.name); + const sourceFile = displayPath(workspace.projectRoot, absoluteFile); + + if (entry.isSymbolicLink()) { + result.errors.push(issue({ + file: sourceFile, + problem: "Integration Profile files must not be symbolic links.", + fix: "Store the Profile as a regular YAML file inside the selected workspace." + })); + continue; + } + if (!entry.isFile()) { + result.errors.push(issue({ + file: sourceFile, + problem: "Integration Profile path is not a regular YAML file.", + fix: "Replace it with a regular .yaml or .yml file." + })); + continue; + } + + let profile; + try { + profile = parseYamlMapping(await readFile(absoluteFile, "utf8"), sourceFile, { + label: "Integration Profile" + }); + } catch (error) { + result.errors.push(issue({ + file: sourceFile, + field: error.field ?? "$", + problem: error.problem ?? error.message, + fix: "Use one safe YAML mapping that satisfies the Integration Profile v1 schema." + })); + continue; + } + + let schemaIssues; + try { + schemaIssues = validateIntegrationValue( + "profile", + profile, + options.schemaRegistry + ); + } catch (error) { + result.profiles = []; + result.errors.push(issue({ + file: "schemas", + problem: `Integration schema registry is unavailable: ${error.message}`, + fix: "Restore or reinstall the packaged integration schemas before loading Profiles." + })); + result.errors.sort(compareIssues); + return result; + } + if (schemaIssues.length > 0) { + result.errors.push(...schemaIssues.map((schemaIssue) => ({ + ...schemaIssue, + file: sourceFile + }))); + continue; + } + + const semanticIssues = validateProfileSemantics(profile, sourceFile); + if (semanticIssues.length > 0) { + result.errors.push(...semanticIssues); + continue; + } + + result.profiles.push(Object.freeze({ + id: profile.id, + sourceFile, + absoluteFile, + profile + })); + } + + validateRegistryIds(result); + result.profiles.sort((left, right) => ( + compareText(left.sourceFile, right.sourceFile) + || compareText(left.id, right.id) + )); + result.errors.sort(compareIssues); + + return result; +} + +export async function inspectIntegrations(options = {}) { + const registry = await loadIntegrationProfiles(options); + return { + workspace: registry.workspace, + workspace_mode: registry.workspace_mode, + profile_directory: registry.profile_directory, + profile_file_count: registry.profile_file_count, + valid_profile_count: registry.profiles.length, + integrations: [ + builtinOpenSpecSummary(), + ...registry.profiles.map(profileSummary) + ], + warnings: registry.warnings, + errors: registry.errors + }; +} + +export function profileMatchesPath(profile, workspaceRelativePath) { + return profile.source_unit.match.paths.some((pattern) => ( + new Minimatch(pattern, GLOB_OPTIONS).match(workspaceRelativePath) + )); +} + +function validateProfileSemantics(profile, sourceFile) { + const errors = []; + const sourceUnit = profile.source_unit; + + sourceUnit.match.paths.forEach((pattern, index) => { + const field = `source_unit.match.paths[${index}]`; + const problem = unsafeGlobProblem(pattern); + if (problem) { + errors.push(issue({ + file: sourceFile, + field, + problem, + fix: "Use a normalized, workspace-relative glob without parent traversal or backslashes." + })); + return; + } + + try { + const matcher = new Minimatch(pattern, GLOB_OPTIONS); + if (!matcher.makeRe()) { + throw new Error("glob could not be compiled"); + } + } catch (error) { + errors.push(issue({ + file: sourceFile, + field, + problem: `Invalid Profile glob '${pattern}': ${error.message}`, + fix: "Use a valid deterministic glob over workspace-relative paths." + })); + } + }); + + if (sourceUnit.kind === "bundle") { + validateRelativeConfigPath( + sourceUnit.match.root_marker, + sourceFile, + "source_unit.match.root_marker", + errors + ); + + const memberPaths = new Map(); + for (const role of MEMBER_ROLES) { + const member = sourceUnit.members[role]; + if (!member) { + continue; + } + validateRelativeConfigPath( + member.path, + sourceFile, + `source_unit.members.${role}.path`, + errors + ); + const existingRole = memberPaths.get(member.path); + if (existingRole) { + errors.push(issue({ + file: sourceFile, + field: `source_unit.members.${role}.path`, + problem: `Source Unit roles '${existingRole}' and '${role}' use the same member path '${member.path}'.`, + fix: "Assign one distinct exact path to each configured member role." + })); + } else { + memberPaths.set(member.path, role); + } + } + } + + const configuredRoles = new Set( + sourceUnit.kind === "file" + ? ["primary"] + : MEMBER_ROLES.filter((role) => sourceUnit.members[role]) + ); + for (const [field, selector] of collectSelectors(profile)) { + const paths = selector.from ? [selector.from] : selector.first_of; + for (const selectorPath of paths) { + const role = selectorPath.split(".", 1)[0]; + if (!configuredRoles.has(role)) { + errors.push(issue({ + file: sourceFile, + field, + problem: `Selector '${selectorPath}' references unconfigured Source Unit role '${role}'.`, + fix: `Configure the '${role}' member or select a field from an available member.` + })); + } + } + } + + if (profile.references.mode === "sidecar") { + const declaration = sourceUnit.kind === "bundle" + ? sourceUnit.members.declaration + : null; + if (!declaration || declaration.required !== true) { + errors.push(issue({ + file: sourceFile, + field: "references.mode", + problem: "Sidecar mode requires a bundle Source Unit with one required declaration member.", + fix: "Configure source_unit.kind as bundle and add members.declaration with required: true." + })); + } + } + + if ( + profile.references.mode === "native" + && sourceUnit.kind === "bundle" + && sourceUnit.members.declaration + ) { + errors.push(issue({ + file: sourceFile, + field: "source_unit.members.declaration", + problem: "Native Mapping must not configure a Domain Declaration member.", + fix: "Remove the declaration member or select references.mode: sidecar." + })); + } + + return errors.sort(compareIssues); +} + +function validateRegistryIds(result) { + const byId = new Map(); + for (const entry of result.profiles) { + if (BUILTIN_ID_SET.has(entry.id)) { + result.errors.push(issue({ + file: entry.sourceFile, + field: "id", + problem: `Profile ID '${entry.id}' conflicts with a built-in integration.`, + fix: "Choose a repository-local Profile ID that does not use a built-in ID." + })); + continue; + } + + const existing = byId.get(entry.id); + if (existing) { + result.errors.push(issue({ + file: entry.sourceFile, + field: "id", + problem: `Duplicate Integration Profile ID '${entry.id}' also declared in '${existing.sourceFile}'.`, + fix: "Give every repository-local Profile a unique ID." + })); + continue; + } + byId.set(entry.id, entry); + } + + if (result.errors.length > 0) { + const invalidIds = new Set( + result.errors + .filter((error) => error.field === "id") + .flatMap((error) => ( + result.profiles + .filter((entry) => error.file === entry.sourceFile || error.problem.includes(`'${entry.id}'`)) + .map((entry) => entry.id) + )) + ); + result.profiles = result.profiles.filter((entry) => !invalidIds.has(entry.id)); + } +} + +function collectSelectors(profile) { + const selectors = [ + ["intent.id", profile.intent.id], + ["intent.name", profile.intent.name], + ["intent.status", profile.intent.status] + ]; + if (profile.references.mode === "native") { + for (const field of ["concepts", "rules", "lifecycles", "events"]) { + const selector = profile.references.affects_domain[field]; + if (selector) { + selectors.push([`references.affects_domain.${field}`, selector]); + } + } + } + return selectors; +} + +function unsafeGlobProblem(pattern) { + if (pattern.includes("\0")) { + return "Profile glob contains a null byte."; + } + if (pattern.includes("\\")) { + return "Profile glob uses backslashes instead of normalized '/' separators."; + } + if (path.posix.isAbsolute(pattern) || path.win32.isAbsolute(pattern)) { + return "Profile glob must be workspace-relative, not absolute."; + } + if (pattern.split("/").includes("..")) { + return "Profile glob must not contain parent traversal ('..')."; + } + return null; +} + +function validateRelativeConfigPath(value, sourceFile, field, errors) { + const segments = value.split("/"); + if ( + value.includes("\0") + || value.includes("\\") + || path.posix.isAbsolute(value) + || path.win32.isAbsolute(value) + || segments.includes("..") + || segments.includes(".") + || segments.includes("") + ) { + errors.push(issue({ + file: sourceFile, + field, + problem: `Configured path '${value}' is not a normalized relative path.`, + fix: "Use an exact '/'-separated path inside the Source Unit without '.', '..', or absolute prefixes." + })); + } +} + +function builtinOpenSpecSummary() { + return { + id: "openspec", + kind: "builtin", + source_type: "openspec", + source_unit_kind: "builtin", + reference_mode: "native", + source_file: null + }; +} + +function profileSummary(entry) { + return { + id: entry.id, + kind: "profile", + source_type: entry.profile.source_type, + source_unit_kind: entry.profile.source_unit.kind, + reference_mode: entry.profile.references.mode, + source_file: entry.sourceFile + }; +} + +function displayPath(projectRoot, file) { + const relative = path.relative(projectRoot, file); + if ( + relative === ".." + || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative) + ) { + return file; + } + return (relative || ".").split(path.sep).join("/"); +} + +function isWithin(parent, child) { + const relative = path.relative(parent, child); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function issue(fields) { + return { + severity: fields.severity ?? "error", + file: fields.file, + field: fields.field ?? "$", + problem: fields.problem, + fix: fields.fix + }; +} + +function compareIssues(left, right) { + return compareText(left.file, right.file) + || compareText(left.field, right.field) + || compareText(left.problem, right.problem); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/src/source-unit.mjs b/src/source-unit.mjs new file mode 100644 index 0000000..74057da --- /dev/null +++ b/src/source-unit.mjs @@ -0,0 +1,444 @@ +import { lstat, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { profileMatchesPath } from "./profile-registry.mjs"; + +const MEMBER_ROLES = ["primary", "manifest", "declaration"]; + +export async function findMatchingProfileSourceUnits(profiles, inputPath, options = {}) { + const inputResult = await inspectProfileInput(inputPath, options); + if (inputResult.errors.length > 0) { + return { + input: null, + matches: [], + errors: inputResult.errors + }; + } + if (!inputResult.input) { + return { + input: null, + matches: [], + errors: [] + }; + } + + const matches = []; + for (const entry of profiles) { + const resolution = await resolveWithInput(entry, inputResult.input); + if (resolution.matched) { + matches.push({ + entry, + sourceUnit: resolution.sourceUnit, + errors: resolution.errors + }); + } + } + + return { + input: inputResult.input, + matches, + errors: [] + }; +} + +export async function resolveProfileSourceUnit(entry, inputPath, options = {}) { + const inputResult = await inspectProfileInput(inputPath, options); + if (inputResult.errors.length > 0) { + return { + matched: false, + sourceUnit: null, + errors: inputResult.errors + }; + } + return resolveWithInput(entry, inputResult.input); +} + +async function inspectProfileInput(inputPath, options) { + const cwd = path.resolve(options.cwd ?? process.cwd()); + let projectRoot; + try { + projectRoot = await realpath(cwd); + } catch (error) { + return { + input: null, + errors: [issue({ + file: cwd, + problem: `Project workspace cannot be resolved: ${error.message}`, + fix: "Run the command from a readable project directory." + })] + }; + } + + if (!inputPath) { + return { + input: null, + errors: [issue({ + file: "", + problem: "Missing Profile input path.", + fix: "Run opendomain prepare --profile ." + })] + }; + } + + const absoluteInput = path.resolve(cwd, inputPath); + let inputRealPath; + let inputStat; + try { + inputRealPath = await realpath(absoluteInput); + inputStat = await stat(inputRealPath); + } catch (error) { + return { + input: null, + errors: [issue({ + file: String(inputPath), + problem: error.code === "ENOENT" + ? "Profile input path does not exist." + : `Profile input path cannot be resolved: ${error.message}`, + fix: "Pass an existing structured file, bundle root, or bundle member." + })] + }; + } + + if (!isWithin(projectRoot, inputRealPath)) { + if (options.outsideWorkspaceIsNoMatch === true) { + return { + input: null, + errors: [] + }; + } + return { + input: null, + errors: [issue({ + file: String(inputPath), + problem: "Profile input resolves outside the project workspace.", + fix: "Use a structured input contained by the current project." + })] + }; + } + if (!inputStat.isFile() && !inputStat.isDirectory()) { + return { + input: null, + errors: [issue({ + file: displayPath(projectRoot, inputRealPath), + problem: "Profile input is neither a regular file nor a directory.", + fix: "Pass a regular structured file, bundle root, or bundle member." + })] + }; + } + + return { + input: { + projectRoot, + realPath: inputRealPath, + displayPath: displayPath(projectRoot, inputRealPath), + stat: inputStat + }, + errors: [] + }; +} + +async function resolveWithInput(entry, input) { + return entry.profile.source_unit.kind === "file" + ? resolveFileUnit(entry, input) + : resolveBundleUnit(entry, input); +} + +function resolveFileUnit(entry, input) { + if (!input.stat.isFile() || !profileMatchesPath(entry.profile, input.displayPath)) { + return { + matched: false, + sourceUnit: null, + errors: [] + }; + } + + return { + matched: true, + sourceUnit: { + schema_version: "1.0", + kind: "file", + input_path: input.displayPath, + root_path: input.displayPath, + source_type: entry.profile.source_type, + profile_id: entry.id, + members: [{ + role: "primary", + path: input.displayPath, + absolutePath: input.realPath + }] + }, + errors: [] + }; +} + +async function resolveBundleUnit(entry, input) { + const profile = entry.profile; + const markerResult = await findNearestBundleRoot( + input, + profile.source_unit.match.root_marker + ); + if (!markerResult.found) { + return { + matched: false, + sourceUnit: null, + errors: [] + }; + } + + const rootPath = displayPath(input.projectRoot, markerResult.root); + if (!profileMatchesPath(profile, rootPath)) { + return { + matched: false, + sourceUnit: null, + errors: [] + }; + } + + const errors = [...markerResult.errors]; + const members = []; + const resolvedMemberPaths = new Map(); + + for (const role of MEMBER_ROLES) { + const configured = profile.source_unit.members[role]; + if (!configured) { + continue; + } + + const memberResult = await resolveMember({ + role, + configured, + root: markerResult.root, + projectRoot: input.projectRoot + }); + errors.push(...memberResult.errors); + if (!memberResult.member) { + continue; + } + + const existingRole = resolvedMemberPaths.get(memberResult.member.absolutePath); + if (existingRole) { + errors.push(issue({ + file: entry.sourceFile, + field: `source_unit.members.${role}.path`, + problem: `Source Unit members '${existingRole}' and '${role}' resolve to the same file.`, + fix: "Use distinct member files after symlink resolution." + })); + continue; + } + + resolvedMemberPaths.set(memberResult.member.absolutePath, role); + members.push(memberResult.member); + } + + return { + matched: true, + sourceUnit: { + schema_version: "1.0", + kind: "bundle", + input_path: input.displayPath, + root_path: rootPath, + source_type: profile.source_type, + profile_id: entry.id, + members + }, + errors: errors.sort(compareIssues) + }; +} + +async function findNearestBundleRoot(input, markerPath) { + let current = input.stat.isDirectory() + ? input.realPath + : path.dirname(input.realPath); + + while (isWithin(input.projectRoot, current)) { + const marker = path.join(current, ...markerPath.split("/")); + let markerStat; + try { + markerStat = await lstat(marker); + } catch (error) { + if (error.code !== "ENOENT") { + return { + found: true, + root: current, + errors: [issue({ + file: displayPath(input.projectRoot, marker), + problem: `Bundle root marker cannot be inspected: ${error.message}`, + fix: "Make the configured root marker readable." + })] + }; + } + markerStat = null; + } + + if (markerStat) { + const errors = []; + let markerRealPath = marker; + let resolvedStat = markerStat; + try { + markerRealPath = await realpath(marker); + resolvedStat = await stat(markerRealPath); + } catch (error) { + errors.push(issue({ + file: displayPath(input.projectRoot, marker), + problem: `Bundle root marker cannot be resolved: ${error.message}`, + fix: "Replace the marker with a readable regular file inside the bundle." + })); + } + + if (errors.length === 0 && !resolvedStat.isFile()) { + errors.push(issue({ + file: displayPath(input.projectRoot, markerRealPath), + problem: "Bundle root marker is not a regular file.", + fix: "Use a regular file for the configured root marker." + })); + } + if ( + errors.length === 0 + && (!isWithin(input.projectRoot, markerRealPath) || !isWithin(current, markerRealPath)) + ) { + errors.push(issue({ + file: displayPath(input.projectRoot, marker), + problem: "Bundle root marker resolves outside the bundle or project workspace.", + fix: "Keep the root marker inside the bundle without an escaping symlink." + })); + } + + return { + found: true, + root: current, + errors + }; + } + + if (current === input.projectRoot) { + break; + } + const parent = path.dirname(current); + if (parent === current) { + break; + } + current = parent; + } + + return { + found: false, + root: null, + errors: [] + }; +} + +async function resolveMember({ role, configured, root, projectRoot }) { + const configuredPath = path.join(root, ...configured.path.split("/")); + let memberRealPath; + let memberStat; + + try { + memberRealPath = await realpath(configuredPath); + memberStat = await stat(memberRealPath); + } catch (error) { + if (error.code === "ENOENT" && !configured.required) { + return { + member: null, + errors: [] + }; + } + return { + member: null, + errors: [issue({ + file: displayPath(projectRoot, configuredPath), + field: `source_unit.members.${role}`, + problem: error.code === "ENOENT" + ? `Required Source Unit member '${role}' is missing.` + : `Source Unit member '${role}' cannot be resolved: ${error.message}`, + fix: `Create the configured '${configured.path}' member inside the bundle or update the Profile.` + })] + }; + } + + if (!isWithin(projectRoot, memberRealPath) || !isWithin(root, memberRealPath)) { + return { + member: null, + errors: [issue({ + file: displayPath(projectRoot, configuredPath), + field: `source_unit.members.${role}`, + problem: `Source Unit member '${role}' resolves outside the bundle or project workspace.`, + fix: "Keep every member inside the bundle without an escaping symlink." + })] + }; + } + if (!memberStat.isFile()) { + return { + member: null, + errors: [issue({ + file: displayPath(projectRoot, memberRealPath), + field: `source_unit.members.${role}`, + problem: `Source Unit member '${role}' is not a regular file.`, + fix: "Use a supported structured regular file for the configured member." + })] + }; + } + + return { + member: { + role, + path: displayPath(projectRoot, memberRealPath), + absolutePath: memberRealPath + }, + errors: [] + }; +} + +export function publicSourceUnit(sourceUnit) { + if (!sourceUnit) { + return null; + } + return { + schema_version: sourceUnit.schema_version, + kind: sourceUnit.kind, + input_path: sourceUnit.input_path, + root_path: sourceUnit.root_path, + source_type: sourceUnit.source_type, + profile_id: sourceUnit.profile_id, + members: sourceUnit.members.map(({ role, path: memberPath }) => ({ + role, + path: memberPath + })) + }; +} + +function isWithin(parent, child) { + const relative = path.relative(parent, child); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function displayPath(projectRoot, file) { + const relative = path.relative(projectRoot, file); + if ( + relative === ".." + || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative) + ) { + return file; + } + return (relative || ".").split(path.sep).join("/"); +} + +function issue(fields) { + return { + severity: fields.severity ?? "error", + file: fields.file, + field: fields.field ?? "$", + problem: fields.problem, + fix: fields.fix + }; +} + +function compareIssues(left, right) { + return compareText(left.file, right.file) + || compareText(left.field, right.field) + || compareText(left.problem, right.problem); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 06b710a..7fe8cce 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -18,6 +18,8 @@ test("help explains canonical and legacy workspace resolution", async () => { assert.match(output, /canonical opendomain\//); assert.match(output, /domain\/ is a warned fallback/); assert.match(output, /roots are never merged/); + assert.match(output, /integrations list/); + assert.match(output, /--profile /); }); test("validate command returns JSON and zero exit code for valid ERP example", async () => { @@ -106,6 +108,8 @@ test("init command creates a minimal valid OpenDomain structure", async () => { assert.match(output, /opendomain\/contexts\/example\.md/); assert.match(output, /opendomain\/concepts\/example\.concept\.md/); assert.match(output, /opendomain\/candidates\/candidate-0001-first-domain-model\.md/); + assert.match(output, /opendomain\/integrations\/profiles\/README\.md/); + await access("opendomain/integrations/profiles/README.md"); const validateStdout = memoryStream(); const validateExitCode = await runCli(["validate", "--json"], { stdout: validateStdout, stderr: memoryStream() }); @@ -201,6 +205,35 @@ test("init command can copy the ERP example", async () => { }); }); +test("integrations commands expose deterministic Profile inspection", async () => { + const listStdout = memoryStream(); + const listExitCode = await runCli(["integrations", "list", "--json"], { + stdout: listStdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + const payload = JSON.parse(listStdout.toString()); + + assert.equal(listExitCode, 0); + assert.equal(payload.workspace, "opendomain"); + assert.equal(payload.profile_file_count, 1); + assert.equal(payload.valid_profile_count, 1); + assert.deepEqual( + payload.integrations.map((integration) => integration.id), + ["openspec", "structured-feature"] + ); + + const validateStdout = memoryStream(); + const validateExitCode = await runCli(["integrations", "validate"], { + stdout: validateStdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + + assert.equal(validateExitCode, 0); + assert.match(validateStdout.toString(), /1 repository-local Profiles checked/); +}); + test("prepare command returns grounding pack for a feature spec", async () => { const stdout = memoryStream(); const stderr = memoryStream(); @@ -242,6 +275,175 @@ test("prepare command returns JSON grounding pack", async () => { assert.equal(payload.errors.length, 0); }); +test("prepare command supports explicit and automatic Profile selection", async () => { + const explicitStdout = memoryStream(); + const explicitExitCode = await runCli([ + "prepare", + "--profile", + "structured-feature", + "external-features/order-cancellation.yaml", + "--json" + ], { + stdout: explicitStdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + const explicit = JSON.parse(explicitStdout.toString()); + + assert.equal(explicitExitCode, 0); + assert.equal(explicit.grounding_request.integration.id, "structured-feature"); + assert.equal(explicit.grounding_request.integration.kind, "profile"); + assert.equal(explicit.grounding_request.integration.selected, "explicit"); + assert.equal(explicit.grounding_request.source.type, "structured-feature"); + assert.equal(explicit.feature.id, "external.order-cancellation"); + assert.deepEqual(explicit.grounding_request.affects_domain.rules, [ + "sales.confirmed-order-cannot-be-deleted" + ]); + assert.ok(explicit.read_first.some((item) => item.id === "sales.order")); + + const automaticStdout = memoryStream(); + const automaticExitCode = await runCli([ + "prepare", + "external-features/order-cancellation.yaml", + "--json" + ], { + stdout: automaticStdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + const automatic = JSON.parse(automaticStdout.toString()); + + assert.equal(automaticExitCode, 0); + assert.equal(automatic.grounding_request.integration.id, "structured-feature"); + assert.equal(automatic.grounding_request.integration.selected, "auto"); + assert.deepEqual(automatic.read_first, explicit.read_first); + assert.deepEqual(automatic.candidate_boundaries, explicit.candidate_boundaries); +}); + +test("prepare command rejects conflicting Profile selection before reading input", async () => { + const stdout = memoryStream(); + const exitCode = await runCli([ + "prepare", + "--integration", + "openspec", + "--profile", + "structured-feature", + "missing.yaml", + "--json" + ], { + stdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.equal(payload.grounding_request, null); + assert.ok(payload.errors.some((error) => ( + error.field === "integration" + && error.problem.includes("cannot be used together") + ))); + assert.equal(payload.errors.some((error) => error.problem.includes("does not exist")), false); +}); + +test("prepare command rejects repeated selectors instead of applying argument order", async () => { + for (const args of [ + [ + "--profile", + "first-profile", + "--profile", + "second-profile", + "missing.yaml" + ], + [ + "--integration", + "auto", + "--integration", + "openspec", + "missing.md" + ] + ]) { + const stdout = memoryStream(); + const exitCode = await runCli(["prepare", ...args, "--json"], { + stdout, + stderr: memoryStream(), + cwd: ERP_ROOT + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((error) => ( + error.problem.includes("provided more than once") + ))); + assert.equal(payload.errors.some((error) => error.problem.includes("does not exist")), false); + } +}); + +test("automatic Profile selection fails on ambiguity", async () => { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-cli-ambiguity-")); + try { + await writeCliProfile(project, "first-profile", "features/*.yaml"); + await writeCliProfile(project, "second-profile", "features/*.yaml"); + await mkdir(path.join(project, "features"), { recursive: true }); + await writeFile(path.join(project, "features/add-x.yaml"), structuredFeature(), "utf8"); + + const stdout = memoryStream(); + const exitCode = await runCli([ + "prepare", + "features/add-x.yaml", + "--json" + ], { + stdout, + stderr: memoryStream(), + cwd: project + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((error) => ( + error.problem.includes("Multiple integrations match") + && error.problem.includes("first-profile") + && error.problem.includes("second-profile") + ))); + } finally { + await rm(project, { recursive: true, force: true }); + } +}); + +test("Profile prepare fails when structured input declares no domain references", async () => { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-cli-empty-scope-")); + try { + await writeCliProfile(project, "structured-feature", "features/*.yaml"); + await mkdir(path.join(project, "features"), { recursive: true }); + await writeFile(path.join(project, "features/add-x.yaml"), `id: feature.add-x +name: Add X +status: proposed +`, "utf8"); + + const stdout = memoryStream(); + const exitCode = await runCli([ + "prepare", + "--profile", + "structured-feature", + "features/add-x.yaml", + "--json" + ], { + stdout, + stderr: memoryStream(), + cwd: project + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((error) => ( + error.field === "references.affects_domain" + && error.problem.includes("no explicit OpenDomain references") + ))); + } finally { + await rm(project, { recursive: true, force: true }); + } +}); + test("prepare command supports explicit OpenSpec integration", async () => { const stdout = memoryStream(); const stderr = memoryStream(); @@ -594,3 +796,41 @@ ${possibleConflicts}${reviewLines.join("\n")} ${body}`, "utf8"); } + +async function writeCliProfile(project, id, pattern) { + const directory = path.join(project, "opendomain/integrations/profiles"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, `${id}.yaml`), `schema_version: "1.0" +id: ${id} +source_type: structured-feature +source_unit: + kind: file + match: + paths: + - ${pattern} +intent: + id: + from: primary.id + name: + from: primary.name + status: + from: primary.status + default: proposed +references: + mode: native + affects_domain: + concepts: + from: primary.affects.concepts + coerce: array +`, "utf8"); +} + +function structuredFeature() { + return `id: feature.add-x +name: Add X +status: proposed +affects: + concepts: + - example.concept +`; +} diff --git a/tests/integration-schema-validator.test.mjs b/tests/integration-schema-validator.test.mjs new file mode 100644 index 0000000..262f269 --- /dev/null +++ b/tests/integration-schema-validator.test.mjs @@ -0,0 +1,157 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateIntegrationValue } from "../src/integration-schema-validator.mjs"; + +test("Integration Profile schema accepts bounded file and bundle profiles", () => { + assert.deepEqual(validateIntegrationValue("profile", nativeFileProfile()), []); + assert.deepEqual(validateIntegrationValue("profile", sidecarBundleProfile()), []); +}); + +test("Integration Profile schema rejects executable fields and unsupported modes", () => { + const executable = nativeFileProfile(); + executable.javascript = "return process.env.SECRET"; + const executableIssues = validateIntegrationValue("profile", executable); + + assert.ok(executableIssues.some((issue) => ( + issue.field === "javascript" + && issue.problem.includes("integration-profile.schema.json") + ))); + + const embedded = sidecarBundleProfile(); + embedded.references.mode = "embedded"; + const embeddedIssues = validateIntegrationValue("profile", embedded); + + assert.ok(embeddedIssues.some((issue) => ( + issue.field === "references.mode" + && issue.problem.includes("must equal") + ))); +}); + +test("Domain Declaration schema requires strict non-empty domain scope", () => { + const valid = { + schema_version: "1.0", + affects_domain: { + concepts: ["sales.order"], + rules: [], + lifecycles: [], + events: [] + } + }; + assert.deepEqual(validateIntegrationValue("declaration", valid), []); + + const empty = structuredClone(valid); + empty.affects_domain.concepts = []; + const emptyIssues = validateIntegrationValue("declaration", empty); + assert.ok(emptyIssues.length > 0); + + const extra = structuredClone(valid); + extra.intent = { id: "spec.invalid" }; + const extraIssues = validateIntegrationValue("declaration", extra); + assert.ok(extraIssues.some((issue) => issue.field === "intent")); +}); + +test("Grounding Request schema remains compatible with optional Profile metadata", () => { + const issues = validateIntegrationValue("request", { + protocol_version: "1.0", + source: { + type: "structured-feature", + path: "features/add-x.yaml" + }, + intent: { + id: "feature.add-x", + name: "Add X", + status: "proposed" + }, + affects_domain: { + concepts: ["sales.order"], + rules: [], + lifecycles: [], + events: [] + }, + integration: { + id: "structured-feature", + kind: "profile" + } + }); + + assert.deepEqual(issues, []); +}); + +function nativeFileProfile() { + return { + schema_version: "1.0", + id: "structured-feature", + source_type: "structured-feature", + source_unit: { + kind: "file", + match: { + paths: ["features/**/*.md"] + } + }, + intent: { + id: { + from: "primary.id" + }, + name: { + first_of: ["primary.name", "primary.title"] + }, + status: { + from: "primary.status", + default: "proposed" + } + }, + references: { + mode: "native", + affects_domain: { + concepts: { + from: "primary.opendomain.concepts", + coerce: "array" + } + } + } + }; +} + +function sidecarBundleProfile() { + return { + schema_version: "1.0", + id: "structured-change", + source_type: "structured-change", + source_unit: { + kind: "bundle", + match: { + paths: ["changes/**"], + root_marker: "change.yaml" + }, + members: { + primary: { + path: "feature.md", + required: true + }, + manifest: { + path: "change.yaml", + required: true + }, + declaration: { + path: "opendomain.yaml", + required: true + } + } + }, + intent: { + id: { + from: "manifest.id" + }, + name: { + from: "manifest.name" + }, + status: { + from: "manifest.status", + default: "proposed" + } + }, + references: { + mode: "sidecar" + } + }; +} diff --git a/tests/parser.test.mjs b/tests/parser.test.mjs index d66fe78..e2bda44 100644 --- a/tests/parser.test.mjs +++ b/tests/parser.test.mjs @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { FrontMatterError, + parseJsonMapping, parseMarkdown, + parseYamlMapping, serializeFrontmatter } from "../src/frontmatter.mjs"; @@ -142,6 +144,35 @@ unsupported.key: value `, /Unsupported front matter key/i); }); +test("standalone YAML and JSON mappings reuse the structured trust boundary", () => { + const yaml = parseYamlMapping(`id: feature.add-x +intent: + name: Add X +`, "feature.yaml"); + const json = parseJsonMapping( + `{"id":"feature.add-x","intent":{"name":"Add X"}}`, + "feature.json" + ); + + assert.equal(yaml.id, "feature.add-x"); + assert.equal(yaml.intent.name, "Add X"); + assert.equal(json.id, "feature.add-x"); + assert.equal(json.intent.name, "Add X"); + + assert.throws( + () => parseYamlMapping("__proto__: injected\n", "unsafe.yaml"), + /prototype-sensitive/ + ); + assert.throws( + () => parseJsonMapping(`{"constructor":"injected"}`, "unsafe.json"), + /prototype-sensitive/ + ); + assert.throws( + () => parseJsonMapping(`["not", "an", "object"]`, "array.json"), + /must be an object/ + ); +}); + function assertFrontMatterFailure(content, pattern) { assert.throws( () => parseMarkdown(content, "unsafe.md"), diff --git a/tests/profile-mapping.test.mjs b/tests/profile-mapping.test.mjs new file mode 100644 index 0000000..8639fdd --- /dev/null +++ b/tests/profile-mapping.test.mjs @@ -0,0 +1,297 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { buildProfileGroundingRequest } from "../src/profile-mapping.mjs"; +import { resolveProfileSourceUnit } from "../src/source-unit.mjs"; + +test("Native Mapping normalizes fallback, default, coercion, and duplicates", async () => { + await withProject(async (project) => { + await write(project, "features/add-x.yaml", `intent: + id: feature.add-x + title: Add X +domain: + concepts: sales.order + rules: + - sales.confirmed-order-cannot-be-deleted + - sales.confirmed-order-cannot-be-deleted +`); + const entry = nativeEntry("structured-yaml", "features/**/*.yaml"); + const source = await resolveProfileSourceUnit( + entry, + "features/add-x.yaml", + { cwd: project } + ); + const result = await buildProfileGroundingRequest( + entry, + source.sourceUnit, + { selected: "auto" } + ); + + assert.equal(source.errors.length, 0); + assert.equal(result.errors.length, 0); + assert.deepEqual(result.request.intent, { + id: "feature.add-x", + name: "Add X", + status: "proposed" + }); + assert.deepEqual(result.request.affects_domain, { + concepts: ["sales.order"], + rules: ["sales.confirmed-order-cannot-be-deleted"], + lifecycles: [], + events: [] + }); + assert.equal(result.request.integration.selected, "auto"); + assert.equal(result.request.integration.source_unit.root_path, "features/add-x.yaml"); + assert.equal(JSON.stringify(result.request).includes(project), false); + }); +}); + +test("Profile readers support JSON and ignore Markdown body prose", async () => { + await withProject(async (project) => { + await write(project, "features/json-feature.json", JSON.stringify({ + intent: { + id: "feature.json", + name: "JSON feature", + status: "proposed" + }, + domain: { + concepts: ["sales.order"] + } + })); + await write(project, "features/markdown-feature.md", `--- +intent: + id: feature.markdown + name: Markdown feature + status: proposed +domain: + concepts: + - sales.order +--- + +This prose mentions invented.domain-id and must not be parsed. +`); + + for (const [file, id] of [ + ["features/json-feature.json", "feature.json"], + ["features/markdown-feature.md", "feature.markdown"] + ]) { + const entry = nativeEntry(`profile-${path.extname(file).slice(1)}`, `features/*${path.extname(file)}`); + const source = await resolveProfileSourceUnit(entry, file, { cwd: project }); + const result = await buildProfileGroundingRequest(entry, source.sourceUnit); + + assert.equal(result.errors.length, 0); + assert.equal(result.request.intent.id, id); + assert.deepEqual(result.request.affects_domain.concepts, ["sales.order"]); + assert.equal(result.request.affects_domain.concepts.includes("invented.domain-id"), false); + } + }); +}); + +test("Sidecar Mapping uses strict declaration scope and external intent", async () => { + await withProject(async (project) => { + await write(project, "changes/add-x/change.yaml", `id: feature.add-x +name: Add X +status: proposed +`); + await write(project, "changes/add-x/feature.md", `--- +kind: feature +--- + +# Add X +`); + await write(project, "changes/add-x/opendomain.yaml", `schema_version: "1.0" +affects_domain: + concepts: + - sales.order + rules: [] + lifecycles: + - sales.order-lifecycle + events: [] +`); + const entry = sidecarEntry(); + const source = await resolveProfileSourceUnit( + entry, + "changes/add-x/feature.md", + { cwd: project } + ); + const result = await buildProfileGroundingRequest(entry, source.sourceUnit); + + assert.equal(source.errors.length, 0); + assert.equal(result.errors.length, 0); + assert.equal(result.request.source.path, "changes/add-x"); + assert.deepEqual(result.request.affects_domain, { + concepts: ["sales.order"], + rules: [], + lifecycles: ["sales.order-lifecycle"], + events: [] + }); + }); +}); + +test("Profile Mapping fails closed for missing intent, empty scope, and unsupported members", async () => { + await withProject(async (project) => { + await write(project, "features/empty.yaml", `intent: + id: feature.empty + name: "" +domain: + concepts: [] +`); + const entry = nativeEntry("empty-profile", "features/*.yaml"); + const source = await resolveProfileSourceUnit(entry, "features/empty.yaml", { cwd: project }); + const result = await buildProfileGroundingRequest(entry, source.sourceUnit); + + assert.ok(result.errors.some((error) => error.field === "intent.name")); + assert.ok(result.errors.some((error) => ( + error.field === "references.affects_domain" + && error.problem.includes("no explicit OpenDomain references") + ))); + + await write(project, "features/unsupported.txt", "structured=false\n"); + const unsupportedEntry = nativeEntry("unsupported-profile", "features/*.txt"); + const unsupportedSource = await resolveProfileSourceUnit( + unsupportedEntry, + "features/unsupported.txt", + { cwd: project } + ); + const unsupported = await buildProfileGroundingRequest( + unsupportedEntry, + unsupportedSource.sourceUnit + ); + assert.ok(unsupported.errors.some((error) => error.problem.includes("Unsupported structured member extension"))); + }); +}); + +test("Sidecar Mapping rejects declarations with copied intent or empty scope", async () => { + await withProject(async (project) => { + await write(project, "changes/invalid/change.yaml", `id: feature.invalid +name: Invalid feature +status: proposed +`); + await write(project, "changes/invalid/feature.md", "---\nkind: feature\n---\n"); + await write(project, "changes/invalid/opendomain.yaml", `schema_version: "1.0" +intent: + id: copied.intent +affects_domain: + concepts: [] + rules: [] + lifecycles: [] + events: [] +`); + const entry = sidecarEntry(); + const source = await resolveProfileSourceUnit(entry, "changes/invalid", { cwd: project }); + const result = await buildProfileGroundingRequest(entry, source.sourceUnit); + + assert.ok(result.errors.some((error) => ( + error.file === "changes/invalid/opendomain.yaml" + && error.field === "intent" + ))); + assert.equal(result.request, null); + }); +}); + +async function withProject(callback) { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-profile-mapping-")); + try { + await callback(project); + } finally { + await rm(project, { recursive: true, force: true }); + } +} + +async function write(root, relativePath, content) { + const file = path.join(root, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content, "utf8"); +} + +function nativeEntry(id, pattern) { + return { + id, + sourceFile: `opendomain/integrations/profiles/${id}.yaml`, + profile: { + schema_version: "1.0", + id, + source_type: "structured-feature", + source_unit: { + kind: "file", + match: { + paths: [pattern] + } + }, + intent: { + id: { + from: "primary.intent.id" + }, + name: { + first_of: ["primary.intent.name", "primary.intent.title"] + }, + status: { + from: "primary.intent.status", + default: "proposed" + } + }, + references: { + mode: "native", + affects_domain: { + concepts: { + from: "primary.domain.concepts", + coerce: "array" + }, + rules: { + from: "primary.domain.rules" + } + } + } + } + }; +} + +function sidecarEntry() { + return { + id: "structured-change", + sourceFile: "opendomain/integrations/profiles/structured-change.yaml", + profile: { + schema_version: "1.0", + id: "structured-change", + source_type: "structured-change", + source_unit: { + kind: "bundle", + match: { + paths: ["changes/**"], + root_marker: "change.yaml" + }, + members: { + primary: { + path: "feature.md", + required: true + }, + manifest: { + path: "change.yaml", + required: true + }, + declaration: { + path: "opendomain.yaml", + required: true + } + } + }, + intent: { + id: { + from: "manifest.id" + }, + name: { + from: "manifest.name" + }, + status: { + from: "manifest.status", + default: "proposed" + } + }, + references: { + mode: "sidecar" + } + } + }; +} diff --git a/tests/profile-registry.test.mjs b/tests/profile-registry.test.mjs new file mode 100644 index 0000000..cdf7a9b --- /dev/null +++ b/tests/profile-registry.test.mjs @@ -0,0 +1,232 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + inspectIntegrations, + loadIntegrationProfiles +} from "../src/profile-registry.mjs"; + +test("Profile registry is optional and integration inspection includes OpenSpec", async () => { + await withProject(async (project) => { + await mkdir(path.join(project, "opendomain"), { recursive: true }); + + const registry = await loadIntegrationProfiles({ cwd: project }); + const inspection = await inspectIntegrations({ cwd: project }); + + assert.equal(registry.errors.length, 0); + assert.equal(registry.profile_file_count, 0); + assert.deepEqual(registry.profiles, []); + assert.deepEqual(inspection.integrations, [{ + id: "openspec", + kind: "builtin", + source_type: "openspec", + source_unit_kind: "builtin", + reference_mode: "native", + source_file: null + }]); + }); +}); + +test("Profile registry loads valid YAML files in deterministic order", async () => { + await withProject(async (project) => { + await writeProfile(project, "z.yaml", nativeProfile("z-profile", "features/z/**")); + await writeProfile(project, "a.yml", nativeProfile("a-profile", "features/a/**")); + + const registry = await loadIntegrationProfiles({ cwd: project }); + + assert.equal(registry.errors.length, 0); + assert.equal(registry.profile_file_count, 2); + assert.deepEqual( + registry.profiles.map((entry) => entry.id), + ["a-profile", "z-profile"] + ); + assert.deepEqual( + registry.profiles.map((entry) => entry.sourceFile), + [ + "opendomain/integrations/profiles/a.yml", + "opendomain/integrations/profiles/z.yaml" + ] + ); + }); +}); + +test("Profile registry rejects duplicate and built-in IDs", async () => { + await withProject(async (project) => { + await writeProfile(project, "first.yaml", nativeProfile("duplicate", "features/first/**")); + await writeProfile(project, "second.yaml", nativeProfile("duplicate", "features/second/**")); + await writeProfile(project, "openspec.yaml", nativeProfile("openspec", "features/open/**")); + + const registry = await loadIntegrationProfiles({ cwd: project }); + + assert.ok(registry.errors.some((error) => error.problem.includes("Duplicate Integration Profile ID 'duplicate'"))); + assert.ok(registry.errors.some((error) => error.problem.includes("conflicts with a built-in integration"))); + assert.deepEqual(registry.profiles, []); + }); +}); + +test("Profile semantic validation rejects unsafe paths and unavailable member roles", async () => { + await withProject(async (project) => { + const profile = nativeProfile("unsafe-profile", "../features/**"); + profile.intent.name = { from: "manifest.name" }; + await writeProfile(project, "unsafe.yaml", profile); + + const registry = await loadIntegrationProfiles({ cwd: project }); + + assert.ok(registry.errors.some((error) => ( + error.field === "source_unit.match.paths[0]" + && error.problem.includes("parent traversal") + ))); + assert.ok(registry.errors.some((error) => ( + error.field === "intent.name" + && error.problem.includes("unconfigured") + ))); + }); +}); + +test("Profile discovery uses legacy fallback but never merges dual roots", async () => { + await withProject(async (project) => { + await writeProfile( + project, + "legacy.yaml", + nativeProfile("legacy-profile", "legacy/**"), + "domain" + ); + + const legacy = await loadIntegrationProfiles({ cwd: project }); + assert.deepEqual(legacy.profiles.map((entry) => entry.id), ["legacy-profile"]); + assert.ok(legacy.warnings.some((warning) => warning.problem.includes("legacy"))); + + await writeProfile( + project, + "canonical.yaml", + nativeProfile("canonical-profile", "canonical/**"), + "opendomain" + ); + const dual = await loadIntegrationProfiles({ cwd: project }); + + assert.deepEqual(dual.profiles.map((entry) => entry.id), ["canonical-profile"]); + assert.ok(dual.warnings.some((warning) => warning.problem.includes("ignoring 'domain/'"))); + }); +}); + +test("Profile discovery rejects directories reached through intermediate symlinks", async () => { + await withProject(async (project) => { + const externalDirectory = path.join(project, "profile-store"); + await mkdir(path.join(externalDirectory, "profiles"), { recursive: true }); + await writeFile( + path.join(externalDirectory, "profiles/linked.yaml"), + toYaml(nativeProfile("linked-profile", "features/**")), + "utf8" + ); + await mkdir(path.join(project, "opendomain"), { recursive: true }); + await symlink( + externalDirectory, + path.join(project, "opendomain/integrations") + ); + + const registry = await loadIntegrationProfiles({ cwd: project }); + + assert.deepEqual(registry.profiles, []); + assert.ok(registry.errors.some((error) => ( + error.problem.includes("must not resolve through a symbolic link") + ))); + }); +}); + +test("Profile discovery fails closed when its schema registry is unavailable", async () => { + await withProject(async (project) => { + await writeProfile(project, "valid.yaml", nativeProfile("valid-profile", "features/**")); + + const registry = await loadIntegrationProfiles({ + cwd: project, + schemaRegistry: { + validate() { + throw new Error("fixture schema failure"); + } + } + }); + + assert.deepEqual(registry.profiles, []); + assert.equal(registry.errors.length, 1); + assert.equal(registry.errors[0].file, "schemas"); + assert.ok(registry.errors[0].problem.includes("fixture schema failure")); + }); +}); + +async function withProject(callback) { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-profile-registry-")); + try { + await callback(project); + } finally { + await rm(project, { recursive: true, force: true }); + } +} + +async function writeProfile(project, file, profile, workspace = "opendomain") { + const directory = path.join(project, workspace, "integrations", "profiles"); + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, file), toYaml(profile), "utf8"); +} + +function nativeProfile(id, pattern) { + return { + schema_version: "1.0", + id, + source_type: "structured-feature", + source_unit: { + kind: "file", + match: { + paths: [pattern] + } + }, + intent: { + id: { + from: "primary.id" + }, + name: { + from: "primary.name" + }, + status: { + from: "primary.status", + default: "proposed" + } + }, + references: { + mode: "native", + affects_domain: { + concepts: { + from: "primary.affects.concepts", + coerce: "array" + } + } + } + }; +} + +function toYaml(profile) { + return `schema_version: "${profile.schema_version}" +id: ${profile.id} +source_type: ${profile.source_type} +source_unit: + kind: ${profile.source_unit.kind} + match: + paths: + - ${profile.source_unit.match.paths[0]} +intent: + id: + from: ${profile.intent.id.from} + name: + from: ${profile.intent.name.from} + status: + from: ${profile.intent.status.from} + default: ${profile.intent.status.default} +references: + mode: native + affects_domain: + concepts: + from: primary.affects.concepts + coerce: array +`; +} diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs index 249c130..a3b5b1a 100644 --- a/tests/protocol.test.mjs +++ b/tests/protocol.test.mjs @@ -1,5 +1,11 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile +} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -99,6 +105,55 @@ affects_domain: assert.deepEqual(result.request.affects_domain.concepts, ["sales.order"]); }); +test("automatic selection preserves OpenSpec input outside a project with local Profiles", async (context) => { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-project-with-profile-")); + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-external-openspec-")); + context.after(() => Promise.all([ + rm(project, { recursive: true, force: true }), + rm(external, { recursive: true, force: true }) + ])); + + await mkdir(path.join(project, "opendomain/integrations/profiles"), { recursive: true }); + await writeFile(path.join(project, "opendomain/integrations/profiles/local.yaml"), `schema_version: "1.0" +id: local-profile +source_type: structured-feature +source_unit: + kind: file + match: + paths: + - features/** +intent: + id: + from: primary.id + name: + from: primary.name + status: + from: primary.status +references: + mode: native + affects_domain: + concepts: + from: primary.affects.concepts +`, "utf8"); + const externalSpec = path.join(external, "spec.md"); + await writeFile(externalSpec, `--- +type: feature_spec +id: spec.external +name: External OpenSpec request +status: proposed +affects_domain: + concepts: + - sales.order +--- +`, "utf8"); + + const result = await buildGroundingRequest(externalSpec, { cwd: project }); + + assert.equal(result.errors.length, 0); + assert.equal(result.request.integration.id, "openspec"); + assert.equal(result.request.integration.selected, "auto"); +}); + test("Context Budget deterministically estimates complete selected files", async () => { const pack = await prepareGroundingPack(FEATURE_PATH, { cwd: ERP_ROOT }); const requiredEstimate = await estimateFiles(pack.read_first.map((item) => path.join(ERP_ROOT, item.file))); diff --git a/tests/source-unit.test.mjs b/tests/source-unit.test.mjs new file mode 100644 index 0000000..be3f44d --- /dev/null +++ b/tests/source-unit.test.mjs @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + findMatchingProfileSourceUnits, + publicSourceUnit, + resolveProfileSourceUnit +} from "../src/source-unit.mjs"; + +test("file Source Unit uses one canonical primary member", async () => { + await withProject(async (project) => { + await write(project, "features/add-x.yaml", "id: feature.add-x\n"); + const entry = fileEntry("structured-feature", "features/**/*.yaml"); + + const result = await resolveProfileSourceUnit( + entry, + "features/add-x.yaml", + { cwd: project } + ); + + assert.equal(result.matched, true); + assert.equal(result.errors.length, 0); + assert.deepEqual(publicSourceUnit(result.sourceUnit), { + schema_version: "1.0", + kind: "file", + input_path: "features/add-x.yaml", + root_path: "features/add-x.yaml", + source_type: "structured-feature", + profile_id: "structured-feature", + members: [{ + role: "primary", + path: "features/add-x.yaml" + }] + }); + }); +}); + +test("bundle Source Unit selects nearest root and only exact members", async () => { + await withProject(async (project) => { + await write(project, "changes/outer/change.yaml", "id: outer\n"); + await write(project, "changes/outer/feature.md", "---\nid: outer\n---\n"); + await write(project, "changes/outer/nested/change.yaml", "id: nested\n"); + await write(project, "changes/outer/nested/feature.md", "---\nid: nested\n---\n"); + await write(project, "changes/outer/nested/opendomain.yaml", "schema_version: \"1.0\"\n"); + await write(project, "changes/outer/nested/notes.md", "unrelated prose\n"); + + const result = await resolveProfileSourceUnit( + bundleEntry("structured-change"), + "changes/outer/nested/notes.md", + { cwd: project } + ); + + assert.equal(result.matched, true); + assert.equal(result.errors.length, 0); + assert.equal(result.sourceUnit.root_path, "changes/outer/nested"); + assert.deepEqual( + result.sourceUnit.members.map((member) => [member.role, member.path]), + [ + ["primary", "changes/outer/nested/feature.md"], + ["manifest", "changes/outer/nested/change.yaml"], + ["declaration", "changes/outer/nested/opendomain.yaml"] + ] + ); + assert.equal(result.sourceUnit.members.some((member) => member.path.endsWith("notes.md")), false); + }); +}); + +test("bundle Source Unit reports missing required members after matching", async () => { + await withProject(async (project) => { + await write(project, "changes/incomplete/change.yaml", "id: incomplete\n"); + + const result = await resolveProfileSourceUnit( + bundleEntry("structured-change"), + "changes/incomplete", + { cwd: project } + ); + + assert.equal(result.matched, true); + assert.ok(result.errors.some((error) => ( + error.field === "source_unit.members.primary" + && error.problem.includes("missing") + ))); + assert.ok(result.errors.some((error) => ( + error.field === "source_unit.members.declaration" + && error.problem.includes("missing") + ))); + }); +}); + +test("Source Unit rejects workspace and bundle symlink escapes", async () => { + await withProject(async (project) => { + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-source-external-")); + try { + await write(external, "feature.md", "---\nid: escaped\n---\n"); + await write(project, "changes/escaped/change.yaml", "id: escaped\n"); + await write(project, "changes/escaped/opendomain.yaml", "schema_version: \"1.0\"\n"); + await symlink( + path.join(external, "feature.md"), + path.join(project, "changes/escaped/feature.md") + ); + + const memberEscape = await resolveProfileSourceUnit( + bundleEntry("structured-change"), + "changes/escaped", + { cwd: project } + ); + assert.equal(memberEscape.matched, true); + assert.ok(memberEscape.errors.some((error) => error.problem.includes("outside the bundle"))); + + await symlink(external, path.join(project, "outside-link")); + const inputEscape = await resolveProfileSourceUnit( + fileEntry("external", "outside-link/**"), + "outside-link/feature.md", + { cwd: project } + ); + assert.equal(inputEscape.matched, false); + assert.ok(inputEscape.errors.some((error) => error.problem.includes("outside the project workspace"))); + } finally { + await rm(external, { recursive: true, force: true }); + } + }); +}); + +test("automatic matching returns every matching Profile without precedence", async () => { + await withProject(async (project) => { + await write(project, "features/add-x.yaml", "id: feature.add-x\n"); + const result = await findMatchingProfileSourceUnits([ + fileEntry("first-profile", "features/**"), + fileEntry("second-profile", "features/**/*.yaml"), + fileEntry("unrelated-profile", "tasks/**") + ], "features/add-x.yaml", { cwd: project }); + + assert.equal(result.errors.length, 0); + assert.deepEqual( + result.matches.map((match) => match.entry.id), + ["first-profile", "second-profile"] + ); + }); +}); + +async function withProject(callback) { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-source-unit-")); + try { + await callback(project); + } finally { + await rm(project, { recursive: true, force: true }); + } +} + +async function write(root, relativePath, content) { + const file = path.join(root, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content, "utf8"); +} + +function fileEntry(id, pattern) { + return { + id, + sourceFile: `opendomain/integrations/profiles/${id}.yaml`, + profile: { + source_type: "structured-feature", + source_unit: { + kind: "file", + match: { + paths: [pattern] + } + } + } + }; +} + +function bundleEntry(id) { + return { + id, + sourceFile: `opendomain/integrations/profiles/${id}.yaml`, + profile: { + source_type: "structured-change", + source_unit: { + kind: "bundle", + match: { + paths: ["changes/**"], + root_marker: "change.yaml" + }, + members: { + primary: { + path: "feature.md", + required: true + }, + manifest: { + path: "change.yaml", + required: true + }, + declaration: { + path: "opendomain.yaml", + required: true + } + } + } + } + }; +}