diff --git a/.gitignore b/.gitignore index 1a7c539..e790103 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ coverage/ .codex/ .echopath/ .opendomain/ +/opendomain/generated/ /AGENTS.md /openspec/ .npmrc diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a17bb3..5859ec1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,8 @@ Keep the layers separate: - Run `npm run opendomain -- validate`. - Add or update tests for parser, validator, CLI, or index behavior changes. - Update docs when commands, formats, or workflow expectations change. -- Do not commit generated `.opendomain/` indexes. +- Do not treat `opendomain/generated/` or legacy `.opendomain/index.json` + as authoritative domain knowledge. ## Planning Artifacts diff --git a/README.md b/README.md index 72b55eb..e8974ec 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,8 @@ This workspace now includes the first MVP slices: - OpenSpec `affects_domain` grounding - Domain Candidate boundary checks - Semantic Retrieval Index as a derived read-first view -- OpenDomain dogfooding under `domain/` +- Deterministic workspace resolution with canonical `opendomain/` sources +- OpenDomain dogfooding under `opendomain/` The source of truth remains Markdown with YAML front matter stored in Git. @@ -60,7 +61,7 @@ Install the CLI from npm: ```bash npm install -g @echopath-labs/opendomain opendomain init -opendomain validate domain +opendomain validate ``` Or try it from a source checkout: @@ -71,8 +72,7 @@ Common commands: npm run opendomain -- help npm run opendomain -- init npm run opendomain -- validate -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md -npm run opendomain -- prepare --integration openspec examples/erp/openspec/changes/order-cancellation/spec.md +npm run prepare:demo 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 @@ -80,6 +80,12 @@ npm run opendomain -- index query sales.order --index /tmp/erp-index.json npm test ``` +Commands without a source path resolve the current project's canonical +`opendomain/` workspace. During `0.x`, a legacy `domain/` workspace remains +readable when the canonical root is absent. If both exist, `opendomain/` wins +with a warning; the roots are never merged. Pass a file or directory explicitly +when validating a fixture or external corpus such as `examples/erp`. + ## Project Status OpenDomain is early alpha. The current repository is ready for public iteration, @@ -111,7 +117,7 @@ License: MIT. │ ├── grounding-protocol.md │ ├── candidate-workflow.md │ └── decisions/ -├── domain/ +├── opendomain/ │ └── README.md ├── examples/ │ └── erp/ @@ -146,11 +152,11 @@ See [docs/semantic-retrieval-index.md](docs/semantic-retrieval-index.md). ## Dogfooding -OpenDomain now models part of its own product semantics under `domain/`. +OpenDomain now models part of its own product semantics under `opendomain/`. ```bash -npm run opendomain -- validate domain -npm run opendomain -- prepare domain/openspec/changes/self-model-maintenance/spec.md +npm run opendomain -- validate +npm run opendomain -- prepare examples/self-model/openspec/changes/self-model-maintenance/spec.md ``` See [docs/dogfooding-self-model.md](docs/dogfooding-self-model.md). @@ -159,7 +165,7 @@ See [docs/dogfooding-self-model.md](docs/dogfooding-self-model.md). Use this rule when preserving planning: -- `domain/`: long-lived OpenDomain semantics -- `domain/candidates/`: proposed or inferred semantics +- `opendomain/`: long-lived OpenDomain semantics +- `opendomain/candidates/`: proposed or inferred semantics - a project's optional `openspec/changes/`: future delivery work - `docs/`: narrative explanation and product guidance diff --git a/README.zh-CN.md b/README.zh-CN.md index 0d709ad..6f39c3a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,6 +103,7 @@ OpenDomain 适合: - OpenSpec `affects_domain` grounding; - Candidate 边界检查; - Semantic Retrieval Index,作为派生的 read-first 检索视图; +- 以 canonical `opendomain/` 为入口的确定性工作区解析; - ERP Order Cancellation 示例; - OpenDomain 自己的 self-model dogfooding。 @@ -130,7 +131,7 @@ OpenDomain 的 npm 包名是 `@echopath-labs/opendomain`,CLI 命令是 `opendo ```bash npm install -g @echopath-labs/opendomain opendomain init -opendomain validate domain +opendomain validate ``` 也可以从源码运行。 @@ -175,7 +176,7 @@ npm run opendomain -- validate examples/erp 为一个 Feature 准备 Codex grounding: ```bash -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md +npm run prepare:demo ``` 构建并查询 Semantic Retrieval Index: @@ -189,10 +190,10 @@ npm run opendomain -- index query sales.order --index /tmp/erp-index.json ### 1. 写长期业务语义 -把长期成立的业务知识写入 `domain/` 或 `examples//domain/`: +把长期成立的业务知识写入项目根目录的 `opendomain/`: ```text -domain/ +opendomain/ contexts/ concepts/ rules/ @@ -201,6 +202,14 @@ domain/ candidates/ ``` +不传 source path 时,CLI 会从当前项目根目录解析工作区: + +- 优先使用 canonical `opendomain/`; +- 在 `0.x` 期间,只有 `domain/` 时会兼容读取并给出迁移提示; +- 两个根目录同时存在时只读取 `opendomain/`,发出 warning,不合并语料; +- 默认只读取六类语义目录,不扫描 `examples/`、`generated/` 或 integration 配置; +- 验证示例、fixture 或外部语料时,需要显式传入文件或目录。 + 例如 `sales.order` 应该说明 Order 在 Sales context 中是什么意思,它不是什么,它受哪些规则和生命周期约束,以及证据是什么。 ### 2. 用 OpenSpec 引用 OpenDomain @@ -259,7 +268,7 @@ extracted_by: codex extracted_at: 2026-07-07 evidence: - type: spec - location: examples/erp/domain/lifecycles/sales.order-lifecycle.md + location: examples/erp/opendomain/lifecycles/sales.order-lifecycle.md summary: Accepted lifecycle does not include Closed state. confidence: medium possible_conflicts: @@ -342,7 +351,7 @@ OpenDomain 目前是 early alpha。 - 内部项目记忆; - 未经授权的业务文档或代码片段。 -如果你要在公司内部使用 OpenDomain,建议在私有仓库维护项目自己的 `domain/` 文件。 +如果你要在公司内部使用 OpenDomain,建议在私有仓库维护项目自己的 `opendomain/` 文件。 ## 贡献 diff --git a/ROADMAP.md b/ROADMAP.md index 8cdff0c..ad23ff5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,7 +11,7 @@ human maintainers. - OpenSpec `affects_domain` grounding - Domain Candidate boundary checks - Semantic Retrieval Index as a derived read-first view -- OpenDomain self-model dogfooding under `domain/` +- OpenDomain self-model dogfooding under `opendomain/` ## Near-Term Work diff --git a/docs/OPEN_DOMAIN_DEVELOPMENT_GUIDE.md b/docs/OPEN_DOMAIN_DEVELOPMENT_GUIDE.md index 5f9d8b7..2178e9c 100644 --- a/docs/OPEN_DOMAIN_DEVELOPMENT_GUIDE.md +++ b/docs/OPEN_DOMAIN_DEVELOPMENT_GUIDE.md @@ -592,7 +592,7 @@ Codex 在 Greenfield 开发时,可采用以下结构作为初始建议。 │ ├── architecture.md │ └── decisions/ │ └── ADR-0001-git-native-markdown-first.md -├── domain/ +├── opendomain/ │ ├── contexts/ │ │ └── sales.md │ ├── concepts/ @@ -1104,7 +1104,7 @@ OpenDomain CLI 应该简单、确定、适合 CI。 ```bash opendomain validate -opendomain validate domain/concepts/sales.order.md +opendomain validate opendomain/concepts/sales.order.md opendomain graph export --format json opendomain candidate validate opendomain ids list @@ -1439,10 +1439,10 @@ Create: - schemas/rule.schema.json - schemas/lifecycle.schema.json - schemas/candidate.schema.json -- examples/erp/domain/concepts/sales.order.md -- examples/erp/domain/rules/sales.confirmed-order-cannot-be-deleted.md -- examples/erp/domain/lifecycles/sales.order-lifecycle.md -- examples/erp/domain/candidates/candidate-0001-order-lifecycle.md +- examples/erp/opendomain/concepts/sales.order.md +- examples/erp/opendomain/rules/sales.confirmed-order-cannot-be-deleted.md +- examples/erp/opendomain/lifecycles/sales.order-lifecycle.md +- examples/erp/opendomain/candidates/candidate-0001-order-lifecycle.md Keep schemas minimal but valid. Make examples human-readable and machine-parseable. diff --git a/docs/architecture.md b/docs/architecture.md index 80c3b16..069f161 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,13 +24,19 @@ Derived graph, index, search, MCP, or export views The source of truth is the set of OpenDomain files in Git: -- `domain/` for project domain knowledge +- `opendomain/` for canonical project domain knowledge - `examples/` for illustrative fixtures - `schemas/` for machine validation Derived views are not authoritative. A graph export, search index, embedding index, generated docs site, or MCP resource must be rebuildable from Git files. +Commands without an explicit source target resolve only the current project's +canonical `opendomain/` semantic directories. During `0.x`, legacy `domain/` +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. + The first derived retrieval view is documented in `docs/semantic-retrieval-index.md`. diff --git a/docs/decisions/ADR-0007-non-destructive-workspace-migration.md b/docs/decisions/ADR-0007-non-destructive-workspace-migration.md index 45df902..d006adc 100644 --- a/docs/decisions/ADR-0007-non-destructive-workspace-migration.md +++ b/docs/decisions/ADR-0007-non-destructive-workspace-migration.md @@ -6,7 +6,7 @@ Accepted ## Context -OpenDomain alpha releases use `domain/` for semantic sources and +Earlier OpenDomain alpha releases used `domain/` for semantic sources and `.opendomain/index.json` for a generated index. Moving to canonical `opendomain/` must not make existing knowledge disappear or let tooling delete or overwrite user files. @@ -17,11 +17,12 @@ Throughout `0.x`, OpenDomain falls back to legacy `domain/` when canonical `opendomain/` is absent. If both roots exist, canonical `opendomain/` wins with an explicit warning. -`opendomain migrate workspace` copies and validates source files and Candidates -without overwriting targets, deleting legacy content, changing Git state, or -editing ignore rules. Legacy generated indexes are rebuilt rather than copied. -Legacy discovery may only be removed in `1.0` with documented migration -guidance. +The current version provides discovery fallback only; it does not expose an +automatic migration command. If a migration command is introduced later, it +must copy and validate source files and Candidates without overwriting targets, +deleting legacy content, changing Git state, or editing ignore rules. Legacy +generated indexes must be rebuilt rather than copied. Legacy discovery may only +be removed in `1.0` with documented migration guidance. ## Consequences @@ -29,3 +30,4 @@ guidance. - Migration remains reversible because legacy files are preserved. - Dual roots are deterministic but produce a visible warning. - The CLI carries a legacy discovery path until `1.0`. +- Maintainers perform the current source move manually and review the Git diff. diff --git a/docs/dogfooding-self-model.md b/docs/dogfooding-self-model.md index 36937d0..2f92210 100644 --- a/docs/dogfooding-self-model.md +++ b/docs/dogfooding-self-model.md @@ -3,16 +3,16 @@ OpenDomain should use its own format to model its own long-lived product semantics. -This is the first real `domain/` model in the repository. The ERP files under -`examples/` remain examples; the OpenDomain self model under `domain/` is actual -project knowledge. +This is the first real `opendomain/` model in the repository. The ERP files under +`examples/` remain examples; the OpenDomain self model under `opendomain/` is +actual project knowledge. ## Boundary Use the repository layers this way: -- `domain/`: long-lived OpenDomain product semantics -- `domain/candidates/`: proposed OpenDomain semantics awaiting human review +- `opendomain/`: long-lived OpenDomain product semantics +- `opendomain/candidates/`: proposed OpenDomain semantics awaiting human review - a maintainer's private `openspec/changes/`: change intent, design, tasks, and acceptance - `docs/`: PRD, architecture, and development guidance - `examples/`: external example domains @@ -54,8 +54,8 @@ Proposed self-model Candidate: Use this split when preserving planning output: -- Put stable OpenDomain semantics in `domain/`. -- Put uncertain inferred semantics in `domain/candidates/`. +- Put stable OpenDomain semantics in `opendomain/`. +- Put uncertain inferred semantics in `opendomain/candidates/`. - Put delivery plans in a private planning workspace such as `openspec/changes/`. - Put explanatory narrative in `docs/`. @@ -79,7 +79,7 @@ This is now accepted self-model knowledge through: Before changing OpenDomain's own domain semantics, run: ```bash -npm run opendomain -- prepare domain/openspec/changes/self-model-maintenance/spec.md +npm run opendomain -- prepare examples/self-model/openspec/changes/self-model-maintenance/spec.md ``` This returns the accepted OpenDomain files to read first and the Candidate @@ -90,7 +90,7 @@ boundary for the proposed Semantic Retrieval Index. Validate real OpenDomain self-model files: ```bash -npm run opendomain -- validate domain +npm run opendomain -- validate ``` Run all tests: diff --git a/docs/getting-started.md b/docs/getting-started.md index 656b27f..d828022 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,7 +4,7 @@ 目标是在 10 分钟内完成三件事: -1. 初始化 `domain/` 目录。 +1. 初始化 canonical `opendomain/` 目录。 2. 验证生成的 OpenDomain 文件。 3. 明确第一条真实业务知识应该如何进入模型。 @@ -33,7 +33,7 @@ opendomain init `opendomain init` 会创建: ```text -domain/ +opendomain/ README.md contexts/ example.md @@ -52,7 +52,7 @@ AGENTS.md ## 3. 验证生成结果 ```bash -opendomain validate domain +opendomain validate ``` 通过验证只说明文件格式和引用关系成立,不代表这些 starter 内容已经是你的真实业务知识。 @@ -97,7 +97,7 @@ ERP 示例展示了: - Agent 本轮工作记录; - 只从代码或数据库里猜出来、还没人确认的业务知识。 -如果你不确定,就先写入 `domain/candidates/`。 +如果你不确定,就先写入 `opendomain/candidates/`。 可以用 CLI 浏览和记录 Candidate 审查结果: diff --git a/docs/grounding-protocol.md b/docs/grounding-protocol.md index 1347f16..6570589 100644 --- a/docs/grounding-protocol.md +++ b/docs/grounding-protocol.md @@ -35,7 +35,7 @@ metadata 会继续输出,但 consumer 不应依赖它们作为 Grounding Proto protocol_version: "1.0" source: type: openspec - path: examples/erp/openspec/changes/order-cancellation/spec.md + path: openspec/changes/order-cancellation/spec.md intent: id: spec.order-cancellation name: Order cancellation @@ -68,11 +68,13 @@ Grounding Request 只引用 OpenDomain ID,不复制业务定义,也不代表 使用 JSON 输出: ```bash -npm run opendomain -- prepare \ - examples/erp/openspec/changes/order-cancellation/spec.md \ - --json +cd examples/erp +opendomain prepare openspec/changes/order-cancellation/spec.md --json ``` +`prepare` resolves accepted OpenDomain sources from the current project +workspace, so it must run from the project that owns the Feature spec. + ## Semantic Closure Grounding Request 中的 `affects_domain` 是 declared roots。OpenDomain 使用 diff --git a/docs/mvp-grounding-demo.md b/docs/mvp-grounding-demo.md index c92fbf4..30df675 100644 --- a/docs/mvp-grounding-demo.md +++ b/docs/mvp-grounding-demo.md @@ -50,7 +50,7 @@ npm run opendomain -- validate examples/erp Prepare the Codex grounding pack for the feature: ```bash -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md +npm run prepare:demo ``` Validate with machine-readable output: @@ -62,7 +62,8 @@ npm run opendomain -- validate examples/erp --json Prepare with machine-readable output: ```bash -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md --json +cd examples/erp +node ../../bin/opendomain.mjs prepare openspec/changes/order-cancellation/spec.md --json ``` List known IDs: diff --git a/docs/semantic-retrieval-index.md b/docs/semantic-retrieval-index.md index 60c47f2..38781b5 100644 --- a/docs/semantic-retrieval-index.md +++ b/docs/semantic-retrieval-index.md @@ -14,6 +14,11 @@ Build the default index: npm run opendomain -- index build ``` +For a canonical workspace, this reads `opendomain/` and writes +`opendomain/generated/index.json`. During the `0.x` legacy compatibility period, +a project with only `domain/` writes `.opendomain/index.json`. If both roots +exist, canonical wins with a warning and the corpora are not merged. + Build an index for one domain tree: ```bash @@ -56,7 +61,7 @@ Each entry contains a compact, parseable view of one source file: "name": "Order", "context": "sales", "status": "accepted", - "source_file": "examples/erp/domain/concepts/sales.order.md", + "source_file": "examples/erp/opendomain/concepts/sales.order.md", "summary": "An Order represents a customer's commercial request...", "relationships": [], "rules": ["sales.confirmed-order-cannot-be-deleted"], @@ -93,3 +98,7 @@ results point Codex to source files; they do not replace source files. When a source file changes after index build, query emits a stale warning and the index should be rebuilt. + +Generated indexes are workspace artifacts, not accepted OpenDomain sources. +Default discovery scans only the six semantic source directories and therefore +does not ingest `opendomain/generated/`. diff --git a/docs/usage.md b/docs/usage.md index 6c33046..1c03c37 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -49,7 +49,7 @@ OpenDomain 的 npm 包名是 `@echopath-labs/opendomain`,CLI 命令是 `opendo ```bash npm install -g @echopath-labs/opendomain opendomain init -opendomain validate domain +opendomain validate ``` 也可以在仓库源码中直接运行 CLI。 @@ -101,7 +101,7 @@ npm test OpenDomain 默认使用 Git 中的 Markdown + YAML front matter。 ```text -domain/ +opendomain/ contexts/ concepts/ rules/ @@ -120,14 +120,37 @@ schemas/ 当前仓库里: -- `domain/` 是 OpenDomain 自己的真实业务模型。 +- `opendomain/` 是 OpenDomain 自己的真实业务模型。 - `examples/erp/` 是 ERP Order Cancellation 示例。 - 项目选择使用 OpenSpec 时,可由自己的 `openspec/changes/` 保存变更计划、任务和验收要求。 - `docs/` 保存产品说明、架构说明和使用说明。 首次在自己的仓库中使用时,建议先读 [快速上手](getting-started.md)。 -### 3.1 YAML front matter 支持边界 +### 3.1 Workspace Resolution + +CLI 不传 source path 时,会把当前工作目录视为项目根目录,并按以下规则解析: + +1. canonical `opendomain/` 存在时只读取它; +2. canonical 不存在而 legacy `domain/` 存在时,在 `0.x` 期间兼容读取并给出 warning; +3. 两个根目录同时存在时,canonical 胜出并给出 warning,不合并两个语料库; +4. 两者都不存在,或选中的工作区没有可读取的语义 Markdown 时,命令失败; +5. 默认只扫描 `contexts/`、`concepts/`、`rules/`、`lifecycles/`、`events/` 和 + `candidates/`,不会扫描 `examples/`、`generated/` 或 integration 配置。 + +显式传入 Markdown 文件或目录时,CLI 只处理该目标。这适合验证示例、fixture +或项目外的语料;外部路径不会被隐式发现。隐式工作区必须位于当前项目内, +symlink descendant 不会进入默认语料。 + +默认 index 路径跟随工作区模式: + +- canonical:`opendomain/generated/index.json` +- legacy 兼容:`.opendomain/index.json` + +`opendomain init` 只创建 canonical 工作区。若项目只有 legacy `domain/`, +`init` 会拒绝创建一个会遮蔽旧语料的新根目录;当前版本不包含自动迁移命令。 + +### 3.2 YAML front matter 支持边界 每个 OpenDomain Markdown 文件的 front matter 必须是一个 YAML 1.2 mapping。 当前支持的值包括: @@ -149,7 +172,7 @@ UTF-8 BOM 均可正常解析。为保证 validator、index 和 Grounding Pack matter 时复用同一安全序列化入口,因此会保证语义 round trip,但不承诺 保留原始 YAML 的逐字节排版。 -### 3.2 Runtime Schema 与安全语料 +### 3.3 Runtime Schema 与安全语料 `opendomain validate` 会按以下顺序处理输入: @@ -240,7 +263,7 @@ events: - sales.order-confirmed evidence: - type: human_review - location: examples/erp/domain/contexts/sales.md + location: examples/erp/opendomain/contexts/sales.md summary: Order is accepted as the central Sales concept. confidence: high review: @@ -275,7 +298,7 @@ severity: must rule_type: invariant evidence: - type: human_review - location: examples/erp/domain/concepts/sales.order.md + location: examples/erp/opendomain/concepts/sales.order.md summary: Confirmed order deletion is treated as a stable invariant. confidence: high review: @@ -320,7 +343,7 @@ related_lifecycle: - sales.order-lifecycle evidence: - type: human_review - location: examples/erp/domain/lifecycles/sales.order-lifecycle.md + location: examples/erp/opendomain/lifecycles/sales.order-lifecycle.md summary: The lifecycle emits OrderConfirmed when the order enters Confirmed. confidence: high review: @@ -358,7 +381,7 @@ extracted_by: codex extracted_at: 2026-07-07 evidence: - type: spec - location: examples/erp/domain/lifecycles/sales.order-lifecycle.md + location: examples/erp/opendomain/lifecycles/sales.order-lifecycle.md summary: Accepted lifecycle does not include Closed state. confidence: medium possible_conflicts: @@ -419,16 +442,21 @@ OpenSpec 不应该复制 `sales.order` 的定义。它只引用 ID,然后解 Codex 的默认入口是 `opendomain prepare`。 ```bash -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md +cd examples/erp +opendomain prepare openspec/changes/order-cancellation/spec.md ``` OpenDomain 会自动识别 OpenSpec-style feature spec。你也可以显式指定 built-in OpenSpec integration: ```bash -npm run opendomain -- prepare --integration openspec examples/erp/openspec/changes/order-cancellation/spec.md +opendomain prepare --integration openspec openspec/changes/order-cancellation/spec.md ``` +`prepare` 始终从当前项目工作区读取长期领域语义,因此命令应在 Feature +所属项目的根目录执行。若从本仓库源码运行 ERP 示例,可直接使用 +`npm run prepare:demo`。 + 当前 MVP 只支持 `openspec` built-in integration。Spec Kit 或其他工具的 Integration Profile 仍处于设计阶段,不会执行动态插件代码。 @@ -448,7 +476,7 @@ of truth,只是 Grounding Pack 的输入。 查看完整协议 JSON: ```bash -npm run opendomain -- prepare examples/erp/openspec/changes/order-cancellation/spec.md --json +opendomain prepare openspec/changes/order-cancellation/spec.md --json ``` `read_first` 不是简单复制 `affects_domain`。Semantic Closure v1 会沿明确的 @@ -499,6 +527,11 @@ Index query 返回的是 read-first plan: Index 不是 source of truth。它只回答“应该读哪些 source files”。重要语义仍必须回到 Git 中的 OpenDomain Markdown 文件确认。 +在项目根目录省略 source path 和 `--out` 时,`index build` 会从默认工作区构建 +并写入 `opendomain/generated/index.json`;`index query` 省略 `--index` 时读取 +同一路径。Legacy `domain/` 模式在 `0.x` 期间继续使用 +`.opendomain/index.json`。显式 `--out` / `--index` 始终优先。 + ## 9. 常用命令 | 目标 | 命令 | diff --git a/examples/erp/domain/candidates/candidate-0001-order-lifecycle.md b/examples/erp/opendomain/candidates/candidate-0001-order-lifecycle.md similarity index 93% rename from examples/erp/domain/candidates/candidate-0001-order-lifecycle.md rename to examples/erp/opendomain/candidates/candidate-0001-order-lifecycle.md index 88ce35b..b1067f2 100644 --- a/examples/erp/domain/candidates/candidate-0001-order-lifecycle.md +++ b/examples/erp/opendomain/candidates/candidate-0001-order-lifecycle.md @@ -11,7 +11,7 @@ extracted_by: codex extracted_at: 2026-06-29 evidence: - type: spec - location: examples/erp/domain/lifecycles/sales.order-lifecycle.md + location: examples/erp/opendomain/lifecycles/sales.order-lifecycle.md summary: Accepted lifecycle has Fulfilled as terminal but no Closed state. confidence: medium possible_conflicts: diff --git a/examples/erp/domain/concepts/sales.order.md b/examples/erp/opendomain/concepts/sales.order.md similarity index 95% rename from examples/erp/domain/concepts/sales.order.md rename to examples/erp/opendomain/concepts/sales.order.md index 6bbdd96..a2e3a51 100644 --- a/examples/erp/domain/concepts/sales.order.md +++ b/examples/erp/opendomain/concepts/sales.order.md @@ -21,7 +21,7 @@ events: - sales.order-confirmed evidence: - type: human_review - location: examples/erp/domain/contexts/sales.md + location: examples/erp/opendomain/contexts/sales.md summary: Example fixture defines Order as the central Sales concept. confidence: high review: diff --git a/examples/erp/domain/contexts/sales.md b/examples/erp/opendomain/contexts/sales.md similarity index 100% rename from examples/erp/domain/contexts/sales.md rename to examples/erp/opendomain/contexts/sales.md diff --git a/examples/erp/domain/events/sales.order-confirmed.md b/examples/erp/opendomain/events/sales.order-confirmed.md similarity index 91% rename from examples/erp/domain/events/sales.order-confirmed.md rename to examples/erp/opendomain/events/sales.order-confirmed.md index 5f2b49d..cdf292f 100644 --- a/examples/erp/domain/events/sales.order-confirmed.md +++ b/examples/erp/opendomain/events/sales.order-confirmed.md @@ -12,7 +12,7 @@ related_lifecycle: - sales.order-lifecycle evidence: - type: human_review - location: examples/erp/domain/lifecycles/sales.order-lifecycle.md + location: examples/erp/opendomain/lifecycles/sales.order-lifecycle.md summary: Example fixture emits OrderConfirmed when the order enters Confirmed. confidence: high review: diff --git a/examples/erp/domain/lifecycles/sales.order-lifecycle.md b/examples/erp/opendomain/lifecycles/sales.order-lifecycle.md similarity index 95% rename from examples/erp/domain/lifecycles/sales.order-lifecycle.md rename to examples/erp/opendomain/lifecycles/sales.order-lifecycle.md index 36b71a0..58de911 100644 --- a/examples/erp/domain/lifecycles/sales.order-lifecycle.md +++ b/examples/erp/opendomain/lifecycles/sales.order-lifecycle.md @@ -46,7 +46,7 @@ related_rules: - sales.confirmed-order-cannot-be-deleted evidence: - type: human_review - location: examples/erp/domain/concepts/sales.order.md + location: examples/erp/opendomain/concepts/sales.order.md summary: Example fixture defines the accepted order lifecycle. confidence: high review: diff --git a/examples/erp/domain/rules/sales.confirmed-order-cannot-be-deleted.md b/examples/erp/opendomain/rules/sales.confirmed-order-cannot-be-deleted.md similarity index 92% rename from examples/erp/domain/rules/sales.confirmed-order-cannot-be-deleted.md rename to examples/erp/opendomain/rules/sales.confirmed-order-cannot-be-deleted.md index e932de4..a11ffbb 100644 --- a/examples/erp/domain/rules/sales.confirmed-order-cannot-be-deleted.md +++ b/examples/erp/opendomain/rules/sales.confirmed-order-cannot-be-deleted.md @@ -10,7 +10,7 @@ severity: must rule_type: invariant evidence: - type: human_review - location: examples/erp/domain/concepts/sales.order.md + location: examples/erp/opendomain/concepts/sales.order.md summary: Example fixture treats confirmed order deletion as a stable invariant. confidence: high review: diff --git a/domain/openspec/changes/self-model-maintenance/spec.md b/examples/self-model/openspec/changes/self-model-maintenance/spec.md similarity index 93% rename from domain/openspec/changes/self-model-maintenance/spec.md rename to examples/self-model/openspec/changes/self-model-maintenance/spec.md index 475baad..c0c8979 100644 --- a/domain/openspec/changes/self-model-maintenance/spec.md +++ b/examples/self-model/openspec/changes/self-model-maintenance/spec.md @@ -36,7 +36,7 @@ domain-modeling workflow. Before modifying OpenDomain's self model, Codex should run: ```bash -npm run opendomain -- prepare domain/openspec/changes/self-model-maintenance/spec.md +npm run opendomain -- prepare examples/self-model/openspec/changes/self-model-maintenance/spec.md ``` Then read the accepted self-model files listed under `Read first`. diff --git a/domain/README.md b/opendomain/README.md similarity index 100% rename from domain/README.md rename to opendomain/README.md diff --git a/domain/candidates/.gitkeep b/opendomain/candidates/.gitkeep similarity index 100% rename from domain/candidates/.gitkeep rename to opendomain/candidates/.gitkeep diff --git a/domain/candidates/candidate-0002-semantic-retrieval-index.md b/opendomain/candidates/candidate-0002-semantic-retrieval-index.md similarity index 100% rename from domain/candidates/candidate-0002-semantic-retrieval-index.md rename to opendomain/candidates/candidate-0002-semantic-retrieval-index.md diff --git a/domain/candidates/candidate-0003-grounding-protocol-boundary.md b/opendomain/candidates/candidate-0003-grounding-protocol-boundary.md similarity index 100% rename from domain/candidates/candidate-0003-grounding-protocol-boundary.md rename to opendomain/candidates/candidate-0003-grounding-protocol-boundary.md diff --git a/domain/candidates/candidate-0004-profile-runtime-normalizes-not-infers.md b/opendomain/candidates/candidate-0004-profile-runtime-normalizes-not-infers.md similarity index 100% rename from domain/candidates/candidate-0004-profile-runtime-normalizes-not-infers.md rename to opendomain/candidates/candidate-0004-profile-runtime-normalizes-not-infers.md diff --git a/domain/candidates/candidate-0005-git-native-not-git-required.md b/opendomain/candidates/candidate-0005-git-native-not-git-required.md similarity index 100% rename from domain/candidates/candidate-0005-git-native-not-git-required.md rename to opendomain/candidates/candidate-0005-git-native-not-git-required.md diff --git a/domain/candidates/candidate-0006-opendomain-workspace.md b/opendomain/candidates/candidate-0006-opendomain-workspace.md similarity index 100% rename from domain/candidates/candidate-0006-opendomain-workspace.md rename to opendomain/candidates/candidate-0006-opendomain-workspace.md diff --git a/domain/candidates/candidate-0007-domain-declaration.md b/opendomain/candidates/candidate-0007-domain-declaration.md similarity index 100% rename from domain/candidates/candidate-0007-domain-declaration.md rename to opendomain/candidates/candidate-0007-domain-declaration.md diff --git a/domain/candidates/candidate-0008-semantic-integrity-outranks-token-efficiency.md b/opendomain/candidates/candidate-0008-semantic-integrity-outranks-token-efficiency.md similarity index 100% rename from domain/candidates/candidate-0008-semantic-integrity-outranks-token-efficiency.md rename to opendomain/candidates/candidate-0008-semantic-integrity-outranks-token-efficiency.md diff --git a/domain/candidates/candidate-0009-missing-reference-fails-not-proposes.md b/opendomain/candidates/candidate-0009-missing-reference-fails-not-proposes.md similarity index 100% rename from domain/candidates/candidate-0009-missing-reference-fails-not-proposes.md rename to opendomain/candidates/candidate-0009-missing-reference-fails-not-proposes.md diff --git a/domain/candidates/candidate-0010-source-unit.md b/opendomain/candidates/candidate-0010-source-unit.md similarity index 100% rename from domain/candidates/candidate-0010-source-unit.md rename to opendomain/candidates/candidate-0010-source-unit.md diff --git a/domain/concepts/.gitkeep b/opendomain/concepts/.gitkeep similarity index 100% rename from domain/concepts/.gitkeep rename to opendomain/concepts/.gitkeep diff --git a/domain/concepts/opendomain.business-rule.md b/opendomain/concepts/opendomain.business-rule.md similarity index 100% rename from domain/concepts/opendomain.business-rule.md rename to opendomain/concepts/opendomain.business-rule.md diff --git a/domain/concepts/opendomain.domain-candidate.md b/opendomain/concepts/opendomain.domain-candidate.md similarity index 100% rename from domain/concepts/opendomain.domain-candidate.md rename to opendomain/concepts/opendomain.domain-candidate.md diff --git a/domain/concepts/opendomain.domain-concept.md b/opendomain/concepts/opendomain.domain-concept.md similarity index 100% rename from domain/concepts/opendomain.domain-concept.md rename to opendomain/concepts/opendomain.domain-concept.md diff --git a/domain/concepts/opendomain.domain-knowledge.md b/opendomain/concepts/opendomain.domain-knowledge.md similarity index 100% rename from domain/concepts/opendomain.domain-knowledge.md rename to opendomain/concepts/opendomain.domain-knowledge.md diff --git a/domain/concepts/opendomain.grounding-pack.md b/opendomain/concepts/opendomain.grounding-pack.md similarity index 100% rename from domain/concepts/opendomain.grounding-pack.md rename to opendomain/concepts/opendomain.grounding-pack.md diff --git a/domain/contexts/.gitkeep b/opendomain/contexts/.gitkeep similarity index 100% rename from domain/contexts/.gitkeep rename to opendomain/contexts/.gitkeep diff --git a/domain/contexts/opendomain.md b/opendomain/contexts/opendomain.md similarity index 100% rename from domain/contexts/opendomain.md rename to opendomain/contexts/opendomain.md diff --git a/domain/events/.gitkeep b/opendomain/events/.gitkeep similarity index 100% rename from domain/events/.gitkeep rename to opendomain/events/.gitkeep diff --git a/domain/events/opendomain.grounding-prepared.md b/opendomain/events/opendomain.grounding-prepared.md similarity index 100% rename from domain/events/opendomain.grounding-prepared.md rename to opendomain/events/opendomain.grounding-prepared.md diff --git a/domain/lifecycles/.gitkeep b/opendomain/lifecycles/.gitkeep similarity index 100% rename from domain/lifecycles/.gitkeep rename to opendomain/lifecycles/.gitkeep diff --git a/domain/lifecycles/opendomain.candidate-review-lifecycle.md b/opendomain/lifecycles/opendomain.candidate-review-lifecycle.md similarity index 100% rename from domain/lifecycles/opendomain.candidate-review-lifecycle.md rename to opendomain/lifecycles/opendomain.candidate-review-lifecycle.md diff --git a/domain/rules/.gitkeep b/opendomain/rules/.gitkeep similarity index 100% rename from domain/rules/.gitkeep rename to opendomain/rules/.gitkeep diff --git a/domain/rules/opendomain.accepted-knowledge-requires-human-review.md b/opendomain/rules/opendomain.accepted-knowledge-requires-human-review.md similarity index 100% rename from domain/rules/opendomain.accepted-knowledge-requires-human-review.md rename to opendomain/rules/opendomain.accepted-knowledge-requires-human-review.md diff --git a/domain/rules/opendomain.ai-inference-must-start-as-candidate.md b/opendomain/rules/opendomain.ai-inference-must-start-as-candidate.md similarity index 100% rename from domain/rules/opendomain.ai-inference-must-start-as-candidate.md rename to opendomain/rules/opendomain.ai-inference-must-start-as-candidate.md diff --git a/domain/rules/opendomain.candidate-is-not-accepted-knowledge.md b/opendomain/rules/opendomain.candidate-is-not-accepted-knowledge.md similarity index 100% rename from domain/rules/opendomain.candidate-is-not-accepted-knowledge.md rename to opendomain/rules/opendomain.candidate-is-not-accepted-knowledge.md diff --git a/domain/rules/opendomain.codex-must-prepare-domain-grounding.md b/opendomain/rules/opendomain.codex-must-prepare-domain-grounding.md similarity index 100% rename from domain/rules/opendomain.codex-must-prepare-domain-grounding.md rename to opendomain/rules/opendomain.codex-must-prepare-domain-grounding.md diff --git a/domain/rules/opendomain.codex-readable-entrypoints-must-be-structured.md b/opendomain/rules/opendomain.codex-readable-entrypoints-must-be-structured.md similarity index 100% rename from domain/rules/opendomain.codex-readable-entrypoints-must-be-structured.md rename to opendomain/rules/opendomain.codex-readable-entrypoints-must-be-structured.md diff --git a/domain/rules/opendomain.index-is-derived-view-not-source-of-truth.md b/opendomain/rules/opendomain.index-is-derived-view-not-source-of-truth.md similarity index 100% rename from domain/rules/opendomain.index-is-derived-view-not-source-of-truth.md rename to opendomain/rules/opendomain.index-is-derived-view-not-source-of-truth.md diff --git a/domain/rules/opendomain.openspec-references-opendomain-not-duplicate.md b/opendomain/rules/opendomain.openspec-references-opendomain-not-duplicate.md similarity index 100% rename from domain/rules/opendomain.openspec-references-opendomain-not-duplicate.md rename to opendomain/rules/opendomain.openspec-references-opendomain-not-duplicate.md diff --git a/package.json b/package.json index 16179c3..a329d42 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "test": "node --test", "opendomain": "node ./bin/opendomain.mjs", "validate": "node ./bin/opendomain.mjs validate", - "prepare:demo": "node ./bin/opendomain.mjs prepare examples/erp/openspec/changes/order-cancellation/spec.md", + "prepare:demo": "cd examples/erp && node ../../bin/opendomain.mjs prepare openspec/changes/order-cancellation/spec.md", "demo": "node ./bin/opendomain.mjs demo order-cancellation", "prepublishOnly": "npm test && npm run opendomain -- validate" }, diff --git a/src/cli.mjs b/src/cli.mjs index 42f111b..a576ce2 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -12,7 +12,8 @@ import { export async function runCli(argv, options = {}) { const io = { stdout: options.stdout ?? process.stdout, - stderr: options.stderr ?? process.stderr + stderr: options.stderr ?? process.stderr, + cwd: options.cwd ?? process.cwd() }; const [command, subcommand, ...rest] = argv; @@ -88,6 +89,12 @@ Usage: opendomain candidate review --decision --reviewed-by --reason [path] [--json] opendomain demo order-cancellation +Workspace: + Commands without [path] read the current project's canonical opendomain/. + During 0.x, domain/ is a warned fallback when opendomain/ is absent. + If both roots exist, opendomain/ wins; the roots are never merged. + Pass a Markdown file or directory explicitly for examples or external corpora. + `); } @@ -103,10 +110,11 @@ async function runInit(args, io) { if (parsed.errors.length > 0) { const result = { - target: process.cwd(), + target: io.cwd, example: parsed.example ?? null, created: [], skipped: [], + warnings: [], errors: parsed.errors, next_steps: [] }; @@ -118,7 +126,7 @@ async function runInit(args, io) { return 1; } - const result = await initializeProject({ cwd: process.cwd(), example: parsed.example }); + const result = await initializeProject({ cwd: io.cwd, example: parsed.example }); if (parsed.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); } else { @@ -130,7 +138,7 @@ async function runInit(args, io) { async function runValidate(args, io) { const { json, paths } = splitArgs(args); - const result = await validatePath(paths[0], { cwd: process.cwd() }); + const result = await validatePath(paths[0], { cwd: io.cwd }); if (json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -143,7 +151,7 @@ async function runValidate(args, io) { async function runCandidateList(args, io) { const parsed = parseCandidatePathArgs(args); - const result = await listCandidates(parsed.path, { cwd: process.cwd() }); + const result = await listCandidates(parsed.path, { cwd: io.cwd }); if (parsed.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -158,7 +166,7 @@ async function runCandidateShow(args, io) { const parsed = parseCandidateShowArgs(args); const result = parsed.errors.length > 0 ? { source: parsed.path ?? "", candidate: null, warnings: [], errors: parsed.errors } - : await showCandidate(parsed.id, parsed.path, { cwd: process.cwd() }); + : await showCandidate(parsed.id, parsed.path, { cwd: io.cwd }); if (parsed.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -185,7 +193,7 @@ async function runCandidateReview(args, io) { reviewedBy: parsed.reviewedBy, reviewedAt: parsed.reviewedAt, reason: parsed.reason - }, { cwd: process.cwd() }); + }, { cwd: io.cwd }); if (parsed.json) { io.stdout.write(`${JSON.stringify(result, null, 2)}\n`); @@ -355,7 +363,7 @@ function parseCandidateReviewArgs(args) { async function runPrepare(args, io) { const parsed = parsePrepareArgs(args); const pack = await prepareGroundingPack(parsed.path, { - cwd: process.cwd(), + cwd: io.cwd, integration: parsed.integration }); @@ -396,10 +404,14 @@ function parsePrepareArgs(args) { async function runIndexBuild(args, io) { const parsed = parseIndexBuildArgs(args); - const result = await buildSemanticIndex(parsed.path, { cwd: process.cwd() }); + const result = await buildSemanticIndex(parsed.path, { cwd: io.cwd }); if (result.errors.length === 0) { - const file = await writeSemanticIndex(result.index, parsed.out, { cwd: process.cwd() }); + const file = await writeSemanticIndex( + result.index, + parsed.out ?? result.defaultIndexPath, + { cwd: io.cwd } + ); result.file = file; } @@ -436,7 +448,7 @@ async function runIndexQuery(args, io) { try { result = await querySemanticIndex( parsed.context ? { context: parsed.context } : { id: parsed.id }, - { cwd: process.cwd(), indexPath: parsed.indexPath } + { cwd: io.cwd, indexPath: parsed.indexPath } ); } catch (error) { result = { @@ -448,7 +460,7 @@ async function runIndexQuery(args, io) { warnings: [], errors: [{ severity: "error", - file: parsed.indexPath, + file: parsed.indexPath ?? "", field: "$", problem: error instanceof Error ? error.message : String(error), fix: "Run opendomain index build or pass --index ." @@ -467,7 +479,7 @@ async function runIndexQuery(args, io) { async function runIdsList(args, io) { const { json, paths } = splitArgs(args); - const result = await validatePath(paths[0], { cwd: process.cwd() }); + const result = await validatePath(paths[0], { cwd: io.cwd }); const ids = result.documents .filter((document) => document.id) .map((document) => ({ @@ -478,11 +490,17 @@ async function runIdsList(args, io) { .sort((left, right) => left.id.localeCompare(right.id)); if (json) { - io.stdout.write(`${JSON.stringify({ ids }, null, 2)}\n`); + io.stdout.write(`${JSON.stringify({ + ids, + workspace: result.workspace, + warnings: result.warnings, + errors: result.errors + }, null, 2)}\n`); } else { for (const item of ids) { io.stdout.write(`${item.id}\t${item.type}\t${item.file}\n`); } + printIssues([...result.errors, ...result.warnings], io.stdout); } return result.errors.length > 0 ? 1 : 0; @@ -491,7 +509,7 @@ async function runIdsList(args, io) { function parseIndexBuildArgs(args) { const parsed = { json: false, - out: DEFAULT_INDEX_PATH, + out: undefined, path: undefined }; @@ -517,7 +535,7 @@ function parseIndexBuildArgs(args) { function parseIndexQueryArgs(args) { const parsed = { json: false, - indexPath: DEFAULT_INDEX_PATH, + indexPath: undefined, id: undefined, context: undefined, errors: [] @@ -703,7 +721,7 @@ function printCandidateReviewResult(result, stream) { function printInitResult(result, stream) { if (result.errors.length > 0) { stream.write(`OpenDomain init failed: ${result.errors.length} errors.\n`); - for (const issue of result.errors) { + for (const issue of [...result.errors, ...(result.warnings ?? [])]) { stream.write(`\n[${issue.severity}] ${issue.file}\n`); stream.write(` field: ${issue.field}\n`); stream.write(` problem: ${issue.problem}\n`); @@ -740,6 +758,13 @@ function printInitResult(result, stream) { for (const step of result.next_steps) { stream.write(`- ${step}\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 printIndexQueryResult(result, stream) { @@ -788,7 +813,7 @@ function printIndexQueryResult(result, stream) { } async function runOrderCancellationDemo(io) { - const result = await validatePath("examples/erp", { cwd: process.cwd() }); + const result = await validatePath("examples/erp", { cwd: io.cwd }); const feature = result.documents.find((document) => document.id === "spec.order-cancellation"); const references = feature?.frontmatter?.affects_domain ?? {}; diff --git a/src/indexer.mjs b/src/indexer.mjs index 342bd9d..203c4ba 100644 --- a/src/indexer.mjs +++ b/src/indexer.mjs @@ -3,8 +3,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { collectSemanticClosure } from "./semantic-closure.mjs"; import { validatePath } from "./validator.mjs"; +import { + LEGACY_DEFAULT_INDEX_PATH, + resolveDefaultIndexPath +} from "./workspace-resolver.mjs"; -export const DEFAULT_INDEX_PATH = ".opendomain/index.json"; +export const DEFAULT_INDEX_PATH = LEGACY_DEFAULT_INDEX_PATH; export const INDEX_SCHEMA = "opendomain.semantic-index.v1"; export async function buildSemanticIndex(targetPath, options = {}) { @@ -15,7 +19,8 @@ export async function buildSemanticIndex(targetPath, options = {}) { const result = { index: null, errors: validation.errors, - warnings: validation.warnings + warnings: validation.warnings, + defaultIndexPath: validation.workspace?.default_index_path ?? DEFAULT_INDEX_PATH }; if (validation.errors.length > 0) { @@ -31,7 +36,7 @@ export async function buildSemanticIndex(targetPath, options = {}) { result.index = { schema: INDEX_SCHEMA, generated_at: now.toISOString(), - source_root: targetPath ?? "", + source_root: validation.workspace?.source_root ?? targetPath ?? "", derived_from: "OpenDomain Markdown source files in Git", authoritative_source: "OpenDomain source files, not this index", entries: entries.sort(compareById) @@ -66,12 +71,25 @@ export async function loadSemanticIndex(indexPath = DEFAULT_INDEX_PATH, options export async function querySemanticIndex(query, options = {}) { const cwd = options.cwd ?? process.cwd(); - const indexPath = options.indexPath ?? DEFAULT_INDEX_PATH; + let indexPath = options.indexPath; + const resolutionWarnings = []; + if (!indexPath) { + const resolution = await resolveDefaultIndexPath({ cwd }); + resolutionWarnings.push(...resolution.warnings); + if (resolution.errors.length > 0) { + return emptyQueryResult(query, { + indexPath: null, + warnings: resolutionWarnings, + errors: resolution.errors + }); + } + indexPath = resolution.defaultIndexPath; + } const loaded = await loadSemanticIndex(indexPath, { cwd }); const index = loaded.index; const entriesById = new Map((index.entries ?? []).map((entry) => [entry.id, entry])); const errors = []; - const warnings = []; + const warnings = [...resolutionWarnings]; const queryMode = query.context ? "context" : "id"; let selectedEntries = []; @@ -138,6 +156,27 @@ export async function querySemanticIndex(query, options = {}) { }; } +function emptyQueryResult(query, { indexPath, warnings, errors }) { + return { + query: query.context ? { context: query.context } : { id: query.id }, + index_file: indexPath, + schema: null, + source_files_authoritative: true, + authoritative_source: "OpenDomain source files, not this index", + semantic_closure: { + policy: null, + root_ids: [], + selection_paths: [] + }, + read_first: [], + accepted_ids: [], + candidate_boundaries: [], + verify_with: [], + warnings, + errors + }; +} + async function toIndexEntry(document, cwd, now) { const frontmatter = document.frontmatter; const sourceFile = document.file; diff --git a/src/init.mjs b/src/init.mjs index b6b1501..85d0b21 100644 --- a/src/init.mjs +++ b/src/init.mjs @@ -1,17 +1,18 @@ import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { inspectWorkspaceRoots } from "./workspace-resolver.mjs"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const DOMAIN_DIRECTORIES = [ - "domain", - "domain/contexts", - "domain/concepts", - "domain/rules", - "domain/lifecycles", - "domain/events", - "domain/candidates" +const WORKSPACE_DIRECTORIES = [ + "opendomain", + "opendomain/contexts", + "opendomain/concepts", + "opendomain/rules", + "opendomain/lifecycles", + "opendomain/events", + "opendomain/candidates" ]; export async function initializeProject(options = {}) { @@ -22,6 +23,7 @@ export async function initializeProject(options = {}) { example: options.example ?? null, created: [], skipped: [], + warnings: [], errors: [], next_steps: [] }; @@ -37,15 +39,32 @@ export async function initializeProject(options = {}) { return result; } - for (const directory of DOMAIN_DIRECTORIES) { + const workspace = await inspectWorkspaceRoots({ cwd }); + result.warnings.push(...workspace.warnings); + result.errors.push(...workspace.errors); + if (result.errors.length > 0) { + return result; + } + if (workspace.mode === "legacy") { + result.errors.push({ + severity: "error", + file: "domain", + field: "$", + problem: "Cannot initialize canonical 'opendomain/' while a legacy-only 'domain/' workspace exists.", + fix: "Continue using the legacy workspace during 0.x, or move it non-destructively before running init again." + }); + return result; + } + + for (const directory of WORKSPACE_DIRECTORIES) { await ensureDirectory(path.join(cwd, directory), cwd, result); } - await writeFileIfMissing(path.join(cwd, "domain/README.md"), domainReadmeTemplate(), cwd, result); - await writeFileIfMissing(path.join(cwd, "domain/contexts/example.md"), contextTemplate(), cwd, result); - await writeFileIfMissing(path.join(cwd, "domain/concepts/example.concept.md"), conceptTemplate(), cwd, result); + await writeFileIfMissing(path.join(cwd, "opendomain/README.md"), domainReadmeTemplate(), cwd, result); + await writeFileIfMissing(path.join(cwd, "opendomain/contexts/example.md"), contextTemplate(), cwd, result); + await writeFileIfMissing(path.join(cwd, "opendomain/concepts/example.concept.md"), conceptTemplate(), cwd, result); await writeFileIfMissing( - path.join(cwd, "domain/candidates/candidate-0001-first-domain-model.md"), + path.join(cwd, "opendomain/candidates/candidate-0001-first-domain-model.md"), candidateTemplate(today), cwd, result @@ -56,9 +75,9 @@ export async function initializeProject(options = {}) { await copyExample(options.example, cwd, result); } - result.next_steps.push("Edit domain/contexts/example.md and domain/concepts/example.concept.md for your first real bounded context."); - result.next_steps.push("Run opendomain validate domain."); - result.next_steps.push("Keep inferred or uncertain business knowledge in domain/candidates/ until human review."); + result.next_steps.push("Edit opendomain/contexts/example.md and opendomain/concepts/example.concept.md for your first real bounded context."); + result.next_steps.push("Run opendomain validate."); + result.next_steps.push("Keep inferred or uncertain business knowledge in opendomain/candidates/ until human review."); if (options.example) { result.next_steps.push("Run opendomain validate examples/erp to inspect the copied ERP example."); } @@ -219,7 +238,7 @@ Describe what this concept means in the business world. ## Agent Guidance -Keep uncertain or AI-inferred knowledge in \`domain/candidates/\` until human +Keep uncertain or AI-inferred knowledge in \`opendomain/candidates/\` until human review. `; } diff --git a/src/validator.mjs b/src/validator.mjs index c67785c..2660bce 100644 --- a/src/validator.mjs +++ b/src/validator.mjs @@ -1,6 +1,6 @@ -import { access, readdir, stat } from "node:fs/promises"; import path from "node:path"; import { parseMarkdownFile, FrontMatterError } from "./frontmatter.mjs"; +import { resolveWorkspaceSources } from "./workspace-resolver.mjs"; import { createDomainSchemaRegistry, DOMAIN_SOURCE_TYPES, @@ -49,7 +49,8 @@ export async function validatePath(targetPath, options = {}) { const result = { documents: [], errors: [], - warnings: [] + warnings: [], + workspace: null }; let schemaRegistry; @@ -60,11 +61,21 @@ export async function validatePath(targetPath, options = {}) { return result; } - const files = await collectMarkdownFiles(targetPath, cwd, result); + const resolution = await resolveWorkspaceSources(targetPath, { cwd }); + result.workspace = { + mode: resolution.mode, + source_root: resolution.sourceRootDisplay, + default_index_path: resolution.defaultIndexPath, + explicit: resolution.explicit + }; + result.errors.push(...resolution.errors); + result.warnings.push(...resolution.warnings); + + const files = resolution.files; for (const file of files) { try { const parsed = await parseMarkdownFile(file); - const relativeFile = path.relative(cwd, file) || path.basename(file); + const relativeFile = documentPath(file, resolution.projectRoot); const type = parsed.frontmatter.type; if (type === undefined || type === null || type === "") { @@ -122,7 +133,7 @@ export async function validatePath(targetPath, options = {}) { }); } catch (error) { addIssue(result.errors, { - file: path.relative(cwd, file) || file, + file: documentPath(file, resolution.projectRoot), field: error instanceof FrontMatterError ? error.field : "$", problem: error instanceof FrontMatterError ? error.problem : String(error.message ?? error), fix: "Add valid YAML front matter delimited by --- at the top of the Markdown file." @@ -154,65 +165,6 @@ function addSchemaRegistryIssue(result, error) { }); } -async function collectMarkdownFiles(targetPath, cwd, result) { - if (!targetPath) { - const roots = ["domain", "examples"] - .map((root) => path.join(cwd, root)); - const files = []; - for (const root of roots) { - if (await exists(root)) { - files.push(...await walkMarkdown(root)); - } - } - return files; - } - - const absoluteTarget = path.resolve(cwd, targetPath); - if (!await exists(absoluteTarget)) { - addIssue(result.errors, { - file: targetPath, - field: "$", - problem: "Path does not exist.", - fix: "Pass an existing OpenDomain file or directory." - }); - return []; - } - - const targetStat = await stat(absoluteTarget); - if (targetStat.isFile()) { - return absoluteTarget.endsWith(".md") ? [absoluteTarget] : []; - } - return walkMarkdown(absoluteTarget); -} - -async function walkMarkdown(root) { - const entries = await readdir(root, { withFileTypes: true }); - const files = []; - for (const entry of entries) { - if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".codex") { - continue; - } - const fullPath = path.join(root, entry.name); - if (entry.isDirectory()) { - files.push(...await walkMarkdown(fullPath)); - continue; - } - if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "README.md") { - files.push(fullPath); - } - } - return files.sort(); -} - -async function exists(file) { - try { - await access(file); - return true; - } catch { - return false; - } -} - function validateDocuments(result, now) { const byId = new Map(); const domainById = new Map(); @@ -646,3 +598,15 @@ function deleteInternalPaths(result) { delete document.absoluteFile; } } + +function documentPath(file, projectRoot) { + const relative = path.relative(projectRoot, file); + if ( + relative === ".." + || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative) + ) { + return file; + } + return (relative || path.basename(file)).split(path.sep).join("/"); +} diff --git a/src/workspace-resolver.mjs b/src/workspace-resolver.mjs new file mode 100644 index 0000000..dffbbae --- /dev/null +++ b/src/workspace-resolver.mjs @@ -0,0 +1,396 @@ +import { lstat, readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; + +export const CANONICAL_WORKSPACE_DIRECTORY = "opendomain"; +export const LEGACY_WORKSPACE_DIRECTORY = "domain"; +export const CANONICAL_DEFAULT_INDEX_PATH = "opendomain/generated/index.json"; +export const LEGACY_DEFAULT_INDEX_PATH = ".opendomain/index.json"; + +export const SEMANTIC_SOURCE_DIRECTORIES = Object.freeze([ + "contexts", + "concepts", + "rules", + "lifecycles", + "events", + "candidates" +]); + +const SKIPPED_DIRECTORY_NAMES = new Set([".git", "node_modules", ".codex"]); + +export async function inspectWorkspaceRoots(options = {}) { + const cwd = path.resolve(options.cwd ?? process.cwd()); + const projectRoot = await realpath(cwd); + const canonical = await inspectImplicitRoot( + path.join(cwd, CANONICAL_WORKSPACE_DIRECTORY), + projectRoot, + CANONICAL_WORKSPACE_DIRECTORY + ); + const legacy = await inspectImplicitRoot( + path.join(cwd, LEGACY_WORKSPACE_DIRECTORY), + projectRoot, + LEGACY_WORKSPACE_DIRECTORY + ); + const warnings = []; + const errors = []; + + if (canonical.exists && legacy.exists) { + warnings.push(issue({ + severity: "warning", + file: CANONICAL_WORKSPACE_DIRECTORY, + problem: "Both canonical 'opendomain/' and legacy 'domain/' workspaces exist; using 'opendomain/' and ignoring 'domain/'.", + fix: "Keep canonical sources in 'opendomain/' and remove the legacy root only after confirming it is no longer needed." + })); + } + + const selected = canonical.exists ? canonical : legacy.exists ? legacy : null; + if (selected?.error) { + errors.push(selected.error); + } + + if (selected === legacy) { + warnings.push(issue({ + severity: "warning", + file: LEGACY_WORKSPACE_DIRECTORY, + problem: "Using legacy OpenDomain workspace 'domain/' during the 0.x compatibility period.", + fix: "Plan a non-destructive move to canonical 'opendomain/'; OpenDomain will continue reading 'domain/' throughout 0.x." + })); + } + + return { + projectRoot, + mode: selected === canonical + ? "canonical" + : selected === legacy + ? "legacy" + : null, + sourceRoot: selected?.realPath ?? null, + sourceRootDisplay: selected?.displayPath ?? null, + defaultIndexPath: selected === canonical + ? CANONICAL_DEFAULT_INDEX_PATH + : LEGACY_DEFAULT_INDEX_PATH, + canonicalExists: canonical.exists, + legacyExists: legacy.exists, + warnings, + errors + }; +} + +export async function resolveWorkspaceSources(targetPath, options = {}) { + const cwd = path.resolve(options.cwd ?? process.cwd()); + if (targetPath !== undefined && targetPath !== null && targetPath !== "") { + return resolveExplicitSources(targetPath, cwd); + } + + const workspace = await inspectWorkspaceRoots({ cwd }); + const result = { + ...workspace, + files: [], + explicit: false + }; + + if (result.errors.length > 0) { + return result; + } + + if (!workspace.sourceRoot) { + result.errors.push(issue({ + file: "", + problem: "No OpenDomain workspace found.", + fix: "Run 'opendomain init' to create 'opendomain/', or pass an explicit Markdown file or directory." + })); + return result; + } + + try { + result.files = await collectImplicitMarkdown( + workspace.sourceRoot, + workspace.sourceRootDisplay, + result.errors + ); + } catch (error) { + result.errors.push(issue({ + file: workspace.sourceRootDisplay, + problem: `Unable to read the selected OpenDomain workspace: ${error.message}`, + fix: "Check workspace permissions and directory structure." + })); + return result; + } + + if (result.files.length === 0 && result.errors.length === 0) { + result.errors.push(issue({ + file: workspace.sourceRootDisplay, + problem: "Selected OpenDomain workspace contains no eligible Markdown sources.", + fix: "Add a non-README Markdown source under contexts, concepts, rules, lifecycles, events, or candidates." + })); + } + + return result; +} + +export async function resolveDefaultIndexPath(options = {}) { + const workspace = await inspectWorkspaceRoots(options); + if (!workspace.sourceRoot && workspace.errors.length === 0) { + workspace.errors.push(issue({ + file: "", + problem: "No OpenDomain workspace found for the default index path.", + fix: "Run 'opendomain init', or pass --index explicitly." + })); + } + return workspace; +} + +async function resolveExplicitSources(targetPath, cwd) { + const absoluteTarget = path.resolve(cwd, targetPath); + const result = { + projectRoot: await realpath(cwd), + mode: "explicit", + sourceRoot: null, + sourceRootDisplay: displayPath(cwd, absoluteTarget), + defaultIndexPath: LEGACY_DEFAULT_INDEX_PATH, + canonicalExists: false, + legacyExists: false, + warnings: [], + errors: [], + files: [], + explicit: true + }; + + let targetInfo; + try { + const targetRealPath = await realpath(absoluteTarget); + targetInfo = { + realPath: targetRealPath, + stat: await stat(targetRealPath) + }; + } catch (error) { + result.errors.push(issue({ + file: String(targetPath), + problem: error.code === "ENOENT" + ? "Path does not exist." + : `Unable to resolve path: ${error.message}`, + fix: "Pass an existing OpenDomain Markdown file or directory." + })); + return result; + } + + result.sourceRoot = targetInfo.realPath; + result.sourceRootDisplay = displayPath(cwd, targetInfo.realPath); + + if (targetInfo.stat.isFile()) { + if (!isMarkdownFile(targetInfo.realPath)) { + result.errors.push(issue({ + file: String(targetPath), + problem: "Explicit file target is not a Markdown file.", + fix: "Pass a file ending in .md or an existing directory." + })); + return result; + } + result.files = [targetInfo.realPath]; + return result; + } + + if (!targetInfo.stat.isDirectory()) { + result.errors.push(issue({ + file: String(targetPath), + problem: "Explicit target is neither a regular Markdown file nor a directory.", + fix: "Pass an existing Markdown file or directory." + })); + return result; + } + + try { + result.files = await walkMarkdown(targetInfo.realPath); + } catch (error) { + result.errors.push(issue({ + file: String(targetPath), + problem: `Unable to read explicit source directory: ${error.message}`, + fix: "Check target permissions and directory structure." + })); + return result; + } + + if (result.files.length === 0) { + result.errors.push(issue({ + file: String(targetPath), + problem: "Explicit directory contains no eligible Markdown sources.", + fix: "Pass a directory containing at least one non-README .md file." + })); + } + + return result; +} + +async function inspectImplicitRoot(rootPath, projectRoot, displayName) { + let rootStat; + try { + rootStat = await lstat(rootPath); + } catch (error) { + if (error.code === "ENOENT") { + return { + exists: false, + realPath: null, + displayPath: displayName, + error: null + }; + } + return { + exists: true, + realPath: null, + displayPath: displayName, + error: issue({ + file: displayName, + problem: `Unable to inspect workspace root: ${error.message}`, + fix: "Check workspace path permissions." + }) + }; + } + + let rootRealPath; + let resolvedStat = rootStat; + try { + rootRealPath = await realpath(rootPath); + if (rootStat.isSymbolicLink()) { + resolvedStat = await stat(rootRealPath); + } + } catch (error) { + return { + exists: true, + realPath: null, + displayPath: displayName, + error: issue({ + file: displayName, + problem: `Workspace root cannot be resolved: ${error.message}`, + fix: "Replace the broken workspace path with a readable directory." + }) + }; + } + + if (!resolvedStat.isDirectory()) { + return { + exists: true, + realPath: rootRealPath, + displayPath: displayName, + error: issue({ + file: displayName, + problem: "OpenDomain workspace root is not a directory.", + fix: `Replace '${displayName}' with a directory or pass an explicit Markdown target.` + }) + }; + } + + if (!isWithin(projectRoot, rootRealPath)) { + return { + exists: true, + realPath: rootRealPath, + displayPath: displayName, + error: issue({ + file: displayName, + problem: "OpenDomain workspace root resolves outside the project root.", + fix: "Use a workspace directory contained by the project, or pass the external path explicitly." + }) + }; + } + + return { + exists: true, + realPath: rootRealPath, + displayPath: displayName, + error: null + }; +} + +async function collectImplicitMarkdown(workspaceRoot, workspaceDisplay, errors) { + const files = []; + for (const directoryName of SEMANTIC_SOURCE_DIRECTORIES) { + const sourcePath = path.join(workspaceRoot, directoryName); + let sourceStat; + try { + sourceStat = await lstat(sourcePath); + } catch (error) { + if (error.code === "ENOENT") { + continue; + } + throw error; + } + + if (sourceStat.isSymbolicLink()) { + continue; + } + if (!sourceStat.isDirectory()) { + errors.push(issue({ + file: `${workspaceDisplay}/${directoryName}`, + problem: `Semantic source slot '${directoryName}/' is not a directory.`, + fix: `Replace '${directoryName}' with a directory or remove it from the workspace.` + })); + continue; + } + files.push(...await walkMarkdown(sourcePath)); + } + return sortPaths(files); +} + +async function walkMarkdown(root) { + const entries = (await readdir(root, { withFileTypes: true })) + .sort((left, right) => compareText(left.name, right.name)); + const files = []; + + for (const entry of entries) { + if (entry.isSymbolicLink()) { + continue; + } + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORY_NAMES.has(entry.name)) { + files.push(...await walkMarkdown(path.join(root, entry.name))); + } + continue; + } + if ( + entry.isFile() + && isMarkdownFile(entry.name) + && entry.name.toLowerCase() !== "readme.md" + ) { + files.push(path.join(root, entry.name)); + } + } + + return sortPaths(files); +} + +function sortPaths(files) { + return files.sort(compareText); +} + +function compareText(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + +function isMarkdownFile(file) { + return file.toLowerCase().endsWith(".md"); +} + +function isWithin(parent, child) { + const relative = path.relative(parent, child); + return relative === "" + || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +function displayPath(cwd, file) { + const relative = path.relative(cwd, file); + if (relative === "") { + return "."; + } + 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: "$", + problem: fields.problem, + fix: fields.fix + }; +} diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 09690dd..06b710a 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -1,11 +1,25 @@ import assert from "node:assert/strict"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { runCli } from "../src/cli.mjs"; import { parseMarkdown } from "../src/frontmatter.mjs"; +const REPOSITORY_ROOT = process.cwd(); +const ERP_ROOT = path.join(REPOSITORY_ROOT, "examples/erp"); + +test("help explains canonical and legacy workspace resolution", async () => { + const stdout = memoryStream(); + const exitCode = await runCli(["help"], { stdout, stderr: memoryStream() }); + const output = stdout.toString(); + + assert.equal(exitCode, 0); + assert.match(output, /canonical opendomain\//); + assert.match(output, /domain\/ is a warned fallback/); + assert.match(output, /roots are never merged/); +}); + test("validate command returns JSON and zero exit code for valid ERP example", async () => { const stdout = memoryStream(); const stderr = memoryStream(); @@ -89,12 +103,12 @@ test("init command creates a minimal valid OpenDomain structure", async () => { assert.equal(exitCode, 0); assert.match(output, /OpenDomain init completed/); - assert.match(output, /domain\/contexts\/example\.md/); - assert.match(output, /domain\/concepts\/example\.concept\.md/); - assert.match(output, /domain\/candidates\/candidate-0001-first-domain-model\.md/); + 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/); const validateStdout = memoryStream(); - const validateExitCode = await runCli(["validate", "domain", "--json"], { stdout: validateStdout, stderr: memoryStream() }); + const validateExitCode = await runCli(["validate", "--json"], { stdout: validateStdout, stderr: memoryStream() }); const payload = JSON.parse(validateStdout.toString()); assert.equal(validateExitCode, 0); @@ -107,19 +121,61 @@ test("init command creates a minimal valid OpenDomain structure", async () => { test("init command does not overwrite existing files", async () => { await withTempCwd(async () => { - await mkdir("domain/contexts", { recursive: true }); - await writeFile("domain/contexts/example.md", "existing content\n", "utf8"); + await mkdir("opendomain/contexts", { recursive: true }); + await writeFile("opendomain/contexts/example.md", "existing content\n", "utf8"); const stdout = memoryStream(); const stderr = memoryStream(); const exitCode = await runCli(["init"], { stdout, stderr }); const output = stdout.toString(); - const existing = await readFile("domain/contexts/example.md", "utf8"); + const existing = await readFile("opendomain/contexts/example.md", "utf8"); assert.equal(exitCode, 0); assert.equal(existing, "existing content\n"); - assert.match(output, /domain\/contexts\/example\.md \(already exists\)/); + assert.match(output, /opendomain\/contexts\/example\.md \(already exists\)/); + }); +}); + +test("init command refuses to shadow a legacy-only workspace", async () => { + await withTempCwd(async () => { + await mkdir("domain/contexts", { recursive: true }); + await writeFile("domain/contexts/existing.md", "legacy content\n", "utf8"); + + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--json"], { + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 1); + assert.ok(payload.errors.some((error) => error.problem.includes("legacy-only"))); + assert.ok(payload.warnings.some((warning) => warning.problem.includes("legacy"))); + await assert.rejects(access("opendomain"), { code: "ENOENT" }); + assert.equal(await readFile("domain/contexts/existing.md", "utf8"), "legacy content\n"); + }); +}); + +test("init command uses canonical workspace when both roots exist", async () => { + await withTempCwd(async () => { + await mkdir("opendomain/contexts", { recursive: true }); + await writeFile("opendomain/contexts/example.md", "canonical content\n", "utf8"); + await mkdir("domain/contexts", { recursive: true }); + await writeFile("domain/contexts/example.md", "legacy content\n", "utf8"); + + const stdout = memoryStream(); + const exitCode = await runCli(["init", "--json"], { + stdout, + stderr: memoryStream() + }); + const payload = JSON.parse(stdout.toString()); + + assert.equal(exitCode, 0); + assert.ok(payload.warnings.some((warning) => warning.problem.includes("ignoring 'domain/'"))); + assert.equal(await readFile("opendomain/contexts/example.md", "utf8"), "canonical content\n"); + assert.equal(await readFile("domain/contexts/example.md", "utf8"), "legacy content\n"); + await access("opendomain/candidates/candidate-0001-first-domain-model.md"); }); }); @@ -133,7 +189,7 @@ test("init command can copy the ERP example", async () => { assert.equal(exitCode, 0); assert.match(output, /Example: erp/); - assert.match(output, /examples\/erp\/domain\/concepts\/sales\.order\.md/); + assert.match(output, /examples\/erp\/opendomain\/concepts\/sales\.order\.md/); const validateStdout = memoryStream(); const validateExitCode = await runCli(["validate", "examples/erp", "--json"], { stdout: validateStdout, stderr: memoryStream() }); @@ -149,7 +205,11 @@ test("prepare command returns grounding pack for a feature spec", async () => { const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["prepare", "examples/erp/openspec/changes/order-cancellation/spec.md"], { stdout, stderr }); + const exitCode = await runCli(["prepare", "openspec/changes/order-cancellation/spec.md"], { + stdout, + stderr, + cwd: ERP_ROOT + }); const output = stdout.toString(); assert.equal(exitCode, 0); @@ -164,7 +224,11 @@ test("prepare command returns JSON grounding pack", async () => { const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["prepare", "examples/erp/openspec/changes/order-cancellation/spec.md", "--json"], { stdout, stderr }); + const exitCode = await runCli(["prepare", "openspec/changes/order-cancellation/spec.md", "--json"], { + stdout, + stderr, + cwd: ERP_ROOT + }); const payload = JSON.parse(stdout.toString()); assert.equal(exitCode, 0); @@ -186,9 +250,9 @@ test("prepare command supports explicit OpenSpec integration", async () => { "prepare", "--integration", "openspec", - "examples/erp/openspec/changes/order-cancellation/spec.md", + "openspec/changes/order-cancellation/spec.md", "--json" - ], { stdout, stderr }); + ], { stdout, stderr, cwd: ERP_ROOT }); const payload = JSON.parse(stdout.toString()); assert.equal(exitCode, 0); @@ -203,11 +267,11 @@ test("prepare command supports trailing explicit OpenSpec integration", async () const exitCode = await runCli([ "prepare", - "examples/erp/openspec/changes/order-cancellation/spec.md", + "openspec/changes/order-cancellation/spec.md", "--integration", "openspec", "--json" - ], { stdout, stderr }); + ], { stdout, stderr, cwd: ERP_ROOT }); const payload = JSON.parse(stdout.toString()); assert.equal(exitCode, 0); @@ -223,8 +287,8 @@ test("prepare command rejects unsupported integrations", async () => { "prepare", "--integration", "spec-kit", - "examples/erp/openspec/changes/order-cancellation/spec.md" - ], { stdout, stderr }); + "openspec/changes/order-cancellation/spec.md" + ], { stdout, stderr, cwd: ERP_ROOT }); const output = stdout.toString(); assert.equal(exitCode, 1); @@ -236,7 +300,11 @@ test("prepare command accepts a directory containing one feature spec", async () const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["prepare", "examples/erp/openspec/changes/order-cancellation"], { stdout, stderr }); + const exitCode = await runCli(["prepare", "openspec/changes/order-cancellation"], { + stdout, + stderr, + cwd: ERP_ROOT + }); assert.equal(exitCode, 0); assert.match(stdout.toString(), /Feature: spec\.order-cancellation/); @@ -246,7 +314,10 @@ test("prepare command works for the OpenDomain self model", async () => { const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["prepare", "domain/openspec/changes/self-model-maintenance/spec.md"], { stdout, stderr }); + const exitCode = await runCli([ + "prepare", + "examples/self-model/openspec/changes/self-model-maintenance/spec.md" + ], { stdout, stderr }); const output = stdout.toString(); assert.equal(exitCode, 0); @@ -259,7 +330,11 @@ test("prepare command fails when no feature spec exists", async () => { const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["prepare", "examples/erp/domain"], { stdout, stderr }); + const exitCode = await runCli(["prepare", "opendomain"], { + stdout, + stderr, + cwd: ERP_ROOT + }); assert.equal(exitCode, 1); assert.match(stdout.toString(), /No feature_spec found/); @@ -322,11 +397,11 @@ test("candidate review records rejected decision metadata", async () => { "2026-07-08", "--reason", "Evidence does not support the proposed concept.", - "domain", + "opendomain", "--json" ], { stdout, stderr }); const payload = JSON.parse(stdout.toString()); - const file = await readFile("domain/candidates/candidate-1000-review.md", "utf8"); + const file = await readFile("opendomain/candidates/candidate-1000-review.md", "utf8"); assert.equal(exitCode, 0); assert.equal(payload.effective_state, "rejected"); @@ -336,7 +411,7 @@ test("candidate review records rejected decision metadata", async () => { assert.match(file, /decision_reason: Evidence does not support the proposed concept\./); const validateStdout = memoryStream(); - const validateExitCode = await runCli(["validate", "domain", "--json"], { stdout: validateStdout, stderr: memoryStream() }); + const validateExitCode = await runCli(["validate", "opendomain", "--json"], { stdout: validateStdout, stderr: memoryStream() }); const validatePayload = JSON.parse(validateStdout.toString()); assert.equal(validateExitCode, 0); @@ -362,10 +437,10 @@ test("candidate accepted review maps to superseded without modifying accepted kn "2026-07-08", "--reason", "Human accepted the candidate; accepted source files will be updated manually.", - "domain" + "opendomain" ], { stdout, stderr }); const output = stdout.toString(); - const file = await readFile("domain/candidates/candidate-1001-accepted.md", "utf8"); + const file = await readFile("opendomain/candidates/candidate-1001-accepted.md", "utf8"); assert.equal(exitCode, 0); assert.match(output, /Decision: accepted/); @@ -385,8 +460,8 @@ test("candidate review safely round trips YAML-like decision metadata and body", body: "# Candidate Fixture\n\nBody: keep [brackets], commas, and \"quotes\" unchanged.\n" }); const before = parseMarkdown( - await readFile(`domain/candidates/${id}.md`, "utf8"), - `domain/candidates/${id}.md` + await readFile(`opendomain/candidates/${id}.md`, "utf8"), + `opendomain/candidates/${id}.md` ); const stdout = memoryStream(); @@ -403,12 +478,12 @@ test("candidate review safely round trips YAML-like decision metadata and body", "2026-07-22", "--reason", reason, - "domain", + "opendomain", "--json" ], { stdout, stderr }); const payload = JSON.parse(stdout.toString()); - const content = await readFile(`domain/candidates/${id}.md`, "utf8"); - const after = parseMarkdown(content, `domain/candidates/${id}.md`); + const content = await readFile(`opendomain/candidates/${id}.md`, "utf8"); + const after = parseMarkdown(content, `opendomain/candidates/${id}.md`); assert.equal(exitCode, 0); assert.equal(payload.errors.length, 0); @@ -420,7 +495,7 @@ test("candidate review safely round trips YAML-like decision metadata and body", assert.equal(after.body, before.body); const validateStdout = memoryStream(); - const validateExitCode = await runCli(["validate", "domain", "--json"], { + const validateExitCode = await runCli(["validate", "opendomain", "--json"], { stdout: validateStdout, stderr: memoryStream() }); @@ -445,7 +520,7 @@ test("candidate list handles deprecated and stale candidates", async () => { const stdout = memoryStream(); const stderr = memoryStream(); - const exitCode = await runCli(["candidate", "list", "domain", "--json"], { stdout, stderr }); + const exitCode = await runCli(["candidate", "list", "opendomain", "--json"], { stdout, stderr }); const payload = JSON.parse(stdout.toString()); assert.equal(exitCode, 0); @@ -479,7 +554,7 @@ async function withTempCwd(callback) { } async function writeCandidate(id, options = {}) { - await mkdir("domain/candidates", { recursive: true }); + await mkdir("opendomain/candidates", { recursive: true }); const status = options.status ?? "proposed"; const reviewState = status; const extractedAt = options.extractedAt ?? "2026-07-08"; @@ -498,7 +573,7 @@ async function writeCandidate(id, options = {}) { : ""; const body = options.body ?? "# Candidate Fixture\n\nFixture candidate for CLI tests.\n"; - await writeFile(`domain/candidates/${id}.md`, `--- + await writeFile(`opendomain/candidates/${id}.md`, `--- type: domain_candidate id: ${id} status: ${status} diff --git a/tests/indexer.test.mjs b/tests/indexer.test.mjs index 05e57e9..4522b3e 100644 --- a/tests/indexer.test.mjs +++ b/tests/indexer.test.mjs @@ -79,7 +79,7 @@ test("index query warns when source file changed after build", async () => { assert.equal(build.errors.length, 0); const indexFile = await writeSemanticIndex(build.index, "index.json", { cwd: fixtureRoot }); - await appendFile(path.join(fixtureRoot, "examples/erp/domain/concepts/sales.order.md"), "\n"); + await appendFile(path.join(fixtureRoot, "examples/erp/opendomain/concepts/sales.order.md"), "\n"); const query = await querySemanticIndex({ id: "sales.order" }, { cwd: fixtureRoot, diff --git a/tests/protocol.test.mjs b/tests/protocol.test.mjs index 0e90002..249c130 100644 --- a/tests/protocol.test.mjs +++ b/tests/protocol.test.mjs @@ -12,10 +12,11 @@ import { } from "../src/indexer.mjs"; import { prepareGroundingPack } from "../src/prepare.mjs"; -const FEATURE_PATH = "examples/erp/openspec/changes/order-cancellation/spec.md"; +const ERP_ROOT = path.resolve("examples/erp"); +const FEATURE_PATH = "openspec/changes/order-cancellation/spec.md"; test("successful Grounding Pack exposes stable v1 fields and alpha metadata", async () => { - const pack = await prepareGroundingPack(FEATURE_PATH); + const pack = await prepareGroundingPack(FEATURE_PATH, { cwd: ERP_ROOT }); const packSchema = await readJson("schemas/grounding-pack.schema.json"); const requestSchema = await readJson("schemas/grounding-request.schema.json"); @@ -99,9 +100,9 @@ affects_domain: }); test("Context Budget deterministically estimates complete selected files", async () => { - const pack = await prepareGroundingPack(FEATURE_PATH); - const requiredEstimate = await estimateFiles(pack.read_first.map((item) => item.file)); - const candidateEstimate = await estimateFiles(pack.candidate_boundaries.map((item) => item.file)); + const pack = await prepareGroundingPack(FEATURE_PATH, { cwd: ERP_ROOT }); + const requiredEstimate = await estimateFiles(pack.read_first.map((item) => path.join(ERP_ROOT, item.file))); + const candidateEstimate = await estimateFiles(pack.candidate_boundaries.map((item) => path.join(ERP_ROOT, item.file))); assert.deepEqual(pack.context_budget.estimator, { id: "chars-div-4", @@ -119,11 +120,14 @@ test("Context Budget deterministically estimates complete selected files", async }); test("prepare and index query share accepted closure IDs for the same root", async () => { - const pack = await prepareGroundingPack(FEATURE_PATH); - const built = await buildSemanticIndex("examples/erp"); + const pack = await prepareGroundingPack(FEATURE_PATH, { cwd: ERP_ROOT }); + const built = await buildSemanticIndex(undefined, { cwd: ERP_ROOT }); const directory = await mkdtemp(path.join(os.tmpdir(), "opendomain-protocol-index-")); const indexPath = await writeSemanticIndex(built.index, path.join(directory, "index.json")); - const query = await querySemanticIndex({ id: "sales.order" }, { indexPath }); + const query = await querySemanticIndex({ id: "sales.order" }, { + cwd: ERP_ROOT, + indexPath + }); assert.equal(built.errors.length, 0); assert.equal(query.errors.length, 0); diff --git a/tests/schema-validator.test.mjs b/tests/schema-validator.test.mjs index 0ac08c7..7460ddc 100644 --- a/tests/schema-validator.test.mjs +++ b/tests/schema-validator.test.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import test from "node:test"; import { validatePath } from "../src/validator.mjs"; -const VALID_TARGET = "examples/erp/domain/contexts"; +const VALID_TARGET = "examples/erp/opendomain/contexts"; for (const failure of [ { diff --git a/tests/validator.test.mjs b/tests/validator.test.mjs index 1007616..2c03c5a 100644 --- a/tests/validator.test.mjs +++ b/tests/validator.test.mjs @@ -14,7 +14,7 @@ test("valid ERP grounding example passes", async () => { }); test("OpenDomain self model validates as real domain knowledge", async () => { - const result = await validatePath("domain", { + const result = await validatePath("opendomain", { cwd: process.cwd(), now: new Date("2026-07-03T00:00:00Z") }); diff --git a/tests/workspace-consumers.test.mjs b/tests/workspace-consumers.test.mjs new file mode 100644 index 0000000..bbfa56d --- /dev/null +++ b/tests/workspace-consumers.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { access, cp, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { runCli } from "../src/cli.mjs"; + +const REPOSITORY_ROOT = process.cwd(); + +test("canonical workspace resolution is shared across every corpus consumer", async () => { + await withTempProject(async (project) => { + await seedErpWorkspace(project, "opendomain"); + await mkdir(path.join(project, "examples/ignored"), { recursive: true }); + await writeFile( + path.join(project, "examples/ignored/not-domain.md"), + "This default-excluded example has no front matter.\n", + "utf8" + ); + + const validation = await runJson(["validate", "--json"], project); + assert.equal(validation.exitCode, 0); + assert.equal(validation.payload.workspace.mode, "canonical"); + assert.equal(validation.payload.warnings.length, 0); + assert.equal(validation.payload.documents.some((item) => item.id === "spec.order-cancellation"), false); + + const grounding = await runJson(["prepare", "feature.md", "--json"], project); + assert.equal(grounding.exitCode, 0); + assert.equal(grounding.payload.errors.length, 0); + assert.ok(grounding.payload.read_first.some((item) => item.id === "sales.order")); + + const candidates = await runJson(["candidate", "list", "--json"], project); + assert.equal(candidates.exitCode, 0); + assert.equal(candidates.payload.warnings.length, 0); + assert.ok(candidates.payload.candidates.some((item) => item.id === "candidate-0001-order-lifecycle")); + + const ids = await runJson(["ids", "list", "--json"], project); + assert.equal(ids.exitCode, 0); + assert.equal(ids.payload.workspace.mode, "canonical"); + assert.equal(ids.payload.warnings.length, 0); + + const index = await runJson(["index", "build", "--json"], project); + assert.equal(index.exitCode, 0); + assert.equal(index.payload.file, "opendomain/generated/index.json"); + assert.equal(index.payload.warnings.length, 0); + await access(path.join(project, "opendomain/generated/index.json")); + + const query = await runJson(["index", "query", "sales.order", "--json"], project); + assert.equal(query.exitCode, 0); + assert.equal(query.payload.index_file, "opendomain/generated/index.json"); + assert.ok(query.payload.accepted_ids.includes("sales.order")); + + const validationAfterIndex = await runJson(["validate", "--json"], project); + assert.equal(validationAfterIndex.exitCode, 0); + assert.equal(validationAfterIndex.payload.documents.length, validation.payload.documents.length); + }); +}); + +test("legacy workspace warnings and index compatibility are shared without creating canonical root", async () => { + await withTempProject(async (project) => { + await seedErpWorkspace(project, "domain"); + + const validation = await runJson(["validate", "--json"], project); + assert.equal(validation.exitCode, 0); + assertLegacyWarning(validation.payload.warnings); + + const grounding = await runJson(["prepare", "feature.md", "--json"], project); + assert.equal(grounding.exitCode, 0); + assertLegacyWarning(grounding.payload.warnings); + + const candidates = await runJson(["candidate", "list", "--json"], project); + assert.equal(candidates.exitCode, 0); + assertLegacyWarning(candidates.payload.warnings); + + const ids = await runJson(["ids", "list", "--json"], project); + assert.equal(ids.exitCode, 0); + assertLegacyWarning(ids.payload.warnings); + + const index = await runJson(["index", "build", "--json"], project); + assert.equal(index.exitCode, 0); + assert.equal(index.payload.file, ".opendomain/index.json"); + assertLegacyWarning(index.payload.warnings); + await access(path.join(project, ".opendomain/index.json")); + await assert.rejects(access(path.join(project, "opendomain")), { code: "ENOENT" }); + + const query = await runJson(["index", "query", "sales.order", "--json"], project); + assert.equal(query.exitCode, 0); + assert.equal(query.payload.index_file, ".opendomain/index.json"); + assertLegacyWarning(query.payload.warnings); + }); +}); + +test("dual-root projects use only canonical sources across corpus consumers", async () => { + await withTempProject(async (project) => { + await seedErpWorkspace(project, "opendomain"); + await cp( + path.join(project, "opendomain"), + path.join(project, "domain"), + { recursive: true } + ); + + const validation = await runJson(["validate", "--json"], project); + assert.equal(validation.exitCode, 0); + assertDualRootWarning(validation.payload.warnings); + assert.ok(validation.payload.documents.every((item) => !item.file.startsWith("domain/"))); + + const grounding = await runJson(["prepare", "feature.md", "--json"], project); + assert.equal(grounding.exitCode, 0); + assertDualRootWarning(grounding.payload.warnings); + assert.ok(grounding.payload.read_first.every((item) => item.file.startsWith("opendomain/"))); + + const candidates = await runJson(["candidate", "list", "--json"], project); + assert.equal(candidates.exitCode, 0); + assertDualRootWarning(candidates.payload.warnings); + assert.ok(candidates.payload.candidates.every((item) => item.file.startsWith("opendomain/"))); + + const ids = await runJson(["ids", "list", "--json"], project); + assert.equal(ids.exitCode, 0); + assertDualRootWarning(ids.payload.warnings); + assert.ok(ids.payload.ids.every((item) => item.file.startsWith("opendomain/"))); + + const index = await runJson(["index", "build", "--json"], project); + assert.equal(index.exitCode, 0); + assert.equal(index.payload.file, "opendomain/generated/index.json"); + assertDualRootWarning(index.payload.warnings); + assert.ok(index.payload.index.entries.every((item) => item.source_file.startsWith("opendomain/"))); + }); +}); + +async function seedErpWorkspace(project, workspaceDirectory) { + await cp( + path.join(REPOSITORY_ROOT, "examples/erp/opendomain"), + path.join(project, workspaceDirectory), + { recursive: true } + ); + await cp( + path.join(REPOSITORY_ROOT, "examples/erp/openspec/changes/order-cancellation/spec.md"), + path.join(project, "feature.md") + ); +} + +async function runJson(args, cwd) { + const stdout = memoryStream(); + const stderr = memoryStream(); + const exitCode = await runCli(args, { stdout, stderr, cwd }); + return { + exitCode, + payload: JSON.parse(stdout.toString()), + stderr: stderr.toString() + }; +} + +function assertLegacyWarning(warnings) { + assert.ok(warnings.some((warning) => warning.problem.includes("legacy"))); +} + +function assertDualRootWarning(warnings) { + assert.ok(warnings.some((warning) => warning.problem.includes("ignoring 'domain/'"))); +} + +async function withTempProject(callback) { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-consumers-")); + try { + await callback(project); + } finally { + await rm(project, { recursive: true, force: true }); + } +} + +function memoryStream() { + let value = ""; + return { + write(chunk) { + value += chunk; + }, + toString() { + return value; + } + }; +} diff --git a/tests/workspace-resolver.test.mjs b/tests/workspace-resolver.test.mjs new file mode 100644 index 0000000..4a34b51 --- /dev/null +++ b/tests/workspace-resolver.test.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { + mkdtemp, + mkdir, + realpath, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + CANONICAL_DEFAULT_INDEX_PATH, + LEGACY_DEFAULT_INDEX_PATH, + resolveWorkspaceSources +} from "../src/workspace-resolver.mjs"; + +test("canonical workspace is selected without scanning repository examples", async () => { + await withTempProject(async (project) => { + await writeMarkdown(project, "opendomain/contexts/canonical.md"); + await writeMarkdown(project, "examples/demo/opendomain/contexts/example.md"); + await writeMarkdown(project, "opendomain/generated/derived.md"); + await writeMarkdown(project, "opendomain/integrations/profiles/profile.md"); + + const result = await resolveWorkspaceSources(undefined, { cwd: project }); + + assert.equal(result.mode, "canonical"); + assert.equal(result.defaultIndexPath, CANONICAL_DEFAULT_INDEX_PATH); + assert.equal(result.errors.length, 0); + assert.equal(result.warnings.length, 0); + assert.deepEqual(relativeFiles(project, result.files), [ + "opendomain/contexts/canonical.md" + ]); + }); +}); + +test("legacy workspace remains readable with an actionable warning", async () => { + await withTempProject(async (project) => { + await writeMarkdown(project, "domain/contexts/legacy.md"); + + const result = await resolveWorkspaceSources(undefined, { cwd: project }); + + assert.equal(result.mode, "legacy"); + assert.equal(result.defaultIndexPath, LEGACY_DEFAULT_INDEX_PATH); + assert.equal(result.errors.length, 0); + assert.ok(result.warnings.some((warning) => warning.problem.includes("legacy"))); + assert.deepEqual(relativeFiles(project, result.files), [ + "domain/contexts/legacy.md" + ]); + }); +}); + +test("canonical workspace wins without merging a coexisting legacy root", async () => { + await withTempProject(async (project) => { + await writeMarkdown(project, "opendomain/contexts/canonical.md"); + await writeMarkdown(project, "domain/contexts/legacy.md"); + + const result = await resolveWorkspaceSources(undefined, { cwd: project }); + + assert.equal(result.mode, "canonical"); + assert.equal(result.errors.length, 0); + assert.ok(result.warnings.some((warning) => warning.problem.includes("ignoring 'domain/'"))); + assert.deepEqual(relativeFiles(project, result.files), [ + "opendomain/contexts/canonical.md" + ]); + }); +}); + +test("missing and empty implicit workspaces fail instead of validating zero documents", async () => { + await withTempProject(async (project) => { + const missing = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(missing.errors.some((error) => error.problem.includes("No OpenDomain workspace"))); + + await mkdir(path.join(project, "opendomain/contexts"), { recursive: true }); + await writeFile(path.join(project, "opendomain/README.md"), "# README\n", "utf8"); + const empty = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(empty.errors.some((error) => error.problem.includes("no eligible Markdown"))); + }); +}); + +test("invalid implicit workspace roots and semantic source slots fail safely", async () => { + await withTempProject(async (project) => { + await writeFile(path.join(project, "opendomain"), "not a directory\n", "utf8"); + const invalidRoot = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(invalidRoot.errors.some((error) => error.problem.includes("not a directory"))); + + await rm(path.join(project, "opendomain")); + await mkdir(path.join(project, "opendomain"), { recursive: true }); + await writeFile(path.join(project, "opendomain/contexts"), "not a directory\n", "utf8"); + const invalidSlot = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.ok(invalidSlot.errors.some((error) => error.problem.includes("source slot"))); + }); +}); + +test("explicit targets work without a default workspace and reject unsupported selections", async () => { + await withTempProject(async (project) => { + await writeMarkdown(project, "examples/demo/domain/context.md"); + await writeFile(path.join(project, "notes.txt"), "not markdown\n", "utf8"); + await mkdir(path.join(project, "empty"), { recursive: true }); + + const directory = await resolveWorkspaceSources("examples/demo", { cwd: project }); + assert.equal(directory.mode, "explicit"); + assert.equal(directory.errors.length, 0); + assert.deepEqual(relativeFiles(project, directory.files), [ + "examples/demo/domain/context.md" + ]); + + const file = await resolveWorkspaceSources("examples/demo/domain/context.md", { cwd: project }); + assert.equal(file.errors.length, 0); + assert.equal(file.files.length, 1); + + const missing = await resolveWorkspaceSources("missing", { cwd: project }); + assert.ok(missing.errors.some((error) => error.problem.includes("does not exist"))); + + const unsupported = await resolveWorkspaceSources("notes.txt", { cwd: project }); + assert.ok(unsupported.errors.some((error) => error.problem.includes("not a Markdown"))); + + const empty = await resolveWorkspaceSources("empty", { cwd: project }); + assert.ok(empty.errors.some((error) => error.problem.includes("no eligible Markdown"))); + }); +}); + +test("source ordering is deterministic and symlinked descendants are not followed", async () => { + await withTempProject(async (project) => { + await writeMarkdown(project, "opendomain/concepts/z.md"); + await writeMarkdown(project, "opendomain/concepts/a.md"); + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-external-")); + try { + await writeMarkdown(external, "escaped.md"); + await symlink(external, path.join(project, "opendomain/concepts/external")); + + const first = await resolveWorkspaceSources(undefined, { cwd: project }); + const second = await resolveWorkspaceSources(undefined, { cwd: project }); + + assert.deepEqual(relativeFiles(project, first.files), [ + "opendomain/concepts/a.md", + "opendomain/concepts/z.md" + ]); + assert.deepEqual(first.files, second.files); + } finally { + await rm(external, { recursive: true, force: true }); + } + }); +}); + +test("implicit workspace roots cannot resolve outside the project", async () => { + await withTempProject(async (project) => { + const external = await mkdtemp(path.join(os.tmpdir(), "opendomain-root-escape-")); + try { + await writeMarkdown(external, "contexts/escaped.md"); + await symlink(external, path.join(project, "opendomain")); + + const implicit = await resolveWorkspaceSources(undefined, { cwd: project }); + assert.equal(implicit.files.length, 0); + assert.ok(implicit.errors.some((error) => error.problem.includes("outside the project root"))); + + const explicit = await resolveWorkspaceSources(external, { cwd: project }); + assert.equal(explicit.errors.length, 0); + assert.equal(explicit.files.length, 1); + } finally { + await rm(external, { recursive: true, force: true }); + } + }); +}); + +async function withTempProject(callback) { + const project = await mkdtemp(path.join(os.tmpdir(), "opendomain-workspace-")); + try { + await callback(await realpath(project)); + } finally { + await rm(project, { recursive: true, force: true }); + } +} + +async function writeMarkdown(project, relativePath) { + const file = path.join(project, relativePath); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "---\ntype: fixture\n---\n", "utf8"); +} + +function relativeFiles(project, files) { + return files.map((file) => path.relative(project, file).split(path.sep).join("/")); +}